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_CascadeDeleteParent_Throws() { // The apply path writes remote rows with INSERT OR REPLACE, whose implicit delete fires // ON DELETE CASCADE with capture suppressed — the child rows would die on this node only. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync( "CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " + "order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)"); var ex = Assert.Throws(() => db.RegisterReplicated("orders")); Assert.Contains("orders", ex.Message); Assert.Contains("order_lines.order_id", ex.Message); Assert.Contains("ON DELETE CASCADE", ex.Message); // Refused, not half-registered. Assert.Empty(await Triggers(db)); Assert.False(db.ReplicatedTables.ContainsKey("orders")); } [Fact] public async Task Register_CascadeDeleteParent_IsWhatWouldActuallyDiverge() { // Proves the guard is guarding something real: with the parent NOT registered, applying a // replace to it silently wipes child rows that produce no oplog entry. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync( "CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " + "order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)"); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 1)"); await db.ExecuteAsync("INSERT INTO order_lines (id, order_id, sku) VALUES (10, 1, 'L')"); await db.ExecuteAsync("INSERT OR REPLACE INTO orders (id, sku, qty) VALUES (1, 'B', 2)"); var lines = await db.QueryAsync("SELECT COUNT(*) FROM order_lines", r => r.GetInt64(0)); Assert.Equal(0, lines[0]); } [Theory] [InlineData("SET NULL")] [InlineData("SET DEFAULT")] public async Task Register_RowMutatingOnDeleteParent_Throws(string action) { using var db = Create(); await CreateOrders(db); await db.ExecuteAsync( "CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " + $"order_id INTEGER DEFAULT 0 REFERENCES orders(id) ON DELETE {action}, sku TEXT)"); var ex = Assert.Throws(() => db.RegisterReplicated("orders")); Assert.Contains($"ON DELETE {action}", ex.Message); } [Fact] public async Task Register_NonMutatingOnDeleteParent_IsAllowed() { // RESTRICT/NO ACTION abort the statement instead of changing rows behind capture's back, // which the apply path already handles as per-entry poison. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync( "CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " + "order_id INTEGER REFERENCES orders(id) ON DELETE RESTRICT, sku TEXT)"); db.RegisterReplicated("orders"); Assert.Equal(3, (await Triggers(db)).Count); } [Fact] public async Task Register_SelfReferencingCascade_Throws() { // A table can be its own cascading parent: replacing a row would delete that row's // children in the SAME table, uncaptured. The guard reads every foreign key in the // database, so it does not need a special case — but the README promises this, so it is // pinned. using var db = Create(); await db.ExecuteAsync( "CREATE TABLE nodes (id INTEGER PRIMARY KEY, " + "parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE, name TEXT)"); var ex = Assert.Throws(() => db.RegisterReplicated("nodes")); Assert.Contains("nodes.parent_id", ex.Message); Assert.Contains("ON DELETE CASCADE", ex.Message); } [Fact] public async Task Register_CascadeDeleteChild_IsAllowed() { // Only the PARENT side is hazardous: replacing a child row deletes no other table's rows. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync( "CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " + "order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)"); var table = db.RegisterReplicated("order_lines"); Assert.Equal("order_lines", table.Name); } [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)); // ...and it is flagged for THIS TABLE ONLY. Through 0.2.x this set the one database-wide // bit, so baselining `orders` re-streamed every other registered table with it. Assert.False(await NeedsFullSnapshot(db)); } [Fact] public async Task Register_WithBaseline_FlagsOnlyTheBaselinedTable() { // The per-table flag's reason to exist: opting one table into replication late must not // schedule a full re-stream of its unrelated neighbours. using var db = Create(); await CreateOrders(db); await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)"); 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.RegisterReplicated("widgets"); db.RegisterReplicated("orders", baselineExistingRows: true); Assert.True(await NeedsSnapshot(db, "orders")); Assert.False(await NeedsSnapshot(db, "widgets")); Assert.False(await NeedsFullSnapshot(db)); } [Fact] public async Task Deregister_DropsThePendingSnapshotFlag() { // A flag naming a table nothing streams any more can never clear itself — the streamer skips // unregistered tables — so it would sit in the file and re-appear as unasked-for debt if the // table were registered again. 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); Assert.True(await NeedsSnapshot(db)); db.DeregisterReplicated("orders"); Assert.False(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); // Stand in for the snapshot having been delivered and acknowledged. await db.ExecuteAsync("DELETE FROM __localdb_snapshot_state"); 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)); } // ---- Deregistration and the HLC crash anchor ------------------------------------------ [Fact] public async Task Deregister_FlushesHlcAnchor_SoAnUngracefulReopenCannotRegress() { // The clock recovers as MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc), and // deregistering the only registered table empties both ledgers. Without the in-transaction // flush, an ungraceful exit right afterwards leaves hlc_high_water at 0 and the reopened // clock restarts from wall-clock time — below stamps the peer already holds. var crashed = Create(); try { await CreateOrders(crashed); crashed.RegisterReplicated("orders"); await crashed.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)"); // A peer HLC an hour ahead, merged as the applier would merge it: this is what makes the // clock outrun wall-clock time, and therefore what a wall-clock restart would regress. var peerHlc = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() << 16; crashed.Clock.Observe(peerHlc); var preDereg = crashed.Clock.Current; Assert.True(preDereg >= peerHlc); crashed.DeregisterReplicated("orders"); Assert.Equal(preDereg, await CountAsync(crashed, "SELECT hlc_high_water FROM __localdb_meta WHERE id = 1")); // The evidence the clock would otherwise have recovered from is gone. Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_oplog")); Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_row_version")); Assert.Equal(0, await CountAsync(crashed, "SELECT last_seen_hlc FROM __localdb_peer_state WHERE id = 1")); // Ungraceful reopen: a second instance over the same file while the first was NEVER // disposed, so nothing but the deregistration itself can have written the anchor. using var reopened = Create(); Assert.True(reopened.Clock.Current >= preDereg, $"recovered clock {reopened.Clock.Current} regressed below the pre-deregistration stamp {preDereg}"); Assert.True(reopened.Clock.Next() > preDereg); } finally { crashed.Dispose(); } } [Fact] public async Task Deregister_HlcFlush_DoesNotCountAsWorkDone() { // The flush is an UPDATE on every call, including the ones that clean up nothing; it must // not turn the "was there anything to do?" answer into a permanent true. using var db = Create(); await CreateOrders(db); Assert.False(db.DeregisterReplicated("orders")); Assert.False(db.DeregisterReplicated("ghost")); } private static async Task CountAsync(SqliteLocalDb db, string sql) => (await db.QueryAsync(sql, r => r.GetInt64(0)))[0]; /// Whether individually owes the peer a snapshot (schema v3). private static async Task NeedsSnapshot(SqliteLocalDb db, string table = "orders") => (await db.QueryAsync( "SELECT COUNT(*) FROM __localdb_snapshot_state WHERE table_name = @t AND needs_snapshot <> 0", r => r.GetInt64(0), new { t = table }))[0] != 0; /// /// The DATABASE-WIDE flag — "every registered table". Baselining must never set it: that is the /// whole point of the per-table flags, and setting it would restore the 0.2.x behaviour silently. /// private static async Task NeedsFullSnapshot(SqliteLocalDb db) => (await db.QueryAsync( "SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", r => r.GetInt64(0)))[0] != 0; }