# Conflicts: # lib/storage/contact_discovery_store.dart # lib/storage/contact_store.dart # lib/storage/message_store.dartintegration/290-306-335
commit
4cf2c632df
@ -0,0 +1,213 @@
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../prefs_manager.dart';
|
||||
import 'offband_database.dart';
|
||||
|
||||
/// Bulk-data store backed by drift, replacing SharedPreferences for anything
|
||||
/// large (#335).
|
||||
///
|
||||
/// Why this exists: SharedPreferences is a settings store. On Windows every
|
||||
/// mutation re-encodes and rewrites the WHOLE file synchronously, and on web it
|
||||
/// is backed by `localStorage`, which is capped at 5 MiB per origin and is also
|
||||
/// synchronous. This install holds ~5.2 MB of bulk data, so the web build
|
||||
/// cannot function at all today and Windows stalls for tens of seconds (#306).
|
||||
///
|
||||
/// Each key becomes one row, so a write touches one row rather than the entire
|
||||
/// store.
|
||||
class BlobStore {
|
||||
BlobStore(this._db);
|
||||
|
||||
final OffbandDatabase _db;
|
||||
|
||||
static OffbandDatabase? _sharedDb;
|
||||
|
||||
/// Process-wide instance. The database must be opened once; opening it twice
|
||||
/// is an error on the web backends.
|
||||
static BlobStore get instance => BlobStore(_sharedDb ??= OffbandDatabase());
|
||||
|
||||
/// Key families that hold bulk data. Everything else stays in
|
||||
/// SharedPreferences, which is what it is for.
|
||||
static const List<String> migratedPrefixes = [
|
||||
'channel_messages_',
|
||||
'messages_',
|
||||
'contacts',
|
||||
'discovered_contacts',
|
||||
];
|
||||
|
||||
static bool isBulkKey(String key) => migratedPrefixes.any(key.startsWith);
|
||||
|
||||
/// Reads a bulk key, falling back to SharedPreferences if drift does not
|
||||
/// have it.
|
||||
///
|
||||
/// Belt and braces for the switchover: if migration ever missed a key, the
|
||||
/// data must still be reachable rather than silently reading as empty, which
|
||||
/// is exactly how #333 presented. The fallback is LOUD, because a fallback
|
||||
/// that fires in normal operation means the migration is incomplete and
|
||||
/// somebody needs to know.
|
||||
Future<String?> readWithPrefsFallback(String key) async {
|
||||
final fromDrift = await read(key);
|
||||
if (fromDrift != null) return fromDrift;
|
||||
|
||||
final raw = PrefsManager.instance.get(key);
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
|
||||
appLogger.warn(
|
||||
'Blob read for $key fell back to SharedPreferences: it is NOT in drift. '
|
||||
'Migration is incomplete for this key; serving the prefs copy.',
|
||||
tag: 'Storage',
|
||||
);
|
||||
return raw;
|
||||
}
|
||||
|
||||
Future<String?> read(String key) async {
|
||||
final row = await (_db.select(
|
||||
_db.storedBlobs,
|
||||
)..where((t) => t.key.equals(key))).getSingleOrNull();
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
Future<void> write(String key, String value) async {
|
||||
await _db
|
||||
.into(_db.storedBlobs)
|
||||
.insertOnConflictUpdate(
|
||||
StoredBlobsCompanion.insert(key: key, value: value),
|
||||
);
|
||||
}
|
||||
|
||||
/// Keys beginning with [prefix], across BOTH backends.
|
||||
///
|
||||
/// Callers that clear a family of keys must see prefs-resident keys too, or
|
||||
/// a pre-migration copy survives the clear and reappears through the read
|
||||
/// fallback.
|
||||
Future<List<String>> keysWithPrefix(String prefix) async {
|
||||
// Filtered in Dart rather than SQL: the table holds tens of rows, so the
|
||||
// cost is irrelevant and it avoids depending on a version-specific LIKE
|
||||
// API for a pinned dependency.
|
||||
final rows = await _db.select(_db.storedBlobs).get();
|
||||
|
||||
return {
|
||||
...rows.map((r) => r.key).where((k) => k.startsWith(prefix)),
|
||||
...PrefsManager.instance.getKeys().where((k) => k.startsWith(prefix)),
|
||||
}.toList();
|
||||
}
|
||||
|
||||
/// Removes a key from BOTH backends, so a clear cannot be undone by a
|
||||
/// leftover prefs copy surfacing through the fallback.
|
||||
Future<void> deleteEverywhere(String key) async {
|
||||
await delete(key);
|
||||
await PrefsManager.instance.remove(key);
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
await (_db.delete(_db.storedBlobs)..where((t) => t.key.equals(key))).go();
|
||||
}
|
||||
|
||||
/// Moves bulk keys out of SharedPreferences into drift.
|
||||
///
|
||||
/// Ordering is deliberate and non-negotiable: **write, verify by reading
|
||||
/// back, and only then remove the source.** #333 was caused by a storage path
|
||||
/// that chose a key silently and made 566 real messages read as empty; a
|
||||
/// migration that deleted before verifying could do that permanently rather
|
||||
/// than cosmetically.
|
||||
///
|
||||
/// Idempotent: keys already migrated are skipped, so re-running is a no-op
|
||||
/// rather than a duplicate or an overwrite of newer data.
|
||||
///
|
||||
/// Every outcome is logged (SAFELANE 6). A failure never silently drops a
|
||||
/// key: the source is left intact and the error surfaces.
|
||||
Future<MigrationReport> migrateFromPrefs() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final report = MigrationReport();
|
||||
|
||||
final bulkKeys = prefs.getKeys().where(isBulkKey).toList();
|
||||
if (bulkKeys.isEmpty) {
|
||||
appLogger.info(
|
||||
'Blob migration: nothing to migrate (already done or fresh install)',
|
||||
tag: 'Storage',
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
appLogger.info(
|
||||
'Blob migration: ${bulkKeys.length} key(s) to move out of prefs',
|
||||
tag: 'Storage',
|
||||
);
|
||||
|
||||
for (final key in bulkKeys) {
|
||||
try {
|
||||
// Type-check rather than calling getString directly: getString THROWS
|
||||
// on a non-string value, which would be counted as a migration failure
|
||||
// and cry wolf. A non-string under a bulk prefix is simply not bulk
|
||||
// data.
|
||||
final raw = prefs.get(key);
|
||||
if (raw is! String || raw.isEmpty) {
|
||||
report.skipped++;
|
||||
continue;
|
||||
}
|
||||
final source = raw;
|
||||
|
||||
if (await read(key) != null) {
|
||||
// Already migrated on a previous run. Leave the prefs copy for the
|
||||
// sweep below rather than assuming; the read-back proved the data is
|
||||
// present in drift.
|
||||
report.alreadyPresent++;
|
||||
await prefs.remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
await write(key, source);
|
||||
|
||||
// Verify BEFORE removing the source. Length is compared rather than
|
||||
// full equality to keep a multi-MB comparison cheap while still
|
||||
// catching truncation, which is the realistic corruption here.
|
||||
final readBack = await read(key);
|
||||
if (readBack == null || readBack.length != source.length) {
|
||||
report.failed++;
|
||||
appLogger.error(
|
||||
'Blob migration FAILED for $key: wrote ${source.length} chars, '
|
||||
'read back ${readBack?.length ?? "null"}. Source left intact.',
|
||||
tag: 'Storage',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prefs.remove(key);
|
||||
report.migrated++;
|
||||
report.bytes += source.length;
|
||||
} catch (e) {
|
||||
report.failed++;
|
||||
appLogger.error(
|
||||
'Blob migration FAILED for $key: $e. Source left intact.',
|
||||
tag: 'Storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final level = report.failed > 0 ? 'WITH FAILURES' : 'ok';
|
||||
appLogger.info(
|
||||
'Blob migration complete ($level): ${report.migrated} moved, '
|
||||
'${report.alreadyPresent} already present, ${report.skipped} skipped, '
|
||||
'${report.failed} failed, '
|
||||
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB',
|
||||
tag: 'Storage',
|
||||
);
|
||||
if (report.failed > 0) {
|
||||
appLogger.error(
|
||||
'Blob migration left ${report.failed} key(s) in SharedPreferences. '
|
||||
'No data was lost, but those keys still carry the old cost.',
|
||||
tag: 'Storage',
|
||||
);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable tally of a migration run; surfaced in the log and used by tests.
|
||||
class MigrationReport {
|
||||
int migrated = 0;
|
||||
int alreadyPresent = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
int bytes = 0;
|
||||
|
||||
bool get hadFailures => failed > 0;
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
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'),
|
||||
// Filename matches the asset published by the drift release, which is
|
||||
// `drift_worker.js` — not the `drift_worker.dart.js` the older docs
|
||||
// name. Both assets are taken from the SAME drift release so they are
|
||||
// built against each other.
|
||||
driftWorker: Uri.parse('drift_worker.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<bool> 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<Column> get primaryKey => {key};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,118 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Migration safety for #335.
|
||||
///
|
||||
/// The bar here is set by #333: a storage path chose a key silently and 566
|
||||
/// real messages read as empty. A migration that gets this wrong loses data
|
||||
/// permanently rather than cosmetically, so the failure paths are tested, not
|
||||
/// just the happy one.
|
||||
void main() {
|
||||
late OffbandDatabase db;
|
||||
late BlobStore store;
|
||||
|
||||
setUp(() async {
|
||||
db = OffbandDatabase(NativeDatabase.memory());
|
||||
store = BlobStore(db);
|
||||
PrefsManager.reset();
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
Future<void> seedPrefs(Map<String, Object> values) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
await PrefsManager.initialize();
|
||||
}
|
||||
|
||||
test('moves bulk keys and removes them from prefs', () async {
|
||||
await seedPrefs({
|
||||
'channel_messages_devpsk_abc': '[{"m":1}]',
|
||||
'messages_devcontact': '[{"m":2}]',
|
||||
'contacts_dev': '[{"c":1}]',
|
||||
'discovered_contacts': '[{"d":1}]',
|
||||
});
|
||||
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
expect(report.migrated, 4);
|
||||
expect(report.failed, 0);
|
||||
expect(await store.read('channel_messages_devpsk_abc'), '[{"m":1}]');
|
||||
expect(await store.read('discovered_contacts'), '[{"d":1}]');
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
expect(prefs.getString('channel_messages_devpsk_abc'), isNull);
|
||||
expect(prefs.getString('discovered_contacts'), isNull);
|
||||
});
|
||||
|
||||
test('leaves settings alone', () async {
|
||||
await seedPrefs({
|
||||
'ui_channels_sort_option': 'manual',
|
||||
'app_settings': '{"theme":"dark"}',
|
||||
'contacts_dev': '[{"c":1}]',
|
||||
});
|
||||
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
expect(report.migrated, 1, reason: 'only the bulk key should move');
|
||||
final prefs = PrefsManager.instance;
|
||||
expect(prefs.getString('ui_channels_sort_option'), 'manual');
|
||||
expect(prefs.getString('app_settings'), '{"theme":"dark"}');
|
||||
});
|
||||
|
||||
test('is idempotent: a second run moves nothing and loses nothing', () async {
|
||||
await seedPrefs({'contacts_dev': '[{"c":1}]'});
|
||||
|
||||
final first = await store.migrateFromPrefs();
|
||||
expect(first.migrated, 1);
|
||||
|
||||
final second = await store.migrateFromPrefs();
|
||||
expect(second.migrated, 0);
|
||||
expect(second.failed, 0);
|
||||
expect(await store.read('contacts_dev'), '[{"c":1}]');
|
||||
});
|
||||
|
||||
test('a re-added prefs key does not clobber migrated data', () async {
|
||||
// Simulates an older build writing the key again after migration. The
|
||||
// migrated copy must win; the stale prefs copy is discarded, not promoted.
|
||||
await seedPrefs({'contacts_dev': '[{"c":"new"}]'});
|
||||
await store.write('contacts_dev', '[{"c":"migrated"}]');
|
||||
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
expect(report.alreadyPresent, 1);
|
||||
expect(await store.read('contacts_dev'), '[{"c":"migrated"}]');
|
||||
expect(PrefsManager.instance.getString('contacts_dev'), isNull);
|
||||
});
|
||||
|
||||
test('preserves a payload larger than the 5 MiB localStorage cap', () async {
|
||||
// 4 chars per element, so >1.4M elements clears 5 MiB.
|
||||
final big = '[${'"x",' * 1400000}"end"]';
|
||||
expect(big.length, greaterThan(5 * 1024 * 1024));
|
||||
await seedPrefs({'channel_messages_devpsk_big': big});
|
||||
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
expect(report.failed, 0);
|
||||
expect(
|
||||
(await store.read('channel_messages_devpsk_big'))!.length,
|
||||
big.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-string values are skipped, not failed', () async {
|
||||
// getString THROWS on a non-string, so this must be type-checked, not
|
||||
// caught as a failure. Verified against the real store: 'contacts' only
|
||||
// ever matches bulk blobs there, but the store must not mis-report if a
|
||||
// non-string ever lands under a bulk prefix.
|
||||
await seedPrefs({'contacts_probe': 42});
|
||||
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
expect(report.failed, 0);
|
||||
expect(report.skipped, 1);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Guards the one way the web build can break silently (#335).
|
||||
///
|
||||
/// drift on the web needs two binaries shipped in `web/`, and they must match
|
||||
/// the resolved `drift` package. If someone bumps drift without re-downloading
|
||||
/// them, nothing fails at build time: the app compiles, deploys, and then
|
||||
/// fails in the browser. SAFELANE 6 says that must not be silent, so this
|
||||
/// turns it into a red test with instructions.
|
||||
///
|
||||
/// Update `web/drift_assets.version` whenever the assets are re-downloaded.
|
||||
void main() {
|
||||
test('shipped drift web assets match the pinned drift version', () {
|
||||
final root = Directory.current.path;
|
||||
|
||||
final lock = File('$root/pubspec.lock');
|
||||
expect(
|
||||
lock.existsSync(),
|
||||
isTrue,
|
||||
reason:
|
||||
'pubspec.lock must be committed so dependency resolution is '
|
||||
'reproducible; the drift web assets are matched against it.',
|
||||
);
|
||||
|
||||
final resolved = RegExp(
|
||||
r'^ drift:\n(?:.*\n)*? version: "([^"]+)"',
|
||||
multiLine: true,
|
||||
).firstMatch(lock.readAsStringSync())?.group(1);
|
||||
expect(resolved, isNotNull, reason: 'drift not found in pubspec.lock');
|
||||
|
||||
final stamp = File('$root/web/drift_assets.version');
|
||||
expect(
|
||||
stamp.existsSync(),
|
||||
isTrue,
|
||||
reason:
|
||||
'web/drift_assets.version is missing. It records which drift release '
|
||||
'web/sqlite3.wasm and web/drift_worker.js came from.',
|
||||
);
|
||||
|
||||
expect(
|
||||
stamp.readAsStringSync().trim(),
|
||||
resolved,
|
||||
reason:
|
||||
'\nDrift web assets are STALE.\n'
|
||||
'pubspec.lock resolves drift $resolved, but the shipped assets are '
|
||||
'from ${stamp.readAsStringSync().trim()}.\n'
|
||||
'The web build would compile and then fail in the browser.\n\n'
|
||||
'Fix:\n'
|
||||
' gh release download drift-$resolved --repo simolus3/drift \\\n'
|
||||
' --pattern drift_worker.js --pattern sqlite3.wasm --clobber\n'
|
||||
' (run inside web/, then write $resolved into web/drift_assets.version)\n',
|
||||
);
|
||||
|
||||
for (final name in ['sqlite3.wasm', 'drift_worker.js']) {
|
||||
expect(
|
||||
File('$root/web/$name').existsSync(),
|
||||
isTrue,
|
||||
reason: 'web/$name is missing; the web build would fail at runtime.',
|
||||
);
|
||||
}
|
||||
|
||||
// Cheap integrity check: the wasm must actually be WebAssembly.
|
||||
final magic = File('$root/web/sqlite3.wasm').openSync().readSync(4);
|
||||
expect(
|
||||
magic,
|
||||
orderedEquals([0x00, 0x61, 0x73, 0x6d]),
|
||||
reason:
|
||||
'web/sqlite3.wasm does not start with the WebAssembly magic '
|
||||
'bytes (\\0asm) — the download is corrupt or is not a wasm file.',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
@Tags(['rehearsal'])
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Rehearses the #335 migration against a COPY of a real 7 MB store.
|
||||
///
|
||||
/// Required by the plan before the migration may touch live data. Reads a
|
||||
/// snapshot from disk; it never opens the user's actual store. Skipped when
|
||||
/// the snapshot is absent, so CI does not depend on one machine's data.
|
||||
void main() {
|
||||
final path = Platform.environment['OFFBAND_REAL_STORE'];
|
||||
|
||||
test(
|
||||
'migrates a real 7 MB store with zero loss',
|
||||
() async {
|
||||
if (path == null || !File(path).existsSync()) {
|
||||
markTestSkipped(
|
||||
'set OFFBAND_REAL_STORE to a shared_preferences.json copy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final raw =
|
||||
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||
// Strip the `flutter.` prefix the plugin adds on disk.
|
||||
final seed = <String, Object>{
|
||||
for (final e in raw.entries)
|
||||
if (e.value is String || e.value is int || e.value is bool)
|
||||
e.key.replaceFirst('flutter.', ''): e.value as Object,
|
||||
};
|
||||
|
||||
final expected = <String, int>{
|
||||
for (final e in seed.entries)
|
||||
if (e.value is String && BlobStore.isBulkKey(e.key))
|
||||
e.key: (e.value as String).length,
|
||||
};
|
||||
|
||||
SharedPreferences.setMockInitialValues(seed);
|
||||
PrefsManager.reset();
|
||||
await PrefsManager.initialize();
|
||||
|
||||
final db = OffbandDatabase(NativeDatabase.memory());
|
||||
final store = BlobStore(db);
|
||||
final report = await store.migrateFromPrefs();
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'REHEARSAL: ${report.migrated} migrated, ${report.failed} failed, '
|
||||
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB, '
|
||||
'${expected.length} bulk keys expected',
|
||||
);
|
||||
|
||||
expect(report.failed, 0, reason: 'no key may fail on real data');
|
||||
expect(report.migrated, expected.length);
|
||||
|
||||
// Every byte accounted for, per key.
|
||||
for (final e in expected.entries) {
|
||||
final got = await store.read(e.key);
|
||||
expect(got, isNotNull, reason: '${e.key} missing after migration');
|
||||
expect(got!.length, e.value, reason: '${e.key} changed length');
|
||||
expect(
|
||||
PrefsManager.instance.get(e.key),
|
||||
isNull,
|
||||
reason: '${e.key} should be gone from prefs',
|
||||
);
|
||||
}
|
||||
|
||||
// Settings must survive untouched.
|
||||
final settings = seed.keys.where((k) => !BlobStore.isBulkKey(k));
|
||||
for (final k in settings) {
|
||||
expect(
|
||||
PrefsManager.instance.get(k),
|
||||
isNotNull,
|
||||
reason: 'setting $k was wrongly removed',
|
||||
);
|
||||
}
|
||||
|
||||
await db.close();
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 5)),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
# Cloudflare Pages response headers (#335, web storage via drift/SQLite-WASM).
|
||||
#
|
||||
# Syntax: a URL pattern, then indented headers. Cloudflare Pages reads this
|
||||
# file from the build output and can add, override or remove response headers.
|
||||
#
|
||||
# Two things drift needs on the web:
|
||||
#
|
||||
# 1. sqlite3.wasm MUST be served as Content-Type: application/wasm, or
|
||||
# WebAssembly streaming compilation refuses it.
|
||||
#
|
||||
# 2. COOP + COEP (cross-origin isolation) unlock drift's best storage backend,
|
||||
# OPFS (`opfsLocks`). Verified locally: with these headers drift logs
|
||||
# "Using WasmStorageImplementation.opfsLocks". WITHOUT them it still works,
|
||||
# falling back to IndexedDB, which is slower but still unbounded compared to
|
||||
# localStorage's 5 MiB cap. So these are a performance tier, not a
|
||||
# correctness requirement.
|
||||
#
|
||||
# Verify after the first deploy:
|
||||
# curl -sI https://<host>/sqlite3.wasm | grep -i 'content-type'
|
||||
# -> expect: content-type: application/wasm
|
||||
# and check the browser console for the opfsLocks line above.
|
||||
|
||||
/sqlite3.wasm
|
||||
Content-Type: application/wasm
|
||||
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
@ -0,0 +1 @@
|
||||
2.34.2
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -0,0 +1,87 @@
|
||||
// Standalone web probe for #335.
|
||||
//
|
||||
// Loads ONLY the drift stack, so a pass or fail is unambiguous: it exercises
|
||||
// sqlite3.wasm + drift_worker.js in a real browser without the rest of the app
|
||||
// (BLE, permissions, radio) confusing the result.
|
||||
//
|
||||
// Renders the outcome into the DOM so the result is readable without a
|
||||
// debugger attached.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
|
||||
void main() => runApp(const _ProbeApp());
|
||||
|
||||
class _ProbeApp extends StatelessWidget {
|
||||
const _ProbeApp();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const MaterialApp(
|
||||
home: Scaffold(body: Center(child: _Probe())),
|
||||
);
|
||||
}
|
||||
|
||||
class _Probe extends StatefulWidget {
|
||||
const _Probe();
|
||||
|
||||
@override
|
||||
State<_Probe> createState() => _ProbeState();
|
||||
}
|
||||
|
||||
class _ProbeState extends State<_Probe> {
|
||||
String _status = 'running…';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_run();
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
final lines = <String>[];
|
||||
OffbandDatabase? db;
|
||||
try {
|
||||
db = OffbandDatabase();
|
||||
|
||||
final ok = await db.verifyReadWrite();
|
||||
final r1 = ok
|
||||
? 'PROBE-OK open+write+read'
|
||||
: 'PROBE-FAIL readback mismatch';
|
||||
lines.add(r1);
|
||||
// ignore: avoid_print
|
||||
print(r1);
|
||||
|
||||
// The point of the migration: exceed the 5 MiB localStorage ceiling.
|
||||
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();
|
||||
final r2 = row.value.length == big.length
|
||||
? 'PROBE-OK 6MiB round-trip (localStorage cap is 5MiB)'
|
||||
: 'PROBE-FAIL 6MiB truncated to ${row.value.length}';
|
||||
lines.add(r2);
|
||||
// ignore: avoid_print
|
||||
print(r2);
|
||||
|
||||
await (db.delete(db.storedBlobs)..where((t) => t.key.equals('big'))).go();
|
||||
} catch (e) {
|
||||
lines.add('PROBE-FAIL $e');
|
||||
// ignore: avoid_print
|
||||
print('PROBE-FAIL $e');
|
||||
} finally {
|
||||
await db?.close();
|
||||
}
|
||||
if (mounted) setState(() => _status = lines.join('\n'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
_status,
|
||||
key: const Key('probe-result'),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in new issue