diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 32224e6..f8ff1cc 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -666,7 +666,7 @@ class VarietyRepository { }) async { final varietyId = idGen.newId(); await _db.transaction(() async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.varieties) .insert( @@ -681,7 +681,7 @@ class VarietyRepository { ); if (quantity != null) { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.lots) .insert( @@ -700,7 +700,7 @@ class VarietyRepository { } if (photoBytes != null) { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.attachments) .insert( @@ -727,7 +727,7 @@ class VarietyRepository { Future addDraftVariety(Uint8List photoBytes) async { final varietyId = idGen.newId(); await _db.transaction(() async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.varieties) .insert( @@ -740,7 +740,7 @@ class VarietyRepository { isDraft: const Value(true), ), ); - final (createdA, updatedA) = _stamp(); + final (createdA, updatedA) = await _stamp(); await _db .into(_db.attachments) .insert( @@ -763,7 +763,7 @@ class VarietyRepository { /// Names a draft and promotes it out of the "to catalogue" tray: sets its /// [label] and clears [isDraft] in one LWW write. Future 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( VarietiesCompanion( label: Value(label), @@ -952,7 +952,7 @@ class VarietyRepository { /// Links [varietyId] to a catalog [speciesId]. If the variety has no category /// yet, prefill it from the species' botanical family (data-model §6). Future linkSpecies(String varietyId, String speciesId) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); final species = await (_db.select( _db.species, )..where((s) => s.id.equals(speciesId))).getSingleOrNull(); @@ -993,7 +993,7 @@ class VarietyRepository { Value fruitingMonths = const Value.absent(), Value seedHarvestMonths = const Value.absent(), }) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( VarietiesCompanion( label: label == null ? const Value.absent() : Value(label), @@ -1029,7 +1029,7 @@ class VarietyRepository { PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, }) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.lots) @@ -1074,7 +1074,7 @@ class VarietyRepository { PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, }) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( type: Value(type), @@ -1155,7 +1155,7 @@ class VarietyRepository { /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( isDeleted: const Value(true), @@ -1172,7 +1172,7 @@ class VarietyRepository { String? language, String? region, }) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.varietyVernacularNames) @@ -1193,7 +1193,7 @@ class VarietyRepository { /// Soft-deletes a vernacular name (tombstone). Future removeVernacularName(String nameId) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update( _db.varietyVernacularNames, )..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. Future addPhoto(String varietyId, Uint8List bytes) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.attachments) @@ -1229,7 +1229,7 @@ class VarietyRepository { /// Soft-deletes a photo (tombstone). Future removePhoto(String attachmentId) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update( _db.attachments, )..where((a) => a.id.equals(attachmentId))).write( @@ -1257,7 +1257,7 @@ class VarietyRepository { for (final a in siblings) { if (a.sortOrder < minOrder) minOrder = a.sortOrder; } - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update( _db.attachments, )..where((a) => a.id.equals(attachmentId))).write( @@ -1275,7 +1275,7 @@ class VarietyRepository { String url, { String? title, }) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.externalLinks) @@ -1296,7 +1296,7 @@ class VarietyRepository { /// Soft-deletes an external link (tombstone). Future removeExternalLink(String linkId) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update( _db.externalLinks, )..where((e) => e.id.equals(linkId))).write( @@ -1311,7 +1311,7 @@ class VarietyRepository { /// Soft-deletes a variety (tombstone); it disappears from the inventory but /// the row survives for correct CRDT merges later. Future softDeleteVariety(String id) async { - final (_, updated) = _stamp(); + final (_, updated) = await _stamp(); await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( VarietiesCompanion( isDeleted: const Value(true), @@ -1329,7 +1329,7 @@ class VarietyRepository { int? germinatedCount, String? notes, }) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.germinationTests) @@ -1358,7 +1358,7 @@ class VarietyRepository { DesiccantState? desiccantState, String? notes, }) async { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.conditionChecks) @@ -1537,7 +1537,7 @@ class VarietyRepository { for (final v in csv.varieties) { final speciesId = await _resolveSpeciesByName(v.scientificName); final varietyId = idGen.newId(); - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.varieties) .insert( @@ -1562,7 +1562,7 @@ class VarietyRepository { inserted++; for (final lot in v.lots) { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.lots) .insert( @@ -1590,7 +1590,7 @@ class VarietyRepository { } for (final name in v.vernacularNames) { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.varietyVernacularNames) .insert( @@ -1607,7 +1607,7 @@ class VarietyRepository { } for (final link in v.links) { - final (created, updated) = _stamp(); + final (created, updated) = await _stamp(); await _db .into(_db.externalLinks) .insert( @@ -1767,9 +1767,65 @@ class VarietyRepository { QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew; /// 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(); _clock = _clock.localEvent(now); return (now, _clock.pack()); } + + Future? _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 _ensureClockSeeded() => + _clockSeeded ??= _seedClockFromStoredRows(); + + Future _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 _maxStoredHlc() async { + final tables = + < + ( + ResultSetImplementation, + GeneratedColumn, + ) + >[ + (_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); + } } diff --git a/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart b/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart index 418c72e..2c299fe 100644 --- a/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart +++ b/apps/app_seeds/test/data/export_import/inventory_round_trip_test.dart @@ -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 _lotStamp(AppDatabase db, String lotId) async { + final lot = await (db.select( + db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + return lot.updatedAt; +}