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 {
|
||||
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<String> 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<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(
|
||||
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<void> 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<int?> fruitingMonths = const Value.absent(),
|
||||
Value<int?> 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<void> 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<void> 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<String> 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<void> 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<void> 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<void> 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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue