Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 lines
2.0 KiB
TypeScript
61 lines
2.0 KiB
TypeScript
/**
|
|
* Opens the database at {@link DB_PATH} when first imported, creating it from
|
|
* schema.sql if the file doesn't exist, then applies the pending migrations
|
|
* and runs the startup tasks before any importer runs. Only the services
|
|
* import it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { DB_PATH } from "../config.ts";
|
|
import { runMigrations } from "./migrate.ts";
|
|
import { runStartupTasks } from "./startup.ts";
|
|
|
|
const isNew = await Deno.stat(DB_PATH).then(() => false, () => true);
|
|
|
|
/**
|
|
* The server's only connection. Its queries are synchronous, so each one
|
|
* blocks the event loop while it runs.
|
|
*/
|
|
export const db: DatabaseSync = new DatabaseSync(DB_PATH);
|
|
// schema.sql holds every migration's change, so on a new database each
|
|
// migration below finds its work done and is only recorded.
|
|
if (isNew) {
|
|
db.exec(
|
|
await Deno.readTextFile(new URL("./schema.sql", import.meta.url)),
|
|
);
|
|
}
|
|
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a
|
|
// commit doesn't wait on an fsync: a power loss can lose the last commits but
|
|
// never corrupts the file. busy_timeout makes another connection to the file
|
|
// wait for the lock instead of failing. SQLite enforces foreign keys, and so
|
|
// the schema's ON DELETE cascades, only when each connection asks.
|
|
db.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA busy_timeout = 5000;
|
|
PRAGMA foreign_keys = ON;
|
|
`);
|
|
runMigrations(db);
|
|
await runStartupTasks(db);
|
|
|
|
/**
|
|
* Runs `fn` in a transaction, so its writes commit together, in one commit
|
|
* rather than one each, and a throw rolls them all back and rethrows. A call
|
|
* inside another joins the outer transaction. `fn` must be synchronous: an
|
|
* `await` would commit before the rest of it runs.
|
|
*/
|
|
export function transaction<T>(fn: () => T): T {
|
|
if (db.isTransaction) return fn();
|
|
db.exec("BEGIN;");
|
|
try {
|
|
const result = fn();
|
|
db.exec("COMMIT;");
|
|
return result;
|
|
} catch (err) {
|
|
db.exec("ROLLBACK;");
|
|
throw err;
|
|
}
|
|
}
|