@@ -0,0 +1,203 @@
-- 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 ;