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);
+ }
+ }
+}