You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/storage/drift/offband_database.dart

508 lines
21 KiB

import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../utils/app_logger.dart';
// sqlite3 uses dart:ffi, which is unavailable on web; import the native impl
// only off web so `flutter build web` still compiles (#363).
import 'db_snapshot_web.dart' if (dart.library.io) 'db_snapshot_io.dart';
part 'offband_database.g.dart';
/// Thrown when the database on disk is at a NEWER schema than this build
/// understands (#943).
///
/// The upgrade to schema 2 is one-way by design, so this is a refusal and not
/// a recoverable condition. It exists to make that refusal legible: drift
/// would otherwise run this build's `onUpgrade` for a downgrade and then stamp
/// `user_version` backwards, leaving no trace that the database had been on a
/// newer build.
class OffbandSchemaDowngrade implements Exception {
const OffbandSchemaDowngrade({
required this.from,
required this.to,
this.databasePath,
});
/// The schema the database on disk is at.
final int from;
/// The newest schema this build understands.
final int to;
/// Where that database is, when it could be resolved.
final String? databasePath;
@override
String toString() =>
'OffbandSchemaDowngrade: this database is at schema $from and this '
'build only understands $to. A newer version of the app has already '
'migrated it, and that upgrade is one-way, so this build cannot open '
'it. Install the newer version again, or restore a pre-migration copy '
'of the database, which loses everything received since.'
'${databasePath == null ? '' : ' Database: $databasePath'}';
}
/// 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, MessageRecords])
class OffbandDatabase extends _$OffbandDatabase {
OffbandDatabase([QueryExecutor? executor])
: super(executor ?? _defaultExecutor());
static const String _dbName = 'offband_store';
/// `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.js`).
static QueryExecutor _defaultExecutor() {
return driftDatabase(
name: _dbName,
// The DB directory is PINNED (see _pinnedDatabaseDirectory): a constant
// path that never depends on the exe's version metadata or launch
// location, so builds cannot diverge onto different databases (#363).
// This also keeps the file out of the Documents/OneDrive dir (the native
// default), where syncing a live SQLite file risks lock contention and
// corruption. path_provider has no web implementation, so this only
// applies off web; web ignores databaseDirectory and uses OPFS/IndexedDB.
native: DriftNativeOptions(databaseDirectory: _pinnedDatabaseDirectory),
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'),
),
);
}
/// Resolves the ONE fixed directory the database lives in, and migrates a DB
/// left in an older location by a previous build into it.
///
/// The path is pinned so it never depends on the executable's version
/// metadata or launch location (#363). `getApplicationSupportDirectory()` on
/// Windows derives the folder from the exe's embedded Company/Product
/// strings, so two builds - or a future rebrand - would read DIFFERENT
/// databases and the history would appear to vanish. On Windows the DB is
/// therefore pinned to `%APPDATA%\Offband MeshCore`, a constant.
///
/// On first run at the pinned location, the richest `offband_store.sqlite`
/// from the old locations (the metadata-derived support dir; the
/// documents/OneDrive dir; the earlier nested support path) is copied in - a
/// copy, never a move: the source is left intact so nothing is stranded if
/// anything goes wrong.
static Future<Directory> _pinnedDatabaseDirectory() async {
final dir = await _resolvePinnedDir();
if (!dir.existsSync()) dir.createSync(recursive: true);
final target = p.join(dir.path, '$_dbName.sqlite');
if (!File(target).existsSync()) {
try {
await _importRichestLegacyDatabase(target);
} catch (e) {
// Best-effort: on failure every SOURCE is left intact and any partial
// destination is rolled back, so drift opens a fresh DB and the old
// data stays recoverable. Loud, never silent (SAFELANE 6).
appLogger.error(
'Failed to migrate an existing drift DB into the pinned directory '
'($target): $e. Sources are intact; a fresh DB will open here and the '
'old data remains recoverable.',
tag: 'Storage',
);
}
}
return dir;
}
/// The fixed directory: `<data root>/Offband MeshCore`, a constant.
///
/// Windows uses `%APPDATA%`. Linux is pinned too, because its support dir
/// derives from the executable NAME and so has the same divergence bug this
/// fixes; it uses `$XDG_DATA_HOME` (or `~/.local/share`). macOS is left on
/// `getApplicationSupportDirectory()`, which is keyed by the stable bundle id.
static Future<Directory> _resolvePinnedDir() async {
if (Platform.isWindows) {
final appData = Platform.environment['APPDATA'];
if (appData != null && appData.isNotEmpty) {
return Directory(p.join(appData, _pinnedFolder));
}
}
if (Platform.isLinux) {
final xdg = Platform.environment['XDG_DATA_HOME'];
final home = Platform.environment['HOME'];
final base = (xdg != null && xdg.isNotEmpty)
? xdg
: (home != null && home.isNotEmpty
? p.join(home, '.local', 'share')
: null);
if (base != null) return Directory(p.join(base, _pinnedFolder));
}
return getApplicationSupportDirectory();
}
static const String _pinnedFolder = 'Offband MeshCore';
/// Absolute path of the pinned database file drift opens. #367.
static Future<String> pinnedDatabasePath() async {
final dir = await _resolvePinnedDir();
return p.join(dir.path, '$_dbName.sqlite');
}
/// Every candidate store path on this machine: the known canonical locations
/// (metadata-derived support dir; documents/OneDrive dir) plus the bounded
/// app-data scan. Used to consolidate stranded stores (#367). May include the
/// pinned path and non-existent paths; callers filter.
static Future<List<String>> discoverStorePaths() async {
final paths = <String>{};
try {
final support = await getApplicationSupportDirectory();
paths.add(p.join(support.path, '$_dbName.sqlite'));
} catch (_) {}
try {
final docs = await getApplicationDocumentsDirectory();
paths.add(p.join(docs.path, '$_dbName.sqlite'));
} catch (_) {}
paths.addAll(_scanAppDataForDatabases());
return paths.toList();
}
/// Snapshots the legacy DB (chosen from the known and scanned locations) into
/// [target] (which must not yet exist), leaving the source intact. A no-op on
/// a fresh install where no legacy DB exists.
static Future<void> _importRichestLegacyDatabase(String target) async {
// Prefer the KNOWN canonical locations in priority order: these are where a
// normal prior build wrote (the metadata-derived support dir, then the
// documents/OneDrive dir the first builds used). Using them directly avoids
// the size heuristic picking a larger-but-junk DB from some other build.
final known = <String>[];
try {
final support = await getApplicationSupportDirectory();
known.add(p.join(support.path, '$_dbName.sqlite'));
} catch (_) {}
try {
final docs = await getApplicationDocumentsDirectory();
known.add(p.join(docs.path, '$_dbName.sqlite'));
} catch (_) {}
final scanned = _scanAppDataForDatabases()
.where((c) => c != target)
.toList();
final source = chooseMigrationSource(
known.where((c) => c != target).toList(),
scanned,
);
if (source == null) return; // fresh install, nothing to migrate
snapshotDatabase(source, target);
appLogger.info(
'Migrated drift DB into the pinned directory: $source -> $target '
'(source left intact)',
tag: 'Storage',
);
}
/// Writes a consistent snapshot of the [source] DB to [target] via SQLite
/// `VACUUM INTO`, which takes a read transaction and so produces a correct
/// snapshot even if the source is being written by another process (an old
/// build left running) - the failure mode a raw file copy of a live WAL
/// database has (Gemini review).
///
/// On ANY failure it deletes the partial output and RETHROWS - it does NOT
/// fall back to a file copy. VACUUM most often fails precisely because a
/// writer holds a lock, which is exactly when a raw copy would be unsafe, so
/// the safe move is to abort: the source is untouched and drift opens a fresh
/// DB, keeping the history recoverable.
///
/// The source's main and `-wal` files are not written; SQLite may create or
/// touch the temporary `-shm` file next to the source when opening a WAL DB.
/// Visible for testing.
@visibleForTesting
static void snapshotDatabase(String source, String target) {
try {
vacuumInto(source, target);
final out = File(target);
if (!out.existsSync() || out.lengthSync() == 0) {
throw StateError('VACUUM INTO produced no output at $target');
}
} catch (_) {
final out = File(target);
if (out.existsSync()) {
try {
out.deleteSync();
} catch (_) {}
}
rethrow;
}
}
/// Bounded scan of the app-data roots for the DB file, so one left by a build
/// whose folder name we do not know (its metadata differed) is still found.
static List<String> _scanAppDataForDatabases() {
final roots = <Directory>[];
for (final env in const ['APPDATA', 'LOCALAPPDATA']) {
final root = Platform.environment[env];
if (root == null || root.isEmpty) continue;
final dir = Directory(root);
if (dir.existsSync()) roots.add(dir);
}
return findDatabaseFiles(roots, '$_dbName.sqlite');
}
/// Depth-limited, entry-capped search of [roots] for files named [fileName].
///
/// No directory-name blocklist: a build's data can legitimately sit under a
/// folder named after ANY company string (e.g. `%APPDATA%\Microsoft\...` for
/// a runner that never set custom metadata), so skipping by name would lose
/// exactly the data this migration exists to preserve. Instead the walk is
/// bounded by [maxDepth] and a hard [maxEntries] visit cap so a pathological
/// tree (a folder with hundreds of thousands of files, a slow network drive)
/// cannot stall startup. Symlinks are not followed, so it cannot cycle;
/// unreadable directories are skipped. Visible for testing.
@visibleForTesting
static List<String> findDatabaseFiles(
List<Directory> roots,
String fileName, {
int maxDepth = 2,
int maxEntries = 50000,
}) {
final found = <String>[];
var visited = 0;
void walk(Directory dir, int depth) {
List<FileSystemEntity> entries;
try {
entries = dir.listSync(followLinks: false);
} catch (_) {
return; // permission denied etc.
}
for (final e in entries) {
if (visited++ >= maxEntries) return;
if (e is File) {
if (p.basename(e.path) == fileName) found.add(e.path);
} else if (e is Directory && depth < maxDepth) {
walk(e, depth + 1);
}
}
}
for (final root in roots) {
walk(root, 0);
}
return found;
}
/// Picks the DB to migrate: the first existing, non-empty path in
/// [knownInPriority] (the canonical locations a normal prior build wrote to),
/// else the largest DB in [scanned] (the unknown-folder fallback).
///
/// Preferring the known locations stops a larger-but-junk DB from some other
/// build (a size-heuristic misfire) from beating the real one. Visible for
/// testing.
@visibleForTesting
static String? chooseMigrationSource(
List<String> knownInPriority,
List<String> scanned,
) {
for (final k in knownInPriority) {
final f = File(k);
if (f.existsSync() && f.lengthSync() > 0) return k;
}
return richestExisting(scanned);
}
/// Returns the existing, non-empty candidate path with the LARGEST main file,
/// or null if none exist. Choosing by size guarantees a small/fresh DB never
/// wins over one holding real history. Visible for testing.
@visibleForTesting
static String? richestExisting(List<String> candidatePaths) {
String? best;
int bestSize = 0;
for (final path in candidatePaths) {
final f = File(path);
if (!f.existsSync()) continue;
final size = f.lengthSync();
if (size > bestSize) {
bestSize = size;
best = path;
}
}
return best;
}
@override
int get schemaVersion => 2;
/// Schema 2 adds [MessageRecords] (#920). Purely additive: [StoredBlobs] is
/// untouched and keeps every byte it had.
///
/// That is true of the data and NOT of a downgrade, which is what #943
/// corrects. drift has no "newer schema" refusal: it compares the stored
/// `user_version` against [schemaVersion] for inequality and routes BOTH
/// directions into this callback. The pre-#917 build declared schema 1 and
/// no strategy at all, so it got drift's default `onUpgrade`, which throws,
/// and so it could never reach the blobs it would have found.
///
/// A downgrade therefore has to be refused HERE, which is what the first
/// branch below does (#943, owner decision 2026-10-22). Without it the
/// `from < 2` guard let a future schema open silently, and drift then
/// stamped `user_version` back down, erasing the only record the database
/// had ever been on a newer build.
///
/// Downgrades are deliberately unsupported rather than made to work. Once a
/// key is on rows its blob is frozen, so an older build would read stale
/// history and write into the blob while this one reads rows, orphaning
/// everything it received. Supporting a downgrade would mean dual-writing
/// the blob on every save, which is the cost #917 exists to remove.
/// `test/storage/schema_downgrade_test.dart` pins all of this.
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from > to) {
// Logged BEFORE the path lookup and before the throw, deliberately.
// This runs inside drift's beforeOpen, so it fires on whichever
// isolate issued the first query, and whoever catches the exception
// decides what the user sees. A downgrade that only stopped
// background work, with nothing in the log, would be the quiet
// failure this branch exists to replace (Gemini review).
appLogger.error(
'Refusing to open the database: it is at schema $from and this '
'build understands $to. The upgrade to a newer schema is one-way '
'(#943).',
tag: 'Storage',
);
// Best-effort: path_provider has no web implementation, and on the
// platforms where a downgrade is reachable this reads environment
// variables rather than a platform channel, and has already resolved
// once to open this database. A message without the path still beats
// the silent version rewrite it replaces.
String? path;
try {
path = await pinnedDatabasePath();
} catch (_) {}
throw OffbandSchemaDowngrade(from: from, to: to, databasePath: path);
}
if (from < 2) {
await m.createTable(messageRecords);
await m.createIndex(
Index(
'idx_message_records_store_ts',
'CREATE INDEX IF NOT EXISTS idx_message_records_store_ts '
'ON message_records (store_key, timestamp_ms)',
),
);
appLogger.info(
'drift schema $from -> $to: created message_records (#920). '
'stored_blobs untouched.',
);
}
},
beforeOpen: (details) async {
// A fresh database is created at the current schema, so onUpgrade never
// runs for it and the index would be missing.
if (details.wasCreated) {
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_message_records_store_ts '
'ON message_records (store_key, timestamp_ms)',
);
}
},
);
/// 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};
}
/// One row per message, replacing one element of a [StoredBlobs] JSON array
/// each (#920).
///
/// Why this exists: with the whole history in one blob, saving a single message
/// decoded, rebuilt and re-encoded every message that came before it. On the
/// owner's largest channel that was 9,230 records and 134 ms of work per
/// arriving message, and #919 could only move that cost to another isolate,
/// not remove it. One row per message makes a save an upsert of one row.
///
/// [storeKey] is deliberately the SAME key the blob used, not a new scheme.
/// All of the per-radio and per-channel scoping rules (#194, #472) already live
/// in those keys, so reusing them means this table inherits that scoping
/// unchanged, and the migration becomes a per-key operation with an exact
/// record count to verify against.
///
/// [payload] holds the per-message JSON exactly as the stores already emit it,
/// rather than a column per field. That keeps the migration a pure re-key with
/// no field mapping that could silently drop or coerce a value, lets the
/// stores reuse their existing encoders, and means a new message field needs
/// no second data migration. The trade: message fields are not SQL-queryable.
/// Columns can be added beside the payload later without another migration if
/// that is ever wanted.
class MessageRecords extends Table {
@override
String get tableName => 'message_records';
/// The store key this message belongs to, byte-identical to the blob key.
TextColumn get storeKey => text()();
/// Stable per-message identity: the stores' existing `_mergeKey`. Upserting
/// on it preserves exactly the dedupe semantics the blob merge had.
TextColumn get mergeKey => text()();
/// Sender-claimed time in epoch milliseconds, for ordering and paging. An
/// INTEGER column so ORDER BY and LIMIT happen in SQLite, not in Dart.
IntColumn get timestampMs => integer()();
TextColumn get payload => text()();
@override
Set<Column> get primaryKey => {storeKey, mergeKey};
}

Powered by TurnKey Linux.