feat(inventory): CSV/JSON export and JSON import with LWW reconciliation

Interchange export/import for Phase 1 (data-model §7):

- JSON: canonical, versioned envelope (formatVersion 1) with all sync
  metadata verbatim, photos embedded as base64, tombstones excluded.
  Species are re-resolved on import by scientific name (catalog ids are
  per-install). Reader tolerates unknown fields/enum values (§5.2) and
  rejects newer format versions with a clear error.
- CSV: export-only spreadsheet flatten, one row per lot, RFC 4180
  escaping, never any photo bytes.
- Import merges by UUIDv7 id in one transaction: insert-if-unknown
  preserving original stamps, last-writer-wins by packed HLC for known
  mutable rows, append-only movements; afterwards the local clock
  receiveEvent()s the newest imported stamp so it never runs behind.
- Settings gains a Backup section (export CSV/JSON, import JSON with
  confirmation); file dialogs behind a FileService interface backed by
  file_picker (MIT).
- Tests: codec round-trip and tolerance, reconciler LWW, repository
  export→import round-trip (fresh DB, idempotent re-import, newer-local
  wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
vjrj 2026-07-09 12:46:53 +02:00
parent 136ed701a7
commit 2812c99280
25 changed files with 2207 additions and 7 deletions

View file

@ -5,6 +5,8 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders).
class VarietyListItem extends Equatable {
@ -826,6 +828,223 @@ class VarietyRepository {
return id;
}
/// Snapshots the live inventory (tombstones excluded) for the interchange
/// export data-model §7. Includes photo bytes; the JSON codec embeds them
/// as base64 and the CSV codec ignores them.
Future<InventorySnapshot> exportInventory() async {
final varieties = await (_db.select(
_db.varieties,
)..where((v) => v.isDeleted.equals(false))).get();
final speciesNamesById = await _scientificNamesFor(
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
);
return InventorySnapshot(
varieties: varieties,
speciesNamesById: speciesNamesById,
lots: await (_db.select(
_db.lots,
)..where((l) => l.isDeleted.equals(false))).get(),
vernacularNames: await (_db.select(
_db.varietyVernacularNames,
)..where((n) => n.isDeleted.equals(false))).get(),
externalLinks: await (_db.select(
_db.externalLinks,
)..where((e) => e.isDeleted.equals(false))).get(),
germinationTests: await (_db.select(
_db.germinationTests,
)..where((g) => g.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(),
parties: await (_db.select(
_db.parties,
)..where((p) => p.isDeleted.equals(false))).get(),
attachments: await (_db.select(
_db.attachments,
)..where((a) => a.isDeleted.equals(false))).get(),
);
}
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
/// unknown id insert preserving the original sync metadata; known mutable
/// id last-writer-wins on the packed HLC `updatedAt`; movements are
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
/// local clock absorbs the newest imported stamp so it never runs behind.
///
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
/// against the local catalog by scientific name (unmatched null).
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
const reconciler = ImportReconciler();
final localSpeciesIdByIncoming = await _resolveSpecies(
snapshot.speciesNamesById,
);
final varieties = [
for (final v in snapshot.varieties)
v.speciesId == null
? v
: v.copyWith(
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
),
];
var summary = const ImportSummary();
await _db.transaction(() async {
summary += await _importMutableRows(
table: _db.varieties,
idColumn: _db.varieties.id,
rows: varieties,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.lots,
idColumn: _db.lots.id,
rows: snapshot.lots,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.varietyVernacularNames,
idColumn: _db.varietyVernacularNames.id,
rows: snapshot.vernacularNames,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.externalLinks,
idColumn: _db.externalLinks.id,
rows: snapshot.externalLinks,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.germinationTests,
idColumn: _db.germinationTests.id,
rows: snapshot.germinationTests,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.parties,
idColumn: _db.parties.id,
rows: snapshot.parties,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.attachments,
idColumn: _db.attachments.id,
rows: snapshot.attachments,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMovements(snapshot.movements, reconciler);
});
final maxIncoming = reconciler.maxHlc([
for (final v in snapshot.varieties) v.updatedAt,
for (final l in snapshot.lots) l.updatedAt,
for (final n in snapshot.vernacularNames) n.updatedAt,
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
]);
if (maxIncoming != null) {
_clock = _clock.receiveEvent(maxIncoming, _now());
}
return summary;
}
/// Maps incoming species ids to local catalog ids by scientific name.
Future<Map<String, String?>> _resolveSpecies(
Map<String, String> speciesNamesById,
) async {
final resolved = <String, String?>{};
for (final entry in speciesNamesById.entries) {
final local =
await (_db.select(_db.species)
..where(
(s) =>
s.scientificName.equals(entry.value) &
s.isDeleted.equals(false),
))
.getSingleOrNull();
resolved[entry.key] = local?.id;
}
return resolved;
}
/// LWW import of one mutable table. Existing rows are looked up including
/// tombstones, so a locally-deleted row only resurrects when the incoming
/// version is strictly newer plain last-writer-wins.
Future<ImportSummary> _importMutableRows<T extends Table, R>({
required TableInfo<T, R> table,
required GeneratedColumn<String> idColumn,
required List<R> rows,
required String Function(R) idOf,
required String Function(R) updatedAtOf,
required ImportReconciler reconciler,
}) async {
if (rows.isEmpty) return const ImportSummary();
final byId = {for (final r in rows) idOf(r): r};
final existing = await (_db.select(
table,
)..where((_) => idColumn.isIn(byId.keys))).get();
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
var inserted = 0, updated = 0, skipped = 0;
for (final row in byId.values) {
final action = reconciler.reconcileMutable(
existingUpdatedAt: existingUpdatedAt[idOf(row)],
incomingUpdatedAt: updatedAtOf(row),
);
switch (action) {
case ReconcileAction.insert:
await _db.into(table).insert(row as Insertable<R>);
inserted++;
case ReconcileAction.update:
await _db.update(table).replace(row as Insertable<R>);
updated++;
case ReconcileAction.skip:
skipped++;
}
}
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
}
/// Movements are append-only: insert unknown ids, never touch known ones.
Future<ImportSummary> _importMovements(
List<Movement> movements,
ImportReconciler reconciler,
) async {
if (movements.isEmpty) return const ImportSummary();
final ids = movements.map((m) => m.id).toList();
final existing = await (_db.select(
_db.movements,
)..where((m) => m.id.isIn(ids))).get();
final existingIds = existing.map((m) => m.id).toSet();
var inserted = 0, skipped = 0;
for (final movement in movements) {
final action = reconciler.reconcileAppendOnly(
exists: existingIds.contains(movement.id),
);
if (action == ReconcileAction.insert) {
await _db.into(_db.movements).insert(movement);
inserted++;
} else {
skipped++;
}
}
return ImportSummary(inserted: inserted, skipped: skipped);
}
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
final hasQuantity =
l.quantityKind != null ||