feat(appview): Notifications, Follower-/Following-Listen, Thread-Route

Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 21:36:32 +02:00
co-authored by Claude Opus 5
parent 9d009bfcba
commit c4ca218d97
6 changed files with 2519 additions and 123 deletions
+113
View File
@@ -0,0 +1,113 @@
-- AppView database schema 0008: notifications + follow-list pagination.
--
-- Why
--
-- Phase 5 shipped the read API (timeline / profile / search / post) but
-- the client had no way to learn that *someone else* interacted with
-- the user: a like, a repost, a follow or a reply never produced a
-- durable record. The Tauri client's tray/notification path (Phase 7)
-- therefore had nothing to poll. This migration adds the table the
-- Jetstream indexer writes into and the read API serves from.
--
-- Table shape
-- id BIGSERIAL — monotonic tiebreaker for the keyset
-- cursor. The API's opaque cursor is
-- `(indexed_at, id)`, mirroring the
-- `(indexed_at, uri)` pair used by /api/timeline/home,
-- so the same `routes::cursor` encoder is reused.
-- recipient_did the user who should SEE the notification (the post
-- author for like/repost/reply, the followed user for
-- follow).
-- author_did the user who CAUSED it (the liker / reposter /
-- follower / replier).
-- kind 'like' | 'repost' | 'follow' | 'reply'. Enforced by
-- a CHECK rather than a Postgres ENUM so adding a
-- variant later is an ALTER ... DROP/ADD CONSTRAINT
-- instead of a type migration that locks every
-- dependent object.
-- subject_uri the post the notification is *about*. NULL for
-- 'follow' (there is no post). For 'like'/'repost'
-- it's the liked/reposted post (the recipient's own
-- post); for 'reply' it's the REPLY itself, because
-- the interesting text to show in the notification
-- list is what the replier wrote, not what the
-- recipient already knows they posted.
-- created_at the interaction's own `createdAt` from the AT record.
-- indexed_at when WE saw it. This is what the list is ordered by,
-- for the same reason the timeline orders by
-- `posts.indexed_at`: a client-supplied `created_at`
-- can be arbitrarily far in the past or future and
-- would break keyset pagination.
-- read_at NULL = unread. Set in bulk by
-- `POST /api/notifications/seen`.
--
-- Deliberately NO `CHECK (recipient_did <> author_did)`
-- ------------------------------------------------------
-- Self-interactions must not produce notifications, and the indexer
-- enforces that in two places (a pure `should_notify` guard in Rust
-- plus a `WHERE $1 <> $2` in the INSERT ... SELECT). A CHECK would
-- turn a future slip into a constraint violation that aborts the
-- surrounding like/repost transaction — i.e. it would lose the *like*
-- because of a notification bug. Filtering is strictly better than
-- failing here.
--
-- Idempotency
-- -----------
-- `notifications_dedupe_idx` is the unique constraint the indexer's
-- `ON CONFLICT ... DO NOTHING` infers. `subject_uri` is nullable and
-- NULLs never collide in a plain unique index, so the index is on
-- `COALESCE(subject_uri, '')` — that makes the two follow rows
-- (subject_uri IS NULL) for the same (recipient, author) pair collide
-- as intended.
--
-- Consequence worth knowing: unlike-then-relike (or unfollow-then-
-- refollow) does NOT produce a second notification, because the tuple
-- is identical. That is the desired behaviour — it makes notification
-- spam via toggling impossible — but it does mean a notification is
-- "once per (recipient, author, kind, subject)" for all time.
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
recipient_did TEXT NOT NULL,
author_did TEXT NOT NULL,
kind TEXT NOT NULL
CHECK (kind IN ('like', 'repost', 'follow', 'reply')),
subject_uri TEXT,
created_at TIMESTAMPTZ NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
-- Primary read path: `WHERE recipient_did = $1 ORDER BY indexed_at DESC,
-- id DESC`. The trailing `id DESC` makes the index cover the keyset
-- predicate `(indexed_at, id) < ($2, $3)` end-to-end, so a page fetch
-- never sorts.
CREATE INDEX notifications_recipient_indexed_at_idx
ON notifications (recipient_did, indexed_at DESC, id DESC);
-- Dedupe / ON CONFLICT target. See the "Idempotency" note above.
CREATE UNIQUE INDEX notifications_dedupe_idx
ON notifications (recipient_did, author_did, kind, COALESCE(subject_uri, ''));
-- `GET /api/notifications/count` is a hot poll from the client's tray
-- badge, so the unread slice gets its own partial index. It stays tiny
-- because rows leave it as soon as they're marked seen.
CREATE INDEX notifications_unread_idx
ON notifications (recipient_did)
WHERE read_at IS NULL;
-- =====================================================
-- follows: pagination indexes for the follower/following lists
-- =====================================================
--
-- `GET /api/followers` and `GET /api/following` page with the same
-- keyset scheme as the timeline: `(indexed_at, <other side's did>)`.
-- The pre-existing indexes cover only the equality half (the PK covers
-- `follower_did`, `follows_subject_idx` covers `subject_did`), which
-- leaves Postgres sorting the whole follower set on every page. These
-- two make both directions index-ordered.
CREATE INDEX IF NOT EXISTS follows_subject_indexed_at_idx
ON follows (subject_did, indexed_at DESC, follower_did DESC);
CREATE INDEX IF NOT EXISTS follows_follower_indexed_at_idx
ON follows (follower_did, indexed_at DESC, subject_did DESC);