From 19858698ee127b3f2d9fae5a7ca573986d200091 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 14:29:06 -0400 Subject: [PATCH] feat(#335): add drift and prove it opens on the targets buildable here Step 1 of #335, deliberately before any data migration: prove the store works before trusting it with message history. Adds drift + drift_flutter, a key/value StoredBlobs table mapping 1:1 onto the existing SharedPreferences keys, and verifyReadWrite() as a runtime probe. Key/value rather than relational tables on purpose: it maps directly onto the current store interfaces, so callers do not change and the migration can be verified row-for-row against the old keys. Relational schemas come later, once this is proven and querying is actually wanted. Verified here: - 3 tests pass, including a 6 MiB round-trip - larger than the 5 MiB localStorage cap that makes the web build impossible today - flutter analyze clean - Windows release build succeeds - Web release build compiles Two findings from the gate that a plan-on-paper would have missed: 1. sqlite3_flutter_libs 0.6.0+eol is a DEPRECATED no-op. From sqlite3 v3.x it is unnecessary and drift_flutter already covers it, so it is not a direct dependency here. 2. The web build COMPILES BUT WOULD FAIL AT RUNTIME: drift needs sqlite3.wasm and drift_worker.dart.js shipped in web/, and neither is present. Per drift docs they are downloaded from GitHub releases, version-matched to pubspec.lock (drift 2.34.2, sqlite3 3.5.0), and the server must serve .wasm as Content-Type: application/wasm. Not done here - downloading files needs explicit human approval. Also noted: the drift_dev CLI does not compile at these versions (allSchemaEntities missing from the drift3_preview GeneratedDatabase), so its asset tooling is unavailable. build_runner code generation is unaffected. No user data is touched. No store is switched over. The migration is the next step and remains gated on the web assets question. --- lib/storage/drift/offband_database.dart | 76 +++++++++++++++++++++++++ pubspec.yaml | 6 +- test/storage/offband_database_test.dart | 56 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 lib/storage/drift/offband_database.dart create mode 100644 test/storage/offband_database_test.dart diff --git a/lib/storage/drift/offband_database.dart b/lib/storage/drift/offband_database.dart new file mode 100644 index 0000000..c065662 --- /dev/null +++ b/lib/storage/drift/offband_database.dart @@ -0,0 +1,76 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; + +part 'offband_database.g.dart'; + +/// Bulk data store (#335). Replaces SharedPreferences for message history, +/// contacts and discovered contacts. +/// +/// SharedPreferences is a settings store, and using it for bulk data is what +/// causes #306: on Windows every mutation re-encodes and rewrites the entire +/// file synchronously, and on web it is backed by `localStorage`, which is +/// capped at 5 MiB per origin and is also synchronous. This install already +/// holds ~7 MB, so the web build cannot function at all today. +/// +/// drift is used because it is the only option verified to cover all six +/// targets: native SQLite on Android, iOS, macOS, Windows and Linux, and +/// SQLite compiled to WebAssembly on web, where it stores through OPFS or +/// IndexedDB and runs in a worker rather than on the UI thread. +/// +/// Settings stay in SharedPreferences. Only bulk data moves here. +@DriftDatabase(tables: [StoredBlobs]) +class OffbandDatabase extends _$OffbandDatabase { + OffbandDatabase([QueryExecutor? executor]) + : super(executor ?? _defaultExecutor()); + + /// `drift_flutter` selects the platform backend: native SQLite on desktop + /// and mobile, WASM on web. `web:` names the assets that must be shipped for + /// the web build (`sqlite3.wasm`, `drift_worker.dart.js`). + static QueryExecutor _defaultExecutor() { + return driftDatabase( + name: 'offband_store', + web: DriftWebOptions( + sqlite3Wasm: Uri.parse('sqlite3.wasm'), + driftWorker: Uri.parse('drift_worker.dart.js'), + ), + ); + } + + @override + int get schemaVersion => 1; + + /// Step-1 gate for #335: proves the database opens, writes and reads back on + /// the current platform. Deliberately trivial - no user data is involved, so + /// this can be run on every target before any migration is written. + Future verifyReadWrite() async { + const probeKey = '__offband_probe__'; + final probeValue = 'ok-${DateTime.now().microsecondsSinceEpoch}'; + + await into(storedBlobs).insertOnConflictUpdate( + StoredBlobsCompanion.insert(key: probeKey, value: probeValue), + ); + + final row = await (select( + storedBlobs, + )..where((t) => t.key.equals(probeKey))).getSingleOrNull(); + + await (delete(storedBlobs)..where((t) => t.key.equals(probeKey))).go(); + + return row?.value == probeValue; + } +} + +/// One row per stored blob, replacing one SharedPreferences key each. +/// +/// Deliberately key/value for the first migration: it maps 1:1 onto the +/// existing store interfaces, so callers do not change and the migration is +/// verifiable row-for-row against the old keys. Relational tables for messages +/// and contacts are a later step, once this is proven and querying is wanted. +class StoredBlobs extends Table { + TextColumn get key => text()(); + TextColumn get value => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {key}; +} diff --git a/pubspec.yaml b/pubspec.yaml index 0415a36..90f04ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,7 @@ dependencies: url_launcher: ^6.3.0 # Launch URLs in system browser flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text gpx: ^2.3.0 - path_provider: ^2.1.5 + path_provider: ^2.1.6 share_plus: ^12.0.1 build_pipe: ^0.3.1 material_symbols_icons: ^4.2906.0 @@ -73,6 +73,8 @@ dependencies: ml_dataframe: ^1.0.0 llamadart: '>=0.6.8 <0.7.0' flutter_langdetect: ^0.0.1 + drift: ^2.34.2 + drift_flutter: ^0.3.1 hooks: user_defines: @@ -95,6 +97,8 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + drift_dev: ^2.34.0 + build_runner: ^2.15.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/storage/offband_database_test.dart b/test/storage/offband_database_test.dart new file mode 100644 index 0000000..4e750bc --- /dev/null +++ b/test/storage/offband_database_test.dart @@ -0,0 +1,56 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; + +/// Step-1 gate for #335: the database must open, write and read back before +/// any user data is migrated into it. +/// +/// This runs against an in-memory SQLite instance so it exercises the drift +/// layer and generated code on any host. It does NOT prove the per-platform +/// backends (native libs on desktop/mobile, WASM on web) — those are proven by +/// building each target and by `verifyReadWrite()` at runtime. +void main() { + late OffbandDatabase db; + + setUp(() => db = OffbandDatabase(NativeDatabase.memory())); + tearDown(() => db.close()); + + test('opens, writes and reads back (the #335 gate)', () async { + expect(await db.verifyReadWrite(), isTrue); + }); + + test('round-trips a payload larger than the 5 MiB localStorage cap', () async { + // The web build currently stores through localStorage, capped at 5 MiB per + // origin, while this install already holds ~7 MB. Proving a >5 MiB value + // survives is the whole point of the migration. + final big = 'x' * (6 * 1024 * 1024); + await db + .into(db.storedBlobs) + .insertOnConflictUpdate( + StoredBlobsCompanion.insert(key: 'big', value: big), + ); + + final row = await (db.select( + db.storedBlobs, + )..where((t) => t.key.equals('big'))).getSingle(); + + expect(row.value.length, big.length); + }); + + test('upsert replaces rather than duplicating a key', () async { + for (final v in ['first', 'second']) { + await db + .into(db.storedBlobs) + .insertOnConflictUpdate( + StoredBlobsCompanion.insert(key: 'k', value: v), + ); + } + + final rows = await (db.select( + db.storedBlobs, + )..where((t) => t.key.equals('k'))).get(); + + expect(rows, hasLength(1)); + expect(rows.single.value, 'second'); + }); +}