tane/apps/app_seeds/lib/db/database.dart
vjrj 3de01bd948 perf(inventory): lazy list, photo thumbnails, indexes, debounced reload
Scale the local inventory to 10k+ varieties:
- Render the list with ListView.builder over a flattened header/item model
  instead of building every tile upfront.
- Store a small regenerable JPEG thumbnail per photo (schema v14) and use it
  for the 48px list avatar; full bytes stay for offer image hosting. Existing
  photos are backfilled lazily at startup. Thumbnail is local-only (excluded
  from CRDT sync and backups by the JSON codec).
- Add indexes on varieties(is_deleted,is_draft), attachments(parent_type,
  parent_id,kind), lots(variety_id) via @TableIndex.
- Debounce watchInventoryView (~250ms) so a burst of table writes triggers one
  reload, not seven.
- cacheWidth/cacheHeight on the list avatar decode.
- Scale test raised 3k -> 10k; migration test v13 -> v14.
2026-07-20 22:53:48 +02:00

252 lines
9.2 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,
GardenOutcomes,
Movements,
Parties,
Attachments,
ExternalLinks,
Plantares,
Sales,
],
)
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 = 14;
@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);
}
}
// v10: Sales — recorded seed sales (separate from gift/Plantare). Guarded.
if (from < 10) {
if (!await _hasTable('sales')) {
await m.createTable(sales);
}
}
// v11: asking price on Lots — published with "sell" market offers.
// Guarded (see the v7 note above).
if (from < 11) {
if (!await _hasColumn('lots', 'price_amount')) {
await m.addColumn(lots, lots.priceAmount);
}
if (!await _hasColumn('lots', 'price_currency')) {
await m.addColumn(lots, lots.priceCurrency);
}
}
// v12: the bilateral SIGNED Plantare (plantare-bilateral.md) — keys, both
// signatures, the shared pledge id/Movement, the handshake state, and the
// structured return option. All nullable/defaulted so v1 local rows are
// untouched. Guarded (see the v7 note above).
if (from < 12) {
Future<void> addIfMissing(
String column,
GeneratedColumn<Object> definition,
) async {
if (!await _hasColumn('plantares', column)) {
await m.addColumn(plantares, definition);
}
}
await addIfMissing('pledge_id', plantares.pledgeId);
await addIfMissing('debtor_key', plantares.debtorKey);
await addIfMissing('creditor_key', plantares.creditorKey);
await addIfMissing('debtor_signature', plantares.debtorSignature);
await addIfMissing('creditor_signature', plantares.creditorSignature);
await addIfMissing('movement_id', plantares.movementId);
await addIfMissing('remote_state', plantares.remoteState);
await addIfMissing('return_kind', plantares.returnKind);
await addIfMissing('work_hours', plantares.workHours);
}
// v13: GardenOutcomes — the per-season "how did it do in my garden"
// answer (rating + note) captured when a harvest is recorded. Guarded
// (see the v7 note above).
if (from < 13) {
if (!await _hasTable('garden_outcomes')) {
await m.createTable(gardenOutcomes);
}
}
// v14: scalability for large inventories. A local, regenerable [thumbnail]
// BLOB on attachments (so the list never decodes full-resolution photos)
// plus indexes on the columns the inventory list filters/joins on. All
// additive and guarded, so a half-migrated dev database re-runs cleanly
// (see the v7 note above). Existing thumbnails are backfilled lazily in
// the background, not here (image decoding in a migration is slow/fragile).
if (from < 14) {
if (!await _hasColumn('attachments', 'thumbnail')) {
await m.addColumn(attachments, attachments.thumbnail);
}
// Declared as `@TableIndex` on the tables, so a fresh install gets them
// via createAll(); existing databases get them here. Guarded against a
// half-migrated dev database (see the v7 note above).
for (final index in [
idxVarietiesDeletedDraft,
idxAttachmentsParent,
idxLotsVariety,
]) {
if (!await _hasIndex(index.entityName)) await m.create(index);
}
}
},
);
/// Whether an index named [name] already exists — keeps the additive v14
/// index creation idempotent against partially-migrated databases.
Future<bool> _hasIndex(String name) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?",
variables: [Variable.withString(name)],
).get();
return rows.isNotEmpty;
}
/// 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;
}
}