Skip to content

Commit 7a4cda5

Browse files
authored
Merge pull request #74 from tgiachi/develop
release: persistence and database seeders (0.27.0)
2 parents afa6a1d + 616dd82 commit 7a4cda5

16 files changed

Lines changed: 888 additions & 8 deletions

File tree

samples/SquidStd.Samples.Persistence/Program.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ IPersistenceService BuildPersistence()
3232
return new PersistenceService(registry, journal, snapshot, config);
3333
}
3434

35-
#region step-1: load existing state (snapshot + journal replay)
35+
#region step-1
3636

3737
var persistence = BuildPersistence();
3838
await persistence.InitializeAsync();
@@ -48,7 +48,7 @@ IPersistenceService BuildPersistence()
4848

4949
#endregion
5050

51-
#region step-2: mutate — every upsert/remove is appended to the journal
51+
#region step-2
5252

5353
var nextId = await players.CountAsync() + 1;
5454
await players.UpsertAsync(new() { Id = nextId, Name = $"Hero-{nextId}", Level = nextId * 10 });
@@ -57,7 +57,7 @@ IPersistenceService BuildPersistence()
5757

5858
#endregion
5959

60-
#region step-3: snapshot — capture full state and trim the journal
60+
#region step-3
6161

6262
await persistence.SaveSnapshotAsync();
6363
Console.WriteLine("Snapshot saved. Re-run this sample to see the state reload.");
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using DryIoc;
2+
using SquidStd.Database.Interfaces.Seeding;
3+
4+
namespace SquidStd.Database.Data.Internal;
5+
6+
/// <summary>
7+
/// A declarative database-seeder registration; the resolver runs when the database service is
8+
/// constructed.
9+
/// </summary>
10+
public sealed record DatabaseSeederRegistration(Func<IResolverContext, IDatabaseSeeder> Resolve);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using FreeSql.DataAnnotations;
2+
3+
namespace SquidStd.Database.Data.Internal;
4+
5+
/// <summary>
6+
/// History row recording an applied database seeder.
7+
/// </summary>
8+
[Table(Name = "__squidstd_seed_history")]
9+
public sealed class SeedHistoryEntity
10+
{
11+
/// <summary>The unique seeder name.</summary>
12+
[Column(IsPrimary = true, StringLength = 256)]
13+
public string Name { get; set; } = string.Empty;
14+
15+
/// <summary>UTC timestamp of the successful run.</summary>
16+
public DateTime AppliedAt { get; set; }
17+
}

src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using DryIoc;
22
using SquidStd.Abstractions.Extensions.Config;
3+
using SquidStd.Abstractions.Extensions.Container;
34
using SquidStd.Abstractions.Extensions.Services;
45
using SquidStd.Database.Abstractions.Data.Database;
56
using SquidStd.Database.Abstractions.Interfaces.Data;
67
using SquidStd.Database.Data;
8+
using SquidStd.Database.Data.Internal;
9+
using SquidStd.Database.Interfaces.Seeding;
710
using SquidStd.Database.Interfaces.Services;
11+
using SquidStd.Database.Internal;
812
using SquidStd.Database.Services;
913

