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.