utils/crdt-sync
Krzysztof kuhy Rudnicki a7bccb34bb
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
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
web_ui tests / test (push) Waiting to run
Cover tool/google_id_token.py at 100% branch
Second module of the tool/ coverage task. No network and no browser: the
callback server is replaced wholesale so consent resolves instantly, and
requests.post is mocked at the same boundary test_firebase_auth.py uses.

All six TokenError paths are covered, since each names a different
misconfiguration and the module docstring exists because they are easy to
mistake for one another -- notably the fixed-vs-free port distinction, where
a Web client matching redirect URIs exactly fails with redirect_uri_mismatch
before the account picker ever appears. Also asserts the server is closed on
the failure path, since a leaked listener makes the next run fail to bind.

Split across two files because the cap hook rejected the first attempt at 273
lines: the flow tests here, main()'s argument parsing and exit code in
_cli.py, which imports the shared fixture values rather than redeclaring them.
That is the gate doing its job on this session's own output.

Three things the tooling caught, all fixed rather than suppressed:

- S105/S106 match on the binding *name*, not the value, so any constant
  called SECRET/TOKEN/PASSPHRASE trips them however obviously fake it is.
  Renamed around the rule the way test_firebase_auth.py names _ID_1.
- mypy rejected the previous commit's log_message test for asserting on the
  return of a `-> None` function -- a tautology. Rewritten to make a real
  request and assert stderr stays empty, which is the only thing that
  distinguishes the silenced handler from the stock one.
- ruff pruned the imports that moved out during the split; verified it took
  only those, and that the suite is still green afterwards.

crdt_sync 268 -> 286 passed, still 100%. Both modules verified at 100% branch
individually; the config flip stays last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:32:08 +02:00
..
crdt_sync Cover tool/google_id_token.py at 100% branch 2026-08-22 16:32:08 +02:00
tool Seed a Firebase session for todo as well 2026-08-22 16:17:06 +02:00
.pre-commit-config.yaml Fix the cap hook in CI, and the re-exports its --all-files run exposed 2026-08-22 15:52:59 +02:00
LICENSE Initial implementation: unified CRDT merge scheme + GitHub sync transport 2026-07-05 20:40:09 +02:00
pyproject.toml Group sync_log's 11 arguments into three bundles 2026-08-21 14:22: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