feat(storage): one-time SQLite to sidecar migration
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 5. On first launch with a valid vault, migrate legacy SQLite data into vault sidecars so nothing is lost on upgrade. - SqliteToSidecarMigrator: documents (+ per-page ink, scratch-links + scratchpads, bookmarks) -> notebook folder + <file>.badnote.json; notes (+ strokes) -> notebook.badnote.json. Reuses existing JSON. - Idempotent (skips already-migrated targets); missing source files still get their annotations migrated. - DB relocates to <vault>/.badnote/index.sqlite; the legacy DB is renamed to .premigration ONLY after a successful pass, so a failed migration leaves data intact and the run-once flag unset. - main.dart runs it once, gated on vaultMigrationDone. Golden migrator tests (seeded legacy DB -> sidecars, idempotent re-run, legacy preserved). analyze clean, tests green.
This commit is contained in:
@@ -5,10 +5,12 @@ import 'dart:ui' show Offset, Size;
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../editor/board/board.dart';
|
||||
import '../editor/engine/stroke_model.dart';
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/document.dart' as doc;
|
||||
import '../models/ink_point.dart';
|
||||
@@ -17,6 +19,7 @@ import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/scratch_link.dart';
|
||||
import 'vault_service.dart';
|
||||
|
||||
class DatabaseService {
|
||||
static DatabaseService? _instance;
|
||||
@@ -46,14 +49,30 @@ class DatabaseService {
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
/// Re-resolve the DB location and reopen the singleton there. Called once the
|
||||
/// vault root becomes valid at startup so the live database moves from the
|
||||
/// legacy app-documents `badnote.db` to the vault cache
|
||||
/// `<vault>/.badnote/index.sqlite` (§A.1). No-op-safe: if the resolved path is
|
||||
/// unchanged it simply reopens the same file. Closes the previous handle.
|
||||
static Future<DatabaseService> reopen() async {
|
||||
final existing = _instance;
|
||||
if (existing != null) {
|
||||
await existing._database.close();
|
||||
_instance = null;
|
||||
}
|
||||
return getInstance();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = p.join(dir.path, 'badnote.db');
|
||||
final dbPath = await _resolveDbPath();
|
||||
// Ensure the parent dir exists (the vault's hidden `.badnote/` cache dir is
|
||||
// not guaranteed to exist yet on first run).
|
||||
await Directory(p.dirname(dbPath)).create(recursive: true);
|
||||
|
||||
_database = await openDatabase(
|
||||
dbPath,
|
||||
@@ -63,6 +82,36 @@ class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
/// The application-documents path of the LEGACY (pre-vault) database. This is
|
||||
/// the location [DatabaseService] used before the file-based re-architecture;
|
||||
/// the one-time migrator reads from here, then renames it to `.premigration`.
|
||||
static Future<String> legacyDbPath() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return p.join(dir.path, 'badnote.db');
|
||||
}
|
||||
|
||||
/// Resolve where the live database should live. When a valid vault root is
|
||||
/// set, the DB is the vault's rebuildable cache/index at
|
||||
/// `<vault>/.badnote/index.sqlite` (§A.1). Otherwise (no vault yet — e.g. a
|
||||
/// fresh first run before the gate, or tests) fall back to the legacy
|
||||
/// app-documents `badnote.db` so the app still works.
|
||||
Future<String> _resolveDbPath() async {
|
||||
String? root;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
root = prefs.getString(VaultService.vaultRootKey);
|
||||
} catch (_) {
|
||||
// SharedPreferences may be unavailable (e.g. a unit test that mocks only
|
||||
// the path provider). Fall back to the legacy app-documents location so
|
||||
// the DB still opens — it is never the source of truth anyway.
|
||||
root = null;
|
||||
}
|
||||
if (root != null && root.isNotEmpty && await Directory(root).exists()) {
|
||||
return p.join(root, '.badnote', 'index.sqlite');
|
||||
}
|
||||
return legacyDbPath();
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
// Core tables (original v1)
|
||||
await db.execute('''
|
||||
@@ -1083,4 +1132,176 @@ class DatabaseService {
|
||||
await txn.delete('scratchpads', where: 'document_id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── RAW legacy reads (one-time SQLite→sidecar migration, Phase 5) ───────────
|
||||
//
|
||||
// These operate on an arbitrary [Database] handle (the LEGACY db the migrator
|
||||
// opens directly), NOT the live [_database] cache, so the migrator can read
|
||||
// pre-migration data without touching the relocated index. They reuse this
|
||||
// class's row-parsers so the JSON shapes stay identical to the live reads.
|
||||
|
||||
/// All `documents` rows from [db], oldest first (stable migration order).
|
||||
static Future<List<doc.Document>> rawAllDocuments(Database db) async {
|
||||
if (!await _tableExists(db, 'documents')) return const [];
|
||||
final rows = await db.query('documents', orderBy: 'created_at ASC');
|
||||
final dummy = DatabaseService._();
|
||||
return rows.map(dummy._documentFromRow).toList();
|
||||
}
|
||||
|
||||
/// All `notes` rows (with their `strokes`) from [db], oldest first.
|
||||
static Future<List<Note>> rawAllNotes(Database db) async {
|
||||
if (!await _tableExists(db, 'notes')) return const [];
|
||||
final rows = await db.query('notes', orderBy: 'created_at ASC');
|
||||
final dummy = DatabaseService._();
|
||||
final notes = <Note>[];
|
||||
for (final row in rows) {
|
||||
final strokeRows = await db.query(
|
||||
'strokes',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [row['id'] as String],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
final strokes = strokeRows.map(dummy._strokeFromRow).toList();
|
||||
final tagsJson = jsonDecode(row['tags'] as String) as List;
|
||||
notes.add(Note(
|
||||
id: row['id'] as String,
|
||||
title: row['title'] as String,
|
||||
strokes: strokes,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
tags: tagsJson.cast<String>(),
|
||||
));
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Committed editor strokes for [documentId] from [db], grouped by 0-based
|
||||
/// page index. Parses the `ink.host_id = "doc:<documentId>:page:<i>"` scheme
|
||||
/// (see [EditorRepository.loadDocument]) and decodes each `stroke_json`
|
||||
/// straight into an [EditorStroke]. Returns `{}` when there is no `ink` table
|
||||
/// or no rows.
|
||||
static Future<Map<int, List<EditorStroke>>> rawStrokesByPage(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'ink')) return <int, List<EditorStroke>>{};
|
||||
final rows = await db.query(
|
||||
'ink',
|
||||
where: 'host_kind = ? AND host_id LIKE ?',
|
||||
whereArgs: ['page', 'doc:$documentId:page:%'],
|
||||
orderBy: 'host_id ASC, ordinal ASC',
|
||||
);
|
||||
final out = <int, List<EditorStroke>>{};
|
||||
for (final row in rows) {
|
||||
final hostId = row['host_id'] as String;
|
||||
final pageIndex = _pageIndexFromHostId(hostId);
|
||||
if (pageIndex == null) continue;
|
||||
final json =
|
||||
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
|
||||
out.putIfAbsent(pageIndex, () => []).add(EditorStroke.fromJson(json));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Bookmarks for [documentId] from [db] (empty when no `bookmarks` table).
|
||||
static Future<List<Bookmark>> rawBookmarks(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'bookmarks')) return const [];
|
||||
final rows = await db.query(
|
||||
'bookmarks',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
final dummy = DatabaseService._();
|
||||
return rows.map(dummy._bookmarkFromRow).toList();
|
||||
}
|
||||
|
||||
/// Scratch-link anchors for [documentId] from [db] (empty when no table).
|
||||
static Future<List<ScratchLink>> rawScratchLinks(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'scratch_links')) return const [];
|
||||
final rows = await db.query(
|
||||
'scratch_links',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows
|
||||
.map(
|
||||
(row) => ScratchLink(
|
||||
id: row['id'] as String,
|
||||
documentId: row['document_id'] as String,
|
||||
pageIndex: row['page_index'] as int,
|
||||
nx: (row['nx'] as num).toDouble(),
|
||||
ny: (row['ny'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Scratchpad strokes stored under [key] (an anchor id) from [db]. Empty when
|
||||
/// there is no `scratchpads` table or no row.
|
||||
static Future<List<InkStroke>> rawScratchpad(
|
||||
Database db,
|
||||
String key,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'scratchpads')) return const [];
|
||||
final rows = await db.query(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
if (rows.isEmpty) return const [];
|
||||
final json = rows.first['strokes_json'] as String;
|
||||
if (json.isEmpty || json == '[]') return const [];
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Raw legacy per-page `annotation_json` blobs for [documentId] from [db],
|
||||
/// keyed by page number. These belong to the DEAD pre-editor annotation path
|
||||
/// (§1 `annotations` table); the migrator copies them verbatim into the
|
||||
/// sidecar's `legacyAnnotations` so nothing is silently dropped.
|
||||
static Future<Map<int, String>> rawLegacyAnnotations(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'annotations')) return <int, String>{};
|
||||
final rows = await db.query(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
final out = <int, String>{};
|
||||
for (final row in rows) {
|
||||
out[row['page_number'] as int] = row['annotation_json'] as String;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Parse the 0-based page index out of an `ink.host_id` of the form
|
||||
/// `doc:<documentId>:page:<pageIndex>`. Returns null on an unexpected shape.
|
||||
static int? _pageIndexFromHostId(String hostId) {
|
||||
final i = hostId.lastIndexOf(':page:');
|
||||
if (i == -1) return null;
|
||||
return int.tryParse(hostId.substring(i + ':page:'.length));
|
||||
}
|
||||
|
||||
/// True iff [name] is an existing table in [db]. Lets the raw readers tolerate
|
||||
/// a legacy DB that predates a given table (older schema versions).
|
||||
static Future<bool> _tableExists(Database db, String name) async {
|
||||
final rows = await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
[name],
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ class VaultService {
|
||||
/// SharedPreferences key under which the vault root path is stored.
|
||||
static const String vaultRootKey = 'vaultRoot';
|
||||
|
||||
/// SharedPreferences key gating the one-time SQLite→sidecar migration
|
||||
/// (Phase 5). Set true once the migration completes so it never re-runs.
|
||||
static const String vaultMigrationDoneKey = 'vaultMigrationDone';
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
VaultService._(this._prefs);
|
||||
@@ -132,6 +136,15 @@ class VaultService {
|
||||
await _prefs.remove(vaultRootKey);
|
||||
}
|
||||
|
||||
/// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
|
||||
/// When false, startup runs the migrator before opening the home screen.
|
||||
bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;
|
||||
|
||||
/// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
|
||||
Future<void> setVaultMigrationDone() async {
|
||||
await _prefs.setBool(vaultMigrationDoneKey, true);
|
||||
}
|
||||
|
||||
/// True iff a vault root is set AND that directory currently exists.
|
||||
///
|
||||
/// Returns false when no path is stored or when the stored path no longer
|
||||
|
||||
Reference in New Issue
Block a user