utils/crdt-sync
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
..
crdt_sync crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam 2026-08-03 18:14:25 +02:00
.pre-commit-config.yaml Stop the recovery tick stealing our own grab, and fix pre-commit for the monorepo 2026-07-26 15:08:41 +02:00
LICENSE Initial implementation: unified CRDT merge scheme + GitHub sync transport 2026-07-05 20:40:09 +02:00
pyproject.toml crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam 2026-08-03 18:14:25 +02:00
README.md Docs: correct stale 'not yet consumed' notes and old install URLs 2026-07-11 23:31:41 +02:00
requirements.txt Initial implementation: unified CRDT merge scheme + GitHub sync transport 2026-07-05 20:40:09 +02:00

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, wake_alarm, and screen-locker.

Why

Two of those apps (diet_guard, and independently todo) had already converged on the same shape of solution: treat a private GitHub repo as dumb file storage via the REST Contents API (not a git clone), have each device push its own file, and merge on read. diet_guard's merge logic (entry-id-keyed, tombstone-wins) and todo's merge logic (sqlite_crdt's per-field HLC last-writer-wins) solve the same underlying problem — convergent merge without a central server — for two different data shapes (append-only logs vs. mutable per-field records). crdt-sync generalizes both into one scheme so all four apps can share it, at the cost of todo's notes and wake_alarm's single wake-time value both running through CRDT machinery their own data doesn't strictly need — an explicit consistency-over-leanness tradeoff.

The merge scheme

A generic LWW-map-with-sticky-remove, built from three pieces:

  • Hlc — a Hybrid Logical Clock: (wall_time_ms, counter, node_id), totally ordered by tuple comparison (node_id breaks ties between two clocks with identical wall time and counter, so no two distinct devices' clocks ever compare equal). Hlc.new_tick(node_id, previous) advances off wall-clock time, or bumps counter when the wall clock hasn't moved past previous — the standard HLC construction, guaranteeing monotonicity even across clock skew or two ticks in the same millisecond.
  • Record — id, a fields map of name -> (value, Hlc), and a sticky deleted flag. merge_field keeps whichever side's Hlc is greater, per field — this is what lets two devices concurrently edit different fields of the same record (or the same field) and converge deterministically without a central authority. merge_record's deleted is not itself per-field LWW: it's a.deleted or b.deleted, monotonic and one-directional. This preserves a guarantee diet_guard's original tombstone scheme already relied on — a delete can never be silently undone by merging in an older non-deleted copy pulled from a device that hasn't seen the delete yet.
  • Log = dict[id, Record]. merge_logs takes the union of ids and merge_records the ones present on both sides.

All three merges are commutative and idempotent (provable directly from max-by-Hlc and boolean-or), so pull order between devices never matters and a repeated sync tick is a no-op — see crdt_sync/tests/test_log.py's property-style cases for the concrete proof.

Per-app adapters are out of scope for this library. Translating a Note, a FoodEntry, a workout entry, or wake_alarm's {hour, minute} value to and from Record is each app's own job — keeping this library domain-agnostic is what makes it actually shared, rather than shaped around one app's schema.

Install

pip install "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.2.0#subdirectory=crdt-sync"

Usage

from crdt_sync import GitHubSyncClient, Hlc, Record, sync_log

node_id = "pc"
clock = Hlc.new_tick(node_id)
record = Record(id="abc123", fields={"text": ("buy milk", clock)}, deleted=False)

client = GitHubSyncClient(owner="kuhyx", repo="my-app-sync", token=token)
result = sync_log(
    client=client,
    device_id=node_id,
    path_prefix="devices",
    local_log={record.id: record},
    encode=lambda log: json.dumps({k: r.to_dict() for k, r in log.items()}),
    decode=lambda text: {
        k: Record.from_dict(v) for k, v in json.loads(text).items()
    },
)

sync_log pulls every other device's last-pushed log from <path_prefix>/<other-device-id>/..., merges each into the local log with merge_logs, then pushes this device's own merged result back up — the same pull-all/merge/push-own pattern diet_guard's _sync.py already used, made domain-agnostic via the encode/decode callbacks so callers keep their own on-disk JSON shape instead of crdt-sync dictating one.

Local persistence

For persisting a Log to disk, crdt_sync ships load/save helpers that share a canonical on-disk JSON shape with the Dart crdt_sync_dart.LogStore, so a log written by one language parses in the other:

from crdt_sync import dump_log, load_log, read_log, write_log

write_log(path, log)          # atomic temp-file-then-rename
log = read_log(path)          # empty on a missing or corrupt file
text = dump_log(log)          # -> canonical JSON string
log = load_log(text)          # <- parse it back

These are load/save only. The Dart side additionally offers a reactive LogStore (a change stream to drive a live UI); that's a Flutter-only convenience and has no Python equivalent by design — Python consumers here are headless sync ticks.

Development

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest
pre-commit install && pre-commit install --hook-type pre-push