diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs index 4595c93..d8415f5 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs @@ -23,17 +23,31 @@ public static class LocalDbServiceCollectionExtensions IConfiguration configuration, Action? onReady = null) { + // Idempotent: a second call must not re-bind options or capture a second onReady closure. + if (services.Any(d => d.ServiceType == typeof(ILocalDb))) + return services; + services.AddOptions() .Bind(configuration.GetSection("LocalDb")) .ValidateOnStart(); services.TryAddEnumerable( ServiceDescriptor.Singleton, LocalDbOptionsValidator>()); - services.TryAddSingleton(sp => + services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var db = new SqliteLocalDb(options); - onReady?.Invoke(db); + try + { + onReady?.Invoke(db); + } + catch + { + // A throwing callback must not leak the open master connection: MS.DI does not + // dispose instances a failed factory never returned. + db.Dispose(); + throw; + } return db; }); diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs index 0f4594f..122171e 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs @@ -14,9 +14,6 @@ namespace ZB.MOM.WW.LocalDb.Internal; /// internal sealed class SqliteLocalDb : ILocalDb, IDisposable { - // Allow-list because the value is interpolated into a PRAGMA statement (pragmas cannot be parameterized). - private static readonly string[] AllowedSynchronous = ["OFF", "NORMAL", "FULL", "EXTRA"]; - // Gates every registered table name: it is interpolated into PRAGMA table_info and the trigger DDL. private static readonly Regex TableNameRegex = new("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled); @@ -45,10 +42,15 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable if (string.IsNullOrWhiteSpace(options.Path)) throw new ArgumentException("LocalDbOptions.Path is required.", nameof(options)); - _synchronous = Array.Find(AllowedSynchronous, + _synchronous = Array.Find(LocalDbOptionsValidator.AllowedSynchronous, s => s.Equals(options.Synchronous, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException( - $"LocalDbOptions.Synchronous must be one of {string.Join("/", AllowedSynchronous)}; got '{options.Synchronous}'.", + $"LocalDbOptions.Synchronous must be one of {string.Join("/", LocalDbOptionsValidator.AllowedSynchronous)}; got '{options.Synchronous}'.", + nameof(options)); + + if (options.BusyTimeoutMs <= 0) + throw new ArgumentException( + $"LocalDbOptions.BusyTimeoutMs must be greater than 0; got {options.BusyTimeoutMs}.", nameof(options)); _busyTimeoutMs = options.BusyTimeoutMs; _connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString(); @@ -229,9 +231,9 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable // it is IEnumerable but a legitimate reflection-fallback target is never a bare string anyway. case System.Collections.IEnumerable when parameters is not string: throw new ArgumentException( - "Unsupported parameters type: pass an anonymous object / POCO (public properties bind as @Name) " + - "or an IDictionary (entries bind as @Key). A Dictionary, Hashtable, " + - "array, or other collection is not supported.", + $"Unsupported parameters type '{parameters.GetType()}': pass an anonymous object / POCO " + + "(public properties bind as @Name) or an IDictionary (entries bind as @Key). " + + "A Dictionary, Hashtable, array, or other collection is not supported.", nameof(parameters)); default: foreach (var prop in PropertiesOf(parameters.GetType())) diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs index fd6b238..f4bf7a9 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs @@ -9,8 +9,9 @@ namespace ZB.MOM.WW.LocalDb; /// public sealed class LocalDbOptionsValidator : IValidateOptions { - // Interpolated into a PRAGMA statement by the host (pragmas cannot be parameterized), so it is allow-listed. - private static readonly string[] AllowedSynchronous = ["OFF", "NORMAL", "FULL", "EXTRA"]; + // Interpolated into a PRAGMA statement by the host (pragmas cannot be parameterized), so it is + // allow-listed. Shared with the SqliteLocalDb constructor so both enforce the identical set. + internal static readonly string[] AllowedSynchronous = ["OFF", "NORMAL", "FULL", "EXTRA"]; public ValidateOptionsResult Validate(string? name, LocalDbOptions options) { diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs index caf65d8..eb0922d 100644 --- a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs @@ -56,6 +56,21 @@ public sealed class DependencyInjectionTests : IDisposable Assert.Contains("widget", db.ReplicatedTables.Keys); } + [Fact] + public void AddZbLocalDb_OnReadyThrows_ExceptionPropagates() + { + var services = new ServiceCollection(); + services.AddZbLocalDb(Config(), _ => throw new InvalidOperationException("boom")); + using var provider = services.BuildServiceProvider(); + + var first = Assert.Throws(() => provider.GetRequiredService()); + Assert.Equal("boom", first.Message); + + // No half-initialized singleton is cached: the next resolution re-runs the factory and fails again. + var second = Assert.Throws(() => provider.GetRequiredService()); + Assert.Equal("boom", second.Message); + } + [Fact] public void Validator_EmptyPath_Fails() { diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SqliteLocalDbTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SqliteLocalDbTests.cs index 572ac1d..ec8a855 100644 --- a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SqliteLocalDbTests.cs +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SqliteLocalDbTests.cs @@ -141,6 +141,13 @@ public sealed class SqliteLocalDbTests : IDisposable Assert.Throws(() => new SqliteLocalDb(new LocalDbOptions { Path = " " })); } + [Fact] + public void Ctor_NonPositiveBusyTimeout_Throws() + { + Assert.Throws(() => + new SqliteLocalDb(new LocalDbOptions { Path = _path, BusyTimeoutMs = 0 })); + } + [Fact] public void Ctor_InvalidSynchronous_Throws() { diff --git a/docs/plans/2026-07-17-localdb-implementation.md b/docs/plans/2026-07-17-localdb-implementation.md index e515734..6e3392e 100644 --- a/docs/plans/2026-07-17-localdb-implementation.md +++ b/docs/plans/2026-07-17-localdb-implementation.md @@ -294,6 +294,8 @@ END; **Steps (TDD):** tests: `AddZbLocalDb_ResolvesILocalDb_Singleton`, `AddZbLocalDb_WithRegistrations_TablesRegisteredOnStart`, `Validator_EmptyPath_Fails`, `Validator_NonPositiveBusyTimeout_Fails`. Implement `AddZbLocalDb(IConfiguration config, Action? register = null)`: binds `LocalDb` section, `IValidateOptions` validator (plain `IValidateOptions` — do NOT take a package dep on `ZB.MOM.WW.Configuration`; keep this lib dependency-light like Audit), registers `SqliteLocalDb` as singleton `ILocalDb`; the builder collects table names, applied at first resolution. Commit `feat(localdb): options validation + AddZbLocalDb DI`. +> **Shipped deviation:** the registration callback shipped as a plain `Action? onReady` (invoked once at first resolution, where consumers create tables + call `RegisterReplicated`) instead of the planned `Action` — the builder indirection added no value over handing consumers the `ILocalDb` directly. + --- ### Task 7: Contracts — localdb_sync.v1 proto