feat(storage): one-time SQLite to sidecar migration
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:
2026-06-24 23:05:10 +08:00
parent f4f0853eae
commit 4886f1b2df
6 changed files with 1070 additions and 4 deletions

View File

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

View File

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