From ea0dc9348ca1eecea30d04a2f4db79fd003a592a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 15:36:03 +0200 Subject: [PATCH] =?UTF-8?q?crdt=5Fsync=5Fdart:=20split=20four=20library=20?= =?UTF-8?q?files=20=E2=80=94=20package=20now=20under=20the=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firebase_auth_rest.dart 333 -> 238 + firebase_credentials 71 + firebase_auth_error 31 firebase_client.dart 283 -> 207 + firebase_keys 60 + firebase_errors 19 sync.dart 259 -> 141 + sync_state 121 Mirrors the split just made on the Python side, module for module, so the two implementations stay legible against each other: credentials with their store, key escaping with its escape table, the error type with the response parser that builds its message. Every new file is added to lib/crdt_sync.dart. A src/ file that is not in the barrel compiles and tests green while silently dropping the symbol from every consumer's import, so the export list is part of the split, not an afterthought. 280 tests pass and `dart analyze` is clean — including the four consumers (firebase_config, secure_credential_store) whose imports had to follow the moved types. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- crdt_sync_dart/lib/crdt_sync.dart | 5 + .../lib/src/firebase_auth_error.dart | 31 +++++ .../lib/src/firebase_auth_rest.dart | 101 +-------------- crdt_sync_dart/lib/src/firebase_client.dart | 87 +------------ crdt_sync_dart/lib/src/firebase_config.dart | 6 +- .../lib/src/firebase_credentials.dart | 71 ++++++++++ crdt_sync_dart/lib/src/firebase_errors.dart | 19 +++ crdt_sync_dart/lib/src/firebase_keys.dart | 62 +++++++++ .../lib/src/secure_credential_store.dart | 2 +- crdt_sync_dart/lib/src/sync.dart | 119 +---------------- crdt_sync_dart/lib/src/sync_state.dart | 121 ++++++++++++++++++ 11 files changed, 324 insertions(+), 300 deletions(-) create mode 100644 crdt_sync_dart/lib/src/firebase_auth_error.dart create mode 100644 crdt_sync_dart/lib/src/firebase_credentials.dart create mode 100644 crdt_sync_dart/lib/src/firebase_errors.dart create mode 100644 crdt_sync_dart/lib/src/firebase_keys.dart create mode 100644 crdt_sync_dart/lib/src/sync_state.dart diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index f6da1da..93ff771 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -3,8 +3,12 @@ library; export 'src/device.dart'; export 'src/firebase_account_provisioning.dart'; +export 'src/firebase_auth_error.dart'; export 'src/firebase_auth_rest.dart'; +export 'src/firebase_credentials.dart'; export 'src/firebase_client.dart'; +export 'src/firebase_errors.dart'; +export 'src/firebase_keys.dart'; export 'src/firebase_config.dart'; export 'src/firebase_settings.dart'; export 'src/github_client.dart'; @@ -16,3 +20,4 @@ export 'src/remote_store.dart'; export 'src/secure_credential_store.dart'; export 'src/store.dart'; export 'src/sync.dart'; +export 'src/sync_state.dart'; diff --git a/crdt_sync_dart/lib/src/firebase_auth_error.dart b/crdt_sync_dart/lib/src/firebase_auth_error.dart new file mode 100644 index 0000000..8c57722 --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_auth_error.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'remote_store.dart'; + +/// Raised for an authentication failure the caller must not silently ignore. +/// +/// A [RemoteSyncError] so callers that only care about "sync is broken" can +/// catch one type, while a settings screen can single this out to say +/// "your password is wrong" rather than "the network is down". +class FirebaseAuthError extends RemoteSyncError { + FirebaseAuthError(super.message); +} + +/// The human-readable reason buried in an identitytoolkit error body. +/// +/// Returns an empty string for a non-JSON body (a proxy error page, say), +/// where the status code is all the detail there is. +String reasonFrom(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final error = decoded['error']; + if (error is Map) return '(${error['message']})'; + if (error is String) return '($error)'; + } + } on FormatException { + // Non-JSON body (a proxy error page, say): the status code is all the + // detail there is. + } + return ''; +} diff --git a/crdt_sync_dart/lib/src/firebase_auth_rest.dart b/crdt_sync_dart/lib/src/firebase_auth_rest.dart index 04056a6..011cc74 100644 --- a/crdt_sync_dart/lib/src/firebase_auth_rest.dart +++ b/crdt_sync_dart/lib/src/firebase_auth_rest.dart @@ -11,92 +11,12 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'remote_store.dart'; +import 'firebase_auth_error.dart'; +import 'firebase_credentials.dart'; const _signInBase = 'https://identitytoolkit.googleapis.com/v1'; const _refreshBase = 'https://securetoken.googleapis.com/v1'; -/// Refresh this long before the token actually expires. -/// -/// An ID token lives ~1 h. Refreshing early means a tick that starts just -/// under the wire still finishes with a valid token, rather than 401ing -/// halfway through a multi-file push. -const _refreshSkew = Duration(minutes: 5); - -/// Raised for an authentication failure the caller must not silently ignore. -/// -/// A [RemoteSyncError] so callers that only care about "sync is broken" can -/// catch one type, while a settings screen can single this out to say -/// "your password is wrong" rather than "the network is down". -class FirebaseAuthError extends RemoteSyncError { - FirebaseAuthError(super.message); -} - -/// One device's Firebase session: a short-lived ID token plus the long-lived -/// refresh token that mints the next one. -class FirebaseCredentials { - FirebaseCredentials({ - required this.idToken, - required this.refreshToken, - required this.expiresAt, - }); - - factory FirebaseCredentials.fromJson(Map json) => - FirebaseCredentials( - idToken: json['id_token'] as String, - refreshToken: json['refresh_token'] as String, - expiresAt: DateTime.parse(json['expires_at'] as String).toUtc(), - ); - - /// The bearer credential for Realtime Database requests. Expires quickly. - final String idToken; - - /// The long-lived credential. **This is the secret worth protecting** -- - /// store it in `flutter_secure_storage` on Android or a `0600` file on - /// Linux, never in plain `SharedPreferences`. - final String refreshToken; - - final DateTime expiresAt; - - /// Whether [idToken] is expired, or close enough that a tick starting now - /// might outlive it. - bool isExpiredAt(DateTime now) => !now.add(_refreshSkew).isBefore(expiresAt); - - Map toJson() => { - 'id_token': idToken, - 'refresh_token': refreshToken, - 'expires_at': expiresAt.toUtc().toIso8601String(), - }; -} - -/// Where a device keeps its [FirebaseCredentials] between runs. -/// -/// Abstract because the right answer differs per platform, and one of the -/// callers (`wake_alarm` PC) is a **fresh process every minute** -- without -/// persistence it would re-authenticate 1440 times a day. -abstract interface class FirebaseCredentialStore { - Future load(); - Future save(FirebaseCredentials credentials); - Future clear(); -} - -/// An in-memory [FirebaseCredentialStore], for tests and one-shot scripts. -class InMemoryCredentialStore implements FirebaseCredentialStore { - InMemoryCredentialStore([this._credentials]); - - FirebaseCredentials? _credentials; - - @override - Future load() async => _credentials; - - @override - Future save(FirebaseCredentials credentials) async => - _credentials = credentials; - - @override - Future clear() async => _credentials = null; -} - /// Signs in and keeps a valid ID token available, refreshing as needed. /// /// [apiKey] is the project's public Web API key -- not a secret, and safe to @@ -305,7 +225,7 @@ class FirebaseTokenProvider { } if (res.statusCode < 200 || res.statusCode >= 300) { throw FirebaseAuthError( - 'failed to $what: HTTP ${res.statusCode} ${_reason(res.body)}', + 'failed to $what: HTTP ${res.statusCode} ${reasonFrom(res.body)}', ); } return jsonDecode(res.body) as Map; @@ -314,20 +234,5 @@ class FirebaseTokenProvider { /// Pulls Google's machine-readable reason (`INVALID_PASSWORD`, /// `TOKEN_EXPIRED`, `USER_DISABLED`, ...) out of an error body, so the /// thrown message says what actually went wrong instead of just "400". - static String _reason(String body) { - try { - final decoded = jsonDecode(body); - if (decoded is Map) { - final error = decoded['error']; - if (error is Map) return '(${error['message']})'; - if (error is String) return '($error)'; - } - } on FormatException { - // Non-JSON body (a proxy error page, say): the status code is all the - // detail there is. - } - return ''; - } - void close() => _http.close(); } diff --git a/crdt_sync_dart/lib/src/firebase_client.dart b/crdt_sync_dart/lib/src/firebase_client.dart index 4660838..819645a 100644 --- a/crdt_sync_dart/lib/src/firebase_client.dart +++ b/crdt_sync_dart/lib/src/firebase_client.dart @@ -16,90 +16,13 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'firebase_auth_rest.dart'; +import 'firebase_errors.dart'; +import 'firebase_keys.dart'; import 'remote_store.dart'; const _httpUnauthorized = 401; const _httpForbidden = 403; -/// Characters Realtime Database forbids in a key, plus `~` itself because it -/// is this module's escape character. -/// -/// `/` is absent deliberately: it is the path separator, so it is handled by -/// splitting into segments before any segment is escaped. -const _escapedChars = { - '~': '~7E', - '.': '~2E', - r'$': '~24', - '#': '~23', - '[': '~5B', - ']': '~5D', -}; - -/// Raised for a Realtime Database failure the caller must not silently -/// ignore. -class FirebaseSyncError extends RemoteSyncError { - FirebaseSyncError(super.message); -} - -/// Raised when the database itself is unreachable or the credential is not -/// authorized for it. -/// -/// The Firebase counterpart of `RepoNotFoundError`: it means "the database -/// URL is wrong, or the security rules reject this uid", as opposed to -/// "nothing has been pushed to that path yet", which is benign and surfaces -/// as a null/empty result. -class DatabaseNotFoundError extends FirebaseSyncError - implements RemoteNotFoundError { - DatabaseNotFoundError(super.message); -} - -/// Escapes one path segment into a legal Realtime Database key. -/// -/// RTDB rejects `. $ # [ ] /` in keys, and the REST API's trailing `.json` -/// is a *format suffix* rather than part of the path -- so a filename like -/// `log.json` cannot be stored verbatim. The mapping is a reversible `~XX` -/// hex escape (`log.json` -> `log~2Ejson`) rather than a lossy "strip the -/// extension", because callers see these names again: todo-app's -/// `listDirectory('todo-sync/notes')` returns *filenames*, not device -/// directories, and must keep getting `.json` back. -/// -/// `~` is used as the escape character because it is legal in RTDB keys and -/// unreserved in URLs, so the escaped form needs no percent-encoding -- which -/// would otherwise be decoded back into the illegal character by the server. -String encodeKey(String segment) { - final buffer = StringBuffer(); - for (final rune in segment.split('')) { - buffer.write(_escapedChars[rune] ?? rune); - } - return buffer.toString(); -} - -/// Reverses [encodeKey]. -String decodeKey(String key) { - final buffer = StringBuffer(); - var index = 0; - while (index < key.length) { - final escape = key.length - index >= 3 ? key.substring(index, index + 3) : ''; - final decoded = _escapeToChar[escape]; - if (decoded == null) { - buffer.write(key[index]); - index += 1; - } else { - buffer.write(decoded); - index += 3; - } - } - return buffer.toString(); -} - -final Map _escapeToChar = { - for (final entry in _escapedChars.entries) entry.value: entry.key, -}; - -/// Escapes every segment of a `/`-separated logical path. -String encodePath(String path) => - path.split('/').where((s) => s.isNotEmpty).map(encodeKey).join('/'); - /// Realtime Database seen through the same three-method contract as GitHub. /// /// Reads and writes UTF-8 text blobs stored as JSON string leaves, so the @@ -124,9 +47,9 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader { Future _uri(String path, {Map query = const {}}) async { final encoded = encodePath(path); - return Uri.parse('$_databaseUrl/$encoded.json').replace( - queryParameters: {'auth': await auth.idToken(), ...query}, - ); + return Uri.parse( + '$_databaseUrl/$encoded.json', + ).replace(queryParameters: {'auth': await auth.idToken(), ...query}); } /// Turns a non-2xx response into the right error type. diff --git a/crdt_sync_dart/lib/src/firebase_config.dart b/crdt_sync_dart/lib/src/firebase_config.dart index 4b471ba..6157523 100644 --- a/crdt_sync_dart/lib/src/firebase_config.dart +++ b/crdt_sync_dart/lib/src/firebase_config.dart @@ -16,6 +16,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'firebase_auth_rest.dart'; +import 'firebase_credentials.dart'; import 'firebase_client.dart'; import 'mirror_store.dart'; import 'remote_store.dart'; @@ -136,7 +137,10 @@ Future firebaseClientFor({ // than failing every sign-in against ''. Apps that know their uid should // pass expectedUid so a mis-tapped account picker is caught here instead // of turning into a silently denied sync. - await auth.signInWithGoogle(idToken: google, expectedUid: expectedUid ?? config.uid); + await auth.signInWithGoogle( + idToken: google, + expectedUid: expectedUid ?? config.uid, + ); } else if (password != null) { await auth.signIn(email: config.email, password: password); } else { diff --git a/crdt_sync_dart/lib/src/firebase_credentials.dart b/crdt_sync_dart/lib/src/firebase_credentials.dart new file mode 100644 index 0000000..e0fa3b4 --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_credentials.dart @@ -0,0 +1,71 @@ +/// Refresh this long before the token actually expires. +/// +/// An ID token lives ~1 h. Refreshing early means a tick that starts just +/// under the wire still finishes with a valid token, rather than 401ing +/// halfway through a multi-file push. +const _refreshSkew = Duration(minutes: 5); + +/// One device's Firebase session: a short-lived ID token plus the long-lived +/// refresh token that mints the next one. +class FirebaseCredentials { + FirebaseCredentials({ + required this.idToken, + required this.refreshToken, + required this.expiresAt, + }); + + factory FirebaseCredentials.fromJson(Map json) => + FirebaseCredentials( + idToken: json['id_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresAt: DateTime.parse(json['expires_at'] as String).toUtc(), + ); + + /// The bearer credential for Realtime Database requests. Expires quickly. + final String idToken; + + /// The long-lived credential. **This is the secret worth protecting** -- + /// store it in `flutter_secure_storage` on Android or a `0600` file on + /// Linux, never in plain `SharedPreferences`. + final String refreshToken; + + final DateTime expiresAt; + + /// Whether [idToken] is expired, or close enough that a tick starting now + /// might outlive it. + bool isExpiredAt(DateTime now) => !now.add(_refreshSkew).isBefore(expiresAt); + + Map toJson() => { + 'id_token': idToken, + 'refresh_token': refreshToken, + 'expires_at': expiresAt.toUtc().toIso8601String(), + }; +} + +/// Where a device keeps its [FirebaseCredentials] between runs. +/// +/// Abstract because the right answer differs per platform, and one of the +/// callers (`wake_alarm` PC) is a **fresh process every minute** -- without +/// persistence it would re-authenticate 1440 times a day. +abstract interface class FirebaseCredentialStore { + Future load(); + Future save(FirebaseCredentials credentials); + Future clear(); +} + +/// An in-memory [FirebaseCredentialStore], for tests and one-shot scripts. +class InMemoryCredentialStore implements FirebaseCredentialStore { + InMemoryCredentialStore([this._credentials]); + + FirebaseCredentials? _credentials; + + @override + Future load() async => _credentials; + + @override + Future save(FirebaseCredentials credentials) async => + _credentials = credentials; + + @override + Future clear() async => _credentials = null; +} diff --git a/crdt_sync_dart/lib/src/firebase_errors.dart b/crdt_sync_dart/lib/src/firebase_errors.dart new file mode 100644 index 0000000..8a5f139 --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_errors.dart @@ -0,0 +1,19 @@ +import 'remote_store.dart'; + +/// Raised for a Realtime Database failure the caller must not silently +/// ignore. +class FirebaseSyncError extends RemoteSyncError { + FirebaseSyncError(super.message); +} + +/// Raised when the database itself is unreachable or the credential is not +/// authorized for it. +/// +/// The Firebase counterpart of `RepoNotFoundError`: it means "the database +/// URL is wrong, or the security rules reject this uid", as opposed to +/// "nothing has been pushed to that path yet", which is benign and surfaces +/// as a null/empty result. +class DatabaseNotFoundError extends FirebaseSyncError + implements RemoteNotFoundError { + DatabaseNotFoundError(super.message); +} diff --git a/crdt_sync_dart/lib/src/firebase_keys.dart b/crdt_sync_dart/lib/src/firebase_keys.dart new file mode 100644 index 0000000..56c17d2 --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_keys.dart @@ -0,0 +1,62 @@ +/// Characters Realtime Database forbids in a key, plus `~` itself because it +/// is this module's escape character. +/// +/// `/` is absent deliberately: it is the path separator, so it is handled by +/// splitting into segments before any segment is escaped. +const _escapedChars = { + '~': '~7E', + '.': '~2E', + r'$': '~24', + '#': '~23', + '[': '~5B', + ']': '~5D', +}; + +/// Escapes one path segment into a legal Realtime Database key. +/// +/// RTDB rejects `. $ # [ ] /` in keys, and the REST API's trailing `.json` +/// is a *format suffix* rather than part of the path -- so a filename like +/// `log.json` cannot be stored verbatim. The mapping is a reversible `~XX` +/// hex escape (`log.json` -> `log~2Ejson`) rather than a lossy "strip the +/// extension", because callers see these names again: todo-app's +/// `listDirectory('todo-sync/notes')` returns *filenames*, not device +/// directories, and must keep getting `.json` back. +/// +/// `~` is used as the escape character because it is legal in RTDB keys and +/// unreserved in URLs, so the escaped form needs no percent-encoding -- which +/// would otherwise be decoded back into the illegal character by the server. +String encodeKey(String segment) { + final buffer = StringBuffer(); + for (final rune in segment.split('')) { + buffer.write(_escapedChars[rune] ?? rune); + } + return buffer.toString(); +} + +/// Reverses [encodeKey]. +String decodeKey(String key) { + final buffer = StringBuffer(); + var index = 0; + while (index < key.length) { + final escape = key.length - index >= 3 + ? key.substring(index, index + 3) + : ''; + final decoded = _escapeToChar[escape]; + if (decoded == null) { + buffer.write(key[index]); + index += 1; + } else { + buffer.write(decoded); + index += 3; + } + } + return buffer.toString(); +} + +final Map _escapeToChar = { + for (final entry in _escapedChars.entries) entry.value: entry.key, +}; + +/// Escapes every segment of a `/`-separated logical path. +String encodePath(String path) => + path.split('/').where((s) => s.isNotEmpty).map(encodeKey).join('/'); diff --git a/crdt_sync_dart/lib/src/secure_credential_store.dart b/crdt_sync_dart/lib/src/secure_credential_store.dart index 9a4fc13..99d8f6f 100644 --- a/crdt_sync_dart/lib/src/secure_credential_store.dart +++ b/crdt_sync_dart/lib/src/secure_credential_store.dart @@ -15,7 +15,7 @@ library; import 'dart:convert'; -import 'firebase_auth_rest.dart'; +import 'firebase_credentials.dart'; /// Reads a secret by key, returning null when absent. typedef SecretReader = Future Function(String key); diff --git a/crdt_sync_dart/lib/src/sync.dart b/crdt_sync_dart/lib/src/sync.dart index 1862ab5..347cfd4 100644 --- a/crdt_sync_dart/lib/src/sync.dart +++ b/crdt_sync_dart/lib/src/sync.dart @@ -5,127 +5,10 @@ import 'package:crypto/crypto.dart'; import 'log.dart'; import 'record.dart'; import 'remote_store.dart'; -import 'store.dart'; +import 'sync_state.dart'; const _defaultFilename = 'log.json'; -/// What one device remembers between ticks so it can skip needless traffic. -/// -/// Two facts, both cheap: -/// * [pushedRev] -- the hash of what this device last pushed, so an unchanged -/// log is not re-uploaded. 88% of the pushes in the GitHub-backed history -/// were byte-identical no-ops. -/// * [peerRevs] -- the hash each peer had last time we downloaded it, so an -/// unchanged peer is not re-downloaded. -/// -/// **Must be stored next to the log itself and cleared with it.** Skipping an -/// unchanged peer is only sound because that peer's records are already -/// merged into the local log; a cache that outlived its log would skip peers -/// whose data had been lost. -class SyncState { - const SyncState({this.pushedRev, this.peerRevs = const {}}); - - factory SyncState.fromJson(Map json) => SyncState( - pushedRev: json['pushed_rev'] as String?, - peerRevs: { - for (final entry - in (json['peer_revs'] as Map? ?? {}).entries) - if (entry.value is String) entry.key: entry.value as String, - }, - ); - - final String? pushedRev; - final Map peerRevs; - - Map toJson() => { - 'pushed_rev': pushedRev, - 'peer_revs': peerRevs, - }; -} - -/// Where [syncLog] persists its [SyncState] between runs. -/// -/// Persistence is what makes the saving real for the short-lived callers: -/// `wake_alarm` PC is a fresh process every minute and `diet_guard` PC a -/// fresh process every 15 minutes, so an in-memory cache would save nothing. -abstract interface class SyncStateStore { - Future load(); - Future save(SyncState state); -} - -/// A [SyncStateStore] that forgets everything, for tests and one-shot runs. -/// -/// Correct but pessimistic: every tick re-downloads every peer and re-pushes -/// the local log, which is exactly the old behaviour. -class InMemorySyncStateStore implements SyncStateStore { - SyncState _state = const SyncState(); - - @override - Future load() async => _state; - - @override - Future save(SyncState state) async => _state = state; -} - -/// A [SyncStateStore] that persists through a [LogPersistence] port. -/// -/// This is the mobile counterpart to Python's `FileSyncStateStore`, and it is -/// what makes the revision saving real on a phone: an app is a fresh process -/// after every cold start, so an [InMemorySyncStateStore] would forget every -/// peer revision and re-download all of them on each launch -- the exact -/// traffic the revision scheme exists to avoid. -/// -/// Takes the pure-Dart [LogPersistence] port rather than a `File` so it works -/// on web (where `dart:io` is unavailable) and is trivially faked in tests. -/// On mobile and desktop, pass a `FileLogPersistence` from -/// `package:crdt_sync/crdt_sync_io.dart`, which already writes atomically. -/// -/// **Store this next to the log it describes and clear the two together.** -/// Skipping an unchanged peer is only sound because that peer's records are -/// already merged into the local log; state that outlived its log would skip -/// peers whose data had been lost. See [SyncState]. -class PersistedSyncStateStore implements SyncStateStore { - /// Persists state through [persistence]. - PersistedSyncStateStore(this._persistence); - - final LogPersistence _persistence; - - /// Returns the stored state, or a default one if absent or unreadable. - /// - /// A corrupt or half-written file degrades to "remember nothing", which - /// costs one tick of extra traffic rather than failing the sync -- the same - /// fail-safe choice the Python side makes. - @override - Future load() async { - final String? text; - try { - text = await _persistence.read(); - } on Exception { - return const SyncState(); - } - if (text == null || text.isEmpty) return const SyncState(); - try { - final decoded = jsonDecode(text); - if (decoded is! Map) return const SyncState(); - return SyncState.fromJson(decoded); - } on FormatException { - return const SyncState(); - } - } - - @override - Future save(SyncState state) async => - _persistence.write(jsonEncode(state.toJson())); -} - -/// The revision of a serialized log: a content hash, not a clock reading. -/// -/// A hash rather than the log's maximum HLC because merging a peer's *older* -/// record changes the content without raising the maximum -- with three -/// devices in one namespace (diet-guard has `pc`, `phone` and `desktop`) a -/// clock-based revision can miss a peer's merged state. It is also the same -/// value used to suppress no-op pushes, so the two optimisations share one -/// mechanism. String revisionOf(String encodedLog) => sha256.convert(utf8.encode(encodedLog)).toString(); diff --git a/crdt_sync_dart/lib/src/sync_state.dart b/crdt_sync_dart/lib/src/sync_state.dart new file mode 100644 index 0000000..239bdf6 --- /dev/null +++ b/crdt_sync_dart/lib/src/sync_state.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'store.dart'; + +/// What one device remembers between ticks so it can skip needless traffic. +/// +/// Two facts, both cheap: +/// * [pushedRev] -- the hash of what this device last pushed, so an unchanged +/// log is not re-uploaded. 88% of the pushes in the GitHub-backed history +/// were byte-identical no-ops. +/// * [peerRevs] -- the hash each peer had last time we downloaded it, so an +/// unchanged peer is not re-downloaded. +/// +/// **Must be stored next to the log itself and cleared with it.** Skipping an +/// unchanged peer is only sound because that peer's records are already +/// merged into the local log; a cache that outlived its log would skip peers +/// whose data had been lost. +class SyncState { + const SyncState({this.pushedRev, this.peerRevs = const {}}); + + factory SyncState.fromJson(Map json) => SyncState( + pushedRev: json['pushed_rev'] as String?, + peerRevs: { + for (final entry + in (json['peer_revs'] as Map? ?? {}).entries) + if (entry.value is String) entry.key: entry.value as String, + }, + ); + + final String? pushedRev; + final Map peerRevs; + + Map toJson() => { + 'pushed_rev': pushedRev, + 'peer_revs': peerRevs, + }; +} + +/// Where [syncLog] persists its [SyncState] between runs. +/// +/// Persistence is what makes the saving real for the short-lived callers: +/// `wake_alarm` PC is a fresh process every minute and `diet_guard` PC a +/// fresh process every 15 minutes, so an in-memory cache would save nothing. +abstract interface class SyncStateStore { + Future load(); + Future save(SyncState state); +} + +/// A [SyncStateStore] that forgets everything, for tests and one-shot runs. +/// +/// Correct but pessimistic: every tick re-downloads every peer and re-pushes +/// the local log, which is exactly the old behaviour. +class InMemorySyncStateStore implements SyncStateStore { + SyncState _state = const SyncState(); + + @override + Future load() async => _state; + + @override + Future save(SyncState state) async => _state = state; +} + +/// A [SyncStateStore] that persists through a [LogPersistence] port. +/// +/// This is the mobile counterpart to Python's `FileSyncStateStore`, and it is +/// what makes the revision saving real on a phone: an app is a fresh process +/// after every cold start, so an [InMemorySyncStateStore] would forget every +/// peer revision and re-download all of them on each launch -- the exact +/// traffic the revision scheme exists to avoid. +/// +/// Takes the pure-Dart [LogPersistence] port rather than a `File` so it works +/// on web (where `dart:io` is unavailable) and is trivially faked in tests. +/// On mobile and desktop, pass a `FileLogPersistence` from +/// `package:crdt_sync/crdt_sync_io.dart`, which already writes atomically. +/// +/// **Store this next to the log it describes and clear the two together.** +/// Skipping an unchanged peer is only sound because that peer's records are +/// already merged into the local log; state that outlived its log would skip +/// peers whose data had been lost. See [SyncState]. +class PersistedSyncStateStore implements SyncStateStore { + /// Persists state through [persistence]. + PersistedSyncStateStore(this._persistence); + + final LogPersistence _persistence; + + /// Returns the stored state, or a default one if absent or unreadable. + /// + /// A corrupt or half-written file degrades to "remember nothing", which + /// costs one tick of extra traffic rather than failing the sync -- the same + /// fail-safe choice the Python side makes. + @override + Future load() async { + final String? text; + try { + text = await _persistence.read(); + } on Exception { + return const SyncState(); + } + if (text == null || text.isEmpty) return const SyncState(); + try { + final decoded = jsonDecode(text); + if (decoded is! Map) return const SyncState(); + return SyncState.fromJson(decoded); + } on FormatException { + return const SyncState(); + } + } + + @override + Future save(SyncState state) async => + _persistence.write(jsonEncode(state.toJson())); +} + +/// The revision of a serialized log: a content hash, not a clock reading. +/// +/// A hash rather than the log's maximum HLC because merging a peer's *older* +/// record changes the content without raising the maximum -- with three +/// devices in one namespace (diet-guard has `pc`, `phone` and `desktop`) a +/// clock-based revision can miss a peer's merged state. It is also the same +/// value used to suppress no-op pushes, so the two optimisations share one +/// mechanism.