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

@@ -13,6 +13,7 @@ import 'screens/home_screen.dart';
import 'screens/vault_setup_screen.dart';
import 'services/database_service.dart';
import 'services/vault_service.dart';
import 'storage/sqlite_to_sidecar_migrator.dart';
Future<void> main() async {
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
@@ -98,6 +99,7 @@ class _VaultGateState extends State<VaultGate> {
bool _valid = false;
bool _hadStoredPath = false;
bool _loading = true;
bool _migrating = false;
@override
void initState() {
@@ -116,6 +118,27 @@ class _VaultGateState extends State<VaultGate> {
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
_loading = false;
});
if (valid) await _maybeMigrate(vault);
}
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
/// Gated on [VaultService.vaultMigrationDone]; a fresh install (no legacy DB)
/// is a fast no-op. The live DB is first reopened at the vault cache location
/// so post-migration reads hit the new index, never the renamed legacy file.
Future<void> _maybeMigrate(VaultService vault) async {
// Move the live cache DB to the vault location now that the root is valid.
await DatabaseService.reopen();
if (vault.vaultMigrationDone) return;
if (mounted) setState(() => _migrating = true);
try {
await SqliteToSidecarMigrator(vault).run();
await vault.setVaultMigrationDone();
} catch (_) {
// A failed migration leaves the legacy DB intact (it is only renamed to
// `.premigration` after a successful pass) and the flag unset, so the
// next launch retries. Never block the user from reaching the app.
}
if (mounted) setState(() => _migrating = false);
}
@override
@@ -125,11 +148,28 @@ class _VaultGateState extends State<VaultGate> {
body: Center(child: CircularProgressIndicator()),
);
}
if (_migrating) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Migrating your notebooks…'),
],
),
),
);
}
if (_valid) return const HomeScreen();
return VaultSetupScreen(
vaultService: _vault!,
missing: _hadStoredPath,
onVaultReady: () => setState(() => _valid = true),
onVaultReady: () async {
if (_vault != null) await _maybeMigrate(_vault!);
if (mounted) setState(() => _valid = true);
},
);
}
}