From 2403dae324149b09c262641d69121d804c0a40b8 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 12:24:20 +0200 Subject: [PATCH 1/2] fix: make product JSON seed truly one-time so deletes survive restart A product deleted via the UI/API reappeared after a restart: the seed migration re-imported any product missing from the DB on every startup, resurrecting it from its lingering ConfigRoot/products/*.json seed file. Add a SeedState marker document recorded once the import has run; the migration returns early when the marker is present and never re-reads the seed files again. Existing/pre-marker databases (already populated) are marked seeded on first boot without re-importing, so previously-deleted products stay deleted. Empty DB + no seed files stays unseeded so a later boot with seed files can still import them once. Regression test: ProductSeedMigrationTests asserts a ghost seed file for a product absent from the DB is not re-imported once the marker exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ src/Cocoar.Shelf/Models/SeedState.cs | 14 ++++ src/Cocoar.Shelf/Program.cs | 5 ++ .../Services/ProductConfigMigrationService.cs | 64 +++++++++++-------- .../Integration/ProductSeedMigrationTests.cs | 56 ++++++++++++++++ 5 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 src/Cocoar.Shelf/Models/SeedState.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs 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..5ab98c3 --- /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..72fda56 --- /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); + } + } +} From 5b3e201def93685bc22e5ffd933a209dddacea55 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 12:25:52 +0200 Subject: [PATCH 2/2] style: normalize new files to CRLF line endings Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Cocoar.Shelf/Models/SeedState.cs | 28 ++--- .../Integration/ProductSeedMigrationTests.cs | 112 +++++++++--------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/src/Cocoar.Shelf/Models/SeedState.cs b/src/Cocoar.Shelf/Models/SeedState.cs index 5ab98c3..440e2c4 100644 --- a/src/Cocoar.Shelf/Models/SeedState.cs +++ b/src/Cocoar.Shelf/Models/SeedState.cs @@ -1,14 +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; } -} +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/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs index 72fda56..cc9c894 100644 --- a/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ProductSeedMigrationTests.cs @@ -1,56 +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); - } - } -} +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); + } + } +}