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
114 lines
5.6 KiB
SQL
114 lines
5.6 KiB
SQL
-- 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);
|