todo-app/lib/data/web_log_persistence.dart
Krzysztof kuhy Rudnicki c37a9ef118 Add the desktop wrapper, and stop the test suite eating BACKLOG.md
The desktop app is a browser now, so it cannot write files. This adds the other
half: a loopback HTTP server that serves the web build and owns the two files
the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and
the todo MCP server read, and a disk copy of the note log so a wiped Chrome
profile is not a total-loss event for unsynced notes.

Notable details:

- Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home
  with no authentication, so a routable bind would let anything on the network
  rewrite the backlog.
- Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything
  else and the app then renders nothing at all.
- Mirroring to disk is debounced. The store persists on every keystroke, so
  mirroring eagerly meant an HTTP request plus a whole-log write per character;
  IndexedDB still gets every keystroke, only the durability copy waits.

Separately, this fixes a pre-existing destructive bug found while testing:
`settings_screen_test` did real file I/O and overwrote the user's canonical
`~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It
went unnoticed because the app rewrites the file whenever it launches. The
export destination is now injectable and the test redirects it to a temp dir;
verified that a full suite run leaves the real file byte-identical.

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

95 lines
3.3 KiB
Dart

import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:idb_shim/idb.dart';
import 'package:todo/data/desktop_backup_client.dart';
/// [LogPersistence] backed by IndexedDB, with a best-effort copy pushed to the
/// desktop wrapper so the notes also exist on disk.
///
/// IndexedDB rather than `localStorage` because the latter caps at roughly
/// 5-10MB per origin and is evicted more eagerly; the note log is the primary
/// copy of the backlog and has to outlive storage pressure.
///
/// The disk copy exists because moving the desktop app into a browser would
/// otherwise leave a wiped Chrome profile as a total-loss event for notes that
/// have not synced to GitHub yet. It is deliberately fire-and-forget: the
/// wrapper is an optimisation, and the app must stay fully usable when it is
/// absent (for instance when the same build is opened in a plain browser tab).
class WebLogPersistence implements LogPersistence {
/// Creates a persistence over [database], mirroring writes to [backup].
WebLogPersistence({
required Database database,
this.backup,
this.mirrorDebounce = const Duration(seconds: 2),
}) : _db = database;
final Database _db;
/// Wrapper client that mirrors writes to disk, or null when absent.
final DesktopBackupClient? backup;
/// How long to coalesce disk mirroring after the last write.
///
/// The store persists on *every* keystroke, so mirroring eagerly would mean
/// an HTTP request and a whole-log disk write per character. IndexedDB is
/// still written synchronously with each keystroke; only the durability copy
/// is debounced.
final Duration mirrorDebounce;
Timer? _mirrorTimer;
/// Object store holding a single record: the serialised log.
static const storeName = 'log';
/// Key of that single record.
static const recordKey = 'notes';
/// IndexedDB database name.
static const databaseName = 'todo';
/// Opens (creating if needed) the IndexedDB database backing the log.
static Future<Database> openDatabase(IdbFactory factory) => factory.open(
databaseName,
version: 1,
onUpgradeNeeded: (event) {
final db = event.database;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
},
);
@override
Future<String?> read() async {
final txn = _db.transaction(storeName, idbModeReadOnly);
final value = await txn.objectStore(storeName).getObject(recordKey);
await txn.completed;
if (value is String && value.isNotEmpty) return value;
// Nothing local yet (fresh profile, or the browser evicted the database).
// Fall back to the wrapper's on-disk copy so a cleared profile recovers
// instead of silently starting from an empty backlog.
return backup?.readLog();
}
@override
Future<void> write(String text) async {
final txn = _db.transaction(storeName, idbModeReadWrite);
await txn.objectStore(storeName).put(text, recordKey);
await txn.completed;
_scheduleMirror(text);
}
void _scheduleMirror(String text) {
final client = backup;
if (client == null) return;
_mirrorTimer?.cancel();
_mirrorTimer = Timer(mirrorDebounce, () {
unawaited(client.writeLog(text));
});
}
/// Cancels any pending disk mirror. Call when tearing the store down.
void dispose() => _mirrorTimer?.cancel();
}