From ea5ab0342da9dcc6bb3364ef80e2e0b7bd5ec5a0 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 15:46:04 +0200 Subject: [PATCH] perf(species): seed the bundled catalog only once per version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the ~1.5 MB catalog parse and the table scan on every launch — the main startup cost. `seedBundledIfNeeded` re-seeds only when the recorded version differs from `speciesCatalogVersion`, writing the version after the seed commits so an interrupted seed retries next launch. Adds `parseSpeciesCatalogVersion` and a test keeping the constant in sync with the asset's `version` field. --- apps/app_seeds/lib/data/species_catalog.dart | 13 ++++++ .../lib/data/species_repository.dart | 18 ++++++++ .../test/data/species_catalog_asset_test.dart | 10 ++++ .../test/data/species_repository_test.dart | 46 +++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/apps/app_seeds/lib/data/species_catalog.dart b/apps/app_seeds/lib/data/species_catalog.dart index f381617..00cc7e7 100644 --- a/apps/app_seeds/lib/data/species_catalog.dart +++ b/apps/app_seeds/lib/data/species_catalog.dart @@ -31,3 +31,16 @@ Future> loadBundledSpecies() async { final jsonString = await rootBundle.loadString(_catalogAsset); return parseSpeciesCatalog(jsonString); } + +/// Version of the bundled catalog, mirrored from the asset's `version` field +/// (written by `tool/gen_species_catalog.dart`). A test asserts the two stay in +/// sync. The app records the version it last seeded and skips the ~1.5 MB parse +/// and the table scan in [SpeciesRepository.seedBundled] on every subsequent +/// launch — so bumping this (alongside the asset) is what triggers a one-time +/// re-seed after the catalog changes. +const int speciesCatalogVersion = 3; + +/// Reads just the `version` field from a catalog JSON string. Used by the sync +/// test to keep [speciesCatalogVersion] aligned with the asset. +int parseSpeciesCatalogVersion(String jsonString) => + (jsonDecode(jsonString) as Map)['version'] as int; diff --git a/apps/app_seeds/lib/data/species_repository.dart b/apps/app_seeds/lib/data/species_repository.dart index d521bc2..7a18f8c 100644 --- a/apps/app_seeds/lib/data/species_repository.dart +++ b/apps/app_seeds/lib/data/species_repository.dart @@ -70,6 +70,24 @@ class SpeciesRepository { /// Reads all existing species once and writes in a single batch: with a /// catalog of ~1000 species a per-row SELECT on every startup would be a real /// cost on the encrypted database. + /// Seeds the bundled catalog only when [readVersion] doesn't already report + /// [version]. [loadSeeds] is awaited lazily and skipped entirely when this + /// install already holds [version] — so the ~1.5 MB catalog parse and the + /// table scan in [seedBundled] are paid once per catalog version, not on + /// every launch (the main startup cost). On a (re)seed, [writeVersion] records + /// [version] only after [seedBundled] commits, so an interrupted seed is + /// retried on the next launch. + Future seedBundledIfNeeded({ + required int version, + required Future Function() readVersion, + required Future Function(String version) writeVersion, + required Future> Function() loadSeeds, + }) async { + if (await readVersion() == version.toString()) return; + await seedBundled(await loadSeeds()); + await writeVersion(version.toString()); + } + Future seedBundled(List seeds) async { final stamp = Hlc.zero(nodeId).pack(); await _db.transaction(() async { diff --git a/apps/app_seeds/test/data/species_catalog_asset_test.dart b/apps/app_seeds/test/data/species_catalog_asset_test.dart index 2ea7509..17c3a27 100644 --- a/apps/app_seeds/test/data/species_catalog_asset_test.dart +++ b/apps/app_seeds/test/data/species_catalog_asset_test.dart @@ -17,6 +17,16 @@ void main() { expect(json['version'], 3); }); + test('speciesCatalogVersion mirrors the asset version', () { + // The startup seed guard skips the reseed by comparing this constant to the + // version it last recorded; it must track the asset or a bumped catalog is + // never re-seeded. + expect( + speciesCatalogVersion, + parseSpeciesCatalogVersion(file.readAsStringSync()), + ); + }); + test('is a broad catalog, not the old starter set', () { // The expanded catalog holds ~1200 species (was 14 by hand). The floor // leaves headroom for Wikidata drift across regenerations. diff --git a/apps/app_seeds/test/data/species_repository_test.dart b/apps/app_seeds/test/data/species_repository_test.dart index bb862e4..9c4a0fe 100644 --- a/apps/app_seeds/test/data/species_repository_test.dart +++ b/apps/app_seeds/test/data/species_repository_test.dart @@ -237,4 +237,50 @@ void main() { // No species named → no suggestion. expect(await repo.classifyLabel('Girasol gigante'), isNull); }); + + group('seedBundledIfNeeded', () { + test('seeds on first run and skips the reload once recorded', () async { + String? stored; + var loads = 0; + Future> load() async { + loads++; + return _seeds; + } + + await repo.seedBundledIfNeeded( + version: 3, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: load, + ); + expect(loads, 1); + expect(stored, '3'); + expect(await repo.search('Tomato'), isNotEmpty); + + // Same version already recorded → the ~1.5 MB parse is never loaded again. + await repo.seedBundledIfNeeded( + version: 3, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: load, + ); + expect(loads, 1); + }); + + test('reseeds when the catalog version changes', () async { + var stored = '3'; + var loads = 0; + await repo.seedBundledIfNeeded( + version: 4, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: () async { + loads++; + return _seeds; + }, + ); + expect(loads, 1); + expect(stored, '4'); + }); + }); }