Groundwork for moving diet-guard, screen-locker and wake-alarm off fixed
role ids ('pc'/'phone'/'desktop') onto per-install uuids, the scheme todo
and home_inventory already use. A role constant collides the moment a
second machine takes the same role, and a reinstall silently inherits the
previous install's CRDT identity.
The migration cannot rewrite history: the device id is baked into every HLC
stamp already written, is the record path segment and the revision key. So
a device that has just switched ids must still recognise its former id as
its own -- otherwise skip-own-writes stops matching the old path, and the
device pulls back and re-merges its own pre-migration log as though a peer
had written it. Idempotent under LWW, but pure wasted transfer on every
tick.
Both halves therefore take an optional legacy id and test set membership
(own_ids / ownIds) rather than equality against a single id:
- crdt-sync (Python) 0.5.1 -> 0.6.0: new crdt_sync._device with
DeviceIdentity + load_device_identity (generate-or-load, persisted); the
mkdir is deliberately unguarded, since handing back an unpersisted id
would mint a fresh uuid every restart and strand a device dir each time.
sync_log gains legacy_device_id.
- crdt_sync_dart 0.6.0 -> 0.7.0: DeviceIdentity value type; syncLog gains
legacyDeviceId. Persistence stays app-side, where the existing uuid apps
already keep it in SharedPreferences under 'crdt.nodeId'.
Both changes are backward compatible: the parameter defaults to null, which
reproduces the previous single-id behaviour exactly.
Each half has a positive test and a negative control proving the old path
*is* pulled when no legacy id is declared -- so the skip is verified to be
the parameter's doing, not an artifact of the fixture.
Python: 239 tests, 100% branch coverage. Dart: 248 tests, analyzer clean,
device.dart 11/11 and sync.dart 57/57 lines covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
|
||
|---|---|---|
| .. | ||
| crdt_sync | ||
| tool | ||
| .pre-commit-config.yaml | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
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_idbreaks 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 bumpscounterwhen the wall clock hasn't moved pastprevious— the standard HLC construction, guaranteeing monotonicity even across clock skew or two ticks in the same millisecond.Record—id, afieldsmap ofname -> (value, Hlc), and a stickydeletedflag.merge_fieldkeeps whichever side'sHlcis 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'sdeletedis not itself per-field LWW: it'sa.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_logstakes the union of ids andmerge_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