-- Chat — DMs, group threads, and Kautilya (AI) threads. -- -- One pipeline serves all three. `conversations.kind` is the only thing that -- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a -- second implementation, and the message list, composer, pagination and -- context menu are written once. -- -- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and -- timestamps collide; a gapless integer gives stable ordering, cheap unread -- maths (last_seq - last_read_seq), and a sync cursor the client can resume -- from after a reconnect (?after_seq=N). CREATE TABLE IF NOT EXISTS conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- dm → exactly two participants -- group → many participants, has a title/avatar -- ai → one participant; the other side is Kautilya (no user row) kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')), title VARCHAR(120), -- groups (and AI thread titles) avatar_key TEXT, -- R2 key, groups only -- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes -- "one thread per pair" a database guarantee instead of a service-layer -- race between two people opening the chat simultaneously. dm_key TEXT, created_by UUID REFERENCES users(id) ON DELETE SET NULL, -- Monotonic message counter. Bumped inside the send transaction, which -- serialises assignment per conversation and leaves no gaps. last_seq BIGINT NOT NULL DEFAULT 0, -- Denormalized purely for inbox ordering; the preview text and its sender -- are read with a LATERAL join on (conversation_id, seq DESC) so there is -- one source of truth for message content even after an edit or delete. last_message_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (kind <> 'dm' OR dm_key IS NOT NULL) ); CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq ON conversations (dm_key) WHERE kind = 'dm'; CREATE TABLE IF NOT EXISTS conversation_participants ( conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(8) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')), -- Delivery/read state lives here, per participant — NOT per message. A -- receipt row per (message × participant) would grow with the product of -- both; two cursors give the same three tick states at constant size: -- sent → the row exists -- delivered → last_delivered_seq >= message.seq -- read → last_read_seq >= message.seq last_read_seq BIGINT NOT NULL DEFAULT 0, last_delivered_seq BIGINT NOT NULL DEFAULT 0, muted_until TIMESTAMPTZ, -- "Friends since" on the chat profile reads joined_at. joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Non-null = left the group. Kept (rather than deleted) so their past -- messages still resolve to a member row when rendering history. left_at TIMESTAMPTZ, PRIMARY KEY (conversation_id, user_id) ); -- The inbox query: this user's threads, most recent first. Partial on left_at -- so departed groups drop out without a filter on every read. CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx ON conversation_participants (user_id) WHERE left_at IS NULL; CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, seq BIGINT NOT NULL, -- NULL for messages no user wrote: 'system' (membership changes) and -- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread -- readable for everyone else. sender_id UUID REFERENCES users(id) ON DELETE SET NULL, kind VARCHAR(12) NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'system', 'assistant')), body TEXT NOT NULL DEFAULT '', -- Inline formatting and detected links as offset/length spans -- ([{type,offset,length}]), never HTML. Storing spans instead of markup -- keeps rendering injection-safe and lets each client render natively. entities JSONB NOT NULL DEFAULT '[]'::jsonb, -- Client-generated id, so a retried send is deduped rather than doubled. client_msg_id UUID, reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL, -- Set when this message was forwarded from another. SET NULL so deleting -- the original leaves the forward intact. forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL, edited_at TIMESTAMPTZ, -- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and -- other participants' clients can replace the bubble in place. deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (conversation_id, seq) ); -- History paging, newest first, and the LATERAL that fetches each inbox row's -- last message. CREATE INDEX IF NOT EXISTS messages_conversation_idx ON messages (conversation_id, seq DESC); -- Idempotent send. Partial because client_msg_id is NULL for system and -- assistant messages, which no client retries. CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL; -- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone): -- this hides a message from one person's view while others keep seeing it. CREATE TABLE IF NOT EXISTS message_hides ( message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (message_id, user_id) ); CREATE TABLE IF NOT EXISTS message_attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, -- Denormalized from the parent message so the chat profile's "shared -- media" and "shared links" tabs are one index range scan, with no join -- back through messages. conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, ord SMALLINT NOT NULL DEFAULT 0, kind VARCHAR(8) NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')), -- Media (kind in image/video/audio/file). r2_key points at the PRIVATE -- bucket: chat media is never public-by-URL, it is read through a -- participant-checked presigned GET. r2_key TEXT, mime VARCHAR(128), size_bytes BIGINT, width INTEGER, height INTEGER, duration_ms INTEGER, blurhash VARCHAR(64), -- instant placeholder while loading file_name TEXT, -- Link preview (kind = 'link'). Unfurled server-side AFTER the message is -- persisted, so a slow remote page never delays the send. Absent when the -- sender dismissed the preview in the composer. url TEXT, title TEXT, description TEXT, site_name VARCHAR(120), image_url TEXT, -- Shared post / comment / profile / collection (kind = 'entity'). -- entity_id is TEXT because these ids are not all UUIDs, and there is -- deliberately no FK: the entity may be deleted or turn private later. -- `snapshot` is a fallback for rendering, but permission is re-checked -- per viewer at read time — a collection made private must stop resolving -- for the recipient, so the snapshot alone must never be trusted. entity_type VARCHAR(16) CHECK (entity_type IS NULL OR entity_type IN ('post', 'comment', 'profile', 'collection')), entity_id TEXT, snapshot JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS message_attachments_message_idx ON message_attachments (message_id, ord); -- The chat profile's media / links tabs. CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx ON message_attachments (conversation_id, kind, created_at DESC); -- Starred messages are per-user, not per-conversation: two people in the same -- thread star different things. CREATE TABLE IF NOT EXISTS message_stars ( message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Denormalized so "starred in this chat" is a single index range scan. conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (message_id, user_id) ); CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx ON message_stars (user_id, conversation_id, created_at DESC); -- Reported messages. `snapshot` is the whole point: it copies the reported -- message (and limited surrounding context) at report time so moderators read -- a report record and never need read access to live threads. message_id is -- SET NULL on delete so a report survives the sender deleting the evidence. CREATE TABLE IF NOT EXISTS message_reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, message_id UUID REFERENCES messages(id) ON DELETE SET NULL, conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL, category VARCHAR(32) NOT NULL, reason TEXT NOT NULL DEFAULT '', snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ, resolution_note TEXT ); CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx ON message_reports (created_at DESC) WHERE resolved_at IS NULL;