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,16 +1,17 @@
/**
* Adds comment likes: the `comment_likes` table and the `comments.like_count`
* counter.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with these columns/tables) or an existing one that predates them.
* Adds likes on comments, as the `comment_likes` table and a `like_count` on
* each comment that comment-like-service keeps in step, so that listing
* comments doesn't count their likes.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing comments start with no likes, and a
* database made from schema.sql already has both.
*/
export function up(db: DatabaseSync): void {
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
name: string;

View File

@@ -1,15 +1,17 @@
/**
* Adds `dumps.custom_thumbnail_mime`, for thumbnails uploaded by the poster.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with this column) or an existing one that predates it.
* Adds `dumps.custom_thumbnail_mime`, so a poster can upload a thumbnail that
* replaces the fetched one. It holds the uploaded image's type, and NULL means
* the dump has none.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing dumps keep their fetched thumbnails,
* and a database made from schema.sql already has the column.
*/
export function up(db: DatabaseSync): void {
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;

View File

@@ -1,16 +1,18 @@
/**
* Adds the `dump_backlinks` table, recording which dumps, comments and
* descriptions link to a dump, and indexes `dumps.url`.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with these) or an existing one that predates them.
* Adds `dump_backlinks`, which records the links from a dump's note or
* comments to other dumps, so a dump page can list the dumps related to it.
* backlink-service fills it, and the index on `dumps.url` lets it find the
* dumps an external link was posted as.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It doesn't scan the notes and comments already
* posted, so their links are recorded only once they are edited.
*/
export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
source_type TEXT NOT NULL,

View File

@@ -1,15 +1,16 @@
/**
* Adds `users.role` and makes every legacy `is_admin` user an admin.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with the `role` column) or an existing one that predates it.
* Adds `users.role`, which replaces the `is_admin` flag so that moderators can
* exist between users and admins. 0005 then drops the flag.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Every user becomes a `user`, except those with
* `is_admin` set, who become admins.
*/
export function up(db: DatabaseSync): void {
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;
@@ -20,8 +21,7 @@ export function up(db: DatabaseSync): void {
);
}
// Backfill existing admins to role 'admin'. Guarded because the legacy
// is_admin column is absent on fresh databases (and dropped by 0005).
// schema.sql never had is_admin.
if (userCols.some((c) => c.name === "is_admin")) {
db.exec(
`UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`,

View File

@@ -1,14 +1,15 @@
/**
* Drops the legacy is_admin column now that `role` is the single source of
* truth. Idempotent: skips when the column is already gone (fresh databases
* created from schema.sql never had it). Must run after 0004 has backfilled.
* Drops `users.is_admin`, now that 0004 has copied it into `users.role`.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`, after 0004. A database made from schema.sql
* never had the column.
*/
export function up(db: DatabaseSync): void {
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;

View File

@@ -1,16 +1,16 @@
/**
* Adds categories: the `categories` table and the `dump_categories` join
* table.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with the categories tables) or an existing one that predates them.
* Adds categories, which admins create and posters file their dumps under, as
* the `categories` table and the `dump_categories` join table.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing dumps start in no category, and a
* database made from schema.sql already has both tables.
*/
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(

View File

@@ -1,15 +1,16 @@
/**
* Adds the `password_reset_tokens` table.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with password_reset_tokens) or an existing one that predates the table.
* Adds `password_reset_tokens`, which holds the single-use tokens that
* password-reset-service emails to users who forgot their password.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(

View File

@@ -1,15 +1,15 @@
/**
* Adds the `chat_messages` table.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with this table) or an existing one that predates it.
* Adds `chat_messages`, which holds the messages of the site-wide chat.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT NOT NULL PRIMARY KEY,

View File

@@ -1,18 +1,18 @@
/**
* Adds an optional self-reference so a chat message can reply to another.
* Kept as a plain column (no FK): when the replied-to message is later deleted
* the id is retained but the join yields null, letting the UI show a subtle
* "deleted message" reference instead of breaking the insert.
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column already exists.
* Adds `chat_messages.reply_to_id`, so a message can reply to another. It has
* no foreign key on purpose: when the replied-to message is deleted, the id
* stays and chat-service's join reads null, so the chat shows "deleted
* message" where the quote was.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing messages reply to nothing, and a
* database made from schema.sql already has the column.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string;

View File

@@ -1,25 +1,22 @@
/**
* Backfills the `start` parameter on stored YouTube embed URLs.
* Adds the `start` parameter to the YouTube embed URLs stored before the
* YouTube provider carried it over. `rich_content.embedUrl` is computed when a
* dump is posted, and used to drop the source URL's `t` (`?t=1265`,
* `?t=1h2m3s`), so those videos played from 0:00.
*
* `rich_content.embedUrl` is computed once, at dump creation, and previously
* dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so
* those videos always opened at 0:00 in the global player. The provider now
* translates `t`/`start` into the `start` param the iframe player honours; this
* migration applies the same translation to rows written before that fix.
*
* Purely local — it re-derives the param from the already-stored source URL and
* makes no network calls. Self-contained (no imports from api/services) so it
* stays reproducible even if the provider's parsing evolves later.
*
* Idempotent: rows whose embed URL already carries a `start` are skipped, so a
* fresh database built from schema.sql is simply a no-op.
* It reads the time from the stored source URL and fetches nothing. It keeps
* its own copy of the parsing, so that it migrates every database alike should
* the provider's change.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
/**
* Returns the seconds in `url`'s `t` or `start`, which may be plain seconds,
* `90s`, `20m5s` or `1h2m3s`.
*/
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
@@ -38,7 +35,10 @@ function extractStartSeconds(url: string): number | null {
Number(hms[3] ?? 0);
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It leaves alone an embed URL that already has a
* `start`, and a row whose `rich_content` doesn't parse.
*/
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, url, rich_content FROM dumps
@@ -55,7 +55,7 @@ export function up(db: DatabaseSync): void {
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
continue;
}
if (rich.type !== "youtube" || !rich.embedUrl) continue;

View File

@@ -1,21 +1,14 @@
/**
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
* `faviconUrl` field.
* Moves site icons out of `rich_content.thumbnailUrl` into the new
* `faviconUrl`. The metadata fetch used to fall back on the page's icon, and
* then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a
* 404, that the UI stretched over a 128×72 box. It now stops at real artwork,
* and a missing `thumbnailUrl` means none, which the frontend draws a
* placeholder for. This gives the older rows that meaning.
*
* The extraction cascade used to end at the page's icon and then at a guessed
* `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
* held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
* box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
* means "no artwork", which is what lets the frontend draw a placeholder.
* This migration gives rows written before that change the same meaning.
*
* Purely local — it classifies the already-stored URL and makes no network
* calls, so `accentColor` is deliberately not backfilled: the frontend derives
* a stable hue from the hostname whenever one is missing, and
* `refreshDumpMetadata` fetches the real color on demand.
*
* Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
* database built from schema.sql is a no-op.
* It judges the stored URL alone and fetches nothing, so it doesn't fill
* `accentColor`. The frontend derives a hue from the hostname when it is
* missing, and refreshing a dump's metadata fetches the real one.
*
* @module
*/
@@ -23,12 +16,9 @@
import type { DatabaseSync } from "node:sqlite";
/**
* Checks whether a stored thumbnail URL is really a site icon.
*
* Deliberately loose. A false positive (a genuine cover image living under
* `/assets/icons/`) renders contained on a tinted field instead of
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
* extra rules.
* Checks whether a stored thumbnail URL is a site icon. It is loose on
* purpose, since artwork taken for an icon still shows whole on a tinted field,
* only not cropped to fill the box. 0012 fixes the rows it got wrong.
*/
function isIconUrl(raw: string): boolean {
let pathname: string;
@@ -41,7 +31,11 @@ function isIconUrl(raw: string): boolean {
/\.(ico|svg)$/i.test(pathname);
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It leaves alone a row that already has a
* `faviconUrl`, and one whose `rich_content` doesn't parse. 0012 undoes its
* mistakes.
*/
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
@@ -58,7 +52,7 @@ export function up(db: DatabaseSync): void {
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
continue;
}
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
if (!isIconUrl(rich.thumbnailUrl)) continue;

View File

@@ -1,30 +1,19 @@
/**
* Repairs 0011, which was too eager about what counts as a site icon.
* Repairs the rows 0011 got wrong. 0011 took any path containing "favicon",
* or ending in `.svg`, for an icon, so artwork such as
* `/wp-content/uploads/2022/04/mir-logo-favicon.png` lost its `thumbnailUrl`,
* and its dump showed as a pull-quote instead of an image in the journal
* mosaic.
*
* 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image`
* like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was
* moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
* the dump from an image card to a pull-quote in the journal mosaic. A blanket
* `.svg` match had the same effect on real artwork.
*
* `isIconUrl` here looks at the *filename* rather than the whole path: an icon
* is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
* directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
* catches `icon_SEARCH.png`, which 0011 missed.
*
* Applied in both directions, and deliberately narrow when reversing: a value
* is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
* this one, so rows written by a normal fetch aren't disturbed.
* This rule judges the file name and its folder instead. It spares
* `mir-logo-favicon.png` and catches `icon_SEARCH.png`, which 0011 missed.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/**
* Applies 0011's rule, kept verbatim so its false positives can be
* identified.
*/
/** 0011's rule, copied as it shipped, to tell which rows it moved. */
function wasIconUrl0011(raw: string): boolean {
let pathname: string;
try {
@@ -37,8 +26,9 @@ function wasIconUrl0011(raw: string): boolean {
}
/**
* Checks whether a URL names a site icon, judged on its filename and
* directory.
* Checks whether a URL is a site icon. It is one when the file ends in `.ico`,
* sits in a `favicon(s)` or `icon(s)` folder, or is named `favicon`,
* `apple-touch-icon` or `icon`, alone or followed by a digit, `-`, `_` or `.`.
*/
function isIconUrl(raw: string): boolean {
let pathname: string;
@@ -55,7 +45,12 @@ function isIconUrl(raw: string): boolean {
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It moves a `faviconUrl` back to `thumbnailUrl`
* only when 0011's rule takes it for an icon and this one doesn't. That also
* catches the ones a later fetch stored that this rule doesn't recognise, such
* as a `logo.svg`. It then moves the icons 0011 missed.
*/
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
@@ -73,10 +68,9 @@ export function up(db: DatabaseSync): void {
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
continue;
}
// Artwork 0011 mistook for an icon: put it back.
if (
rich.faviconUrl && !rich.thumbnailUrl &&
wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl)
@@ -90,7 +84,6 @@ export function up(db: DatabaseSync): void {
continue;
}
// An icon 0011's rule didn't recognize: move it now.
if (
rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl)
) {

View File

@@ -1,19 +1,12 @@
/**
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
* check on the create form — and backfills it for every existing URL dump.
* Adds `dumps.url_canonical`, the key the create form's "already dumped" hint
* looks a URL up by, and fills it for every URL dump already posted.
*
* Purely local: it re-derives the key from the URL already stored on each row
* and makes no network calls.
*
* The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
* as it stood when this migration shipped, not an import of it. A migration
* runs exactly once per database, so importing the live version would mean two
* databases migrating at different times end up with keys computed under
* different rules. Improving the shared canonicalizer therefore calls for a new
* backfill migration rather than an edit here.
*
* Idempotent: the column is only added when missing and only rows whose key is
* still NULL are touched, so a fresh database built from schema.sql is a no-op.
* It fills the key from each stored URL and fetches nothing. Its helpers are a
* copy of lib/canonical-url.ts as it stood when this shipped: importing the
* live one would give two databases migrated at different times keys made by
* different rules. A change to lib/canonical-url.ts therefore needs a new
* migration that fills the keys again.
*
* @module
*/
@@ -98,7 +91,10 @@ function canonicalizeUrl(raw: string): string | null {
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It fills only the keys still NULL, and leaves
* NULL a URL that isn't http(s).
*/
export function up(db: DatabaseSync): void {
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;

View File

@@ -1,21 +1,22 @@
/**
* Chat unread state lives on the server so it survives reloads and agrees
* across tabs and devices: `users.chat_read_at` is the created_at of the newest
* message the user has seen. Existing users start caught up (now) rather than
* with the whole history unread; new users fall back to their own created_at.
* Moves the chat's unread state to the server, so that it survives a reload
* and agrees across tabs and devices. `users.chat_read_at` holds the time of
* the newest message the user has seen.
*
* The history cursor now pages on (created_at, id) so two messages sharing a
* millisecond can't be skipped at a page boundary; the index follows suit.
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column and index already exist.
* It also replaces the index on `created_at` with one on `(created_at, id)`,
* which the history now pages on, so that two messages from the same
* millisecond can't fall between two pages.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing users start with every message read,
* instead of the whole history unread. A user created later starts with a
* NULL `chat_read_at`, which chat-service reads as the user's creation time.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;

View File

@@ -1,18 +1,17 @@
/**
* Changing or resetting a password must end every session opened before it.
* Tokens are stateless JWTs, so each user carries a cut-off instead:
* `users.tokens_valid_after` (ISO), and a token issued before it is refused.
* NULL — the state every existing user starts in — means no cut-off.
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column already exists.
* Adds `users.tokens_valid_after`, so that changing a password ends every
* session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a
* token issued before that time instead.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing users start with NULL, which revokes no
* token.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;

View File

@@ -1,24 +1,21 @@
/**
* Indexes for the queries that ran as full scans.
*
* - feeds page by created_at (newest first), a user's dumps and upvotes by
* (user_id, created_at) — these replace the bare user_id indexes;
* - dump and playlist pages are looked up by slug;
* - the child side of every ON DELETE CASCADE / SET NULL foreign key, which
* SQLite otherwise scans on each parent delete (a comment's replies, a user's
* comments, follows, chat messages, invites, reset tokens), and the
* followers of a user or playlist, read on every new dump;
* - @mentions resolve usernames case-insensitively, by lower(username).
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* these indexes already exist.
* Adds indexes for the queries that scanned a whole table. The feeds page
* dumps by `created_at`, and a user's dumps and upvotes by `(user_id,
* created_at)`, which replace the indexes on `user_id` alone. Dumps and
* playlists are looked up by slug. Each foreign key with ON DELETE gets an
* index on its child column, which SQLite otherwise scans on every delete of
* a parent row, and the followers of a user or playlist are read on every new
* dump. Mentions look usernames up by `lower(username)`.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has
* these indexes.
*/
export function up(db: DatabaseSync): void {
db.exec(`
DROP INDEX IF EXISTS idx_dumps_user;