How sync works
SyncBins sync is deliberately simple: the server assigns a global version integer to every item write and delete. Clients track the highest version they’ve applied and ask for everything newer.
No CRDTs, no operational transform — one human, one timeline, last version wins.
Two transport paths
Section titled “Two transport paths”| Path | Used by | How it works |
|---|---|---|
WebSocket /ws | Web app (primary) | Push item / delete frames in real time |
REST GET /api/items?since=N | Catch-up, MCP polling | Pull batches of rows with version > since |
Both carry the same encrypted ItemRow shape from shared/src/api.ts.
WebSocket lifecycle
Section titled “WebSocket lifecycle”- Client opens
wss://<host>/ws?token=<bearer>(token required before upgrade). - Client sends
{ "type": "hello", "since": <lastVersion> }— use0on first connect. - Server replays backlog: every row with
version > since, asitemordeletemessages, in version order. - Server sends
{ "type": "caught_up", "version": <head> }. - Steady state: server pushes new
item/deleteframes when other devices write. The writing device already has the item locally. - Heartbeat: server sends
{ "type": "ping" }every 30s. Client replies{ "type": "pong" }. Two missed pongs (~90s grace) → server closes the socket.
Full message schemas: WebSocket protocol.
Version numbers
Section titled “Version numbers”- Global across all bins — not per-bin cursors.
- Monotonic — assigned by the server on each
POST /api/itemsandDELETE /api/items/:id. - Durable — soft-deleted items keep their row; deletes get a new higher version and replay as
{ type: "delete" }.
Clients should persist the highest applied version locally. On reconnect, send hello with that value — the backlog fills any gap from the disconnect.
Fanout
Section titled “Fanout”When device A writes an item:
- Server persists ciphertext + wrapped keys, bumps
version, appends tosync_log. - Server pushes an
itemframe to every other connected WebSocket for that tenant. - Device B decrypts, upserts into local state, updates its version cursor.
If device B was offline, it catches up on next hello or REST poll.
Re-wrap after pairing
Section titled “Re-wrap after pairing”When a new device joins, existing online clients run rewrapItemsForAllDevices():
- Scan items this device can decrypt.
- If any paired device’s pubkey is missing from
payloadEnc.keys, re-encrypt andPUTthe item with an expanded key bundle.
That is why historical items eventually appear on a new laptop without manual export.
Deletes
Section titled “Deletes”Deleting an item is a soft delete: deletedAt is set server-side, payload fields cleared, version incremented. Other devices receive a delete push and hide the row locally. There is no trash UI and no timed undelete in the current app.
MCP and polling
Section titled “MCP and polling”The MCP server does not hold a WebSocket. It polls GET /api/items?since=N on tool calls. Higher latency, same ciphertext. See MCP server.
Reconnection guidance
Section titled “Reconnection guidance”Implement exponential backoff with jitter (start ~1s, cap ~60s). Always resume with the last stored version in hello. The server replay is idempotent — applying the same version twice should no-op in the client store.