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
+78 -4
View File
@@ -51,7 +51,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, invites, password_resets, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, invites, password_resets, users"
@pytest_asyncio.fixture
@@ -1292,9 +1292,9 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
assert "fromguest" in owner_tags
assert (await (await recipient.get("/api/labels")).get_json())["labels"] == []
# Everything else stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "archived": True})).status_code == 403
# Everything else but their own pin and archive (#5176) stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"remind_at": "2099-01-01T00:00:00Z"})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "recurrence": "daily"})).status_code == 403
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
assert (await recipient.get(f"/api/notes/{nid}/revisions")).status_code == 404
@@ -1303,6 +1303,41 @@ async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(ap
assert "- [x] milk" in body and "eggs" in body
async def test_owner_and_recipient_each_pin_archive_and_order_their_own(app_client, db):
recipient, stranger, people = await _three_people(app_client)
older = await _owners_note(app_client, "older")
nid = await _owners_note(app_client, "newer")
for note_id in (older, nid):
await _share(app_client, note_id, people["recipient"], "view")
async def pinned(client) -> bool:
return (await (await client.get(f"/api/notes/{nid}")).get_json())["pinned"]
# A view share is enough: pinning is organizing your own board, not changing the note.
mine = await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})
assert mine.status_code == 200, await mine.get_data(as_text=True)
assert (await mine.get_json())["pinned"] is True
assert (await pinned(recipient), await pinned(app_client)) == (True, False)
await app_client.patch(f"/api/notes/{nid}", json={"pinned": True})
await recipient.patch(f"/api/notes/{nid}", json={"pinned": False})
assert (await pinned(recipient), await pinned(app_client)) == (False, True)
assert (await stranger.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 404
# Archived by the recipient, it leaves their board and never the owner's.
await recipient.patch(f"/api/notes/{nid}", json={"archived": True})
assert await _board_ids(recipient) == [older]
assert await _board_ids(recipient, "filter=archived") == [nid]
assert nid in await _board_ids(app_client)
await recipient.patch(f"/api/notes/{nid}", json={"archived": False})
# Until they move them, a recipient sees the owner's order; after, their own.
await app_client.patch(f"/api/notes/{nid}", json={"pinned": False})
assert await _board_ids(recipient) == [nid, older]
assert (await recipient.post("/api/notes/reorder", json={"ids": [older, nid]})).status_code == 200
assert await _board_ids(recipient) == [older, nid]
assert await _board_ids(app_client) == [nid, older]
async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
@@ -1420,6 +1455,45 @@ async def test_an_edit_share_pushes_text_and_nothing_else(app_client, db):
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
async def test_a_recipients_own_state_syncs_to_their_devices_only(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line")
await _share(app_client, nid, people["recipient"], "edit")
start = (await _feed(recipient))["cursor"]
async def push(change: dict) -> dict:
payload = {"changes": [{"entity": "note", "id": nid, "op": "upsert", **change}]}
return (await (await recipient.post("/api/sync/push", json=payload)).get_json())["results"][0]
# The owner edits; the recipient's phone, a version behind, pins its stale copy.
await app_client.patch(f"/api/notes/{nid}", json={"body": "first line, the owner's edit"})
owners = (await _feed(app_client))["cursor"]
pinned = await push(
{
"edited_at": "2000-01-01T00:00:00Z",
"body": "first line",
"pinned": True,
"archived": False,
"position": 7,
"state_at": "2099-01-01T00:00:00Z",
}
)
assert pinned["status"] == "applied", pinned
# The pin's newer time is the state's alone, so the stale text lost to the edit.
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"] == "first line, the owner's edit"
held = _feed_note(await _feed(recipient, start), nid)
assert (held["pinned"], held["position"], held["sync_revision"]) == (True, 7, pinned["sync_revision"])
# Nothing about the note changed for its owner, so their devices aren't sent it.
assert _feed_note(await _feed(app_client, owners), nid) is None
assert _feed_note(await _feed(app_client), nid)["pinned"] is False
# Another device's older unpin loses to the newer pin.
stale = await push({"edited_at": "2000-01-01T00:00:00Z", "pinned": False, "state_at": "2098-01-01T00:00:00Z"})
assert stale["status"] == "kept"
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["pinned"] is True
async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)