fix(localdb): capture-layer review fixes — BLOB fail-closed, identifier escaping, master lock, PK-change tombstone, digest canon

This commit is contained in:
Joseph Doherty
2026-07-17 21:30:45 -04:00
parent f4063f811f
commit df2518611f
5 changed files with 251 additions and 70 deletions
@@ -97,6 +97,63 @@ public sealed class CaptureTriggerTests : IDisposable
Assert.True(DateTimeOffset.TryParse(v.Utc, out _), $"tombstone_utc '{v.Utc}' should be ISO-8601");
}
[Fact]
public async Task Update_ChangingPk_TombstonesOldPk_CapturesNewPk()
{
using var db = await NewOrdersDb();
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'ABC', 5)");
await db.ExecuteAsync("UPDATE orders SET id = 2 WHERE id = 1");
var oplog = await Oplog(db);
Assert.Equal(3, oplog.Count); // insert + (tombstone old, new row)
var tomb = oplog[1];
Assert.Equal("{\"id\":1}", tomb.PkJson);
Assert.Null(tomb.RowJson);
Assert.Equal(1, tomb.IsTombstone);
var moved = oplog[2];
Assert.Equal("{\"id\":2}", moved.PkJson);
Assert.Equal("{\"id\":2,\"sku\":\"ABC\",\"qty\":5}", moved.RowJson);
Assert.Equal(0, moved.IsTombstone);
Assert.NotEqual(tomb.Hlc, moved.Hlc);
var rv = await db.QueryAsync(
"SELECT pk_json, hlc, is_tombstone FROM __localdb_row_version WHERE table_name='orders' ORDER BY pk_json",
r => (PkJson: r.GetString(0), Hlc: r.GetInt64(1), Tomb: r.GetInt64(2)));
Assert.Equal(2, rv.Count);
var oldRv = rv.Single(v => v.PkJson == "{\"id\":1}");
Assert.Equal(1, oldRv.Tomb);
Assert.Equal(tomb.Hlc, oldRv.Hlc);
var newRv = rv.Single(v => v.PkJson == "{\"id\":2}");
Assert.Equal(0, newRv.Tomb);
Assert.Equal(moved.Hlc, newRv.Hlc);
}
[Fact]
public async Task Update_NotChangingPk_NoSpuriousTombstone()
{
using var db = await NewOrdersDb();
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'ABC', 5)");
await db.ExecuteAsync("UPDATE orders SET qty = 9 WHERE id = 1");
var oplog = await Oplog(db);
Assert.Equal(2, oplog.Count);
Assert.All(oplog, o => Assert.Equal(0, o.IsTombstone));
var rv = await db.QueryAsync(
"SELECT pk_json, is_tombstone FROM __localdb_row_version WHERE table_name='orders'",
r => (PkJson: r.GetString(0), Tomb: r.GetInt64(1)));
var v = Assert.Single(rv);
Assert.Equal("{\"id\":1}", v.PkJson);
Assert.Equal(0, v.Tomb);
}
[Fact]
public async Task MultiRowStatement_OneOplogEntryPerRow_DistinctHlcs()
{
@@ -108,6 +165,53 @@ public sealed class CaptureTriggerTests : IDisposable
var oplog = await Oplog(db);
Assert.Equal(3, oplog.Count);
Assert.Equal(3, oplog.Select(o => o.Hlc).Distinct().Count());
var rv = await db.QueryAsync(
"SELECT pk_json, hlc FROM __localdb_row_version WHERE table_name='orders'",
r => (PkJson: r.GetString(0), Hlc: r.GetInt64(1)));
Assert.Equal(3, rv.Count);
foreach (var o in oplog)
Assert.Equal(o.Hlc, rv.Single(v => v.PkJson == o.PkJson).Hlc);
}
[Fact]
public async Task Capture_CoexistsWithConsumerTrigger()
{
using var db = await NewOrdersDb();
await db.ExecuteAsync("CREATE TABLE audit (id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER)");
await db.ExecuteAsync(
"CREATE TRIGGER consumer_ai AFTER INSERT ON orders BEGIN " +
"INSERT INTO audit (order_id) VALUES (NEW.id); END");
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'ABC', 5)");
var audit = await db.QueryAsync("SELECT order_id FROM audit", r => r.GetInt64(0));
Assert.Equal(1L, Assert.Single(audit));
var oplog = await Oplog(db);
var row = Assert.Single(oplog);
Assert.Equal("{\"id\":1}", row.PkJson);
Assert.Equal("{\"id\":1,\"sku\":\"ABC\",\"qty\":5}", row.RowJson);
var rvHlc = await db.QueryAsync(
"SELECT hlc FROM __localdb_row_version WHERE table_name='orders' AND pk_json='{\"id\":1}'",
r => r.GetInt64(0));
Assert.Equal(row.Hlc, Assert.Single(rvHlc));
}
[Fact]
public async Task Capture_ValuesWithQuotesAndUnicode()
{
using var db = await NewOrdersDb();
const string sku = "O'Brien \"α\" ☃ \\slash";
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, @sku, @qty)",
new { id = 1, sku, qty = 5 });
var roundTripped = await db.QueryAsync(
"SELECT json_extract(row_json, '$.sku') FROM __localdb_oplog WHERE table_name='orders'",
r => r.GetString(0));
Assert.Equal(sku, Assert.Single(roundTripped));
}
[Fact]
@@ -58,6 +58,18 @@ public sealed class RegistrationTests : IDisposable
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<LocalDbRegistrationException>(() => 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()
{