Per-peer read watermark for cross-device read state #9

Merged
arne merged 2 commits from read-watermark into main 2026-05-11 13:37:46 +02:00
Owner

Summary

Adds the smallest viable read-state mechanism so iOS, TUI, and any future client can sync "what have I seen?" via the server. Watermark is per-peer + per-identity, stored on contacts.

  • Migration v5: ALTER TABLE contacts ADD COLUMN last_read_row_id INTEGER NOT NULL DEFAULT 0. Additive, idempotent, same shape as the legacy schema.
  • POST /api/v1/contacts/read {peer, rowId}: advances the watermark. 200 with {peer, rowId} on advance, 204 on no-op (request at-or-behind current value — monotonic floor), 404 if the peer isn't in contacts. Publishes a new read-watermark-changed SSE event on advance only, so sibling devices update without polling.
  • GET /api/v1/contacts: every row now carries lastReadRowId. Unread = inbound messages in this thread with rowId > lastReadRowId (client-side derivation; the index on messages.id makes that trivial).

The store-level MarkPeerRead uses UPDATE … WHERE last_read_row_id < ? so the SQL itself arbitrates concurrent advances — the lower of two racing devices sees 0 rows affected and returns advanced=false without an explicit retry loop.

Intentionally not included: per-message read state (e.g. mark-an-individual-message-unread). The watermark is monotonic; if explicit unread surfaces later it should be a separate POST /contacts/unread so the read path stays accident-proof.

Test plan

  • go vet ./... clean
  • go test ./... clean
  • MarkPeerRead: advance from 0, no-op on equal, no-op on lower (monotonic floor), advance to higher, ErrPeerNotFound for unknown peer
  • POST /contacts/read: 200 on advance, 204 on equal, 204 on lower, 404 on unknown peer; exactly one read-watermark-changed SSE event across that sequence (publish fires on advance only)
  • GET /contacts surfaces lastReadRowId after advance
  • Manual after deploy: advance via TUI on arne, observe the SSE event arrives at a second connected client

🤖 Generated with Claude Code

## Summary Adds the smallest viable read-state mechanism so iOS, TUI, and any future client can sync "what have I seen?" via the server. Watermark is per-peer + per-identity, stored on `contacts`. - **Migration v5**: `ALTER TABLE contacts ADD COLUMN last_read_row_id INTEGER NOT NULL DEFAULT 0`. Additive, idempotent, same shape as the legacy schema. - **`POST /api/v1/contacts/read {peer, rowId}`**: advances the watermark. `200` with `{peer, rowId}` on advance, `204` on no-op (request at-or-behind current value — monotonic floor), `404` if the peer isn't in contacts. Publishes a new `read-watermark-changed` SSE event on advance only, so sibling devices update without polling. - **`GET /api/v1/contacts`**: every row now carries `lastReadRowId`. Unread = inbound messages in this thread with `rowId > lastReadRowId` (client-side derivation; the index on `messages.id` makes that trivial). The store-level `MarkPeerRead` uses `UPDATE … WHERE last_read_row_id < ?` so the SQL itself arbitrates concurrent advances — the lower of two racing devices sees 0 rows affected and returns `advanced=false` without an explicit retry loop. Intentionally **not** included: per-message read state (e.g. mark-an-individual-message-unread). The watermark is monotonic; if explicit unread surfaces later it should be a separate `POST /contacts/unread` so the read path stays accident-proof. ## Test plan - [x] `go vet ./...` clean - [x] `go test ./...` clean - [x] `MarkPeerRead`: advance from 0, no-op on equal, no-op on lower (monotonic floor), advance to higher, `ErrPeerNotFound` for unknown peer - [x] `POST /contacts/read`: 200 on advance, 204 on equal, 204 on lower, 404 on unknown peer; exactly one `read-watermark-changed` SSE event across that sequence (publish fires on advance only) - [x] `GET /contacts` surfaces `lastReadRowId` after advance - [ ] Manual after deploy: advance via TUI on `arne`, observe the SSE event arrives at a second connected client 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Migration v5 adds a single last_read_row_id column to contacts (default
0), the natural sync point for "device A read up to here" across iOS,
TUI, and future clients.

New API surface:

- POST /api/v1/contacts/read {peer, rowId}: 200 + body on advance,
  204 on no-op (request was at-or-behind the current watermark — the
  watermark is monotonic, so concurrent advances from two devices
  race safely; the lower loses), 404 if peer not in contacts. Publishes
  a new read-watermark-changed SSE event on advance only.
- GET /api/v1/contacts now includes lastReadRowId per row. Clients
  derive unread as inbound messages with rowId > lastReadRowId.

The store-level MarkPeerRead uses
`UPDATE … SET last_read_row_id = ? WHERE url = ? AND last_read_row_id < ?`,
so the UPDATE itself is the arbiter on concurrent advances — the loser
sees 0 rows affected and returns advanced=false without a read-the-DB
retry loop.

CLIENT_API.md updated for the endpoint, the new contact field, and
the new SSE event type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Document that clients should treat the watermark as monotonic too:
  apply read-watermark-changed events as `local = max(local, event.rowId)`,
  since SSE can deliver out of order under reconnect or concurrent
  advances and the server's monotonic floor only protects the DB
- Drop redundant posta.NormalizeURL call in the markRead handler;
  MarkPeerRead normalizes its own input, so canonicalization lives in
  one place. Echo the normalized form back to the caller in the 200
  response so clients see the canonical URL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arne merged commit dd4dca0875 into main 2026-05-11 13:37:46 +02:00
arne deleted branch read-watermark 2026-05-11 13:37:46 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/server!9
No description provided.