v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* The database handle. Importing this module opens the database at
|
||||
* {@link DB_PATH}, applies the pending migrations and runs the startup tasks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { DB_PATH } from "../config.ts";
|
||||
import { runMigrations } from "./migrate.ts";
|
||||
import { runStartupTasks } from "./startup.ts";
|
||||
|
||||
export const db = new DatabaseSync(DB_PATH);
|
||||
/** The one connection every service queries through. */
|
||||
export const db: DatabaseSync = new DatabaseSync(DB_PATH);
|
||||
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a
|
||||
// commit no longer waits on an fsync (a crash can lose the last transactions,
|
||||
// never corrupt the file). busy_timeout covers the init/migration scripts and
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet;
|
||||
* run once before the server starts (`deno task start`).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { DB_PATH } from "../config.ts";
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Brings an existing database up to date with the migrations under
|
||||
* migrations/, recording each applied one in `schema_migrations`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
||||
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
||||
@@ -21,9 +28,12 @@ interface Migration {
|
||||
up: (db: DatabaseSync) => void;
|
||||
}
|
||||
|
||||
// Append-only — never edit or reorder a migration once it has shipped.
|
||||
// Each `up` must be idempotent: it also runs against fresh databases created
|
||||
// straight from schema.sql, where the change it makes already exists.
|
||||
/**
|
||||
* Every migration, in order. Append-only — never edit or reorder a migration
|
||||
* once it has shipped. Each `up` must be idempotent: it also runs against
|
||||
* fresh databases created straight from schema.sql, where the change it makes
|
||||
* already exists.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
||||
@@ -46,6 +56,13 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0016_performance_indexes", up: up0016PerformanceIndexes },
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies each migration `db` hasn't recorded yet, each in its own
|
||||
* transaction.
|
||||
*
|
||||
* @throws {Error} When a migration fails; that migration is rolled back and
|
||||
* the later ones aren't run.
|
||||
*/
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with these columns/tables) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with this column) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with these) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
|
||||
source_type TEXT NOT NULL,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with the `role` column) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with the categories tables) or an existing one that predates them.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const tables = new Set(
|
||||
(db.prepare(
|
||||
|
||||
@@ -1,7 +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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with this table) or an existing one that predates it.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
|
||||
@@ -1,12 +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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
/**
|
||||
* Backfills the `start` parameter on stored YouTube embed URLs.
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Backfills the `start` parameter on stored YouTube embed URLs.
|
||||
//
|
||||
// `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.
|
||||
|
||||
/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
|
||||
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
|
||||
function extractStartSeconds(url: string): number | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
@@ -34,6 +38,7 @@ function extractStartSeconds(url: string): number | null {
|
||||
Number(hms[3] ?? 0);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, url, rich_content FROM dumps
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
/**
|
||||
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||
* `faviconUrl` field.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||
// `faviconUrl` field.
|
||||
//
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Whether a stored thumbnail URL is really a site icon.
|
||||
* 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
|
||||
@@ -37,6 +41,7 @@ function isIconUrl(raw: string): boolean {
|
||||
/\.(ico|svg)$/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
/**
|
||||
* Repairs 0011, which was too eager about what counts as a site icon.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Repairs 0011, which was too eager about what counts as a site icon.
|
||||
//
|
||||
// 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.
|
||||
|
||||
/** 0011's rule, kept verbatim so its false positives can be identified. */
|
||||
/**
|
||||
* Applies 0011's rule, kept verbatim so its false positives can be
|
||||
* identified.
|
||||
*/
|
||||
function wasIconUrl0011(raw: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
@@ -29,7 +36,10 @@ function wasIconUrl0011(raw: string): boolean {
|
||||
/\.(ico|svg)$/i.test(pathname);
|
||||
}
|
||||
|
||||
/** Whether a URL names a site icon, judged on its filename and directory. */
|
||||
/**
|
||||
* Checks whether a URL names a site icon, judged on its filename and
|
||||
* directory.
|
||||
*/
|
||||
function isIconUrl(raw: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
@@ -45,6 +55,7 @@ function isIconUrl(raw: string): boolean {
|
||||
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, rich_content FROM dumps
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
/**
|
||||
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||
* check on the create form — and backfills it for every existing URL dump.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||
// check on the create form — and backfills it for every existing URL dump.
|
||||
//
|
||||
// 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.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const TRACKING_PARAMS = new Set([
|
||||
"fbclid",
|
||||
@@ -94,6 +98,7 @@ function canonicalizeUrl(raw: string): string | null {
|
||||
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 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.
|
||||
/** Applies this migration to `db`. */
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_dumps_user;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Housekeeping run when the database is opened.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
@@ -7,8 +13,9 @@ import {
|
||||
} from "../config.ts";
|
||||
|
||||
/**
|
||||
* Housekeeping run once when the database is opened, after the migrations:
|
||||
* drops what has expired, and seeds the default admin on an empty instance.
|
||||
* Runs once when the database is opened, after the migrations: drops what has
|
||||
* expired (reset tokens, unused invites, orphaned attachments), and seeds the
|
||||
* default admin (admin/admin) on an empty instance.
|
||||
*/
|
||||
export async function runStartupTasks(db: DatabaseSync): Promise<void> {
|
||||
// Purge expired/used password reset tokens
|
||||
|
||||
Reference in New Issue
Block a user