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.
This commit is contained in:
vjrj 2026-07-20 17:48:37 +02:00
parent 2884ddd3c7
commit 3de01bd948
12 changed files with 5114 additions and 34 deletions

View file

@ -32,7 +32,7 @@ class AppDatabase extends _$AppDatabase {
/// 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 = 13;
static const int currentSchemaVersion = 14;
@override
int get schemaVersion => currentSchemaVersion;
@ -196,9 +196,40 @@ class AppDatabase extends _$AppDatabase {
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 {

View file

@ -7975,6 +7975,17 @@ class $AttachmentsTable extends Attachments
type: DriftSqlType.blob,
requiredDuringInsert: false,
);
static const VerificationMeta _thumbnailMeta = const VerificationMeta(
'thumbnail',
);
@override
late final GeneratedColumn<Uint8List> thumbnail = GeneratedColumn<Uint8List>(
'thumbnail',
aliasedName,
true,
type: DriftSqlType.blob,
requiredDuringInsert: false,
);
static const VerificationMeta _mimeTypeMeta = const VerificationMeta(
'mimeType',
);
@ -8011,6 +8022,7 @@ class $AttachmentsTable extends Attachments
kind,
uri,
bytes,
thumbnail,
mimeType,
sortOrder,
];
@ -8090,6 +8102,12 @@ class $AttachmentsTable extends Attachments
bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta),
);
}
if (data.containsKey('thumbnail')) {
context.handle(
_thumbnailMeta,
thumbnail.isAcceptableOrUnknown(data['thumbnail']!, _thumbnailMeta),
);
}
if (data.containsKey('mime_type')) {
context.handle(
_mimeTypeMeta,
@ -8159,6 +8177,10 @@ class $AttachmentsTable extends Attachments
DriftSqlType.blob,
data['${effectivePrefix}bytes'],
),
thumbnail: attachedDatabase.typeMapping.read(
DriftSqlType.blob,
data['${effectivePrefix}thumbnail'],
),
mimeType: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}mime_type'],
@ -8193,6 +8215,13 @@ class Attachment extends DataClass implements Insertable<Attachment> {
final AttachmentKind kind;
final String? uri;
final Uint8List? bytes;
/// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so
/// the inventory list never has to decode the full-resolution photo for a
/// 48px avatar. Purely derived, local and regenerable it is EXCLUDED from
/// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a
/// restored backup regenerates it lazily via `backfillThumbnails`.
final Uint8List? thumbnail;
final String? mimeType;
/// Display order among sibling attachments (lower first). The lowest-ordered
@ -8212,6 +8241,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
required this.kind,
this.uri,
this.bytes,
this.thumbnail,
this.mimeType,
required this.sortOrder,
});
@ -8241,6 +8271,9 @@ class Attachment extends DataClass implements Insertable<Attachment> {
if (!nullToAbsent || bytes != null) {
map['bytes'] = Variable<Uint8List>(bytes);
}
if (!nullToAbsent || thumbnail != null) {
map['thumbnail'] = Variable<Uint8List>(thumbnail);
}
if (!nullToAbsent || mimeType != null) {
map['mime_type'] = Variable<String>(mimeType);
}
@ -8263,6 +8296,9 @@ class Attachment extends DataClass implements Insertable<Attachment> {
bytes: bytes == null && nullToAbsent
? const Value.absent()
: Value(bytes),
thumbnail: thumbnail == null && nullToAbsent
? const Value.absent()
: Value(thumbnail),
mimeType: mimeType == null && nullToAbsent
? const Value.absent()
: Value(mimeType),
@ -8291,6 +8327,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
),
uri: serializer.fromJson<String?>(json['uri']),
bytes: serializer.fromJson<Uint8List?>(json['bytes']),
thumbnail: serializer.fromJson<Uint8List?>(json['thumbnail']),
mimeType: serializer.fromJson<String?>(json['mimeType']),
sortOrder: serializer.fromJson<int>(json['sortOrder']),
);
@ -8314,6 +8351,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
),
'uri': serializer.toJson<String?>(uri),
'bytes': serializer.toJson<Uint8List?>(bytes),
'thumbnail': serializer.toJson<Uint8List?>(thumbnail),
'mimeType': serializer.toJson<String?>(mimeType),
'sortOrder': serializer.toJson<int>(sortOrder),
};
@ -8331,6 +8369,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
AttachmentKind? kind,
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<Uint8List?> thumbnail = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
int? sortOrder,
}) => Attachment(
@ -8345,6 +8384,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
kind: kind ?? this.kind,
uri: uri.present ? uri.value : this.uri,
bytes: bytes.present ? bytes.value : this.bytes,
thumbnail: thumbnail.present ? thumbnail.value : this.thumbnail,
mimeType: mimeType.present ? mimeType.value : this.mimeType,
sortOrder: sortOrder ?? this.sortOrder,
);
@ -8367,6 +8407,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
kind: data.kind.present ? data.kind.value : this.kind,
uri: data.uri.present ? data.uri.value : this.uri,
bytes: data.bytes.present ? data.bytes.value : this.bytes,
thumbnail: data.thumbnail.present ? data.thumbnail.value : this.thumbnail,
mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType,
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
);
@ -8386,6 +8427,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
..write('kind: $kind, ')
..write('uri: $uri, ')
..write('bytes: $bytes, ')
..write('thumbnail: $thumbnail, ')
..write('mimeType: $mimeType, ')
..write('sortOrder: $sortOrder')
..write(')'))
@ -8405,6 +8447,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
kind,
uri,
$driftBlobEquality.hash(bytes),
$driftBlobEquality.hash(thumbnail),
mimeType,
sortOrder,
);
@ -8423,6 +8466,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
other.kind == this.kind &&
other.uri == this.uri &&
$driftBlobEquality.equals(other.bytes, this.bytes) &&
$driftBlobEquality.equals(other.thumbnail, this.thumbnail) &&
other.mimeType == this.mimeType &&
other.sortOrder == this.sortOrder);
}
@ -8439,6 +8483,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
final Value<AttachmentKind> kind;
final Value<String?> uri;
final Value<Uint8List?> bytes;
final Value<Uint8List?> thumbnail;
final Value<String?> mimeType;
final Value<int> sortOrder;
final Value<int> rowid;
@ -8454,6 +8499,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
this.kind = const Value.absent(),
this.uri = const Value.absent(),
this.bytes = const Value.absent(),
this.thumbnail = const Value.absent(),
this.mimeType = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
@ -8470,6 +8516,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
required AttachmentKind kind,
this.uri = const Value.absent(),
this.bytes = const Value.absent(),
this.thumbnail = const Value.absent(),
this.mimeType = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
@ -8492,6 +8539,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
Expression<String>? kind,
Expression<String>? uri,
Expression<Uint8List>? bytes,
Expression<Uint8List>? thumbnail,
Expression<String>? mimeType,
Expression<int>? sortOrder,
Expression<int>? rowid,
@ -8508,6 +8556,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
if (kind != null) 'kind': kind,
if (uri != null) 'uri': uri,
if (bytes != null) 'bytes': bytes,
if (thumbnail != null) 'thumbnail': thumbnail,
if (mimeType != null) 'mime_type': mimeType,
if (sortOrder != null) 'sort_order': sortOrder,
if (rowid != null) 'rowid': rowid,
@ -8526,6 +8575,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
Value<AttachmentKind>? kind,
Value<String?>? uri,
Value<Uint8List?>? bytes,
Value<Uint8List?>? thumbnail,
Value<String?>? mimeType,
Value<int>? sortOrder,
Value<int>? rowid,
@ -8542,6 +8592,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
kind: kind ?? this.kind,
uri: uri ?? this.uri,
bytes: bytes ?? this.bytes,
thumbnail: thumbnail ?? this.thumbnail,
mimeType: mimeType ?? this.mimeType,
sortOrder: sortOrder ?? this.sortOrder,
rowid: rowid ?? this.rowid,
@ -8588,6 +8639,9 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
if (bytes.present) {
map['bytes'] = Variable<Uint8List>(bytes.value);
}
if (thumbnail.present) {
map['thumbnail'] = Variable<Uint8List>(thumbnail.value);
}
if (mimeType.present) {
map['mime_type'] = Variable<String>(mimeType.value);
}
@ -8614,6 +8668,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
..write('kind: $kind, ')
..write('uri: $uri, ')
..write('bytes: $bytes, ')
..write('thumbnail: $thumbnail, ')
..write('mimeType: $mimeType, ')
..write('sortOrder: $sortOrder, ')
..write('rowid: $rowid')
@ -11435,6 +11490,18 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this);
late final $PlantaresTable plantares = $PlantaresTable(this);
late final $SalesTable sales = $SalesTable(this);
late final Index idxVarietiesDeletedDraft = Index(
'idx_varieties_deleted_draft',
'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)',
);
late final Index idxLotsVariety = Index(
'idx_lots_variety',
'CREATE INDEX idx_lots_variety ON lots (variety_id)',
);
late final Index idxAttachmentsParent = Index(
'idx_attachments_parent',
'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)',
);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -11454,6 +11521,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
externalLinks,
plantares,
sales,
idxVarietiesDeletedDraft,
idxLotsVariety,
idxAttachmentsParent,
];
}
@ -15118,6 +15188,7 @@ typedef $$AttachmentsTableCreateCompanionBuilder =
required AttachmentKind kind,
Value<String?> uri,
Value<Uint8List?> bytes,
Value<Uint8List?> thumbnail,
Value<String?> mimeType,
Value<int> sortOrder,
Value<int> rowid,
@ -15135,6 +15206,7 @@ typedef $$AttachmentsTableUpdateCompanionBuilder =
Value<AttachmentKind> kind,
Value<String?> uri,
Value<Uint8List?> bytes,
Value<Uint8List?> thumbnail,
Value<String?> mimeType,
Value<int> sortOrder,
Value<int> rowid,
@ -15206,6 +15278,11 @@ class $$AttachmentsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<Uint8List> get thumbnail => $composableBuilder(
column: $table.thumbnail,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get mimeType => $composableBuilder(
column: $table.mimeType,
builder: (column) => ColumnFilters(column),
@ -15281,6 +15358,11 @@ class $$AttachmentsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<Uint8List> get thumbnail => $composableBuilder(
column: $table.thumbnail,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get mimeType => $composableBuilder(
column: $table.mimeType,
builder: (column) => ColumnOrderings(column),
@ -15341,6 +15423,9 @@ class $$AttachmentsTableAnnotationComposer
GeneratedColumn<Uint8List> get bytes =>
$composableBuilder(column: $table.bytes, builder: (column) => column);
GeneratedColumn<Uint8List> get thumbnail =>
$composableBuilder(column: $table.thumbnail, builder: (column) => column);
GeneratedColumn<String> get mimeType =>
$composableBuilder(column: $table.mimeType, builder: (column) => column);
@ -15390,6 +15475,7 @@ class $$AttachmentsTableTableManager
Value<AttachmentKind> kind = const Value.absent(),
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<Uint8List?> thumbnail = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -15405,6 +15491,7 @@ class $$AttachmentsTableTableManager
kind: kind,
uri: uri,
bytes: bytes,
thumbnail: thumbnail,
mimeType: mimeType,
sortOrder: sortOrder,
rowid: rowid,
@ -15422,6 +15509,7 @@ class $$AttachmentsTableTableManager
required AttachmentKind kind,
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<Uint8List?> thumbnail = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -15437,6 +15525,7 @@ class $$AttachmentsTableTableManager
kind: kind,
uri: uri,
bytes: bytes,
thumbnail: thumbnail,
mimeType: mimeType,
sortOrder: sortOrder,
rowid: rowid,

View file

@ -5,6 +5,10 @@ import 'sync_columns.dart';
/// The identity/accession one row per distinct thing in the inventory.
/// Only [label] is mandatory (progressive disclosure).
///
/// The index covers the inventory list's hot filter — non-deleted, non-draft
/// rows so it stays fast with a large catalogue.
@TableIndex(name: 'idx_varieties_deleted_draft', columns: {#isDeleted, #isDraft})
class Varieties extends Table with SyncColumns {
TextColumn get label => text()();
TextColumn get speciesId => text().nullable()(); // Species
@ -77,6 +81,10 @@ class SpeciesCommonNames extends Table with SyncColumns {
/// A homogeneous batch held for a Variety its own year and its own unit.
/// Quantity (commons_core value type) is flattened into columns here.
///
/// Indexed by [varietyId] the inventory list joins lots per variety (types,
/// shared status, viability), so this avoids a full scan per reload.
@TableIndex(name: 'idx_lots_variety', columns: {#varietyId})
class Lots extends Table with SyncColumns {
TextColumn get varietyId => text()();
TextColumn get type =>
@ -181,12 +189,26 @@ class Parties extends Table with SyncColumns {
/// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here
/// keeps the "no plaintext at rest" rule for photos in Block 1; an external
/// encrypted file store is a later optimization.
///
/// Indexed by (parentType, parentId, kind) the list's "first photo per
/// variety" lookup filters on exactly these.
@TableIndex(
name: 'idx_attachments_parent',
columns: {#parentType, #parentId, #kind},
)
class Attachments extends Table with SyncColumns {
TextColumn get parentType => textEnum<ParentType>()();
TextColumn get parentId => text()();
TextColumn get kind => textEnum<AttachmentKind>()();
TextColumn get uri => text().nullable()();
BlobColumn get bytes => blob().nullable()();
/// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so
/// the inventory list never has to decode the full-resolution photo for a
/// 48px avatar. Purely derived, local and regenerable it is EXCLUDED from
/// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a
/// restored backup regenerates it lazily via `backfillThumbnails`.
BlobColumn get thumbnail => blob().nullable()();
TextColumn get mimeType => text().nullable()();
/// Display order among sibling attachments (lower first). The lowest-ordered