fix(sync): re-seed HLC clock from stored rows so local edits win LWW

The repository clock started fresh at Hlc.zero on every launch and was
never re-seeded from the database. After a restart, if the device wall
clock lagged behind stamps already stored (e.g. rows imported from a
peer whose clock ran ahead), a local edit was stamped *older* than the
row it edited and silently lost last-writer-wins — so lot changes made
on one device did not update when the backup was imported on another.

Re-seed the clock lazily from the newest stored `updatedAt` before the
first local stamp of the session, mirroring what importInventory already
does with the incoming max. _stamp() is now async.

Tests: add a lot-level LWW re-import test (the variety case was covered,
the lot case was not) and a restart-regression test that reproduces the
lagging-wall-clock scenario.
This commit is contained in:
vjrj 2026-07-10 02:03:59 +02:00
parent 46ba85dad0
commit 2ea8d67cb4
2 changed files with 169 additions and 26 deletions

View file

@ -666,7 +666,7 @@ class VarietyRepository {
}) async { }) async {
final varietyId = idGen.newId(); final varietyId = idGen.newId();
await _db.transaction(() async { await _db.transaction(() async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.varieties) .into(_db.varieties)
.insert( .insert(
@ -681,7 +681,7 @@ class VarietyRepository {
); );
if (quantity != null) { if (quantity != null) {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.lots) .into(_db.lots)
.insert( .insert(
@ -700,7 +700,7 @@ class VarietyRepository {
} }
if (photoBytes != null) { if (photoBytes != null) {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.attachments) .into(_db.attachments)
.insert( .insert(
@ -727,7 +727,7 @@ class VarietyRepository {
Future<String> addDraftVariety(Uint8List photoBytes) async { Future<String> addDraftVariety(Uint8List photoBytes) async {
final varietyId = idGen.newId(); final varietyId = idGen.newId();
await _db.transaction(() async { await _db.transaction(() async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.varieties) .into(_db.varieties)
.insert( .insert(
@ -740,7 +740,7 @@ class VarietyRepository {
isDraft: const Value(true), isDraft: const Value(true),
), ),
); );
final (createdA, updatedA) = _stamp(); final (createdA, updatedA) = await _stamp();
await _db await _db
.into(_db.attachments) .into(_db.attachments)
.insert( .insert(
@ -763,7 +763,7 @@ class VarietyRepository {
/// Names a draft and promotes it out of the "to catalogue" tray: sets its /// Names a draft and promotes it out of the "to catalogue" tray: sets its
/// [label] and clears [isDraft] in one LWW write. /// [label] and clears [isDraft] in one LWW write.
Future<void> nameDraft(String id, String label) async { Future<void> nameDraft(String id, String label) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion( VarietiesCompanion(
label: Value(label), label: Value(label),
@ -952,7 +952,7 @@ class VarietyRepository {
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category /// Links [varietyId] to a catalog [speciesId]. If the variety has no category
/// yet, prefill it from the species' botanical family (data-model §6). /// yet, prefill it from the species' botanical family (data-model §6).
Future<void> linkSpecies(String varietyId, String speciesId) async { Future<void> linkSpecies(String varietyId, String speciesId) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
final species = await (_db.select( final species = await (_db.select(
_db.species, _db.species,
)..where((s) => s.id.equals(speciesId))).getSingleOrNull(); )..where((s) => s.id.equals(speciesId))).getSingleOrNull();
@ -993,7 +993,7 @@ class VarietyRepository {
Value<int?> fruitingMonths = const Value.absent(), Value<int?> fruitingMonths = const Value.absent(),
Value<int?> seedHarvestMonths = const Value.absent(), Value<int?> seedHarvestMonths = const Value.absent(),
}) async { }) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion( VarietiesCompanion(
label: label == null ? const Value.absent() : Value(label), label: label == null ? const Value.absent() : Value(label),
@ -1029,7 +1029,7 @@ class VarietyRepository {
PreservationFormat? preservationFormat, PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private, OfferStatus offerStatus = OfferStatus.private,
}) async { }) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.lots) .into(_db.lots)
@ -1074,7 +1074,7 @@ class VarietyRepository {
PreservationFormat? preservationFormat, PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private, OfferStatus offerStatus = OfferStatus.private,
}) async { }) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion( LotsCompanion(
type: Value(type), type: Value(type),
@ -1155,7 +1155,7 @@ class VarietyRepository {
/// Soft-deletes a lot (tombstone); it leaves the variety's lot list. /// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
Future<void> softDeleteLot(String lotId) async { Future<void> softDeleteLot(String lotId) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion( LotsCompanion(
isDeleted: const Value(true), isDeleted: const Value(true),
@ -1172,7 +1172,7 @@ class VarietyRepository {
String? language, String? language,
String? region, String? region,
}) async { }) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.varietyVernacularNames) .into(_db.varietyVernacularNames)
@ -1193,7 +1193,7 @@ class VarietyRepository {
/// Soft-deletes a vernacular name (tombstone). /// Soft-deletes a vernacular name (tombstone).
Future<void> removeVernacularName(String nameId) async { Future<void> removeVernacularName(String nameId) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update( await (_db.update(
_db.varietyVernacularNames, _db.varietyVernacularNames,
)..where((n) => n.id.equals(nameId))).write( )..where((n) => n.id.equals(nameId))).write(
@ -1207,7 +1207,7 @@ class VarietyRepository {
/// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id. /// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id.
Future<String> addPhoto(String varietyId, Uint8List bytes) async { Future<String> addPhoto(String varietyId, Uint8List bytes) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.attachments) .into(_db.attachments)
@ -1229,7 +1229,7 @@ class VarietyRepository {
/// Soft-deletes a photo (tombstone). /// Soft-deletes a photo (tombstone).
Future<void> removePhoto(String attachmentId) async { Future<void> removePhoto(String attachmentId) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update( await (_db.update(
_db.attachments, _db.attachments,
)..where((a) => a.id.equals(attachmentId))).write( )..where((a) => a.id.equals(attachmentId))).write(
@ -1257,7 +1257,7 @@ class VarietyRepository {
for (final a in siblings) { for (final a in siblings) {
if (a.sortOrder < minOrder) minOrder = a.sortOrder; if (a.sortOrder < minOrder) minOrder = a.sortOrder;
} }
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update( await (_db.update(
_db.attachments, _db.attachments,
)..where((a) => a.id.equals(attachmentId))).write( )..where((a) => a.id.equals(attachmentId))).write(
@ -1275,7 +1275,7 @@ class VarietyRepository {
String url, { String url, {
String? title, String? title,
}) async { }) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.externalLinks) .into(_db.externalLinks)
@ -1296,7 +1296,7 @@ class VarietyRepository {
/// Soft-deletes an external link (tombstone). /// Soft-deletes an external link (tombstone).
Future<void> removeExternalLink(String linkId) async { Future<void> removeExternalLink(String linkId) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update( await (_db.update(
_db.externalLinks, _db.externalLinks,
)..where((e) => e.id.equals(linkId))).write( )..where((e) => e.id.equals(linkId))).write(
@ -1311,7 +1311,7 @@ class VarietyRepository {
/// Soft-deletes a variety (tombstone); it disappears from the inventory but /// Soft-deletes a variety (tombstone); it disappears from the inventory but
/// the row survives for correct CRDT merges later. /// the row survives for correct CRDT merges later.
Future<void> softDeleteVariety(String id) async { Future<void> softDeleteVariety(String id) async {
final (_, updated) = _stamp(); final (_, updated) = await _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion( VarietiesCompanion(
isDeleted: const Value(true), isDeleted: const Value(true),
@ -1329,7 +1329,7 @@ class VarietyRepository {
int? germinatedCount, int? germinatedCount,
String? notes, String? notes,
}) async { }) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.germinationTests) .into(_db.germinationTests)
@ -1358,7 +1358,7 @@ class VarietyRepository {
DesiccantState? desiccantState, DesiccantState? desiccantState,
String? notes, String? notes,
}) async { }) async {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
final id = idGen.newId(); final id = idGen.newId();
await _db await _db
.into(_db.conditionChecks) .into(_db.conditionChecks)
@ -1537,7 +1537,7 @@ class VarietyRepository {
for (final v in csv.varieties) { for (final v in csv.varieties) {
final speciesId = await _resolveSpeciesByName(v.scientificName); final speciesId = await _resolveSpeciesByName(v.scientificName);
final varietyId = idGen.newId(); final varietyId = idGen.newId();
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.varieties) .into(_db.varieties)
.insert( .insert(
@ -1562,7 +1562,7 @@ class VarietyRepository {
inserted++; inserted++;
for (final lot in v.lots) { for (final lot in v.lots) {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.lots) .into(_db.lots)
.insert( .insert(
@ -1590,7 +1590,7 @@ class VarietyRepository {
} }
for (final name in v.vernacularNames) { for (final name in v.vernacularNames) {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.varietyVernacularNames) .into(_db.varietyVernacularNames)
.insert( .insert(
@ -1607,7 +1607,7 @@ class VarietyRepository {
} }
for (final link in v.links) { for (final link in v.links) {
final (created, updated) = _stamp(); final (created, updated) = await _stamp();
await _db await _db
.into(_db.externalLinks) .into(_db.externalLinks)
.insert( .insert(
@ -1767,9 +1767,65 @@ class VarietyRepository {
QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew; QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`. /// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
(int, String) _stamp() { ///
/// Before the first stamp of the session the clock is re-seeded from the
/// newest stamp already stored in the DB (see [_ensureClockSeeded]). The
/// in-memory clock starts fresh at [Hlc.zero] every app launch, so without
/// this a local edit could be stamped *older* than a row written in an
/// earlier session or imported from a peer whose wall clock ran ahead and
/// silently lose last-writer-wins when the backup is shared back.
Future<(int, String)> _stamp() async {
await _ensureClockSeeded();
final now = _now(); final now = _now();
_clock = _clock.localEvent(now); _clock = _clock.localEvent(now);
return (now, _clock.pack()); return (now, _clock.pack());
} }
Future<void>? _clockSeeded;
/// Absorbs the newest stored stamp into the local clock exactly once per
/// repository instance, so it never runs behind data the DB already holds.
Future<void> _ensureClockSeeded() =>
_clockSeeded ??= _seedClockFromStoredRows();
Future<void> _seedClockFromStoredRows() async {
final maxStored = await _maxStoredHlc();
if (maxStored != null) {
_clock = _clock.receiveEvent(maxStored, _now());
}
}
/// The newest packed HLC `updatedAt` across every mutable table, or null when
/// the inventory is empty. Packed stamps are fixed-width and
/// lexicographically sortable, so SQL `MAX` orders them exactly like
/// [Hlc.compareTo].
Future<Hlc?> _maxStoredHlc() async {
final tables =
<
(
ResultSetImplementation<HasResultSet, dynamic>,
GeneratedColumn<String>,
)
>[
(_db.varieties, _db.varieties.updatedAt),
(_db.lots, _db.lots.updatedAt),
(_db.varietyVernacularNames, _db.varietyVernacularNames.updatedAt),
(_db.externalLinks, _db.externalLinks.updatedAt),
(_db.germinationTests, _db.germinationTests.updatedAt),
(_db.conditionChecks, _db.conditionChecks.updatedAt),
(_db.parties, _db.parties.updatedAt),
(_db.attachments, _db.attachments.updatedAt),
];
String? maxPacked;
for (final (table, column) in tables) {
final max = column.max();
final query = _db.selectOnly(table)..addColumns([max]);
final packed = (await query.getSingleOrNull())?.read(max);
if (packed != null &&
(maxPacked == null || packed.compareTo(maxPacked) > 0)) {
maxPacked = packed;
}
}
return maxPacked == null ? null : Hlc.parse(maxPacked);
}
} }

View file

@ -210,6 +210,37 @@ void main() {
expect(variety.lastAuthor, 'node-other'); expect(variety.lastAuthor, 'node-other');
}); });
test('import overwrites a lot edited elsewhere (LWW)', () async {
final ids = await populate();
// Fork: a second device imports this inventory, edits the lot
final json = codec.encode(await source.exportInventory());
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final other = newTestRepository(otherDb, nodeId: 'node-other');
await other.importInventory(codec.decode(json));
await other.updateLot(
lotId: ids.lotId,
type: LotType.seed,
harvestYear: 2025,
quantity: const Quantity(kind: QuantityKind.cob, count: 9),
storageLocation: 'cellar',
);
// and its export comes back: the newer edit wins here.
final back = codec.encode(await other.exportInventory());
final summary = await source.importInventory(codec.decode(back));
expect(summary.updated, greaterThan(0));
final lot = await (sourceDb.select(
sourceDb.lots,
)..where((l) => l.id.equals(ids.lotId))).getSingle();
expect(lot.harvestYear, 2025);
expect(lot.quantityPrecise, 9);
expect(lot.storageLocation, 'cellar');
expect(lot.lastAuthor, 'node-other');
});
test('after an import the local clock never stamps behind it', () async { test('after an import the local clock never stamps behind it', () async {
await populate(); await populate();
final json = codec.encode(await source.exportInventory()); final json = codec.encode(await source.exportInventory());
@ -236,6 +267,54 @@ void main() {
expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0)); expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0));
}); });
test('a restarted repository re-seeds its clock from stored rows so local '
'edits still win LWW (survives a lagging wall clock)', () async {
// A peer device whose wall clock runs far ahead writes the inventory.
final peerDb = newTestDatabase();
addTearDown(peerDb.close);
final peer = VarietyRepository(
peerDb,
idGen: IdGen(),
nodeId: 'peer',
nowMillis: () => 9000000000000, // ~year 2255
);
final varietyId = await peer.addQuickVariety(label: 'Maize');
final lotId = await peer.addLot(varietyId: varietyId, harvestYear: 2024);
final json = codec.encode(await peer.exportInventory());
// Our device (wall clock in the real, much earlier present) imports it.
final myDb = newTestDatabase();
addTearDown(myDb.close);
const myWall = 1000; // far behind the imported stamps
final importing = VarietyRepository(
myDb,
idGen: IdGen(),
nodeId: 'mine',
nowMillis: () => myWall,
);
await importing.importInventory(codec.decode(json));
// App restart: a brand-new repository instance over the same DB, whose
// in-memory clock starts fresh at Hlc.zero and whose wall clock lags.
final restarted = VarietyRepository(
myDb,
idGen: IdGen(),
nodeId: 'mine',
nowMillis: () => myWall,
);
final before = await _lotStamp(myDb, lotId);
await restarted.updateLot(
lotId: lotId,
type: LotType.seed,
harvestYear: 2030,
);
final after = await _lotStamp(myDb, lotId);
// The edit must out-stamp the imported row, or it would silently lose
// last-writer-wins when this device shares its backup back.
expect(Hlc.parse(after).compareTo(Hlc.parse(before)), greaterThan(0));
});
test('unmatched species leaves speciesId null instead of dangling', () async { test('unmatched species leaves speciesId null instead of dangling', () async {
await populate(); await populate();
final json = codec.encode(await source.exportInventory()); final json = codec.encode(await source.exportInventory());
@ -250,3 +329,11 @@ void main() {
expect(variety.speciesId, isNull); expect(variety.speciesId, isNull);
}); });
} }
/// The packed HLC `updatedAt` currently stored for [lotId].
Future<String> _lotStamp(AppDatabase db, String lotId) async {
final lot = await (db.select(
db.lots,
)..where((l) => l.id.equals(lotId))).getSingle();
return lot.updatedAt;
}