1014
namespace SquidStd.Database.Extensions;
@@ -17,6 +21,41 @@ public static class RegisterDatabaseExtension
1721
/// <param name="container">The DI container.</param>
1822
extension(IContainer container)
1923
{
24+
/// <summary>
25+
/// Registers a database seeder by type; it is resolved from the container when the
26+
/// database service is constructed and runs once ever per its Name.
27+
/// </summary>
28+
/// <typeparam name="TSeeder">The seeder type.</typeparam>
29+
/// <returns>The same container for chaining.</returns>
30+
public IContainer RegisterDatabaseSeeder<TSeeder>()
31+
where TSeeder : class, IDatabaseSeeder
32+
{
33+
container.Register<TSeeder>(Reuse.Singleton);
34+
container.AddToRegisterTypedList(
35+
new DatabaseSeederRegistration(static resolver => resolver.Resolve<TSeeder>())
36+
);
37+
38+
return container;
39+
}
40+
41+
/// <summary>
42+
/// Registers a delegate-backed database seeder that runs once ever per
43+
/// <paramref name="name" />.
44+
/// </summary>
45+
/// <param name="name">Unique, stable seeder name recorded in the history table.</param>
46+
/// <param name="seed">The seeding callback.</param>
47+
/// <returns>The same container for chaining.</returns>
48+
public IContainer RegisterDatabaseSeeder(string name, Func<IDatabaseService, CancellationToken, ValueTask> seed)
49+
{
50+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
51+
ArgumentNullException.ThrowIfNull(seed);
52+
container.AddToRegisterTypedList(
53+
new DatabaseSeederRegistration(_ => new DelegateDatabaseSeeder(name, seed))
54+
);
55+
56+
return container;
57+
}
58+
2059
/// <summary>
2160
/// Registers the database config section, the database service, and the open-generic data access.
2261
/// </summary>
@@ -27,6 +66,18 @@ public IContainer RegisterDatabase()
2766
container.RegisterStdService<IDatabaseService, DatabaseService>();
2867
container.Register(typeof(IDataAccess<>), typeof(FreeSqlDataAccess<>), Reuse.Transient);
2968

69+
container.RegisterDelegate<IReadOnlyList<IDatabaseSeeder>>(
70+
static resolver =>
71+
{
72+
var recorded = resolver.Resolve<List<DatabaseSeederRegistration>>(IfUnresolved.ReturnDefault);
73+
74+
return recorded is null
75+
? []
76+
: recorded.Select(registration => registration.Resolve(resolver)).ToList();
77+
},
78+
Reuse.Singleton
79+
);
80+
3081
return container;
3182
}
3283
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using SquidStd.Database.Interfaces.Services;
2+
3+
namespace SquidStd.Database.Interfaces.Seeding;
4+
5+
/// <summary>
6+
/// Seeds initial database data. Each seeder runs once ever per unique <see cref="Name" />,
7+
/// tracked in the __squidstd_seed_history table; the history row is written only after a
8+
/// successful run, so a failed seeder retries at the next start. A seeder exception aborts
9+
/// startup. Retries happen at the next process start: a service instance whose start failed
10+
/// is not restartable. The seed and its history row are not wrapped in a transaction, so a
11+
/// seeder should tolerate running again over data it already wrote. Use case-distinct names:
12+
/// the duplicate check is ordinal, while the history lookup follows the database collation.
13+
/// </summary>
14+
public interface IDatabaseSeeder
15+
{
16+
/// <summary>Unique, stable seeder name recorded in the history table.</summary>
17+
string Name { get; }
18+
19+
/// <summary>Writes the seed data through the ORM.</summary>
20+
/// <param name="database">The started database service.</param>
21+
/// <param name="cancellationToken">Token used to cancel the seeding.</param>
22+
ValueTask SeedAsync(IDatabaseService database, CancellationToken cancellationToken = default);
23+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using SquidStd.Database.Interfaces.Seeding;
2+
using SquidStd.Database.Interfaces.Services;
3+
4+
namespace SquidStd.Database.Internal;
5+
6+
/// <summary>
7+
/// Adapts a seeding delegate to <see cref="IDatabaseSeeder" /> so
8+
/// <c>RegisterDatabaseSeeder(Func{IDatabaseService,CancellationToken,ValueTask})</c> can record
9+
/// it alongside class-based seeders.
10+
/// </summary>
11+
internal sealed class DelegateDatabaseSeeder : IDatabaseSeeder
12+
{
13+
private readonly Func<IDatabaseService, CancellationToken, ValueTask> _seed;
14+
15+
public string Name { get; }
16+
17+
public DelegateDatabaseSeeder(string name, Func<IDatabaseService, CancellationToken, ValueTask> seed)
18+
{
19+
Name = name;
20+
_seed = seed;
21+
}
22+
23+
public ValueTask SeedAsync(IDatabaseService database, CancellationToken cancellationToken = default)
24+
=> _seed(database, cancellationToken);
25+
}

src/SquidStd.Database/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,78 @@ var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name
3737
| `ConnectionStringParser` | URI → provider + native connection string. |
3838
| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. |
3939

40+
## Seeding
41+
42+
Seeders populate initial database data once per seeder name. Each seeder's name is tracked in the
43+
`__squidstd_seed_history` table; a seeder runs only if its name does not yet exist in the history. The
44+
history row is written only after a successful run - if a seeder throws, it is retried at the next
45+
process start. A seeder exception aborts startup (fail-fast). The seed and history row are not wrapped
46+
in a transaction, so a seeder should tolerate running again over data it already wrote.
47+
48+
The history table is created during database service start when at least one seeder is registered,
49+
independent of whether `AutoMigrate` is on or off. Seeder names must be unique; the startup duplicate
50+
check is ordinal (case-sensitive), while the history-table lookup for a given seeder's name follows
51+
the database's collation.
52+
53+
### Delegate seeder
54+
55+
Register an inline seeding callback with a unique name:
56+
57+
```csharp
58+
bootstrap.ConfigureServices(c =>
59+
{
60+
c.RegisterDatabase(); // binds "database" config, runs seeders after init
61+
62+
// Inline delegate seeder
63+
c.RegisterDatabaseSeeder("accounts.admin", async (database, ct) =>
64+
{
65+
await database.Orm.Insert(new User { Id = 1, Name = "Admin" }).ExecuteAffrowsAsync(ct);
66+
});
67+
68+
return c;
69+
});
70+
```
71+
72+
### Class seeder
73+
74+
Implement `IDatabaseSeeder` (which provides the name) and register it by type:
75+
76+
```csharp
77+
public sealed class AdminAccountSeeder : IDatabaseSeeder
78+
{
79+
public string Name => "accounts.admin";
80+
81+
public async ValueTask SeedAsync(IDatabaseService database, CancellationToken cancellationToken = default)
82+
{
83+
await database.Orm.Insert(new User { Id = 1, Name = "Admin" }).ExecuteAffrowsAsync(cancellationToken);
84+
}
85+
}
86+
87+
bootstrap.ConfigureServices(c =>
88+
{
89+
c.RegisterDatabase();
90+
c.RegisterDatabaseSeeder<AdminAccountSeeder>();
91+
92+
return c;
93+
});
94+
```
95+
96+
### Key semantics
97+
98+
- **Run-once per name**: The `__squidstd_seed_history` table tracks which seeders have successfully run.
99+
A seeder is skipped if its name already exists in the history.
100+
- **Failed-seeder retry**: The history row is written only after the seeder succeeds. If a seeder throws,
101+
the row is not written, and the seeder retries at the next process start.
102+
- **History table creation**: The table is created during database service start when at least one seeder
103+
is registered (this is what triggers `RunSeedersAsync`), independent of whether `AutoMigrate` is on or
104+
off. Its schema is internal to SquidStd and should not be modified.
105+
- **Duplicate name check**: Seeder names must be unique. Duplicate names (ordinal, case-sensitive match)
106+
are detected at startup and cause an exception.
107+
- **Execution order**: Seeders run in registration order. Multiple seeders can be registered via chained
108+
`RegisterDatabaseSeeder()` calls.
109+
- **No transaction wrap**: The seed and its history row are not wrapped in a transaction. Seeders should
110+
be idempotent or accept re-running over partially-written state.
111+
40112
## Related
41113

42114
- Tutorial: [Database](https://tgiachi.github.io/squid-std/tutorials/database.html)

src/SquidStd.Database/Services/DatabaseService.cs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using SquidStd.Database.Abstractions.Types.Data;
55
using SquidStd.Core.Directories;
66
using SquidStd.Database.Connection;
7+
using SquidStd.Database.Data.Internal;
8+
using SquidStd.Database.Interfaces.Seeding;
79
using SquidStd.Database.Interfaces.Services;
810

911
namespace SquidStd.Database.Services;
@@ -17,6 +19,7 @@ public sealed class DatabaseService : IDatabaseService
1719

1820
private readonly DatabaseConfig _config;
1921
private readonly DirectoriesConfig _directories;
22+
private readonly IReadOnlyList<IDatabaseSeeder> _seeders;
2023
private IFreeSql? _orm;
2124
private int _started;
2225

@@ -28,20 +31,25 @@ public sealed class DatabaseService : IDatabaseService
2831
/// </summary>
2932
/// <param name="config">The database configuration section.</param>
3033
/// <param name="directories">The directories configuration, providing the root directory.</param>
31-
public DatabaseService(DatabaseConfig config, DirectoriesConfig directories)
34+
/// <param name="seeders">
35+
/// Optional seeders run once ever, in order, right after the FreeSql instance is built in
36+
/// <see cref="StartAsync" />; applied names are tracked in the __squidstd_seed_history table.
37+
/// </param>
38+
public DatabaseService(DatabaseConfig config, DirectoriesConfig directories, IReadOnlyList<IDatabaseSeeder>? seeders = null)
3239
{
3340
_config = config;
3441
_directories = directories;
42+
_seeders = seeders ?? [];
3543
}
3644

3745
/// <inheritdoc />
38-
public ValueTask StartAsync(CancellationToken cancellationToken = default)
46+
public async ValueTask StartAsync(CancellationToken cancellationToken = default)
3947
{
4048
cancellationToken.ThrowIfCancellationRequested();
4149

4250
if (Interlocked.Exchange(ref _started, 1) != 0)
4351
{
44-
return ValueTask.CompletedTask;
52+
return;
4553
}
4654

4755
var parsed = ConnectionStringParser.Parse(_config.ConnectionString, _directories.Root);
@@ -67,13 +75,49 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default)
6775
_orm.Aop.SyncStructureAfter += (_, e) =>
6876
Logger.Verbose("Migrated {Entities} -> {Sql}", e.EntityTypes, e.Sql);
6977

78+
if (_seeders.Count > 0)
79+
{
80+
await RunSeedersAsync(cancellationToken);
81+
}
82+
7083
Logger.Information(
7184
"Database service started ({Provider}, autoMigrate={AutoMigrate})",
7285
parsed.Provider,
7386
_config.AutoMigrate
7487
);
88+
}
7589

76-
return ValueTask.CompletedTask;
90+
private async ValueTask RunSeedersAsync(CancellationToken cancellationToken)
91+
{
92+
var duplicate = _seeders.GroupBy(seeder => seeder.Name, StringComparer.Ordinal)
93+
.FirstOrDefault(group => group.Count() > 1);
94+
95+
if (duplicate is not null)
96+
{
97+
throw new InvalidOperationException($"Duplicate database seeder name '{duplicate.Key}'.");
98+
}
99+
100+
// The history table must exist even when AutoMigrate is off.
101+
Orm.CodeFirst.SyncStructure<SeedHistoryEntity>();
102+
103+
foreach (var seeder in _seeders)
104+
{
105+
var applied = await Orm.Select<SeedHistoryEntity>()
106+
.Where(entry => entry.Name == seeder.Name)
107+
.AnyAsync(cancellationToken);
108+
109+
if (applied)
110+
{
111+
Logger.Debug("Database seeder {Seeder:l} already applied; skipping", seeder.Name);
112+
113+
continue;
114+
}
115+
116+
await seeder.SeedAsync(this, cancellationToken);
117+
await Orm.Insert(new SeedHistoryEntity { Name = seeder.Name, AppliedAt = DateTime.UtcNow })
118+
.ExecuteAffrowsAsync(cancellationToken);
119+
Logger.Information("Database seeder {Seeder:l} applied", seeder.Name);
120+
}
77121
}
78122

79123
/// <inheritdoc />
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence;
2+
3+
/// <summary>
4+
/// Seeds initial data into a fresh persistence store. Seeders run after the snapshot load and
5+
/// journal replay, only when the save is brand new (no snapshot and no journal existed), in
6+
/// registration order. Seed writes go through the normal entity stores, so subsequent boots
7+
/// are no longer fresh and seeders never run again. A seeder exception aborts startup.
8+
/// If an earlier seeder's writes reach the journal before a later seeder fails, the save is no longer fresh at the next boot and the remaining seeders never run - prefer a single seeder, or make the set safe to lose a tail.
9+
/// A seeder that performs no writes leaves the save fresh, so it runs again at every boot.
10+
/// Class-form seeders must not constructor-inject IPersistenceService (circular resolution);
11+
/// use the SeedAsync parameter instead.
12+
/// </summary>
13+
public interface IPersistenceSeeder
14+
{
15+
/// <summary>Writes the seed data through the normal entity stores.</summary>
16+
/// <param name="persistence">The started persistence service.</param>
17+
/// <param name="cancellationToken">Token used to cancel the seeding.</param>
18+
ValueTask SeedAsync(IPersistenceService persistence, CancellationToken cancellationToken = default);
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using DryIoc;
2+
using SquidStd.Persistence.Abstractions.Interfaces.Persistence;
3+
4+
namespace SquidStd.Persistence.Data.Internal;
5+
6+
/// <summary>
7+
/// A declarative persistence-seeder registration; the resolver runs when the persistence
8+
/// service is constructed.
9+
/// </summary>
10+
public sealed record PersistenceSeederRegistration(Func<IResolverContext, IPersistenceSeeder> Resolve);

0 commit comments

Comments
 (0)