Commit Graph

7 Commits

Author SHA1 Message Date
dd6e6d5571 crdt_sync 0.5.0: the shared config and the cutover seam
Everything an app needs to move from GitHub to Firebase without each one
reinventing the wiring.

Python:
- _config.py: FirebaseConfig.load() + firebase_client_for(app) +
  mirror_client_for(app, github). One config file, read in one place.
  Credential caches stay per-app (~/.config/<app>/firebase_auth.json) --
  one file written by several processes is a corruption waiting to happen.
- tool/preflight_firebase.py: validates the config before any data moves.
  Catches a wrong-region databaseUrl (which answers 404 with a correctUrl
  body rather than an error, so it would otherwise pass every check and
  break mid-migration) and confirms the uid in the ID token matches the uid
  the rules pin -- sign_in() returns None and discards localId, so the uid
  is decoded from the JWT.
- tool/interop_seed.py: the missing Python half of the cross-language
  check. interop_check.dart asserts it can see a record written by Python,
  but nothing ever wrote one, so it could only ever fail.

Dart:
- firebase_config.dart: the same config shape, kept free of dart:io so it
  works on web and Android, where the file does not exist at all and
  credentials belong in flutter_secure_storage.
- PersistedSyncStateStore: the revision cache, persisted. Without it a
  phone forgets every peer revision on each cold start and re-downloads
  everything -- the exact traffic the revision scheme exists to avoid, and
  what the free-tier headroom depends on.

Verified live against the real database: preflight passes all five checks,
the Dart smoke test passes end to end, and Python <-> Dart interop is OK.

Python 226 tests at 100% branch coverage; Dart 218 tests, both new files
at 100% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 22:59:48 +02:00
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
5480e72335 GitHubClient: add canAccessRepo() + deleteFile() (v0.3.0)
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
Make the shared client a superset of what todo's and diet_guard-app's local
GitHubClient copies provide, so every app can drop its copy and sync through
one client. canAccessRepo() is a lightweight connection test (bare repo GET,
never throws); deleteFile() resolves the file's own sha and no-ops when
absent. Mirrored on the Dart and Python sides. 90 Dart + 78 Python tests, 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 23:41:10 +02:00
d69598880a Docs: correct stale 'not yet consumed' notes and old install URLs
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Both CRDT libs are now consumed (crdt_sync_dart: diet_guard app, workout_app,
phone_app, todo; crdt-sync: diet-guard, screen-locker). Reconcile the
sub-package READMEs' old standalone git URLs to the monorepo-subdirectory
form, and bump the crdt_sync examples to v0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 23:31:41 +02:00
5be99ab11d Add LogStore local-persistence layer to crdt_sync (v0.2.0)
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
crdt_sync_dart: new domain-agnostic LogStore -- an in-memory Log with a
reactive changes stream and upsert/delete(sticky tombstone)/replaceAll,
behind an injectable LogPersistence port -- plus logToJson/logFromJson.
FileLogPersistence (atomic temp-file-then-rename, generalizing diet_guard's
LogStorageService) lives behind a separate crdt_sync_io.dart entrypoint so
the core barrel stays pure Dart / web-safe.

crdt-sync (Python): mirror dump_log/load_log/read_log/write_log sharing the
same on-disk JSON format so a log written on one side parses on the other.
Load/save only -- the reactive store is a Dart/Flutter convenience with no
Python equivalent by design (Python consumers are headless sync ticks).

Querying stays app-side: a Record's fields are opaque to the library, so
filtering/sorting/search is the caller's job over LogStore.values.

Both packages bumped to 0.2.0. 83 Dart tests + 71 Python tests (100% branch
coverage) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 22:19:34 +02:00
6b14e6d36d Fix wire-format key mismatch with Python (deletedHlc -> deleted_hlc)
Record.toJson() was writing the delete-clock field as deletedHlc while
the Python package's Record.to_dict() writes deleted_hlc -- an
undetected cross-language wire divergence: a tombstoned record synced
Python<->Dart would silently lose its delete clock on the receiving
side. Also widen syncLog's decode error handling from FormatException
alone to also catch TypeError, matching the Python side's
(ValueError, KeyError, TypeError) so a valid-JSON-but-wrong-shape push
from an incompatible writer is skipped per-device instead of aborting
the whole sync. Adds a fixture test shared verbatim with the Python
package's test suite so this class of divergence fails loudly going
forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:55:22 +02:00
4b66703280 Initial implementation: unified CRDT merge scheme + GitHub sync transport
Pure-Dart mirror of the Python crdt-sync package, module-for-module and
test-for-test, so the merge algorithm canonically agrees on both sides of
a sync between a Dart client and a Python one. No Flutter SDK dependency
(package:http only) per Workstream D's scoping decision (2026-07-04).
59 tests, clean dart analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:48:07 +02:00