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
26 changes: 26 additions & 0 deletions docs/articles/concepts/bootstrap-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DirectoriesConfig>();
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:
Expand Down
6 changes: 6 additions & 0 deletions src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ public sealed class SquidStdOptions
/// Serilog is configured from this instance only; the file cannot override it.
/// </summary>
public SquidStdLoggerOptions? Logger { get; set; }

/// <summary>
/// Directory names created under <see cref="RootDirectory" /> when the bootstrap is created
/// (snake_case on disk, same rule as directory path resolution).
/// </summary>
public string[] Directories { get; set; } = [];
}
35 changes: 33 additions & 2 deletions src/SquidStd.Core/Directories/DirectoriesConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace SquidStd.Core.Directories;
/// </summary>
public class DirectoriesConfig
{
private readonly string[] _directories;
private readonly List<string> _directories;

/// <summary>
/// Gets the root directory path.
Expand Down Expand Up @@ -35,7 +35,7 @@ public class DirectoriesConfig
/// <param name="directories">The array of directory types.</param>
public DirectoriesConfig(string rootDirectory, string[] directories)
{
_directories = directories;
_directories = [.. directories];
Root = rootDirectory;

Init();
Expand Down Expand Up @@ -66,6 +66,37 @@ public string GetPath(string directoryType)
return path;
}

/// <summary>
/// Adds a managed directory type and creates it immediately (snake_case under the root).
/// Idempotent: registering the same type again returns the existing path.
/// </summary>
/// <param name="directoryType">The directory type name.</param>
/// <returns>The full path of the created directory.</returns>
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);
}

/// <summary>
/// Adds a managed directory type from an enum value and creates it immediately.
/// </summary>
/// <typeparam name="TEnum">The directory type enum.</typeparam>
/// <param name="value">The directory type value.</param>
/// <returns>The full path of the created directory.</returns>
public string RegisterDirectory<TEnum>(TEnum value) where TEnum : struct, Enum
=> RegisterDirectory(Enum.GetName(value)!);

/// <summary>
/// Returns a string representation of the root directory.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using DryIoc;
using SquidStd.Core.Interfaces.Serialization;

namespace SquidStd.Persistence.MessagePack.Extensions;

/// <summary>
/// DryIoc registration helpers for the MessagePack data serializer.
/// </summary>
public static class MessagePackRegistrationExtensions
{
/// <param name="container">Container that receives the registrations.</param>
extension(IContainer container)
{
/// <summary>
/// Registers the MessagePack serializer for <see cref="IDataSerializer" /> and
/// <see cref="IDataDeserializer" /> (same singleton instance). Existing registrations
/// are kept.
/// </summary>
/// <returns>The same container for chaining.</returns>
public IContainer RegisterMessagePackSerializer()
{
var serializer = new MessagePackDataSerializer();
container.RegisterInstance<IDataSerializer>(serializer, IfAlreadyRegistered.Keep);
container.RegisterInstance<IDataDeserializer>(serializer, IfAlreadyRegistered.Keep);

return container;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Registers persisted entity types for descriptor construction at bootstrap.</summary>
/// <summary>
/// Registers persisted entity types for descriptor construction at bootstrap, and wires the
/// persistence stack itself - entity registry, journal, snapshot service, and the
/// <see cref="IPersistenceService" /> lifecycle service.
/// </summary>
public static class PersistenceRegistrationExtensions
{
extension(IContainer container)
Expand Down Expand Up @@ -62,5 +71,91 @@

return container;
}

/// <summary>
/// Registers the whole persistence stack: entity registry (with the recorded
/// <see cref="RegisterPersistedEntity{TEntity,TKey}" /> registrations applied on first
/// resolution), journal, snapshot service, and <see cref="IPersistenceService" /> as a
/// lifecycle service (snapshot load and journal replay at start, autosave loop, final
/// snapshot at stop). Requires a registered <see cref="IDataSerializer" />. When
/// <paramref name="config" /> is null the "persistence" YAML section is bound; the
/// save directory defaults to the managed "save" directory under the root.
/// Do not call <see cref="ApplyPersistedEntityRegistrations" /> manually when using
/// this registration.
/// </summary>
/// <param name="config">Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section.</param>
/// <returns>The same container for chaining.</returns>
public IContainer RegisterPersistence(PersistenceConfig? config = null)
{
if (!container.IsRegistered<IDataSerializer>())
{
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<IPersistenceEntityRegistry, PersistenceEntityRegistry>(Reuse.Singleton);
container.RegisterInitializer<IPersistenceEntityRegistry>(
static (registry, resolver) =>
{
var registrations = resolver.Resolve<List<PersistedEntityRegistration>>(IfUnresolved.ReturnDefault);

if (registrations is null)
{
return;
}

for (var i = 0; i < registrations.Count; i++)
{
registrations[i].Register(registry, resolver);
}
}
);

container.RegisterDelegate<IJournalService>(
static resolver =>
{
var effective = resolver.Resolve<PersistenceConfig>();

return new BinaryJournalService(
Path.Combine(ResolveSaveDirectory(resolver, effective), effective.JournalFileName),

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
effective.DurabilityMode,
effective.EnableFileLock
);
},
Reuse.Singleton
);

container.RegisterDelegate<ISnapshotService>(
static resolver =>
{
var effective = resolver.Resolve<PersistenceConfig>();

return new SnapshotService(
ResolveSaveDirectory(resolver, effective),
effective.SnapshotFileSuffix,
effective.DurabilityMode
);
},
Reuse.Singleton
);

return container.RegisterStdService<IPersistenceService, PersistenceService>(-1);
}
}

private static string ResolveSaveDirectory(IResolverContext resolver, PersistenceConfig config)
=> !string.IsNullOrWhiteSpace(config.SaveDirectory)
? config.SaveDirectory
: resolver.Resolve<DirectoriesConfig>().RegisterDirectory("save");
}
44 changes: 42 additions & 2 deletions src/SquidStd.Persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Player, int>(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<Player, int>(1, "Player", 1, p => p.Id);
return c;
});

// after StartAsync: snapshot loaded, journal replayed, autosave running
var players = bootstrap.Resolve<IPersistenceService>().GetStore<Player, int>();
```

- **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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="ISquidStdPlugin.Configure" /> 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 <see cref="Exceptions.PluginLoadException" /> 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
Expand Down
11 changes: 6 additions & 5 deletions src/SquidStd.Plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DirectoriesConfig>();

foreach (var directory in Options.Directories)
{
directoriesConfig.RegisterDirectory(directory);
}
}
}

/// <inheritdoc />
Expand Down
Loading
Loading