Files
inkwell/docs/sync.md
T
bvandeusenandClaude Opus 5.5 2b2ceaa82e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
attachments sync: attach offline, upload when linked, removals stick (#5168)
Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:07:52 -04:00

295 lines
15 KiB
Markdown

# Inkwell 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.
## Protocol versioning — the compatibility handshake
Clients and servers update on their own schedules; a self-hosted server can sit on
an older release than the desktop app for months. So the wire protocol is
versioned **separately from either program's release version**, and each side
declares two numbers: what it speaks, and the oldest counterpart it accepts.
| | server (`src/inkwell/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) |
|---|---|---|
| speaks | `SYNC_PROTOCOL_VERSION` | `CLIENT_PROTOCOL_VERSION` |
| accepts down to | `MIN_CLIENT_PROTOCOL_VERSION` | `MIN_SERVER_PROTOCOL_VERSION` |
The server publishes its half on the **public, unauthenticated** `GET /api/config`
— a client must be able to ask "can I talk to you?" before it holds a device
token, or even has an account:
```json
{ "site_name": "...", "version": "0.1.0",
"sync_protocol_version": 1,
"min_client_protocol_version": 1,
"sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"],
"trash_retention_days": 30 }
```
The client identifies itself on every request with
`X-Inkwell-Client: inkwell-desktop/<app version>` and
`X-Inkwell-Protocol: <n>`.
### `sync_features` — why versions alone aren't enough
A version number can only say "newer" or "older". `sync_features` names
capabilities, so a client tests for the one it needs instead of inferring it from
a number. That is what keeps an **additive** change from forcing a lockstep
upgrade: a newer client meeting an older server drops the missing feature and
syncs everything else.
### The policy
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
client kept pushing both and read back notes carrying neither — and `title` was
the note's NAME, so an old client showed nameless notes.
- v3: attachments/tombstones/revisions.
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
in silence and neither loses anything visible — an old client reading a v4 note
falls back to the colour it derives locally, and one pushing `color` has the key
ignored. The test is not "did a field leave" but "does either side end up
showing something wrong".
- v5 (#5168): attachments became a sync entity — an upload route keyed by the
client's id, and `attachment`/`preview` deletes in push. Additive, so it is
the `attachment_sync` feature and the floor stays.
- **Additive change** (a new field, a new capability) → add a `sync_features`
name. Do **not** raise a minimum. Old clients keep working.
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
`MIN_SERVER_PROTOCOL_VERSION`). This is the switch that hard-blocks the other
side, so it is the one to be stingy with.
- Never gate behavior on the *release* version (`version`) — it's for display.
### The three outcomes
The client evaluates the advertisement (`compat::evaluate`) and gets exactly one
of:
- **ok** — full parity; sync everything.
- **degraded** — safe to sync, but named capabilities are unavailable here; the UI
says which.
- **incompatible** — do not sync. Carries `client_must_update` so the message can
point at the side that can actually fix it, rather than just saying
"incompatible".
A server that predates this handshake sends no protocol fields at all. That is
treated as **incompatible (update the server)** — deliberately not as a parse
error, which would look to the user like they mistyped the URL.
## 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-login` with
`{email, password, name}` → `{token, device, user}`. Store `token`; send it as
`Authorization: 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.
- **Self-revoke:** `DELETE /api/auth/devices/self` retires the token presented in
the `Authorization` header. This is what a native client calls when the user
unlinks. It exists because a client can't use the id-keyed route: a token pasted
from the web app arrives without a device id, and `/api/auth/me` describes the
user, not the device row. A caller authenticated by session cookie gets `400` —
it holds no device token, so there is nothing for it to mean.
Unlinking is never blocked on this call. If the server is unreachable or too old
to have the route, the client still unlinks locally and tells the user the token
is still live and where to revoke it.
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:** `#tags` are parsed from the note body. Clients recompute
them locally; the server recomputes them on push. They never travel over the wire.
(`[[wiki-links]]` were derived the same way until they were removed — note 2897.)
- **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_at` is a normal field, and it's **on the wire**: a trashed
note still syncs with its content, the client shows it in its Trash, and the
timestamp is what the client counts the retention window against. Restoring
clears it.
- **Purge (permanent delete)** — becomes a **content-less tombstone**: `purged_at`
is set, title/body/items/labels/attachments/previews/revisions are cleared or
removed, and the row is kept. A client seeing `purged_at != null` deletes the row
from its local store. `deleted_at` deliberately SURVIVES a purge, so ordinary
server-side queries (`deleted_at IS NULL`) never see a tombstone as a live note.
Tombstones themselves are retained indefinitely (cheap for a personal store);
revisit if they ever grow large.
### Retention — trash expires
A trashed note is purged automatically once it is older than the server's
`trash_retention_days` setting (default **30**, `0` = keep forever), advertised on
`/api/config` so a client can show the countdown. A background sweep on the server
does the work; clients learn about it as ordinary tombstones and need no special
handling.
**A linked client must not run its own expiry.** The server owns the policy — one
clock, one window. A client that purged on its own schedule could destroy a note
the server was deliberately keeping and then push that delete upstream. An
*unlinked* client (offline-only, no server to defer to) expires its own trash on
its own default, which is the only case where nothing else can.
## Pull — `GET /api/sync/changes`
Query: `?since=<cursor>&limit=<n>` (limit default 500, max 1000).
Response:
```json
{
"notes": [ { "...full note...", "trashed": false, "deleted_at": null,
"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:
```json
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"body": "...",
"pinned": false, "archived": false, "trashed": false, "remind_at": null,
"recurrence": null, "position": 0,
"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 and sets
manual label memberships from `label_ids` (tag-sourced labels are re-derived
from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
inside `body` (M304), so there is no separate `items` array.
- **Fields a change may still carry, and the server reads past.** `title` and
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
its payload key by key and never validates the shape, which is exactly what lets
an older client keep pushing a field this one has stopped storing — see the
version policy above for why none of those needed a floor raise on their own.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: 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).
- **Attachments and link previews** (`attachment_sync`): `{entity:
"attachment"|"preview", op: "delete", id, edited_at}`. Delete is the only op —
attachments are created by upload (below) and previews by the server. A removal
is **not** weighed under last-write-wins: there is no rival version of a
removed file, so it always applies. A row the caller can't see answers `noop`,
the same as one that never existed. Removals are explicit rather than "the
note's current attachment set" on purpose: push runs before pull, so a set
would delete an attachment another device added that this one has not seen.
### 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:
```json
{ "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:
- **Sync upload** (`attachment_sync`): `PUT /api/sync/attachments/<id>?note_id=
&filename=&sha256=`, body = the raw bytes, type in `Content-Type`. For a file
attached offline, sent after the note itself has landed. `201` stores it under
the client's id; `200 {"status": "exists"}` if the note already holds that id
(a retry); `400` if the bytes don't hash to `sha256`; `409` if the id belongs to
another note; `413` over `max_attachment_mb`; `404` for a note the caller
doesn't own. A `4xx` other than `404` is permanent — the client records it on
the attachment and stops retrying.
- **Web upload:** `POST /api/notes/<note_id>/attachments` (multipart, field
`file`; optional field `id`, same idempotency).
- **Download:** `GET /api/notes/<note_id>/attachments/<id>` (owner/shared scoped).
- The client uses `sha256` to skip blobs it already holds and to verify
integrity after download. Any file type syncs.
- Every insert or delete of an attachment or a link preview bumps its note's
`sync_revision` (triggers from 0015 and 0031), so a change to either reaches
other devices as the note.
## A sync cycle
1. **Push** local changes since the last sync (batched). Apply the per-item
results (mark synced, adopt server version where `kept`). Then upload the bytes
of attachments added offline whose notes have now landed.
2. **Pull** from the stored `since` cursor until `has_more` is false. Upsert
notes/labels into the local store; delete rows whose `purged_at` is set;
download any attachment blobs referenced by a new/changed `sha256`.
3. 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.