Desktop builds of todo/diet-guard/home_inventory are Flutter *web* in a Chrome --app window, so FlutterSecureStorage resolves to the web backend: AES-encrypted localStorage keyed inside the browser profile. That makes the account unsettable from outside the page -- `secret-tool` writes the wrong store (libsecret, which only the Linux build would read), and a Dart CLI cannot even import flutter_secure_storage, since it is a Flutter plugin and fails to resolve on the VM. Verified both, rather than assumed. The one process that can read the filesystem *and* reach the page is the wrapper already serving it -- the same seam diet-guard uses to keep the GitHub token out of the browser. So this adds the shared half of a wrapper route: the path constant, the opt-in env var, an opt-out marker key, and accountFromWrapper(), which fetches and hands the body to the existing FirebaseAccount.tryParse. Additive only, so consumers adopt it per-app on their own schedule; nothing forces a phone rebuild. Route handlers stay per-app because each wrapper owns its own server. Deliberately opt-in (CRDT_SYNC_SERVE_ACCOUNT): the route hands a credential with database write access to anything that can reach the wrapper's port, so it is set once to provision an install rather than left on. kSyncAccountOptOutKey exists so "Disconnect" cannot be silently undone by the next launch re-adopting the account. Every failure path returns null rather than throwing -- an absent route is the *normal* case -- so the caller degrades to "not configured", which the settings screens already handle by asking. 254 tests, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 |
||
|---|---|---|
| .. | ||
| lib | ||
| test | ||
| tool | ||
| .gitignore | ||
| analysis_options.yaml | ||
| LICENSE | ||
| pubspec.yaml | ||
| README.md | ||
crdt_sync
Shared CRDT merge scheme + GitHub-Contents-API sync transport, extracted for
reuse across four personal apps that each need cross-device sync: todo,
diet_guard's Flutter app, wake_alarm's phone_app, and screen-locker's
workout_app. Pure Dart -- no Flutter SDK dependency (package:http only)
-- so it works in both Flutter apps and plain Dart tooling. Mirrors the
Python crdt-sync package
module-for-module and test-for-test, so the merge algorithm canonically
agrees on both sides of any sync between a Dart client and a Python one.
The merge scheme
See the Python package's README for the full rationale; in short, a generic LWW-map-with-sticky-remove:
Hlc-- a Hybrid Logical Clock(wallTimeMs, counter, nodeId), totally ordered, monotonic even across clock skew.Record-- an id, a per-field last-writer-wins map (fieldName -> (value, Hlc)), and a stickydeletedflag (deleted = a.deleted || b.deleted, never the other way -- a delete can never be silently undone).Log--Map<String, Record>, merged by taking the union of ids and merging shared ones.
All three merges are commutative and idempotent -- see test/log_test.dart
for the concrete convergence-property tests.
Install
Consume via a git dependency on the utils monorepo:
dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.2.0
path: crdt_sync_dart
Usage
import 'package:crdt_sync/crdt_sync.dart';
final nodeId = 'phone';
final clock = Hlc.newTick(nodeId);
final record = Record(
id: 'abc123',
fields: {'text': ('buy milk', clock)},
);
final client = GitHubClient(owner: 'kuhyx', repo: 'my-app-sync', token: token);
final merged = await syncLog(
client: client,
deviceId: nodeId,
pathPrefix: 'devices',
localLog: {record.id: record},
encode: (log) => jsonEncode(log.map((k, r) => MapEntry(k, r.toJson()))),
decode: (text) => (jsonDecode(text) as Map<String, dynamic>).map(
(k, v) => MapEntry(k, Record.fromJson(v as Map<String, dynamic>)),
),
);
syncLog pulls every other device's last-pushed log from
<pathPrefix>/<other-device-id>/..., merges each into the local log with
mergeLogs, then pushes this device's own merged result back up.
Local persistence (LogStore)
The merge scheme above is in-memory only. For an app that needs to keep its
Log on disk and drive a live UI off it, this package also ships a
domain-agnostic LogStore:
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync/crdt_sync_io.dart'; // FileLogPersistence (dart:io)
final store = LogStore(
persistence: FileLogPersistence(File('$docsDir/notes.json')),
nodeId: 'phone',
);
await store.load();
// Reactive read: re-derive a domain view whenever anything changes. Filtering,
// sorting and search live here, in the app -- the store is field-agnostic.
final Stream<List<Record>> visible = store.changes.map(
(_) => store.values.where((r) => !r.deleted).toList(),
);
await store.upsert(Record(id: 'n1', fields: {'text': ('hi', store.nextHlc())}));
await store.delete('n1'); // sticky tombstone, not a hard remove
// After a sync tick, swap in the merged result:
await store.replaceAll(await syncLog(/* ... */, localLog: store.snapshot()));
LogStore is pure Dart (import it from the main barrel); the filesystem-backed
FileLogPersistence lives behind the separate crdt_sync_io.dart entrypoint so
the core stays web-safe. Querying is deliberately not in the library — a
Record's fields are an opaque map, so the app filters store.values itself.
The Python side mirrors the on-disk format via crdt_sync.dump_log/read_log
(load/save only; no reactive stream — that's a Dart/Flutter convenience).
Development
dart pub get
dart analyze
dart test