diff --git a/CHANGELOG.md b/CHANGELOG.md index 850845e..2f46451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Shelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.1.1] — 2026-07-19 + +### Fixed + +- **A deleted product no longer reappears after a restart.** The product JSON seed is now genuinely one-time: a `SeedState` marker records that the import has run, and the migration never re-reads the seed files again. Previously the seed re-imported any product that was missing from the database on every startup, so a product deleted via the UI/API was resurrected from its lingering seed file. Existing databases are marked seeded on first boot of this version without re-importing, so previously-deleted products stay deleted. + ## [2.1.0] — 2026-07-19 Access control for restricted documentation, a much richer analytics dashboard, diff --git a/src/Cocoar.Shelf/Models/SeedState.cs b/src/Cocoar.Shelf/Models/SeedState.cs new file mode 100644 index 0000000..440e2c4 --- /dev/null +++ b/src/Cocoar.Shelf/Models/SeedState.cs @@ -0,0 +1,14 @@ +namespace Cocoar.Shelf.Models; + +/// +/// Marker that the one-time product JSON seed has run. Once present, the migration never re-reads the +/// JSON files again — the database is the source of truth, so a deleted product stays deleted across +/// restarts (it is not resurrected from a lingering seed file). +/// +public class SeedState +{ + /// Well-known single id. + public string Id { get; set; } = "products"; + + public DateTimeOffset SeededAt { get; set; } +} diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index aa01f20..1517d2f 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -89,6 +89,11 @@ opts.Schema.For() .DatabaseSchemaName("shelf") .Index(x => x.IsDeleted); + + // One-time product-seed marker + opts.Schema.For() + .DatabaseSchemaName("shelf") + .Identity(x => x.Id); }) .UseLightweightSessions() .ApplyAllDatabaseChangesOnStartup(); diff --git a/src/Cocoar.Shelf/Services/ProductConfigMigrationService.cs b/src/Cocoar.Shelf/Services/ProductConfigMigrationService.cs index bcbd7a5..29a7582 100644 --- a/src/Cocoar.Shelf/Services/ProductConfigMigrationService.cs +++ b/src/Cocoar.Shelf/Services/ProductConfigMigrationService.cs @@ -30,49 +30,59 @@ public ProductConfigMigrationService( public async Task StartAsync(CancellationToken cancellationToken) { - var productsDir = Path.Combine(_config.CurrentValue.ConfigRoot, "products"); + await using var session = _store.LightweightSession(); - if (!Directory.Exists(productsDir)) + // One-time seed: once seeded, the DB owns products — the JSON files are never read again, so a + // product deleted via the UI/API is NOT resurrected from a lingering seed file on restart. + if (await session.LoadAsync("products", cancellationToken) is not null) return; - var jsonFiles = Directory.GetFiles(productsDir, "*.json"); - if (jsonFiles.Length == 0) + var productsDir = Path.Combine(_config.CurrentValue.ConfigRoot, "products"); + var jsonFiles = Directory.Exists(productsDir) + ? Directory.GetFiles(productsDir, "*.json") + : []; + + // Upgrade path: a DB seeded by the pre-marker version (or populated purely via the UI/API) + // already has products — mark it seeded WITHOUT re-importing, so previously-deleted products + // stay deleted. + var alreadyPopulated = await session.Query().AnyAsync(cancellationToken); + + // Nothing to seed yet (empty DB, no seed files) — stay unseeded so a later boot with seed + // files can still import them once. + if (!alreadyPopulated && jsonFiles.Length == 0) return; - await using var session = _store.LightweightSession(); var imported = 0; - - foreach (var file in jsonFiles) + if (!alreadyPopulated) { - try + foreach (var file in jsonFiles) { - var json = await File.ReadAllTextAsync(file, cancellationToken); - var config = JsonSerializer.Deserialize(json, JsonOptions); - - if (config == null) + try { - LogSkipped(_logger, file, "deserialized to null"); - continue; - } + var json = await File.ReadAllTextAsync(file, cancellationToken); + var config = JsonSerializer.Deserialize(json, JsonOptions); - var existing = await session.LoadAsync(config.Name, cancellationToken); - if (existing != null) - continue; // Already in DB + if (config == null) + { + LogSkipped(_logger, file, "deserialized to null"); + continue; + } - session.Store(config); - imported++; - } - catch (Exception ex) - { - LogFailed(_logger, file, ex); + session.Store(config); + imported++; + } + catch (Exception ex) + { + LogFailed(_logger, file, ex); + } } } + session.Store(new SeedState { Id = "products", SeededAt = DateTimeOffset.UtcNow }); + await session.SaveChangesAsync(cancellationToken); + if (imported > 0) - { - await session.SaveChangesAsync(cancellationToken); LogCompleted(_logger, imported, jsonFiles.Length); - } } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs new file mode 100644 index 0000000..cc9c894 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs @@ -0,0 +1,56 @@ +using Cocoar.Configuration.Reactive; +using Cocoar.Shelf; +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests.Integration; + +/// +/// The product JSON seed is one-time: once seeded, a product deleted via the UI/API must not be +/// resurrected from a lingering seed file on the next startup. +/// +[Collection("Integration")] +public class ProductSeedMigrationTests +{ + private readonly ShelfFixture _fixture; + + public ProductSeedMigrationTests(ShelfFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Migration_DoesNotReimportSeedFiles_OnceSeeded() + { + var store = _fixture.Services.GetRequiredService(); + var config = _fixture.Services.GetRequiredService>(); + + // Mark the seed as already done (as it is after the first-ever startup). + await using (var session = store.LightweightSession()) + { + session.Store(new SeedState { Id = "products", SeededAt = DateTimeOffset.UtcNow }); + await session.SaveChangesAsync(); + } + + // Drop a seed JSON for a product that is NOT in the DB — the classic "deleted, but the file + // is still on disk" situation. + var name = "seed-ghost-" + Guid.NewGuid().ToString("N")[..8]; + var jsonPath = Path.Combine(_fixture.ConfigRoot, "products", name + ".json"); + await File.WriteAllTextAsync(jsonPath, $$"""{"Name":"{{name}}","DisplayName":"Ghost","Source":"upload"}"""); + + try + { + // Run the migration again (simulates a restart). + var migration = new ProductConfigMigrationService(store, config, NullLogger.Instance); + await migration.StartAsync(default); + + // The ghost must NOT be re-imported — the seed marker stops the JSON from being read. + await using var query = store.QuerySession(); + Assert.Null(await query.LoadAsync(name)); + } + finally + { + File.Delete(jsonPath); + } + } +}