using Microsoft.Data.Sqlite; using ZB.MOM.WW.LocalDb.Internal; using ZB.MOM.WW.LocalDb.Registration; namespace ZB.MOM.WW.LocalDb.Tests; public sealed class RegistrationTests : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); public void Dispose() { SqliteConnection.ClearAllPools(); if (File.Exists(_path)) File.Delete(_path); } private SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path }); private static async Task CreateOrders(SqliteLocalDb db) => await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)"); private Task> Triggers(SqliteLocalDb db) => db.QueryAsync( "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='orders' ORDER BY name", r => r.GetString(0)); [Fact] public async Task Register_TableWithPk_InstallsThreeTriggers() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); var triggers = await Triggers(db); Assert.Equal( new[] { "__localdb_orders_ad", "__localdb_orders_ai", "__localdb_orders_au" }, triggers); } [Fact] public async Task Register_NoPk_Throws() { using var db = Create(); await db.ExecuteAsync("CREATE TABLE nopk (a TEXT, b TEXT)"); var ex = Assert.Throws(() => db.RegisterReplicated("nopk")); Assert.Contains("nopk", ex.Message); } [Fact] public void Register_MissingTable_Throws() { using var db = Create(); var ex = Assert.Throws(() => db.RegisterReplicated("ghost")); Assert.Contains("ghost", ex.Message); } [Fact] public async Task Register_BlobColumn_Throws() { using var db = Create(); await db.ExecuteAsync("CREATE TABLE blobby (id INTEGER PRIMARY KEY, data BLOB)"); var ex = Assert.Throws(() => db.RegisterReplicated("blobby")); Assert.Contains("blobby", ex.Message); Assert.Contains("data", ex.Message); Assert.Contains("BLOB", ex.Message); } [Fact] public async Task Register_IsIdempotent_RegeneratesTriggers() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); db.RegisterReplicated("orders"); var triggers = await Triggers(db); Assert.Equal(3, triggers.Count); } [Fact] public async Task Register_CompositePk_Works() { using var db = Create(); await db.ExecuteAsync("CREATE TABLE kv (a INTEGER, b INTEGER, v TEXT, PRIMARY KEY (a, b))"); db.RegisterReplicated("kv"); await db.ExecuteAsync("INSERT INTO kv (a, b, v) VALUES (1, 2, 'x')"); var pk = await db.QueryAsync( "SELECT pk_json FROM __localdb_oplog WHERE table_name='kv'", r => r.GetString(0)); Assert.Single(pk); Assert.Equal("{\"a\":1,\"b\":2}", pk[0]); } [Fact] public async Task Digest_ChangesWhenSchemaChanges() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); var before = db.ReplicatedTables["orders"].Digest; await db.ExecuteAsync("ALTER TABLE orders ADD COLUMN note TEXT"); db.RegisterReplicated("orders"); var after = db.ReplicatedTables["orders"].Digest; Assert.NotEqual(before, after); } [Fact] public async Task ReplicatedTables_ExposesRegistry() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); var table = db.ReplicatedTables["orders"]; Assert.Equal("orders", table.Name); Assert.Equal(new[] { "id" }, table.PkColumns); Assert.Equal(new[] { "id", "sku", "qty" }, table.Columns.Select(c => c.Name).ToArray()); } // ---- Deregistration ------------------------------------------------------------------ [Fact] public async Task Deregister_DropsTriggers_PrunesOplogAndLedger_LeavesDataAlone() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)"); var removed = db.DeregisterReplicated("orders"); Assert.True(removed); Assert.Empty(await Triggers(db)); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'")); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'")); Assert.False(db.ReplicatedTables.ContainsKey("orders")); // The consumer's own data is never touched — this removes CAPTURE, not rows. Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM orders")); } [Fact] public async Task Deregister_StopsCapture() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); db.DeregisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); await db.ExecuteAsync("UPDATE orders SET qty = 9 WHERE id = 1"); await db.ExecuteAsync("DELETE FROM orders WHERE id = 1"); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog")); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version")); } [Fact] public async Task Deregister_IsIdempotent_SecondCallReportsNothingToDo() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); Assert.True(db.DeregisterReplicated("orders")); Assert.False(db.DeregisterReplicated("orders")); Assert.False(db.DeregisterReplicated("orders")); Assert.Empty(await Triggers(db)); } [Fact] public async Task Deregister_NeverRegisteredTable_IsANoOp() { using var db = Create(); await CreateOrders(db); Assert.False(db.DeregisterReplicated("orders")); Assert.False(db.DeregisterReplicated("ghost")); } [Fact] public async Task Deregister_ClearsTriggersLeftByAnEarlierProcess() { // The primary use: a database file registered by an OLDER build, opened by one that has // decided this node does not replicate. Nothing in memory says the table was ever // registered — the evidence is entirely on disk. using (var first = Create()) { await CreateOrders(first); first.RegisterReplicated("orders"); await first.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); } SqliteConnection.ClearAllPools(); using var second = Create(); Assert.Equal(3, (await Triggers(second)).Count); Assert.False(second.ReplicatedTables.ContainsKey("orders")); Assert.True(second.DeregisterReplicated("orders")); Assert.Empty(await Triggers(second)); Assert.Equal(0, await CountAsync(second, "SELECT COUNT(*) FROM __localdb_oplog")); } [Fact] public async Task Deregister_LeavesOtherTablesCapturing() { using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)"); db.RegisterReplicated("orders"); db.RegisterReplicated("widgets"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W')"); db.DeregisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (2,'W2')"); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'")); Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='widgets'")); Assert.True(db.ReplicatedTables.ContainsKey("widgets")); } [Fact] public async Task Deregister_ThenRegisterWithBaseline_RestoresTheLedger() { // Pruning the ledger is only safe because re-registration can re-baseline; this is that // round trip. using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)"); db.DeregisterReplicated("orders"); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version")); db.RegisterReplicated("orders", baselineExistingRows: true); Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'")); } // ---- Baselining ---------------------------------------------------------------------- [Fact] public async Task Register_WithoutBaseline_LeavesPreexistingRowsUnledgered() { // The documented CDC contract, unchanged: opting in is what changes behaviour, not upgrading. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); db.RegisterReplicated("orders"); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version")); Assert.False(await NeedsSnapshot(db)); } [Fact] public async Task Register_WithBaseline_SeedsAtHlcZero_AndFlagsSnapshot() { using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)"); db.RegisterReplicated("orders", baselineExistingRows: true); var seeded = await db.QueryAsync( "SELECT pk_json, hlc, node_id, is_tombstone FROM __localdb_row_version ORDER BY pk_json", r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2), Tomb: r.GetInt64(3))); Assert.Equal(2, seeded.Count); Assert.Equal(["{\"id\":1}", "{\"id\":2}"], seeded.Select(s => s.Pk)); // HLC 0 is the floor: below every genuine stamp (physical ms << 16), so it can never beat one. Assert.All(seeded, s => Assert.Equal(0, s.Hlc)); Assert.All(seeded, s => Assert.Equal(db.NodeId, s.Node)); Assert.All(seeded, s => Assert.Equal(0, s.Tomb)); // Without the flag the seeding would be inert: no gap exists at the handshake, so the peer // would be told it needs no snapshot and the baselined rows would never travel. Assert.True(await NeedsSnapshot(db)); } [Fact] public async Task Register_WithBaseline_IsIdempotent_AndDoesNotReflagSnapshot() { using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); db.RegisterReplicated("orders", baselineExistingRows: true); await db.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = 0 WHERE id = 1"); db.RegisterReplicated("orders", baselineExistingRows: true); Assert.Equal(1, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version")); // Nothing new was seeded, so no resync is owed: passing true every boot is free after the first. Assert.False(await NeedsSnapshot(db)); } [Fact] public async Task Register_WithBaseline_NeverOverwritesACapturedVersion() { // The LWW-safety core: a row with a real, captured version must keep it. A floor that // clobbered genuine versions would make every baselined node look newer than its peer. using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); var captured = (await db.QueryAsync( "SELECT hlc FROM __localdb_row_version WHERE pk_json='{\"id\":1}'", r => r.GetInt64(0)))[0]; Assert.True(captured > 0); // A second, pre-existing row that capture never saw (inserted with triggers removed). db.DeregisterReplicated("orders"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2,'B',2)"); await db.ExecuteAsync( "INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone) " + "VALUES ('orders', '{\"id\":1}', @h, 'genuine', 0)", new { h = captured }); db.RegisterReplicated("orders", baselineExistingRows: true); var versions = await db.QueryAsync( "SELECT pk_json, hlc, node_id FROM __localdb_row_version ORDER BY pk_json", r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2))); Assert.Equal(2, versions.Count); Assert.Equal(captured, versions[0].Hlc); Assert.Equal("genuine", versions[0].Node); Assert.Equal(0, versions[1].Hlc); } [Fact] public async Task Register_WithBaseline_SeedsPkJsonIdenticalToWhatCaptureWrites() { // A seeded key that differs by so much as a space from the capture triggers' json_object // output would be a DIFFERENT ledger row, and the baseline would silently never merge. using var db = Create(); await db.ExecuteAsync("CREATE TABLE kv (a INTEGER, b TEXT, v TEXT, PRIMARY KEY (a, b))"); await db.ExecuteAsync("INSERT INTO kv (a, b, v) VALUES (1, 'x', 'seeded')"); db.RegisterReplicated("kv", baselineExistingRows: true); await db.ExecuteAsync("UPDATE kv SET v = 'captured' WHERE a = 1 AND b = 'x'"); // One ledger row, not two: the capture upsert landed on the seeded key. var rows = await db.QueryAsync( "SELECT pk_json, hlc FROM __localdb_row_version", r => (Pk: r.GetString(0), Hlc: r.GetInt64(1))); Assert.Single(rows); Assert.Equal("{\"a\":1,\"b\":\"x\"}", rows[0].Pk); Assert.True(rows[0].Hlc > 0, "the genuine capture must have replaced the floor"); } [Fact] public async Task Register_WithBaseline_FloorLosesToEveryGenuineRemoteWrite() { // Proved through the real LWW path rather than by reasoning about the comparison: an // absurdly small remote HLC of 1 still beats the baseline floor of 0. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'BASELINE',1)"); db.RegisterReplicated("orders", baselineExistingRows: true); var result = await new Replication.Internal.LwwApplier(db).ApplyBatchAsync( [new Replication.Internal.OplogEntryRecord( 1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"REMOTE\",\"qty\":9}", 1, "peer", false)], default); Assert.Equal(1, result.Applied); var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0)); Assert.Equal("REMOTE", sku[0]); } [Fact] public async Task Register_WithBaseline_TieBetweenTwoBaselinedNodes_ResolvesByNodeIdOrdinal() { // Two nodes that baselined the same key both hold HLC 0 — genuinely unordered history. The // node-id tie-break is what keeps that convergent instead of oscillating. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'MINE',1)"); db.RegisterReplicated("orders", baselineExistingRows: true); var applier = new Replication.Internal.LwwApplier(db); var lower = await applier.ApplyBatchAsync( [new Replication.Internal.OplogEntryRecord( 1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"LOWER\",\"qty\":9}", 0, "!" + db.NodeId, false)], default); Assert.Equal(1, lower.Discarded); var higher = await applier.ApplyBatchAsync( [new Replication.Internal.OplogEntryRecord( 2, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"HIGHER\",\"qty\":9}", 0, db.NodeId + "z", false)], default); Assert.Equal(1, higher.Applied); var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0)); Assert.Equal("HIGHER", sku[0]); } [Fact] public async Task Register_WithBaseline_EmptyTable_SeedsNothing_FlagsNothing() { using var db = Create(); await CreateOrders(db); db.RegisterReplicated("orders", baselineExistingRows: true); Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version")); Assert.False(await NeedsSnapshot(db)); } private static async Task CountAsync(SqliteLocalDb db, string sql) => (await db.QueryAsync(sql, r => r.GetInt64(0)))[0]; private static async Task NeedsSnapshot(SqliteLocalDb db) => (await db.QueryAsync( "SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", r => r.GetInt64(0)))[0] != 0; }