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

@ -0,0 +1,42 @@
import 'package:commons_core/commons_core.dart';
/// What to do with one incoming row when importing (data-model §7).
enum ReconcileAction { insert, update, skip }
/// Pure reconciliation rules for the JSON import. No database access the
/// repository queries existing rows and applies the returned action.
class ImportReconciler {
const ImportReconciler();
/// Last-writer-wins for mutable rows, keyed by the packed HLC `updatedAt`
/// (data-model §4): unknown id insert; known id the incoming row wins
/// only if its stamp is strictly newer, otherwise skip. Re-importing the
/// same file is therefore a no-op (idempotent).
ReconcileAction reconcileMutable({
required String? existingUpdatedAt,
required String incomingUpdatedAt,
}) {
if (existingUpdatedAt == null) return ReconcileAction.insert;
final incoming = Hlc.parse(incomingUpdatedAt);
final existing = Hlc.parse(existingUpdatedAt);
return incoming.compareTo(existing) > 0
? ReconcileAction.update
: ReconcileAction.skip;
}
/// Append-only rows (movements): insert when unknown, never update.
ReconcileAction reconcileAppendOnly({required bool exists}) =>
exists ? ReconcileAction.skip : ReconcileAction.insert;
/// The newest stamp among [packedHlcs], or null when empty. After an import
/// the local clock must `receiveEvent` this so it never falls behind the
/// imported rows (the next local write must out-stamp all of them).
Hlc? maxHlc(Iterable<String> packedHlcs) {
Hlc? max;
for (final packed in packedHlcs) {
final hlc = Hlc.parse(packed);
if (max == null || hlc.compareTo(max) > 0) max = hlc;
}
return max;
}
}

View file

@ -0,0 +1,144 @@
import '../../db/database.dart';
import '../../db/enums.dart';
import 'inventory_snapshot.dart';
/// Flattens an [InventorySnapshot] into a spreadsheet-friendly CSV one row
/// per lot, one row for a variety without lots. Export-only: the JSON format
/// is the canonical one for import (data-model §7). Never includes photo
/// bytes. Pure serialization no I/O, no database.
class InventoryCsvCodec {
const InventoryCsvCodec();
static const List<String> _header = [
'variety_id',
'variety_label',
'category',
'species_scientific_name',
'cultivar_name',
'variety_notes',
'vernacular_names',
'external_links',
'lot_id',
'lot_type',
'harvest_year',
'harvest_month',
'quantity_kind',
'quantity_precise',
'quantity_label',
'presentation',
'storage_location',
'offer_status',
'latest_germination_rate',
'created_at',
'updated_at',
];
String encode(InventorySnapshot snapshot) {
final lotsByVariety = <String, List<Lot>>{};
for (final lot in snapshot.lots) {
lotsByVariety.putIfAbsent(lot.varietyId, () => []).add(lot);
}
final namesByVariety = <String, List<String>>{};
for (final n in snapshot.vernacularNames) {
namesByVariety
.putIfAbsent(n.varietyId, () => [])
.add(n.language == null ? n.name : '${n.name} (${n.language})');
}
final linksByVariety = <String, List<String>>{};
for (final e in snapshot.externalLinks) {
if (e.parentType != ParentType.variety) continue;
linksByVariety
.putIfAbsent(e.parentId, () => [])
.add(e.title == null ? e.url : '${e.title}|${e.url}');
}
final latestRateByLot = _latestGerminationRates(snapshot.germinationTests);
final lines = <String>[_header.map(_escape).join(',')];
for (final v in snapshot.varieties) {
final varietyCells = [
v.id,
v.label,
v.category ?? '',
snapshot.speciesNamesById[v.speciesId] ?? '',
v.cultivarName ?? '',
v.notes ?? '',
(namesByVariety[v.id] ?? const []).join('; '),
(linksByVariety[v.id] ?? const []).join('; '),
];
final lots = lotsByVariety[v.id] ?? const <Lot>[];
if (lots.isEmpty) {
lines.add(
[
...varietyCells,
...List.filled(11, ''),
_isoDate(v.createdAt),
_hlcMillisIso(v.updatedAt),
].map(_escape).join(','),
);
continue;
}
for (final l in lots) {
lines.add(
[
...varietyCells,
l.id,
l.type.name,
l.harvestYear?.toString() ?? '',
l.harvestMonth?.toString() ?? '',
l.quantityKind ?? '',
l.quantityPrecise?.toString() ?? '',
l.quantityLabel ?? '',
l.presentation?.name ?? '',
l.storageLocation ?? '',
l.offerStatus.name,
latestRateByLot[l.id]?.toStringAsFixed(2) ?? '',
_isoDate(l.createdAt),
_hlcMillisIso(l.updatedAt),
].map(_escape).join(','),
);
}
}
return '${lines.join('\r\n')}\r\n';
}
/// lotId latest germination rate (0..1), from the most recent test that
/// has a computable rate.
Map<String, double> _latestGerminationRates(List<GerminationTest> tests) {
final latest = <String, GerminationTest>{};
for (final t in tests) {
final sample = t.sampleSize;
if (sample == null || sample <= 0 || t.germinatedCount == null) continue;
final current = latest[t.lotId];
if (current == null || (t.testedOn ?? 0) > (current.testedOn ?? 0)) {
latest[t.lotId] = t;
}
}
return latest.map(
(lotId, t) => MapEntry(lotId, t.germinatedCount! / t.sampleSize!),
);
}
String _isoDate(int millisSinceEpoch) => DateTime.fromMillisecondsSinceEpoch(
millisSinceEpoch,
isUtc: true,
).toIso8601String();
/// Renders the physical-time part of a packed HLC as an ISO date; the raw
/// stamp stays in the JSON export, the CSV favours readability.
String _hlcMillisIso(String packedHlc) {
final millis = int.tryParse(packedHlc.split(':').first);
return millis == null ? '' : _isoDate(millis);
}
/// RFC 4180: quote fields containing separators/quotes/newlines, doubling
/// embedded quotes.
String _escape(String cell) {
if (!cell.contains(',') &&
!cell.contains('"') &&
!cell.contains('\n') &&
!cell.contains('\r')) {
return cell;
}
return '"${cell.replaceAll('"', '""')}"';
}
}

