diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index b76278e2..9b26a114 100644 --- a/docs/articles/concepts/bootstrap-lifecycle.md +++ b/docs/articles/concepts/bootstrap-lifecycle.md @@ -24,6 +24,32 @@ yourself - useful when values from the file must drive registration decisions - `Create(SquidStdConfig, SquidStdOptions)` overload; see [two-phase setup](../guides/configuration.md#two-phase-setup-moongate-style). +## Managed directories + +`SquidStdOptions.Directories` declares directory names that are created under `RootDirectory` as soon +as `Create` runs, before `ConfigureServices` or `StartAsync`: + +```csharp +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory, + Directories = ["scripts", "save"] +}); +``` + +Modules and plugins that need their own managed directory register it against the same +`DirectoriesConfig` instance, resolved from the container: + +```csharp +var directories = bootstrap.Container.Resolve(); +var worldDir = directories.RegisterDirectory("world"); +``` + +Directory names are lower-cased to snake_case on disk (`SavedGames` becomes `saved_games`), and +`RegisterDirectory` is idempotent - registering the same name again just returns the existing path +without creating it twice. + ## ConfigureServices Register your services into the DryIoc container. Call `RegisterCoreServices()` first to bring up the core services, then add the modules you need: diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs index a1538188..2824162e 100644 --- a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs @@ -33,4 +33,10 @@ public sealed class SquidStdOptions /// Serilog is configured from this instance only; the file cannot override it. /// public SquidStdLoggerOptions? Logger { get; set; } + + /// + /// Directory names created under when the bootstrap is created + /// (snake_case on disk, same rule as directory path resolution). + /// + public string[] Directories { get; set; } = []; } diff --git a/src/SquidStd.Core/Directories/DirectoriesConfig.cs b/src/SquidStd.Core/Directories/DirectoriesConfig.cs index 9cfd74bd..cd68b2af 100644 --- a/src/SquidStd.Core/Directories/DirectoriesConfig.cs +++ b/src/SquidStd.Core/Directories/DirectoriesConfig.cs @@ -7,7 +7,7 @@ namespace SquidStd.Core.Directories; /// public class DirectoriesConfig { - private readonly string[] _directories; + private readonly List _directories; /// /// Gets the root directory path. @@ -35,7 +35,7 @@ public class DirectoriesConfig /// The array of directory types. public DirectoriesConfig(string rootDirectory, string[] directories) { - _directories = directories; + _directories = [.. directories]; Root = rootDirectory; Init(); @@ -66,6 +66,37 @@ public string GetPath(string directoryType) return path; } + /// + /// Adds a managed directory type and creates it immediately (snake_case under the root). + /// Idempotent: registering the same type again returns the existing path. + /// + /// The directory type name. + /// The full path of the created directory. + public string RegisterDirectory(string directoryType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directoryType); + + if (!_directories.Any(entry => string.Equals( + entry.ToSnakeCase(), + directoryType.ToSnakeCase(), + StringComparison.Ordinal + ))) + { + _directories.Add(directoryType); + } + + return GetPath(directoryType); + } + + /// + /// Adds a managed directory type from an enum value and creates it immediately. + /// + /// The directory type enum. + /// The directory type value. + /// The full path of the created directory. + public string RegisterDirectory(TEnum value) where TEnum : struct, Enum + => RegisterDirectory(Enum.GetName(value)!); + /// /// Returns a string representation of the root directory. /// diff --git a/src/SquidStd.Persistence.MessagePack/Extensions/MessagePackRegistrationExtensions.cs b/src/SquidStd.Persistence.MessagePack/Extensions/MessagePackRegistrationExtensions.cs new file mode 100644 index 00000000..2d13bdcf --- /dev/null +++ b/src/SquidStd.Persistence.MessagePack/Extensions/MessagePackRegistrationExtensions.cs @@ -0,0 +1,29 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Persistence.MessagePack.Extensions; + +/// +/// DryIoc registration helpers for the MessagePack data serializer. +/// +public static class MessagePackRegistrationExtensions +{ + /// Container that receives the registrations. + extension(IContainer container) + { + /// + /// Registers the MessagePack serializer for and + /// (same singleton instance). Existing registrations + /// are kept. + /// + /// The same container for chaining. + public IContainer RegisterMessagePackSerializer() + { + var serializer = new MessagePackDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + return container; + } + } +} diff --git a/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs b/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs index 19d48e91..3babeccd 100644 --- a/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs +++ b/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs @@ -1,13 +1,22 @@ using DryIoc; +using SquidStd.Abstractions.Extensions.Config; using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Directories; using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Persistence.Abstractions.Data; using SquidStd.Persistence.Abstractions.Interfaces.Persistence; using SquidStd.Persistence.Data; using SquidStd.Persistence.Data.Internal; +using SquidStd.Persistence.Services; namespace SquidStd.Persistence.Extensions; -/// Registers persisted entity types for descriptor construction at bootstrap. +/// +/// Registers persisted entity types for descriptor construction at bootstrap, and wires the +/// persistence stack itself - entity registry, journal, snapshot service, and the +/// lifecycle service. +/// public static class PersistenceRegistrationExtensions { extension(IContainer container) @@ -62,5 +71,91 @@ public IContainer ApplyPersistedEntityRegistrations() return container; } + + /// + /// Registers the whole persistence stack: entity registry (with the recorded + /// registrations applied on first + /// resolution), journal, snapshot service, and as a + /// lifecycle service (snapshot load and journal replay at start, autosave loop, final + /// snapshot at stop). Requires a registered . When + /// is null the "persistence" YAML section is bound; the + /// save directory defaults to the managed "save" directory under the root. + /// Do not call manually when using + /// this registration. + /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. + /// The same container for chaining. + public IContainer RegisterPersistence(PersistenceConfig? config = null) + { + if (!container.IsRegistered()) + { + throw new InvalidOperationException( + "Register a data serializer before RegisterPersistence " + + "(RegisterDataSerializer() or RegisterMessagePackSerializer())." + ); + } + + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("persistence", static () => new PersistenceConfig(), -40); + } + + container.Register(Reuse.Singleton); + container.RegisterInitializer( + static (registry, resolver) => + { + var registrations = resolver.Resolve>(IfUnresolved.ReturnDefault); + + if (registrations is null) + { + return; + } + + for (var i = 0; i < registrations.Count; i++) + { + registrations[i].Register(registry, resolver); + } + } + ); + + container.RegisterDelegate( + static resolver => + { + var effective = resolver.Resolve(); + + return new BinaryJournalService( + Path.Combine(ResolveSaveDirectory(resolver, effective), effective.JournalFileName), + effective.DurabilityMode, + effective.EnableFileLock + ); + }, + Reuse.Singleton + ); + + container.RegisterDelegate( + static resolver => + { + var effective = resolver.Resolve(); + + return new SnapshotService( + ResolveSaveDirectory(resolver, effective), + effective.SnapshotFileSuffix, + effective.DurabilityMode + ); + }, + Reuse.Singleton + ); + + return container.RegisterStdService(-1); + } } + + private static string ResolveSaveDirectory(IResolverContext resolver, PersistenceConfig config) + => !string.IsNullOrWhiteSpace(config.SaveDirectory) + ? config.SaveDirectory + : resolver.Resolve().RegisterDirectory("save"); } diff --git a/src/SquidStd.Persistence/README.md b/src/SquidStd.Persistence/README.md index da1130dd..ff39aa90 100644 --- a/src/SquidStd.Persistence/README.md +++ b/src/SquidStd.Persistence/README.md @@ -13,7 +13,10 @@ dotnet add package SquidStd.Persistence dotnet add package SquidStd.Persistence.MessagePack # recommended binary serializer ``` -## Usage +## Usage (standalone, no bootstrap) + +Wire the stack by hand when you are not using `SquidStdBootstrap` - construct the registry, journal, +snapshot service, and `PersistenceService` yourself: ```csharp using SquidStd.Persistence.Abstractions.Data; @@ -47,13 +50,50 @@ await persistence.SaveSnapshotAsync(); // snapshot + var bob = await players.GetByIdAsync(1); // detached clone ``` -### DI registration +### Manual DI registration (without `RegisterPersistence`) ```csharp container.RegisterPersistedEntity(typeId: 1, typeName: "Player", schemaVersion: 1, p => p.Id); container.ApplyPersistedEntityRegistrations(); // builds descriptors into IPersistenceEntityRegistry ``` +Only needed when the rest of the stack (registry, journal, snapshot, lifecycle service) is assembled by +hand instead of through `RegisterPersistence()`, which applies these registrations itself - do not call +`ApplyPersistedEntityRegistrations()` when `RegisterPersistence()` is in use. + +## Bootstrap registration + +The one-call path for `SquidStdBootstrap` apps: register a serializer, register the persistence stack, +then declare the persisted entities. + +```csharp +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Extensions; +using SquidStd.Persistence.MessagePack.Extensions; + +bootstrap.ConfigureServices(c => +{ + c.RegisterMessagePackSerializer(); // or RegisterDataSerializer() for JSON + c.RegisterPersistence(); // or RegisterPersistence(new PersistenceConfig { ... }) + c.RegisterPersistedEntity(1, "Player", 1, p => p.Id); + return c; +}); + +// after StartAsync: snapshot loaded, journal replayed, autosave running +var players = bootstrap.Resolve().GetStore(); +``` + +- **Serializer prerequisite**: register a serializer (`RegisterMessagePackSerializer()` or + `RegisterDataSerializer()`) before `RegisterPersistence()` - it throws `InvalidOperationException` + otherwise, so a missing serializer fails fast instead of at first use. +- **Config source**: `RegisterPersistence()` binds the `persistence` YAML section by default; pass an + explicit `PersistenceConfig` instance to skip the file entirely for that section (it is then ignored). + Either way, `SaveDirectory` defaults to the managed `save` directory under the bootstrap root when left + blank. +- **Lifecycle**: `IPersistenceService` is registered as a lifecycle service - the snapshot loads and the + journal replays at start, autosave runs while the bootstrap is up, and a final snapshot is written at + stop. + ## Key types | Type | Purpose | diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs index e9fb85fc..40463fc2 100644 --- a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs +++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs @@ -17,9 +17,10 @@ public static class SquidStdBootstrapPluginExtensions /// Collects internal plugins and external plugin directories, resolves the dependency order /// across the whole set, and invokes for each plugin /// in order against the bootstrap container. Must be called before the bootstrap starts, so - /// plugins can register configuration sections before the configuration is loaded. Relative - /// directories are resolved against the bootstrap root directory and created when missing - /// (an empty directory yields no plugins). Any failure aborts startup: + /// plugins can register configuration sections and services against the container; the + /// config-first bootstrap binds those sections eagerly at registration, the same as any + /// other registration. Relative directories are resolved against the bootstrap root directory + /// and created when missing (an empty directory yields no plugins). Any failure aborts startup: /// loader problems raise and plugin exceptions /// propagate unchanged. Plugin assemblies load into the default AssemblyLoadContext and are /// fully trusted: there is no unloading and no version isolation. Note that plugin load diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md index 53e812dc..136a79de 100644 --- a/src/SquidStd.Plugin/README.md +++ b/src/SquidStd.Plugin/README.md @@ -38,8 +38,7 @@ await bootstrap.RunAsync(); - Plugins are ordered by dependency: `PluginMetadata.Dependencies` (plugin ids, compared case-insensitively) is resolved across internal and external plugins together, so an external plugin can depend on an internal one and vice versa. -- `Configure` runs before the configuration load, so plugins can register their own configuration - sections and services ahead of time. +- `Configure` runs before the bootstrap starts, so plugins can register their own configuration sections and services against the container. - Plugin directories are managed like the other bootstrap directories: a missing directory is created on the spot and simply yields no plugins. - Each plugin receives a `PluginContext` populated with the standard keys: `PluginContextKeys.RootDirectory` @@ -52,9 +51,11 @@ per-plugin version isolation, so plugins are expected to be trusted. A failing p either with a `PluginLoadException` (discovery, ordering, or instantiation failure) or with the plugin's own exception from `Configure`. -Call `UsePlugins` before `ConfigureLogging()` or any start method: the bootstrap loads its -configuration during startup, and config sections registered by plugins after that point are never -loaded. +Ordering with `ConfigureLogging()` is free since the config-first bootstrap: configuration sections +bind eagerly at registration, so plugins can register their sections at any point before the services +consume them. Calling `ConfigureLogging()` before `UsePlugins` makes the plugin-load log lines visible. +The one residual rule: `OnConfigLoaded` hooks targeting a plugin's section must be registered before +`ConfigureLogging()` runs, because hooks are applied there. ## Related diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 57a5e529..33612bd2 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -8,6 +8,7 @@ using SquidStd.Abstractions.Interfaces.Services; using SquidStd.Core.Config; using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Directories; using SquidStd.Core.Extensions.Logger; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; @@ -83,6 +84,16 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ow } Container.RegisterConfigServices(config ?? SquidStdConfig.Load(Options.ConfigName, Options.RootDirectory)); + + if (Options.Directories.Length > 0) + { + var directoriesConfig = Container.Resolve(); + + foreach (var directory in Options.Directories) + { + directoriesConfig.RegisterDirectory(directory); + } + } } /// diff --git a/tests/SquidStd.Tests/Bootstrap/BootstrapDirectoriesTests.cs b/tests/SquidStd.Tests/Bootstrap/BootstrapDirectoriesTests.cs new file mode 100644 index 00000000..cb97be66 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/BootstrapDirectoriesTests.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +public class BootstrapDirectoriesTests +{ + [Fact] + public async Task Create_WithDeclaredDirectories_CreatesThemImmediately() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions + { + ConfigName = "dirs", + RootDirectory = root.Path, + Directories = ["scripts", "save"] + } + ); + + // created at Create time, BEFORE any StartAsync + Assert.True(Directory.Exists(root.Combine("scripts"))); + Assert.True(Directory.Exists(root.Combine("save"))); + } +} diff --git a/tests/SquidStd.Tests/Directories/RegisterDirectoryTests.cs b/tests/SquidStd.Tests/Directories/RegisterDirectoryTests.cs new file mode 100644 index 00000000..1360672d --- /dev/null +++ b/tests/SquidStd.Tests/Directories/RegisterDirectoryTests.cs @@ -0,0 +1,69 @@ +using SquidStd.Core.Directories; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Directories; + +public class RegisterDirectoryTests +{ + private enum TestDirType + { + SavedGames + } + + [Fact] + public void RegisterDirectory_CreatesAndReturnsPath() + { + using var root = new TempDirectory(); + var config = new DirectoriesConfig(root.Path, []); + + var path = config.RegisterDirectory("scripts"); + + Assert.Equal(Path.Combine(root.Path, "scripts"), path); + Assert.True(Directory.Exists(path)); + } + + [Fact] + public void RegisterDirectory_IsIdempotent() + { + using var root = new TempDirectory(); + var config = new DirectoriesConfig(root.Path, []); + + var first = config.RegisterDirectory("scripts"); + var second = config.RegisterDirectory("scripts"); + + Assert.Equal(first, second); + } + + [Fact] + public void RegisterDirectory_UsesSnakeCase() + { + using var root = new TempDirectory(); + var config = new DirectoriesConfig(root.Path, []); + + var path = config.RegisterDirectory("SavedGames"); + + Assert.Equal(Path.Combine(root.Path, "saved_games"), path); + Assert.True(Directory.Exists(path)); + } + + [Fact] + public void RegisterDirectory_EnumOverload_MatchesGetPath() + { + using var root = new TempDirectory(); + var config = new DirectoriesConfig(root.Path, []); + + var path = config.RegisterDirectory(TestDirType.SavedGames); + + Assert.Equal(config.GetPath(TestDirType.SavedGames), path); + Assert.True(Directory.Exists(path)); + } + + [Fact] + public void RegisterDirectory_BlankName_Throws() + { + using var root = new TempDirectory(); + var config = new DirectoriesConfig(root.Path, []); + + Assert.Throws(() => config.RegisterDirectory(" ")); + } +} diff --git a/tests/SquidStd.Tests/Persistence/RegisterPersistenceTests.cs b/tests/SquidStd.Tests/Persistence/RegisterPersistenceTests.cs new file mode 100644 index 00000000..6c3d8252 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/RegisterPersistenceTests.cs @@ -0,0 +1,129 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Extensions; +using SquidStd.Persistence.MessagePack.Extensions; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Persistence; + +public class RegisterPersistenceTests +{ + public sealed class PlayerEntity + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + private static SquidStdBootstrap CreateBootstrap(TempDirectory root, PersistenceConfig? config = null) + { + var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "persist", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterDataSerializer(); + c.RegisterPersistence(config); + c.RegisterPersistedEntity(1, "Player", 1, player => player.Id); + + return c; + }); + + return bootstrap; + } + + [Fact] + public async Task RegisterPersistence_RoundTrip_PersistsAcrossBootstraps() + { + using var root = new TempDirectory(); + + await using (var first = CreateBootstrap(root)) + { + await first.StartAsync(); + + var store = first.Resolve().GetStore(); + await store.UpsertAsync(new() { Id = 1, Name = "Hero" }); + + await first.StopAsync(); + } + + await using var second = CreateBootstrap(root); + await second.StartAsync(); + + var reloaded = second.Resolve().GetStore(); + var hero = await reloaded.GetByIdAsync(1); + + Assert.NotNull(hero); + Assert.Equal("Hero", hero!.Name); + await second.StopAsync(); + } + + [Fact] + public async Task RegisterPersistence_DefaultsSaveDirectoryToManagedSaveDir() + { + using var root = new TempDirectory(); + + await using var bootstrap = CreateBootstrap(root); + await bootstrap.StartAsync(); + + // CaptureBucket returns null for an empty bucket (no snapshot file is written for it), so + // an entity must be upserted for the final snapshot to actually land on disk. + var store = bootstrap.Resolve().GetStore(); + await store.UpsertAsync(new() { Id = 1, Name = "Hero" }); + + await bootstrap.StopAsync(); // final snapshot lands in the save dir + + Assert.True(Directory.Exists(root.Combine("save"))); + Assert.NotEmpty(Directory.GetFiles(root.Combine("save"))); + } + + [Fact] + public async Task RegisterPersistence_ExplicitConfigInstance_Survives() + { + using var root = new TempDirectory(); + var explicitConfig = new PersistenceConfig { SaveDirectory = root.Combine("custom_save") }; + + await using var bootstrap = CreateBootstrap(root, explicitConfig); + await bootstrap.StartAsync(); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + await bootstrap.StopAsync(); + + Assert.True(Directory.Exists(root.Combine("custom_save"))); + } + + [Fact] + public void RegisterPersistence_WithoutSerializer_Throws() + { + using var root = new TempDirectory(); + var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "persist", RootDirectory = root.Path } + ); + + var ex = Assert.Throws( + () => bootstrap.ConfigureServices(c => c.RegisterPersistence()) + ); + + Assert.Contains("serializer", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RegisterMessagePackSerializer_RegistersBothInterfaces() + { + using var root = new TempDirectory(); + var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "persist", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => c.RegisterMessagePackSerializer()); + + Assert.Same( + bootstrap.Resolve(), + bootstrap.Resolve() + ); + } +}