sync: recipients keep their own pin, archive and place on shared notes

A note_user_state row per (note, recipient) holds what used to be the owner's
columns as far as anyone else could tell. The board filters and orders through
the viewer's own state; PATCH and reorder write it for a note shared at any
level; the feed's revision for a shared note is the later of the note's and the
caller's row, so a recipient's pin reaches their devices and no one else's.

Push takes the three with their own `state_at` stamp (protocol 7,
`shared_state`), so pinning a copy whose text is behind never makes that text
win over the owner's edit. A body is only stamped as an edit when it changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:13:58 -04:00
co-authored by Claude Opus 5.5
parent 5e8c6dc7bf
commit 63955bbe97
10 changed files with 432 additions and 60 deletions
+26 -6
View File
@@ -67,6 +67,10 @@ syncs everything else.
- v6 (#5175): shared notes — see "Shared notes" below. Opt-in per request
(`changes?shares=1`), so it is the `shares` feature and the floor stays: a v5
client never asks and gets exactly its own notes.
- v7 (#5176): a recipient's own pin, archive and position on a shared note —
see "Shared notes". Additive, so it is the `shared_state` feature and the
floor stays: a v6 client pushes only text, and reads its own state as if the
note were simply unpinned.
- **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
@@ -230,6 +234,13 @@ sequence and page on the same cursor as notes and labels (three streams now; the
smallest full boundary wins). Sharing with the person again deletes their
revocation, so a device that never saw it is never told to drop a note it has.
**Pin, archive and position are personal** (`shared_state`, v7). On a note shared
with the caller, the feed's `pinned`, `archived` and `position` are the caller's
own: unpinned and unarchived until they change them, and the owner's `position`
until they move it. They live in a per-person row with a revision of its own, so
the note's `sync_revision` in the feed is the later of the note's and that row's —
a recipient pinning a note reaches their devices and no one else's.
## Push — `POST /api/sync/push`
Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
@@ -267,12 +278,21 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
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.
- **A note someone else owns** (`shares`): a recipient at `edit` may push an upsert
and only its `body` is applied, under the same last-write-wins — every other
field is ignored, since pinning, archiving, reminders, labels and deleting stay
the owner's. A `view` recipient's change, or any delete, is `rejected` ("only its
owner can change that"); a note the caller can't see at all answers the generic
"cannot apply".
- **A note someone else owns** (`shares`): an upsert may carry two things, each
under its own last-write-wins.
- Its `body`, from a recipient at `edit`, compared on `edited_at` against the
note. From a `view` recipient a body is `rejected` ("only its owner can change
that").
- Their own `pinned`, `archived` and `position` (`shared_state`), from anyone it
is shared with, compared on **`state_at`** against what they last set. A
client stamps `state_at` apart from the text's `edited_at` so that pinning a
copy whose text is out of date never makes that text look newer than the
owner's edit. Without `state_at` the three are ignored.
Trashing, reminders, labels and deleting stay the owner's: those fields are
ignored and a delete is `rejected`. The answer is `applied` if either part
applied, else `kept`, with the revision as that caller's feed would show it. A
note the caller can't see at all answers the generic "cannot apply".
### Conflict resolution — last-write-wins + history