-- 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, )`. -- 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);