todo-app/lib/data/legacy_sqlite_migration.dart
Krzysztof kuhy Rudnicki 66143bdfe3 Make the app compile and run for web, behind platform seams
First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.

The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:

- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
  repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
  io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
  independently-migrating devices still resolve pre-cutover edits by real edit
  time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
  copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
  (added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
  tooling plus the todo MCP server read that path, so leaving it to rot was not
  an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
  profile is not a total-loss event for notes that have not reached GitHub yet.

`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:14:03 +02:00

91 lines
3.0 KiB
Dart

import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
// Only SqliteCrdt/CrdtTableExecutor are needed to read the legacy DB; hide its
// Hlc so the crdt_sync Hlc (the store's clock type) is unambiguous.
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
/// Reads a pre-crdt_sync `sqlite_crdt` database into a crdt_sync [Log].
///
/// Lives in its own `dart:io` library so [NoteRepository] can stay pure Dart:
/// the desktop app is a web build, and one `dart:io` import anywhere in the
/// graph fails the whole web compile. Mobile still runs this on first launch
/// after the cutover; web never does, because there is no legacy DB in a
/// browser profile.
class LegacySqliteMigration {
/// Whether a legacy database exists at [path] to migrate from.
static bool exists(String path) => File(path).existsSync();
/// Opens the legacy DB at [path] and returns its notes as a [Log], plus the
/// legacy CRDT node id.
///
/// Reusing the legacy node id keeps a device that already synced on the same
/// `devices/<id>` identity across the cutover, so it does not appear as a new
/// peer and re-upload everything.
static Future<({Log log, String nodeId})> read(
String path,
String preferredNodeId,
) async {
final legacy = await SqliteCrdt.open(
path,
version: _version,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
final nodeId = preferredNodeId.isEmpty ? legacy.nodeId : preferredNodeId;
final rows = await legacy.query(
'SELECT id, text, priority, status, created_at, updated_at, is_deleted '
'FROM notes',
);
final log = <String, Record>{};
for (final row in rows) {
final note = Note.fromRow(row);
log[note.id] = NoteRepository.legacyRecordFor(
note,
nodeId,
deleted: (row['is_deleted'] as int? ?? 0) != 0,
);
}
await legacy.close();
return (log: log, nodeId: nodeId);
}
static const int _version = 3;
// coverage:ignore-start
// Unreachable: migration only ever opens a legacy DB that already exists
// (guarded by [exists]), so sqlite never runs onCreate. Kept because
// SqliteCrdt.open requires the callback.
static Future<void> _onCreate(CrdtTableExecutor db, int version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 2,
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
}
// coverage:ignore-end
static Future<void> _onUpgrade(
CrdtTableExecutor db,
int from,
int to,
) async {
if (from < 2) {
await db.execute(
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',
);
}
if (from < 3) {
await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0');
}
}
}