mitbringsl/docs/SYNC.md
Tronax 67033e561c
Tests for shared lists & docs catch-up to post-MVP state
Integration tests for the invite/join/membership feature that shipped
without any coverage:

- internal/store/liststore_test.go: CreateList adds owner as member with
  invite code, GetLists returns owned+joined but not foreign lists,
  GetList access control (owner/member yes, stranger and soft-deleted no),
  JoinByInviteCode normalization/idempotency/role-keeping, lazy invite
  code generation. Runs against TEST_DATABASE_URL, skips otherwise.
- internal/httpapi/api_test.go: full E2E over the real router — register,
  create list (code in response), invite endpoint, join (lowercase),
  cross-member op push/pull sync, stranger gets 404 on every list
  endpoint, invalid code 400, idempotent re-join, and 401 gating of all
  protected routes.
- lists.go Invite handler: store errors now map through apiError, so
  non-members get 404 instead of 400 (consistent with Get/Push/Pull).

Docs updated to the actual post-MVP state: AGENTS.md (post-MVP features,
repo structure, roadmap with open points like join rate limiting),
API.md (join/invite endpoints, invite_code fields, membership rules),
SYNC.md (shared lists section), README (local-only default, sharing,
integration test recipe).
2026-08-22 09:40:57 +02:00

3.4 KiB
Raw Blame History

Mitbringsl Synchronization Protocol & Conflict Resolution

1. Synchronization Model

Mitbringsl uses an Append-Only Operation Log (op_log) with Hybrid Logical Clock (HLC) timestamps and Last-Write-Wins (LWW) projections.


2. Hybrid Logical Clock (HLC)

Every operation carries a 64-bit integer timestamp hlc_ts:

\text{hlc\_ts} = (\text{wall\_ms} \ll 16) \mid \text{counter}
  • wall_ms (48 bits): Unix epoch milliseconds.
  • counter (16 bits): Logical counter disambiguating events produced within the same millisecond.

Guarantees

  1. Strict Monotonicity: t_{n+1} > t_n for all events generated on the same device.
  2. Causal Ordering: If event B was generated after observing event A, then HLC(B) > HLC(A).

3. Operations (op_log)

All state modifications are represented as structured operations:

op_type Target Description Payload Example
list_create List UUID Create list {"name":"Wocheneinkauf"}
list_rename List UUID Rename list {"name":"Supermarkt"}
list_delete List UUID Delete list {}
item_add Item UUID Add item {"name":"Milch","quantity":"2L"}
item_update Item UUID Update item {"name":"Hafermilch","checked":true}
item_remove Item UUID Delete item {}

4. Conflict Resolution (LWW & Tombstones)

  1. Projection Updates: Database tables (items, lists) are projections of op_log. When an op arrives:
    UPDATE items
    SET name = $1, hlc_ts = $2, updated_at = now()
    WHERE id = $3 AND hlc_ts < $2 AND deleted_at IS NULL;
    
  2. Tombstones (deleted_at): When an item or list is deleted (item_remove, list_delete), a tombstone is set (deleted_at = now()). Late-arriving offline item_update operations with smaller hlc_ts values will never revive a tombstoned entity.

5. Idempotent Push & Cursor Pull

  • Idempotent Push (POST /api/lists/{id}/ops): Each operation carries a (client_id, client_seq) tuple enforced by a UNIQUE constraint in Postgres (op_log). Re-sent requests return previous (seq, hlc_ts) assignments without duplicating side effects.
  • Cursor Pull (GET /api/lists/{id}/ops?since={seq}): Clients track max(server_seq) locally. Incremental sync fetches ops where seq > cursor, sorted by monotonic server sequence seq ASC.

6. Shared Lists

Since shared lists were introduced, ops flow between all members of a list, not just its owner:

  • Membership: A list has an owner (lists.owner_id, role owner) and any number of members (list_members, role member). Members join via an 8-character invite code (POST /api/lists/join); owner and members can read the code (POST /api/lists/{id}/invite) to share it.
  • Sync scope: Push and Pull verify on every request that the caller is owner or member of the list — non-members receive 404 (not 403, so membership cannot be probed). Once joined, a member pulls the full op history (?since=0) and applies the same LWW projection locally.
  • Conflict semantics are unchanged: With multiple writers the LWW register (hlc_ts, client_id) resolves concurrent edits; the HLC guarantees that ops from different clients that observed each other are still causally ordered. Concurrent edits to the same item converge to the highest hlc_ts on every device — last write wins, no data merge.