The contract the Tauri/Android clients implement against: device-token auth, the shared-sequence revision cursor, note-as-sync-unit (+ derived links/tags not synced), trash vs purge tombstones, pull (GET /changes) + push (POST /push) request/response shapes, last-write-wins + history conflict policy, attachment blob sync by id + sha256, and the idempotent/resumable sync cycle (initial since=0 + resume). Docs only — CI paths exclude *.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
7.7 KiB
ThoughtSync sync protocol
The contract the local-first native clients (Tauri desktop, Android) implement
against. The server is the sync hub: each client keeps a full local store
(SQLite), works fully offline, and reconciles with the server when linked. The
web app does not use this API — it stays on the live REST API (/api/notes,
…) as an online-only stopgap.
All sync endpoints live under /api/sync. Everything is owner-scoped and
deterministic (no AI).
No Postgres CI lane. Trigger/migration/sync behavior is verified by the operator on deploy, not in CI. Pure logic (LWW comparator, paging cursor, token hashing) is unit-tested.
Authentication — device bearer tokens
Native clients authenticate with a long-lived device token, not a session cookie. Only the token's SHA-256 hash is stored server-side; the plaintext is shown once at creation.
- First link (no session):
POST /api/auth/device-loginwith{email, password, name}→{token, device, user}. Storetoken; send it asAuthorization: Bearer <token>on every subsequent call. - From the web app (already signed in): the user creates a token at
/account("Linked devices");POST /api/auth/devices{name}→{token, device}. They paste it into the native app. - Manage:
GET /api/auth/devices(list),DELETE /api/auth/devices/<id>(revoke). A revoked token stops authenticating immediately.
Every authenticated request (sync or otherwise) accepts the bearer token in place of the session cookie.
The revision cursor
Every syncable row carries a monotonic sync_revision (bigint), assigned by
a database trigger from a single shared sequence (sync_revision_seq) on every
insert/update. Because it's a shared sequence, one integer is a total-order
watermark across all of a user's notes and labels. It is not a timestamp —
it is immune to clock skew and is only ever compared with >.
The client persists the highest cursor it has fully consumed and passes it back
as ?since=. since=0 (or absent) is a full initial sync.
Entities and what syncs
- Note — the primary sync unit. It travels with its checklist items,
label memberships, and attachment metadata inline (same shape as the REST
serialization). A change to any child bumps the parent note's
sync_revision, so pulling the note re-syncs the whole thing. - Label — the label catalog (name + color) syncs as its own entity so a rename/recolor/delete propagates independently of notes.
- Derived, NOT synced:
[[wiki-links]]and#tagsare parsed from the note body. Clients recompute them locally; the server recomputes them on push. They never travel over the wire. - Attachment blobs sync by id over the existing upload/download routes (see Attachments below); only their metadata rides the delta feed.
Tombstones (deletes)
Two levels, both propagate:
- Trash —
deleted_atis a normal field. A trashed note still syncs with its content; the client shows it in its Trash. Restoring clearsdeleted_at. - Purge (permanent delete) — becomes a content-less tombstone:
purged_atis set, title/body/items/labels/attachments are cleared/removed, and the row is kept. A client seeingpurged_at != nulldeletes the row from its local store. Tombstones are retained indefinitely (cheap for a personal store); revisit if they ever grow large.
Pull — GET /api/sync/changes
Query: ?since=<cursor>&limit=<n> (limit default 500, max 1000).
Response:
{
"notes": [ { "...full note...", "sync_revision": 42, "purged_at": null } ],
"labels": [ { "id": "...", "name": "...", "color": "...",
"sync_revision": 43, "purged_at": null, "created_at": "..." } ],
"cursor": 43,
"has_more": false
}
Returns all of the caller's notes + labels (any state — active, archived,
trash, purged) whose sync_revision > since, ascending by revision. Notes and
labels share the sequence, so paging merges the two streams: when either stream
fills a page, cursor advances only to the smaller of the two page
boundaries, so nothing between cursor and the next pull is skipped. Loop while
has_more is true, advancing since = cursor each time.
Note attachment metadata carries {id, url, mime, size, sha256}.
Push — POST /api/sync/push
Body: { "changes": [ ... ] } (max 1000 per batch). Each change:
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"title": "...", "body": "...", "color": "blue", "kind": "text",
"pinned": false, "archived": false, "trashed": false, "remind_at": null,
"position": 0, "items": [ {"text": "...", "checked": false} ],
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
- Client-generated ids. Notes/labels are UUIDs; the client mints the id when it creates the row offline and sends it here. Create-if-absent, else update.
- Whole-note semantics. A note upsert carries the client's full current
state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from
label_ids(tag-sourced labels are re-derived from the body).[[links]]/#tagsare recomputed server-side. op: "delete"purges (tombstones) the row. Trashing is just an upsert withtrashed: true.- Labels:
{entity: "label", op: "upsert"|"delete", id, edited_at, name, color}. A per-owner name clash on a different id is rejected (fix locally and retry).
Conflict resolution — last-write-wins + history
On a clash the most-recently-edited version wins, by edited_at
(client wall-clock) compared against the server row's last-edit time
(updated_at). The client applies iff client.edited_at >= server.updated_at.
When a winning upsert overwrites an existing title/body, the server first
snapshots the overwritten version into the note's version history
(note_revisions) — so a "lost" edit is never truly lost; it's one Restore away.
A stale delete loses to a newer server edit.
Response — per item, so the client can mark its local rows synced:
{ "results": [
{ "id": "...", "entity": "note", "status": "created", "sync_revision": 44 },
{ "id": "...", "entity": "note", "status": "kept", "sync_revision": 40 },
{ "id": "...", "entity": "label","status": "rejected","error": "name in use" }
] }
status ∈ created | applied | kept | noop | rejected. kept means the server
had a newer edit and the client should adopt the server version on its next pull.
Attachments (blobs)
Metadata rides the delta feed (id, url, mime, size, sha256); the bytes move
over the existing routes:
- Upload:
POST /api/notes/<note_id>/attachments(multipart, fieldfile; optional fieldidto keep a client-minted attachment id). Re-uploading an id the note already has is an idempotent no-op. Server stores + hashes the bytes. - Download:
GET /api/notes/<note_id>/attachments/<id>(owner/shared scoped). - The client uses
sha256to skip blobs it already holds and to verify integrity after download. (Currently image mimes only; broadening to any file is tracked separately.)
A sync cycle
- Push local changes since the last sync (batched). Apply the per-item
results (mark synced, adopt server version where
kept). - Pull from the stored
sincecursor untilhas_moreis false. Upsert notes/labels into the local store; delete rows whosepurged_atis set; download any attachment blobs referenced by a new/changedsha256. - Persist the new
cursor.
Initial sync is the same with since=0. Because everything is keyed by stable
ids and a monotonic cursor, the cycle is idempotent and resumable — a client
can crash mid-sync and simply resume from its last persisted cursor.