Merge branch 'feat/335-drift-storage' into integration/290-306-335

# Conflicts:
#	lib/storage/contact_discovery_store.dart
#	lib/storage/contact_store.dart
#	lib/storage/message_store.dart
integration/290-306-335
Strycher 1 day ago
commit 4cf2c632df

1
.gitignore vendored

@ -30,7 +30,6 @@ migrate_working_dir/
.flutter-plugins-dependencies
.pub-cache/
.pub/
pubspec.lock
/build/
/coverage/
# fvm project files

@ -27,6 +27,7 @@ import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart';
import 'services/block_service.dart';
import 'storage/drift/blob_store.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@ -36,6 +37,14 @@ void main() async {
// Initialize SharedPreferences cache
await PrefsManager.initialize();
// Move bulk data (message history, contacts) out of SharedPreferences into
// drift (#335). Must run after prefs are up and BEFORE any store reads, so
// no code sees a half-migrated state. Idempotent: a no-op once done.
//
// Deliberately awaited: the alternative is stores racing the migration, and
// this is the failure mode that produced #333.
await BlobStore.instance.migrateFromPrefs();
// Start always-on file logging (#97); no-op on web.
await FileLogService.instance.init();

@ -5,7 +5,7 @@ import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart';
import '../models/translation_support.dart';
import '../helpers/smaz.dart';
import 'prefs_manager.dart';
import 'drift/blob_store.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
@ -65,9 +65,11 @@ class ChannelMessageStore {
);
return;
}
final prefs = PrefsManager.instance;
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
await prefs.setString(_storageKey(channelIndex), jsonEncode(jsonList));
await BlobStore.instance.write(
_storageKey(channelIndex),
jsonEncode(jsonList),
);
}
/// Load messages for a specific channel
@ -78,9 +80,11 @@ class ChannelMessageStore {
);
return [];
}
final prefs = PrefsManager.instance;
final blobs = BlobStore.instance;
final key = _storageKey(channelIndex);
String? jsonString = prefs.getString(key);
// Bulk data lives in drift (#335); the fallback covers an unmigrated key
// and logs loudly if it fires.
String? jsonString = await blobs.readWithPrefsFallback(key);
// One-time migration into the PSK-identity key. Only runs when the PSK is
// known (key != index key). Adopts pre-#194 history keyed by slot index
@ -93,13 +97,15 @@ class ChannelMessageStore {
_indexKey(channelIndex),
'$_keyPrefix$channelIndex', // pre-device-scoping, unscoped index key
]) {
final legacy = prefs.getString(legacyKey);
// Legacy keys may sit in either backend depending on when this
// install last ran, so check both.
final legacy = await blobs.readWithPrefsFallback(legacyKey);
if (legacy != null && legacy.isNotEmpty) {
appLogger.info(
'Migrating channel messages $legacyKey -> $key (PSK-keyed, #194)',
);
await prefs.setString(key, legacy);
await prefs.remove(legacyKey);
await blobs.write(key, legacy);
await blobs.deleteEverywhere(legacyKey);
jsonString = legacy;
break;
}
@ -122,17 +128,16 @@ class ChannelMessageStore {
/// history gone, and setChannel's reuse-clear (#193) needs any stale
/// slot-index history gone so it can't be migrated onto the new occupant.
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance;
await prefs.remove(_storageKey(channelIndex));
await prefs.remove(_indexKey(channelIndex));
final blobs = BlobStore.instance;
await blobs.deleteEverywhere(_storageKey(channelIndex));
await blobs.deleteEverywhere(_indexKey(channelIndex));
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)).toList();
for (var key in keys) {
await prefs.remove(key);
final blobs = BlobStore.instance;
for (final key in await blobs.keysWithPrefix(keyFor)) {
await blobs.deleteEverywhere(key);
}
}

