Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/Cocoar.Shelf/Models/SeedState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Cocoar.Shelf.Models;

/// <summary>
/// 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).
/// </summary>
public class SeedState
{
/// <summary>Well-known single id.</summary>
public string Id { get; set; } = "products";

public DateTimeOffset SeededAt { get; set; }
}
5 changes: 5 additions & 0 deletions src/Cocoar.Shelf/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
// Users (thin local mirror of modgud identities, Id == modgud sub)
opts.Schema.For<UserDocument>()
.DatabaseSchemaName("shelf")
.UniqueIndex(x => x.NormalizedUserName)

Check warning on line 68 in src/Cocoar.Shelf/Program.cs

View workflow job for this annotation

GitHub Actions / Backend Build & Test

Possible null reference return.
.Index(x => x.NormalizedEmail)
.Index(x => x.IsActive);

Expand All @@ -89,6 +89,11 @@
opts.Schema.For<Group>()
.DatabaseSchemaName("shelf")
.Index(x => x.IsDeleted);

// One-time product-seed marker
opts.Schema.For<SeedState>()
.DatabaseSchemaName("shelf")
.Identity(x => x.Id);
})
.UseLightweightSessions()
.ApplyAllDatabaseChangesOnStartup();
Expand Down
64 changes: 37 additions & 27 deletions src/Cocoar.Shelf/Services/ProductConfigMigrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SeedState>("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<ProductConfig>().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<ProductConfig>(json, JsonOptions);

if (config == null)
try
{
LogSkipped(_logger, file, "deserialized to null");
continue;
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var config = JsonSerializer.Deserialize<ProductConfig>(json, JsonOptions);

var existing = await session.LoadAsync<ProductConfig>(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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<IDocumentStore>();
var config = _fixture.Services.GetRequiredService<IReactiveConfig<ShelfOptions>>();

// 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<ProductConfigMigrationService>.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<ProductConfig>(name));
}
finally
{
File.Delete(jsonPath);
}
}
}
Loading