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:
parent
46ba85dad0
commit
2ea8d67cb4
2 changed files with 169 additions and 26 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue