v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -1,5 +1,9 @@
/**
* The site-wide chat: messages, their history, and each user's read marker.
* Stores the site-wide chat's messages and each user's read marker.
*
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
* time.
*
* @module
*/
@@ -12,36 +16,32 @@ import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
/** How many messages a page holds when the caller names no limit. */
const DEFAULT_LIMIT = 50;
/** The most messages a page may hold. */
const MAX_LIMIT = 100;
/**
* The length cap of the replied-to preview snippet, so a reply to a long
* message doesn't ship the whole 2000-char body over the wire for every reply.
* This is raw markdown: the client strips it to text before showing a line of
* it, so it's cut generously enough that an image URL or link doesn't eat it
* all.
* How much of the replied-to message a reply carries. The client shows one line
* of it, once stripped of its Markdown, so the cut leaves room for a long link
* or image URL before the text.
*/
const REPLY_SNIPPET_LEN = 300;
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
/** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
/**
* The common joins: author (required) plus the optional replied-to message and
* its author (LEFT — null when there's no reply or the target was deleted).
* Joins a message to its author, and to the message it replies to and that
* message's author. Those two are left joins: a reply outlives its target.
*/
const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
/** Returns message `id`, or throws a 404. */
/** Returns message `id` as the broadcast sends it, or throws a 404. */
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
@@ -53,8 +53,9 @@ function fetchMessage(id: string): ChatMessage {
}
/**
* Posts a message from `userId`, replying to `replyToId` when that message
* exists, and binds its attachments.
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
* it links to. A `replyToId` naming no message is dropped, and the message is
* posted as no reply. The caller broadcasts the result.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
@@ -79,8 +80,6 @@ export function createChatMessage(
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
// Only store the reply target if it actually exists — drop dangling ids so a
// message never references a phantom parent.
let replyTo: string | null = null;
if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
@@ -93,13 +92,12 @@ export function createChatMessage(
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo);
// Claim any pasted/dropped image attachments so the startup cleanup doesn't
// purge them as orphans (mirrors dumps/comments/playlists).
// Unclaimed attachments are deleted as orphans at startup.
linkAttachments(trimmed, id);
return fetchMessage(id);
}
/** Returns the author of message `id`, or throws a 404. */
/** Returns the id of message `id`'s author, or throws a 404. */
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
@@ -111,8 +109,9 @@ function getMessageOwner(id: string): string {
}
/**
* Replaces a message's body, as its author or a moderator (`canModerate`), and
* binds any new attachment.
* Replaces a message's body on a `chat_edit`, and claims any image the new body
* links to. `canModerate` lets a moderator edit anyone's message. The caller
* broadcasts the result.
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
@@ -149,13 +148,13 @@ export function updateChatMessage(
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
// An edit may introduce a freshly pasted image; claim its attachment too.
linkAttachments(trimmed, id);
return fetchMessage(id);
}
/**
* Deletes a message, as its author or a moderator (`canModerate`).
* Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
* anyone's message. Replies to it keep their `replyToId`, without its preview.
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator.
@@ -165,7 +164,7 @@ export function deleteChatMessage(
requestingUserId: string,
canModerate: boolean,
): void {
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
@@ -177,21 +176,23 @@ export function deleteChatMessage(
}
/**
* A position in the chat stream. Messages are ordered by (createdAt, id), so
* two sharing a millisecond still have a strict order and a page boundary
* between them can't skip either.
* Passed by `routes/chat.ts` to page through the history, from the message the
* client already has at one end. Messages are ordered by `createdAt`, then by
* id, so a page boundary between two messages sent in the same millisecond
* skips neither.
*/
export interface ChatCursor {
/** The `created_at` of the message the cursor sits at. */
/** An ISO timestamp, compared as a string with `created_at`. */
createdAt: string;
/**
* The id of that message. Omitted: the cursor sits before (for `after`) or
* after (for `before`) every message sharing that timestamp.
* Without it, the cursor takes in every message at `createdAt`: they are
* all older for {@link getRecentChatMessages} and all newer for
* {@link getChatMessagesAfter}.
*/
id?: string;
}
/** Converts query rows to messages. */
/** Converts query rows to messages, or throws a 500 if one doesn't parse. */
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(
@@ -203,15 +204,15 @@ function toMessages(rows: unknown[]): ChatMessage[] {
return rows.map(chatMessageRowToApi);
}
/** Clamps a page size to [1, {@link MAX_LIMIT}]. */
function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
}
/**
* Returns up to `limit` most recent messages in ascending (display) order.
* When `before` is given, returns the page of older messages immediately
* preceding it — used for "load older" history.
* Returns the latest `limit` messages, or with `before` the `limit` messages
* just older than it, oldest first either way. `limit` is clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getRecentChatMessages(
limit = DEFAULT_LIMIT,
@@ -226,7 +227,7 @@ export function getRecentChatMessages(
).all(
before.createdAt,
before.createdAt,
// No id: nothing at that timestamp sorts below the empty string.
// Nothing sorts below "", so no message at that timestamp is taken.
before.id ?? "",
capped,
)
@@ -234,14 +235,15 @@ export function getRecentChatMessages(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped);
// Newest-first from SQL; reverse to ascending for display.
return toMessages(rows).reverse();
}
/**
* Returns up to `limit` messages following `after`, in ascending order — used
* to resync the chat after a reconnect. Without an id the cursor is inclusive
* of its timestamp.
* Returns the first `limit` messages after `after`, oldest first. The chatbox
* uses it to fetch what it missed while the socket was down. `limit` is
* clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getChatMessagesAfter(
after: ChatCursor,
@@ -255,17 +257,21 @@ export function getChatMessagesAfter(
return toMessages(rows);
}
/** How far a user has read the chat. */
/**
* Returned to the WebSocket route by {@link getChatReadState} and
* {@link markChatRead}. The route sends it in `welcome` and in
* `chat_read_update`, and the client shows `unread` on the chat's badge.
*/
export interface ChatReadState {
/** The `created_at` of the newest message the user has seen (ISO). */
/** The `created_at` of the newest message the user has read. */
readAt: string;
/** Messages from other people since then. */
/** Counts only the messages of other users after `readAt`. */
unread: number;
}
/**
* Returns how far `userId` has read the chat; a user who never opened it has
* read up to their registration.
* Returns how far `userId` has read the chat. A user who never opened it has
* read up to their registration, so the history from before it isn't unread.
*
* @throws {APIException} 404 when there is no such user.
*/
@@ -284,9 +290,10 @@ export function getChatReadState(userId: string): ChatReadState {
}
/**
* Moves the user's read marker forward to `at` (a message's created_at) —
* never backward, so a lagging tab can't undo a newer tab's read, and never
* past the newest message, so a bogus timestamp can't pre-read the future.
* Moves `userId`'s read marker to `at`, a message's `created_at`, on a
* `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
* read. It never passes the newest message, so a timestamp from the future
* can't mark messages read before they are sent.
*
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user.
@@ -303,8 +310,7 @@ export function markChatRead(userId: string, at: string): ChatReadState {
const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null };
// No messages yet: there's nothing to have read, and no newest message to
// cap a bogus far-future marker at — which could never be moved back.
// With no message to cap it at, a marker from the future would stay stuck.
if (!newest.newest) return getChatReadState(userId);
const iso = parsed.toISOString();
const target = iso > newest.newest ? newest.newest : iso;