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).
3.4 KiB
3.4 KiB
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
- Strict Monotonicity:
t_{n+1} > t_nfor all events generated on the same device. - Causal Ordering: If event
Bwas generated after observing eventA, thenHLC(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)
- Projection Updates:
Database tables (
items,lists) are projections ofop_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; - Tombstones (
deleted_at): When an item or list is deleted (item_remove,list_delete), a tombstone is set (deleted_at = now()). Late-arriving offlineitem_updateoperations with smallerhlc_tsvalues 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 aUNIQUEconstraint 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 trackmax(server_seq)locally. Incremental sync fetches ops whereseq > cursor, sorted by monotonic server sequenceseq 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, roleowner) and any number of members (list_members, rolemember). 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(not403, 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 highesthlc_tson every device — last write wins, no data merge.