tane/apps/app_seeds/lib/db/database.dart
vjrj 81094f25a8 feat(plantare): reproduction-commitment data layer (schema v9)
The Plantare — a promise to reproduce seed and return some (data-model §2.7,
the seed-domain Pledge with return_kind=similar). NOT a sale. Local-first
v1: your honest ledger of commitments (from/to a person by name); the
bilateral signed cross-party form is a later social-layer phase.

- New Plantares table (SyncColumns): varietyId, direction (iReturn/owedToMe),
  counterparty, owedDescription, madeOn, dueBy, status (open/returned/
  forgiven), settledOn, note. schemaVersion 8 -> 9 with a guarded createTable
  migration; schema exported + migration-test helper regenerated.
- VarietyRepository: createPlantare / watchPlantares / watchPlantaresForVariety
  / setPlantareStatus / deletePlantare (soft-delete, CRDT-stamped).
- Included in backups + sync: InventorySnapshot + JSON codec (round-trips
  isDeleted) + exportInventory/exportForSync/importInventory, so commitments
  survive restore and replicate LWW like every other row.

Tests: v1..v8 -> v9 migration + fresh v9; repo create/list/settle/reopen/
delete; and a backup round-trip preserving a commitment. 13 green.

UI (detail section + Plantares screen) follows.
2026-07-11 02:01:25 +02:00

171 lines
5.6 KiB
Dart

import 'package:drift/drift.dart';
import 'enums.dart';
import 'tables.dart';
part 'database.g.dart';
/// The encrypted local inventory database (SQLCipher via an injected executor).
///
/// The executor is passed in so production wires SQLCipher while tests can use
/// an in-memory database — the schema and queries are identical either way.
@DriftDatabase(
tables: [
Varieties,
VarietyVernacularNames,
Species,
SpeciesCommonNames,
Lots,
GerminationTests,
ConditionChecks,
Movements,
Parties,
Attachments,
ExternalLinks,
Plantares,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
/// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 9;
@override
int get schemaVersion => currentSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async => m.createAll(),
onUpgrade: (m, from, to) async {
// v2: lots can hold seeds or living plant material.
if (from < 2) {
await m.addColumn(lots, lots.type);
}
// v3: optional harvest month alongside the harvest year.
if (from < 3) {
await m.addColumn(lots, lots.harvestMonth);
}
// v4: optional packaging/supply form for living lots.
if (from < 4) {
await m.addColumn(lots, lots.presentation);
}
// v5: display order for attachments (cover photo = lowest sortOrder).
if (from < 5) {
await m.addColumn(attachments, attachments.sortOrder);
}
// v6: photo-first draft varieties awaiting a name ("to catalogue" tray).
if (from < 6) {
await m.addColumn(varieties, varieties.isDraft);
}
// v7: organic ("eco") self-declaration on varieties; per-species seed
// viability (years) reference data for expiry warnings. Guarded by a
// column-existence check so a dev database left half-migrated (column
// added but user_version not bumped) re-runs cleanly instead of failing
// on "duplicate column".
if (from < 7) {
if (!await _hasColumn('varieties', 'is_organic')) {
await m.addColumn(varieties, varieties.isOrganic);
}
if (!await _hasColumn('species', 'viability_years')) {
await m.addColumn(species, species.viabilityYears);
}
}
// v8: absorbs fields from a real seed-bank inventory. Varieties gain a
// "needs reproducing" intent and an advisory crop calendar; Lots gain
// provenance (origin name/place), a qualitative abundance level and a
// preservation format; a new ConditionChecks table logs container count +
// drying-agent state. Every step is guarded so a half-migrated dev
// database re-runs cleanly (see the v7 note above).
if (from < 8) {
Future<void> addIfMissing(
String table,
String column,
GeneratedColumn<Object> definition,
TableInfo<Table, dynamic> tableInfo,
) async {
if (!await _hasColumn(table, column)) {
await m.addColumn(tableInfo, definition);
}
}
await addIfMissing(
'varieties',
'needs_reproduction',
varieties.needsReproduction,
varieties,
);
await addIfMissing(
'varieties',
'sow_months',
varieties.sowMonths,
varieties,
);
await addIfMissing(
'varieties',
'transplant_months',
varieties.transplantMonths,
varieties,
);
await addIfMissing(
'varieties',
'flowering_months',
varieties.floweringMonths,
varieties,
);
await addIfMissing(
'varieties',
'fruiting_months',
varieties.fruitingMonths,
varieties,
);
await addIfMissing(
'varieties',
'seed_harvest_months',
varieties.seedHarvestMonths,
varieties,
);
await addIfMissing('lots', 'origin_name', lots.originName, lots);
await addIfMissing('lots', 'origin_place', lots.originPlace, lots);
await addIfMissing('lots', 'abundance', lots.abundance, lots);
await addIfMissing(
'lots',
'preservation_format',
lots.preservationFormat,
lots,
);
if (!await _hasTable('condition_checks')) {
await m.createTable(conditionChecks);
}
}
// v9: Plantares — reproduction commitments (data-model §2.7). Guarded so a
// half-migrated dev database re-runs cleanly (see the v7 note above).
if (from < 9) {
if (!await _hasTable('plantares')) {
await m.createTable(plantares);
}
}
},
);
/// Whether a table named [table] already exists. Keeps the additive v8
/// table creation idempotent against partially-migrated databases.
Future<bool> _hasTable(String table) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
variables: [Variable.withString(table)],
).get();
return rows.isNotEmpty;
}
/// Whether [table] already has a column named [column]. Used to keep additive
/// migrations idempotent against partially-migrated databases.
Future<bool> _hasColumn(String table, String column) async {
final rows = await customSelect(
'SELECT 1 FROM pragma_table_info(?) WHERE name = ?',
variables: [Variable.withString(table), Variable.withString(column)],
).get();
return rows.isNotEmpty;
}
}