@ -2,15 +2,16 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/contact.dart';
import 'prefs_manager.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
class ContactDiscoveryStore {
static const String _keyPrefix = 'discovered_contacts';
Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_keyPrefix);
// Bulk data lives in drift (#335), not SharedPreferences. The fallback
// covers a key the migration has not moved yet and logs loudly if it fires.
final jsonStr = await BlobStore.instance.readWithPrefsFallback(_keyPrefix);
if (jsonStr == null) return [];
try {
@ -19,9 +20,7 @@ class ContactDiscoveryStore {
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
// SAFELANE 6: a decode failure is not "no data".
appLogger.error(
'Failed to decode discovered contacts: $e',
tag: 'Storage',
@ -31,9 +30,8 @@ class ContactDiscoveryStore {
}
Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
await BlobStore.instance.write(_keyPrefix, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {

@ -3,6 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart';
class ContactStore {
@ -19,25 +20,27 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(keyFor);
// Bulk data lives in drift (#335). The fallback covers a key the migration
// has not moved yet, and logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
// Pre-device-scoping data still sits under the legacy unscoped key in
// SharedPreferences. Only touch prefs when it actually exists: an
// unconditional remove costs a full-file rewrite on Windows and a
// 5 MiB-capped synchronous write on web (#306).
final prefs = PrefsManager.instance;
final legacy = prefs.get(_keyPrefix);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
'Migrating contacts from legacy key $_keyPrefix to $keyFor (drift)',
);
await prefs.setString(keyFor, legacyJsonString);
await blobs.write(keyFor, legacy);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
jsonString = legacy;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
@ -47,11 +50,7 @@ class ContactStore {
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode contacts: $e', tag: 'Storage');
} catch (_) {
return [];
}
}
@ -61,9 +60,8 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(keyFor, jsonEncode(jsonList));
await BlobStore.instance.write(keyFor, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {

@ -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};
}

@ -4,6 +4,7 @@ import '../models/message.dart';
import '../models/translation_support.dart';
import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart';
class MessageStore {
@ -23,10 +24,9 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList));
await BlobStore.instance.write(key, jsonEncode(jsonList));
}
Future<List<Message>> loadMessages(String contactKeyHex) async {
@ -34,29 +34,30 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot load messages.');
return [];
}
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
String? jsonString = prefs.getString(key);
// Bulk data lives in drift (#335); fallback covers an unmigrated key and
// logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only touch storage when a legacy key actually exists. This ran
// unconditionally, and loadMessages is called once per contact during a
// contact pull. On Windows shared_preferences rewrites the WHOLE prefs
// file on every mutation, so each no-op remove cost a full multi-MB
// write - hundreds of them back to back on a large address book.
final legacyJsonString = prefs.getString(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
// Only touch prefs when the legacy key actually exists. An unconditional
// remove here ran once per contact and cost a full-file rewrite each
// time on Windows (#306).
final prefs = PrefsManager.instance;
final legacy = prefs.get(oldKey);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key',
'Migrating messages from legacy key $oldKey to $key (drift)',
);
await prefs.setString(key, legacyJsonString);
await blobs.write(key, legacy);
await prefs.remove(oldKey);
jsonString = legacyJsonString;
jsonString = legacy;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
jsonString = await blobs.readWithPrefsFallback(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
@ -66,13 +67,6 @@ class MessageStore {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error(
'Failed to decode messages for $contactKeyHex: $e',
tag: 'Storage',
);
return [];
}
}
@ -82,9 +76,11 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot clear messages.');
return;
}
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex';
await prefs.remove(key);
// Clear both backends: drift is authoritative, but a pre-migration prefs
// copy must not survive a clear and reappear via the read fallback.
await BlobStore.instance.delete(key);
await PrefsManager.instance.remove(key);
}
Map<String, dynamic> _messageToJson(Message msg) {

File diff suppressed because it is too large Load Diff

@ -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,12 @@ dependencies:
ml_dataframe: ^1.0.0
llamadart: '>=0.6.8 <0.7.0'
flutter_langdetect: ^0.0.1
# PINNED (#335): web ships version-matched sqlite3.wasm + drift_worker.js
# from the drift release. A caret here would let pub resolve a newer drift
# than the committed assets, breaking web silently. Bump deliberately, then
# re-download both assets. tool/check_drift_assets.dart enforces the match.
drift: 2.34.2
drift_flutter: 0.3.1
hooks:
user_defines:
@ -95,6 +101,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

@ -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

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…
Cancel
Save

Powered by TurnKey Linux.