/** * Chat threads and their membership on the shared `recflare` D1 database. A thread is a * conversation — a DM pair, a named group chat, or a system thread; the messages in it * live in `message` (see message-db.ts). * * Membership (`thread_member`) does double duty: it is the authorization gate — a * player may read or post to a thread only if they hold a row — and it is what renders * the `playerIds` array the client shows. Nothing here has a foreign key to accounts, * here or on a message's sender: that table belongs to the `auth` worker, and a thread * outlives the accounts in it. * * The thread denormalizes `latest_message_id` so the thread list renders from one * indexed row per thread rather than a per-thread MAX() over `message`, and so it can * be ordered by recency without a join — message ids are monotonic, so the highest id * is the newest thread. `postMessage` keeps it in sync. * * The per-viewer fields — `lastReadMessageId`, `snoozedUntil`, `isFavorited` — live on * the membership row, not the thread: two players in one DM have independent read * positions, snoozes, and favorites. * * The `chat` worker owns this schema/migration (migrations/0002_thread.sql). * `THREAD_SCHEMA_DDL` mirrors it so tests can build the tables directly. */ import { insertMessage } from './message-db' import type { ChatMessage, NewChatMessage } from './message-db' /** Schema DDL (mirror of migrations/0002_thread.sql). */ export const THREAD_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS message_thread ( chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT, chat_thread_name TEXT, latest_message_id INTEGER, created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id)`, `CREATE TABLE IF NOT EXISTS thread_member ( chat_thread_id INTEGER NOT NULL, player_id INTEGER NOT NULL, last_read_message_id INTEGER, snoozed_until TEXT, is_favorited INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (chat_thread_id, player_id) )`, // The thread-list query is "every thread this player is in", so player_id leads. `CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id)`, ] /** * A thread as the client receives it: the thread, its members, its most recent message, * and the viewing player's own read/snooze/favorite state. This is the element shape of * the thread-list response. */ export interface ChatThread { /** Null only for a thread with no messages yet. */ latestMessage: ChatMessage | null chatThreadId: number playerIds: number[] /** * 0 when the player has never read the thread — never null. The client deserializes * this into a non-nullable int and fails the whole response on a null ("expected * 'Number Token', actual 'null'"), unlike `latestMessage`, which it accepts as null. */ lastReadMessageId: number /** * Empty for DMs and unnamed groups — never null. The client dereferences this name * without a null check (a null NullReferenceExceptions its way out of * GetChatBetweenPlayers) and falls back to naming the members when it's blank. */ chatThreadName: string /** * Which kind of conversation this is. Every thread the reference serves here comes back * as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant — * but the field itself has to be present: the client deserializes it as a non-nullable * int and drops the whole response when it's missing. */ chatThreadType: number snoozedUntil: string | null isFavorited: boolean } /** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */ const CHAT_THREAD_TYPE_DEFAULT = 0 /** The joined row backing a rendered thread, before it's shaped for the client. */ interface ThreadRow { chat_thread_id: number chat_thread_name: string | null player_ids: string | null last_read_message_id: number | null snoozed_until: string | null is_favorited: number msg_chat_message_id: number | null msg_chat_thread_id: number | null msg_sender_player_id: number | null msg_time_sent: string | null msg_contents: string | null msg_moderation_state: number | null } function toThread(row: ThreadRow): ChatThread { return { latestMessage: row.msg_chat_message_id === null ? null : { chatMessageId: row.msg_chat_message_id, chatThreadId: row.msg_chat_thread_id!, senderPlayerId: row.msg_sender_player_id!, timeSent: row.msg_time_sent!, contents: row.msg_contents!, moderationState: row.msg_moderation_state!, }, chatThreadId: row.chat_thread_id, // group_concat of the membership rows, already ordered by player id. playerIds: row.player_ids === null ? [] : row.player_ids.split(',').map(Number), // Null in the column means "never read"; the client insists on a number. lastReadMessageId: row.last_read_message_id ?? 0, // Null in the column means "unnamed"; the client dereferences it unchecked. chatThreadName: row.chat_thread_name ?? '', chatThreadType: CHAT_THREAD_TYPE_DEFAULT, snoozedUntil: row.snoozed_until, isFavorited: row.is_favorited !== 0, } } /** * The thread list as it renders for one player, newest conversation first — the * `?MessageCount=N` page of the thread endpoint. * * Reads only threads the player is a member of, so the membership join is the * authorization check as well as the query. The inner ordered subquery around * group_concat is what makes `playerIds` come back sorted rather than in row order. */ export async function getThreadsForPlayer( db: D1Database, playerId: number, { limit = 50 }: { limit?: number } = {} ): Promise { const { results } = await db .prepare( `SELECT t.chat_thread_id, t.chat_thread_name, (SELECT group_concat(player_id) FROM (SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id ORDER BY player_id)) AS player_ids, me.last_read_message_id, me.snoozed_until, me.is_favorited, msg.chat_message_id AS msg_chat_message_id, msg.chat_thread_id AS msg_chat_thread_id, msg.sender_player_id AS msg_sender_player_id, msg.time_sent AS msg_time_sent, msg.contents AS msg_contents, msg.moderation_state AS msg_moderation_state FROM thread_member me JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id WHERE me.player_id = ?1 ORDER BY t.latest_message_id DESC LIMIT ?2` ) .bind(playerId, limit) .all() return results.map(toThread) } /** One thread as it renders for one player, or null if they aren't a member of it. */ export async function getThreadForPlayer( db: D1Database, chatThreadId: number, playerId: number ): Promise { const row = await db .prepare( `SELECT t.chat_thread_id, t.chat_thread_name, (SELECT group_concat(player_id) FROM (SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id ORDER BY player_id)) AS player_ids, me.last_read_message_id, me.snoozed_until, me.is_favorited, msg.chat_message_id AS msg_chat_message_id, msg.chat_thread_id AS msg_chat_thread_id, msg.sender_player_id AS msg_sender_player_id, msg.time_sent AS msg_time_sent, msg.contents AS msg_contents, msg.moderation_state AS msg_moderation_state FROM thread_member me JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id WHERE me.chat_thread_id = ?1 AND me.player_id = ?2` ) .bind(chatThreadId, playerId) .first() return row === null ? null : toThread(row) } /** * Whether a player may read or post to a thread. Every thread-scoped route gates on * this before touching messages. */ export async function isThreadMember( db: D1Database, chatThreadId: number, playerId: number ): Promise { const row = await db .prepare('SELECT 1 AS ok FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2') .bind(chatThreadId, playerId) .first<{ ok: number }>() return row !== null } /** * The pseudo-player system messages are sent as. Not a real account — the client renders * a message from this sender as a notice rather than as someone speaking, which is why * `message.sender_player_id` carries no foreign key and permits negative ids. */ export const SYSTEM_SENDER_ID = -5 /** * The notice a thread opens with: `Player <@U10441985> started a chat`. The `<@U…>` token * is a mention the client resolves to a display name, so the id goes in raw. */ export function startedChatContents(playerId: number): string { return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> started a chat`, }) } /** * The notice left behind when someone walks out of a group: `Player <@U14922080> left`. * Same `<@U…>` mention token the opening notice uses. */ export function leftChatContents(playerId: number): string { return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` }) } /** * Rename a thread. An empty name clears it back to unnamed, which renders as the member * list rather than a blank title. */ export async function setThreadName( db: D1Database, chatThreadId: number, name: string ): Promise { await db .prepare('UPDATE message_thread SET chat_thread_name = ?2 WHERE chat_thread_id = ?1') .bind(chatThreadId, name === '' ? null : name) .run() } /** * Open a thread between a set of players, returning its new id. `name` is null for DMs * and unnamed groups. Duplicate player ids collapse, so a caller need not dedupe. * * Pass `startedBy` to open the thread the way the real server does — with a system * "started a chat" notice as its first message. A thread with no messages at all is one * the client won't display, so every thread born from a request gets one; the parameter * is optional only so tests can build a bare thread directly. * * Every call opens a *distinct* thread, even for a member set that already has one — * threads are not keyed by their membership, and the same pair may hold several. */ export async function createThread( db: D1Database, playerIds: number[], name: string | null = null, startedBy?: number ): Promise { const row = await db .prepare( `INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2) RETURNING chat_thread_id` ) .bind(name, new Date().toISOString()) .first<{ chat_thread_id: number }>() if (row === null) throw new Error('failed to create chat thread') const members = [...new Set(playerIds)] if (members.length > 0) { await db.batch( members.map((playerId) => db .prepare( `INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)` ) .bind(row.chat_thread_id, playerId) ) ) } if (startedBy !== undefined) { await postMessage(db, { chatThreadId: row.chat_thread_id, senderPlayerId: SYSTEM_SENDER_ID, contents: startedChatContents(startedBy), }) } return row.chat_thread_id } /** * The existing thread whose membership is *exactly* this set of players, or null. The * oldest match wins, so a set that somehow accumulated duplicates keeps resolving to the * conversation with the history in it. * * This is what makes "open a chat with these people" reuse the conversation you already * have with them rather than starting an empty one each time. Matching is on the whole * set: a DM and a group that happens to contain those two people are different threads. * * Only threads that still have a `message_thread` row can match. Membership rows whose * thread is gone are ignored rather than resolved to: matching one would hand back an id * that nothing else in the worker can render, and — since the oldest match wins — it * would keep winning on every subsequent call. */ export async function findThreadWithMembers( db: D1Database, playerIds: number[] ): Promise { const members = [...new Set(playerIds)] if (members.length === 0) return null // ?1 is the member count; ?2… are the ids themselves. const placeholders = members.map((_, i) => `?${i + 2}`).join(', ') const row = await db .prepare( `SELECT m.chat_thread_id FROM thread_member m JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id GROUP BY m.chat_thread_id HAVING COUNT(*) = ?1 AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1 ORDER BY m.chat_thread_id LIMIT 1` ) .bind(members.length, ...members) .first<{ chat_thread_id: number }>() return row?.chat_thread_id ?? null } /** * The thread with exactly these members, opening one if it doesn't exist yet. Two * simultaneous first-messages to the same set can still race into two threads; the * oldest-match rule in `findThreadWithMembers` means both parties converge on one of * them afterwards. */ export async function getOrCreateThreadWithMembers( db: D1Database, playerIds: number[], startedBy: number ): Promise { return ( (await findThreadWithMembers(db, playerIds)) ?? (await createThread(db, playerIds, null, startedBy)) ) } /** Everyone in a thread, ordered by id — the fan-out list for a push notification. */ export async function getThreadMemberIds(db: D1Database, chatThreadId: number): Promise { const { results } = await db .prepare('SELECT player_id FROM thread_member WHERE chat_thread_id = ?1 ORDER BY player_id') .bind(chatThreadId) .all<{ player_id: number }>() return results.map((r) => r.player_id) } /** Add a player to an existing thread. A no-op if they're already in it. */ export async function addThreadMember( db: D1Database, chatThreadId: number, playerId: number ): Promise { await db .prepare('INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)') .bind(chatThreadId, playerId) .run() } /** Remove a player from a thread. The thread and its messages outlive the membership. */ export async function removeThreadMember( db: D1Database, chatThreadId: number, playerId: number ): Promise { await db .prepare('DELETE FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2') .bind(chatThreadId, playerId) .run() } /** * Post a message and advance the thread's denormalized `latest_message_id` — the only * way messages should be written, so the thread list never goes stale. Two statements * rather than a batch, because the update needs the id the insert assigns. */ export async function postMessage(db: D1Database, message: NewChatMessage): Promise { const stored = await insertMessage(db, message) await db .prepare('UPDATE message_thread SET latest_message_id = ?2 WHERE chat_thread_id = ?1') .bind(stored.chatThreadId, stored.chatMessageId) .run() return stored } /** * Advance a player's read position, to a specific message or (with no id) to the whole * thread. Only ever moves forward: an out-of-order ack from a second client can't walk * the thread back to unread. * * The id is also clamped to the thread's real latest message, so a client acking an id * that was never stored can't strand the pointer beyond every future message and leave * the thread permanently "read". */ export async function markThreadRead( db: D1Database, chatThreadId: number, playerId: number, chatMessageId?: number ): Promise { await db .prepare( `UPDATE thread_member SET last_read_message_id = MAX( COALESCE(last_read_message_id, 0), MIN( COALESCE(?3, (SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0), COALESCE((SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0) ) ) WHERE chat_thread_id = ?1 AND player_id = ?2` ) .bind(chatThreadId, playerId, chatMessageId ?? null) .run() } /** Favorite or unfavorite a thread, for one player only. */ export async function setThreadFavorited( db: D1Database, chatThreadId: number, playerId: number, isFavorited: boolean ): Promise { await db .prepare( 'UPDATE thread_member SET is_favorited = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2' ) .bind(chatThreadId, playerId, isFavorited ? 1 : 0) .run() } /** Snooze a thread's notifications until an instant, or clear the snooze with null. */ export async function setThreadSnoozed( db: D1Database, chatThreadId: number, playerId: number, snoozedUntil: string | null ): Promise { await db .prepare( 'UPDATE thread_member SET snoozed_until = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2' ) .bind(chatThreadId, playerId, snoozedUntil) .run() }