View file

@ -0,0 +1,411 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Uint8List;
import '../../db/database.dart';
import '../../db/enums.dart';
import 'inventory_snapshot.dart';
/// Encodes/decodes an [InventorySnapshot] to/from the versioned interchange
/// JSON (data-model §7). Pure serialization no I/O, no database.
///
/// Reader tolerance follows data-model §5.2: unknown JSON fields are ignored;
/// unknown enum values fall back to a safe default, or drop the row when no
/// default makes sense (e.g. an unknown movement type). Photo/doc bytes are
/// embedded as base64 the JSON export is the complete interchange backup.
///
/// Throws [FormatException] on malformed input or a `formatVersion` newer
/// than [inventoryFormatVersion].
class InventoryJsonCodec {
const InventoryJsonCodec();
String encode(InventorySnapshot snapshot) {
return const JsonEncoder.withIndent(' ').convert({
'formatVersion': inventoryFormatVersion,
'schemaVersion': AppDatabase.currentSchemaVersion,
'appId': inventoryAppId,
'varieties': [
for (final v in snapshot.varieties)
{
..._syncMeta(
id: v.id,
createdAt: v.createdAt,
updatedAt: v.updatedAt,
lastAuthor: v.lastAuthor,
schemaRowVersion: v.schemaRowVersion,
),
'label': v.label,
'speciesId': v.speciesId,
// Species ids are per-install; the scientific name is what an
// importer can re-resolve against its own bundled catalog.
'speciesScientificName': snapshot.speciesNamesById[v.speciesId],
'cultivarName': v.cultivarName,
'category': v.category,
'notes': v.notes,
},
],
'lots': [
for (final l in snapshot.lots)
{
..._syncMeta(
id: l.id,
createdAt: l.createdAt,
updatedAt: l.updatedAt,
lastAuthor: l.lastAuthor,
schemaRowVersion: l.schemaRowVersion,
),
'varietyId': l.varietyId,
'type': l.type.name,
'harvestYear': l.harvestYear,
'harvestMonth': l.harvestMonth,
'quantityKind': l.quantityKind,
'quantityPrecise': l.quantityPrecise,
'quantityLabel': l.quantityLabel,
'presentation': l.presentation?.name,
'storageLocation': l.storageLocation,
'offerStatus': l.offerStatus.name,
'seedbankId': l.seedbankId,
},
],
'vernacularNames': [
for (final n in snapshot.vernacularNames)
{
..._syncMeta(
id: n.id,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
lastAuthor: n.lastAuthor,
schemaRowVersion: n.schemaRowVersion,
),
'varietyId': n.varietyId,
'name': n.name,
'language': n.language,
'region': n.region,
},
],
'externalLinks': [
for (final e in snapshot.externalLinks)
{
..._syncMeta(
id: e.id,
createdAt: e.createdAt,
updatedAt: e.updatedAt,
lastAuthor: e.lastAuthor,
schemaRowVersion: e.schemaRowVersion,
),
'parentType': e.parentType.name,
'parentId': e.parentId,
'url': e.url,
'title': e.title,
},
],
'germinationTests': [
for (final g in snapshot.germinationTests)
{
..._syncMeta(
id: g.id,
createdAt: g.createdAt,
updatedAt: g.updatedAt,
lastAuthor: g.lastAuthor,
schemaRowVersion: g.schemaRowVersion,
),
'lotId': g.lotId,
'testedOn': g.testedOn,
'sampleSize': g.sampleSize,
'germinatedCount': g.germinatedCount,
'notes': g.notes,
},
],
'movements': [
for (final m in snapshot.movements)
{
// Append-only rows: no updatedAt/isDeleted.
'id': m.id,
'createdAt': m.createdAt,
'lastAuthor': m.lastAuthor,
'schemaRowVersion': m.schemaRowVersion,
'lotId': m.lotId,
'type': m.type.name,
'occurredOn': m.occurredOn,
'counterpartyId': m.counterpartyId,
'quantityKind': m.quantityKind,
'quantityPrecise': m.quantityPrecise,
'quantityLabel': m.quantityLabel,
'parentMovementId': m.parentMovementId,
'plantareId': m.plantareId,
'notes': m.notes,
},
],
'parties': [
for (final p in snapshot.parties)
{
..._syncMeta(
id: p.id,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
lastAuthor: p.lastAuthor,
schemaRowVersion: p.schemaRowVersion,
),
'displayName': p.displayName,
'publicKey': p.publicKey,
'kind': p.kind.name,
'note': p.note,
},
],
'attachments': [
for (final a in snapshot.attachments)
{
..._syncMeta(
id: a.id,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
lastAuthor: a.lastAuthor,
schemaRowVersion: a.schemaRowVersion,
),
'parentType': a.parentType.name,
'parentId': a.parentId,
'kind': a.kind.name,
'uri': a.uri,
'bytesBase64': a.bytes == null ? null : base64Encode(a.bytes!),
'mimeType': a.mimeType,
'sortOrder': a.sortOrder,
},
],
});
}
InventorySnapshot decode(String source) {
final Object? root;
try {
root = jsonDecode(source);
} on FormatException {
throw const FormatException('Not a valid JSON file.');
}
if (root is! Map<String, dynamic>) {
throw const FormatException('Not a Tanemaki inventory file.');
}
final version = root['formatVersion'];
if (version is! int) {
throw const FormatException('Missing formatVersion.');
}
if (version > inventoryFormatVersion) {
throw FormatException(
'This file uses format v$version; this app reads up to '
'v$inventoryFormatVersion. Update the app to import it.',
);
}
final speciesNamesById = <String, String>{};
final varieties = _rows(root, 'varieties', (m) {
final speciesId = m['speciesId'] as String?;
final scientificName = m['speciesScientificName'] as String?;
if (speciesId != null && scientificName != null) {
speciesNamesById[speciesId] = scientificName;
}
return Variety(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
label: _string(m, 'label'),
speciesId: speciesId,
cultivarName: m['cultivarName'] as String?,
category: m['category'] as String?,
notes: m['notes'] as String?,
);
});
return InventorySnapshot(
varieties: varieties,
speciesNamesById: speciesNamesById,
lots: _rows(root, 'lots', (m) {
return Lot(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
varietyId: _string(m, 'varietyId'),
// Unknown enum values safe defaults (data-model §5.2).
type: _enumOr(LotType.values, m['type'], LotType.seed),
harvestYear: m['harvestYear'] as int?,
harvestMonth: m['harvestMonth'] as int?,
quantityKind: m['quantityKind'] as String?,
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
quantityLabel: m['quantityLabel'] as String?,
presentation: _enumOrNull(Presentation.values, m['presentation']),
storageLocation: m['storageLocation'] as String?,
offerStatus: _enumOr(
OfferStatus.values,
m['offerStatus'],
OfferStatus.private,
),
seedbankId: m['seedbankId'] as String?,
);
}),
vernacularNames: _rows(root, 'vernacularNames', (m) {
return VarietyVernacularName(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
varietyId: _string(m, 'varietyId'),
name: _string(m, 'name'),
language: m['language'] as String?,
region: m['region'] as String?,
);
}),
externalLinks: _rows(root, 'externalLinks', (m) {
final parentType = _enumOrNull(ParentType.values, m['parentType']);
if (parentType == null) return null; // no safe default drop row
return ExternalLink(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
parentType: parentType,
parentId: _string(m, 'parentId'),
url: _string(m, 'url'),
title: m['title'] as String?,
);
}),
germinationTests: _rows(root, 'germinationTests', (m) {
return GerminationTest(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
testedOn: m['testedOn'] as int?,
sampleSize: m['sampleSize'] as int?,
germinatedCount: m['germinatedCount'] as int?,
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row
return Movement(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
lastAuthor: _string(m, 'lastAuthor'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
type: type,
occurredOn: m['occurredOn'] as int?,
counterpartyId: m['counterpartyId'] as String?,
quantityKind: m['quantityKind'] as String?,
quantityPrecise: (m['quantityPrecise'] as num?)?.toDouble(),
quantityLabel: m['quantityLabel'] as String?,
parentMovementId: m['parentMovementId'] as String?,
plantareId: m['plantareId'] as String?,
notes: m['notes'] as String?,
);
}),
parties: _rows(root, 'parties', (m) {
return Party(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
displayName: _string(m, 'displayName'),
publicKey: m['publicKey'] as String?,
kind: _enumOr(PartyKind.values, m['kind'], PartyKind.person),
note: m['note'] as String?,
);
}),
attachments: _rows(root, 'attachments', (m) {
final parentType = _enumOrNull(ParentType.values, m['parentType']);
final kind = _enumOrNull(AttachmentKind.values, m['kind']);
if (parentType == null || kind == null) return null; // drop row
final bytesBase64 = m['bytesBase64'] as String?;
return Attachment(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
parentType: parentType,
parentId: _string(m, 'parentId'),
kind: kind,
uri: m['uri'] as String?,
bytes: bytesBase64 == null
? null
: Uint8List.fromList(base64Decode(bytesBase64)),
mimeType: m['mimeType'] as String?,
sortOrder: _int(m, 'sortOrder', fallback: 0),
);
}),
);
}
Map<String, Object?> _syncMeta({
required String id,
required int createdAt,
required String updatedAt,
required String lastAuthor,
required int schemaRowVersion,
}) => {
'id': id,
'createdAt': createdAt,
'updatedAt': updatedAt,
'lastAuthor': lastAuthor,
'schemaRowVersion': schemaRowVersion,
};
/// Maps the JSON list under [key] through [decodeRow], skipping entries
/// that are not objects, fail to decode, or return null (unknown enums).
List<T> _rows<T>(
Map<String, dynamic> root,
String key,
T? Function(Map<String, dynamic>) decodeRow,
) {
final raw = root[key];
if (raw is! List) return const [];
final rows = <T>[];
for (final entry in raw) {
if (entry is! Map<String, dynamic>) continue;
final T? row;
try {
row = decodeRow(entry);
} on FormatException {
continue; // a broken row must not abort the whole import
}
if (row != null) rows.add(row);
}
return rows;
}
String _string(Map<String, dynamic> m, String key) {
final value = m[key];
if (value is! String) throw FormatException('Missing "$key"');
return value;
}
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
final value = m[key];
if (value is int) return value;
if (fallback != null) return fallback;
throw FormatException('Missing "$key"');
}
T _enumOr<T extends Enum>(List<T> values, Object? name, T fallback) =>
_enumOrNull(values, name) ?? fallback;
T? _enumOrNull<T extends Enum>(List<T> values, Object? name) {
if (name is! String) return null;
return values.asNameMap()[name];
}
}

View file

@ -0,0 +1,67 @@
import '../../db/database.dart';
/// The current version of the interchange JSON format (envelope field
/// `formatVersion`). Bump only with additive, documented changes readers
/// must reject files with a *newer* version and tolerate unknown fields in
/// the same version (data-model §5.2, §7).
const int inventoryFormatVersion = 1;
/// Identifies files produced by this app (envelope field `appId`).
const String inventoryAppId = 'tane';
/// An in-memory snapshot of the user's inventory, decoupled from any file
/// format. Produced by `VarietyRepository.exportInventory()` and consumed by
/// `importInventory()`; the JSON/CSV codecs turn it into bytes and back.
///
/// Rows are the plain Drift data classes (immutable). Tombstones
/// (`isDeleted = true`) are never part of a snapshot: this is a user-facing
/// export, not the future sync wire format. The bundled species catalog is
/// not included either [speciesNamesById] carries just the scientific
/// names needed to re-link varieties on import (species ids are generated
/// per install and are not portable).
class InventorySnapshot {
const InventorySnapshot({
this.varieties = const [],
this.lots = const [],
this.vernacularNames = const [],
this.externalLinks = const [],
this.germinationTests = const [],
this.movements = const [],
this.parties = const [],
this.attachments = const [],
this.speciesNamesById = const {},
});
final List<Variety> varieties;
final List<Lot> lots;
final List<VarietyVernacularName> vernacularNames;
final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests;
final List<Movement> movements;
final List<Party> parties;
final List<Attachment> attachments;
/// speciesId scientificName, for the species referenced by [varieties].
final Map<String, String> speciesNamesById;
}
/// What an import did, for user feedback: rows added, rows overwritten by a
/// newer incoming version (LWW), and rows skipped (already present and at
/// least as recent, or not decodable).
class ImportSummary {
const ImportSummary({
this.inserted = 0,
this.updated = 0,
this.skipped = 0,
});
final int inserted;
final int updated;
final int skipped;
ImportSummary operator +(ImportSummary other) => ImportSummary(
inserted: inserted + other.inserted,
updated: updated + other.updated,
skipped: skipped + other.skipped,
);
}

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 ||