utils/crdt_sync_dart/lib/src/remote_store.dart
Krzysztof Rudnicki 02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00

89 lines
3.9 KiB
Dart

/// Raised for a remote-storage failure the caller must not silently ignore.
///
/// The backend-neutral base of the error hierarchy: catch this to handle
/// "the sync transport failed" regardless of which backend is configured.
/// Backends narrow it -- `GitHubSyncError` and `FirebaseSyncError` both
/// extend it -- so existing `on GitHubSyncError` catches keep working while a
/// caller that has been migrated can catch the base type instead.
class RemoteSyncError implements Exception {
RemoteSyncError(this.message);
final String message;
// Uses runtimeType so subclasses read as themselves without each having to
// override toString.
@override
String toString() => '$runtimeType: $message';
}
/// Raised when the configured remote itself is unreachable.
///
/// Distinguished from a missing *path* (nothing pushed there yet, which is
/// benign -- it just means no other device has synced before) so the caller
/// can tell "the repo/database is wrong or the credential isn't scoped to it"
/// apart from "no other device has synced yet".
class RemoteNotFoundError extends RemoteSyncError {
RemoteNotFoundError(super.message);
}
/// The whole remote surface `crdt_sync` syncs through: dumb keyed storage of
/// UTF-8 text blobs, with directory listing.
///
/// Deliberately tiny. `syncLog` and every app-level sync service talk only to
/// this, so swapping the storage backend (GitHub Contents API, Firebase
/// Realtime Database, a dual-writing mirror of both) is a constructor change
/// at the call site and nothing more.
///
/// Implementations are expected to throw [RemoteSyncError] (or a subclass) for
/// any failure the caller must not silently ignore, and to treat a missing
/// path as a benign `null` / empty rather than an error.
abstract interface class RemoteStore {
/// Returns the entry names directly under [path], or an empty list when
/// nothing has been written there yet.
///
/// Includes both files and subdirectories: the
/// `<pathPrefix>/<deviceId>/<filename>` layout needs to discover device
/// *directories*, not just files.
Future<List<String>> listDirectory(String path);
/// Returns the text stored at [path], or null when nothing is there yet.
Future<String?> getFileText(String path);
/// Creates or replaces the text at [path].
///
/// [message] is a human-readable reason for the write. Backends that record
/// one (GitHub commits) use it; backends that don't (Firebase) ignore it.
Future<void> putFileText(String path, String text, {required String message});
/// Deletes [path]. A no-op when [path] does not exist.
Future<void> deleteFile(String path, {String message});
/// Returns whether the configured credential can reach the remote.
///
/// A lightweight connection test for a settings "Test connection" button: it
/// probes the remote root, so it succeeds even before any file has been
/// pushed. Never throws -- a network failure or missing remote returns
/// false.
Future<bool> canAccessRemote();
/// Releases the underlying HTTP client.
void close();
}
/// Optional capability: read a whole small map of text values in one request.
///
/// Implemented by backends where that is a single cheap call
/// (`FirebaseRestClient`), and absent where it would be one request per key
/// (`GitHubClient`). [syncLog] uses it to fetch every peer's revision in one
/// go and skip downloading logs that have not changed -- the difference
/// between a sync tick costing a few hundred bytes and a few hundred
/// kilobytes. A backend that does not implement it simply syncs the old way.
abstract interface class BulkMapReader {
/// Returns the map at [path] as `key -> text`, empty when absent.
///
/// Never throws for a malformed or missing value: this backs an
/// *optimisation*, and a corrupt map must degrade into "fetch everything",
/// never into a failed sync.
Future<Map<String, String>> getStringMap(String path);
}