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

@ -210,6 +210,37 @@ void main() {
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 {
await populate();
final json = codec.encode(await source.exportInventory());
@ -236,6 +267,54 @@ void main() {
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 {
await populate();
final json = codec.encode(await source.exportInventory());
@ -250,3 +329,11 @@ void main() {
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;
}