The home page is rebuilt from scratch and configured from a new Front page tab in the admin, backed by migration 017 and served by GET /api/front-page. Hero: brand, photos (crossfading slideshow with progress and pause) or livestream (YouTube/Facebook/Vimeo embed with a LIVE badge), switched by hand. After it, bands the admin can reorder, retitle or hide: a countdown to the next event (series-aware), the National Retreats carousel, a numbers band (typed in or counted from the database), a horizontal rail of featured timeline entries, and a "Find your way in" pathfinder replacing the old connect section. The CRUD engine gains a `singleton` flag: the entity has one row, made by its migration, and create and delete are refused. The list screen opens that row and the editor drops the slug, back link and delete. shapeSeries moves to shape.js so /front-page can share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
651 lines
22 KiB
JavaScript
651 lines
22 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
|
ADMIN CRUD ENGINE
|
|
|
|
Reads a descriptor from admin-schema.js and does the SQL. No
|
|
entity names appear in this file.
|
|
|
|
Writes run inside tx() so a parent, its side table, and every
|
|
child collection either all land or none do. Children are
|
|
replaced wholesale rather than diffed: the client sends the list
|
|
it wants to exist, the engine deletes and reinserts in array
|
|
order, and sort_order becomes the index. That makes drag-to-
|
|
reorder free and removes a whole class of "which row is this"
|
|
bugs, at the cost of churning autoincrement ids — which is fine
|
|
precisely because nothing references them.
|
|
|
|
A column that isn't present in the payload at all is left out of
|
|
the statement entirely, so the table's DEFAULT applies on insert
|
|
and the existing value survives on update. A column present but
|
|
empty ("" or null) is an explicit clear and writes NULL. The
|
|
difference matters: sending NULL for every unmentioned column is
|
|
what turns a missing form field into a NOT NULL constraint
|
|
failure instead of a default.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
import { tx } from "./db.js";
|
|
|
|
export class HttpError extends Error {
|
|
constructor(status, message, fields) {
|
|
super(message);
|
|
this.status = status;
|
|
this.fields = fields;
|
|
}
|
|
}
|
|
|
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
|
|
/* Not a value the caller can ever send, so it can mean "leave this
|
|
column out of the statement" without colliding with real data. */
|
|
const OMIT = Symbol("omit");
|
|
|
|
/* ── Coercion ──────────────────────────────────────────────────
|
|
SQLite STRICT tables reject a type mismatch at the wall, but
|
|
the error it throws is unreadable. Everything is converted and
|
|
checked here so failures come back as named fields.
|
|
───────────────────────────────────────────────────────────── */
|
|
|
|
function coerceValue(column, raw, errors, prefix = "") {
|
|
const key = `${prefix}${column.name}`;
|
|
|
|
// Absent from the payload. Fall back to the descriptor default
|
|
// if it declares one, otherwise let the column's own DEFAULT do
|
|
// the work — which needs the column omitted, not nulled.
|
|
if (raw === undefined) {
|
|
if (column.default !== undefined) return column.default;
|
|
if (column.required) errors[key] = "Required.";
|
|
return OMIT;
|
|
}
|
|
|
|
// Before the blank check: an unchecked box legitimately arrives
|
|
// as false, "", or null, and all of those mean 0, not NULL.
|
|
if (column.type === "bool") {
|
|
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
|
|
}
|
|
|
|
if (raw === null || raw === "") {
|
|
if (column.required) {
|
|
errors[key] = "Required.";
|
|
return null;
|
|
}
|
|
// The table refuses NULL but declares a default: clearing the
|
|
// field means "use the default", not "write NULL".
|
|
if (column.notNullable) return OMIT;
|
|
return null;
|
|
}
|
|
|
|
switch (column.type) {
|
|
case "int": {
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
|
|
return Number.isInteger(n) ? n : null;
|
|
}
|
|
case "real": {
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
case "enum": {
|
|
const value = String(raw);
|
|
if (!column.values.includes(value)) {
|
|
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
case "date": {
|
|
const value = String(raw).trim();
|
|
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
|
|
return ISO_DATE.test(value) ? value : null;
|
|
}
|
|
case "time": {
|
|
// <input type="time"> sends HH:MM, or HH:MM:SS when a step
|
|
// asks for seconds. Nothing here does, so seconds are dropped.
|
|
const value = String(raw).trim().slice(0, 5);
|
|
if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour.";
|
|
return CLOCK_TIME.test(value) ? value : null;
|
|
}
|
|
default: {
|
|
const value = String(raw).trim();
|
|
return value === "" ? null : value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function coerceRow(columns, data, { prefix = "" } = {}) {
|
|
const errors = {};
|
|
const values = {};
|
|
for (const column of columns) {
|
|
const value = coerceValue(column, data?.[column.name], errors, prefix);
|
|
if (value !== OMIT) values[column.name] = value;
|
|
}
|
|
return { values, errors };
|
|
}
|
|
|
|
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
|
|
|
|
/* ── Read ────────────────────────────────────────────────────── */
|
|
|
|
export function listRows(db, entity, query = {}) {
|
|
const { columns, filters, search, order } = entity.list;
|
|
|
|
const where = [];
|
|
const params = [];
|
|
|
|
for (const name of filters) {
|
|
const value = query[name];
|
|
if (value === undefined || value === "" || value === "all") continue;
|
|
where.push(`${name} = ?`);
|
|
params.push(value);
|
|
}
|
|
|
|
if (query.q) {
|
|
const term = `%${String(query.q).slice(0, 100)}%`;
|
|
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
|
|
params.push(...search.map(() => term));
|
|
}
|
|
|
|
const limit = Math.min(Number(query.limit) || 200, 500);
|
|
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT ${columns.join(", ")}
|
|
FROM ${entity.table}
|
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
ORDER BY ${order}
|
|
LIMIT ?`,
|
|
)
|
|
.all(...params, limit);
|
|
|
|
return { rows, total: rows.length };
|
|
}
|
|
|
|
export function readRow(db, entity, rawId) {
|
|
const id = normalizeId(entity, rawId);
|
|
const row = db
|
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
|
.get(id);
|
|
|
|
if (!row) throw new HttpError(404, "Not found.");
|
|
|
|
for (const ext of entity.extensions ?? []) {
|
|
row[ext.key] = readExtension(db, ext, id);
|
|
}
|
|
|
|
for (const child of entity.children ?? []) {
|
|
row[child.key] = readChildren(db, child, id);
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
/* A 1:1 side table is found one of two ways. `idColumn` is the
|
|
original: the side table's key IS the parent's id, which is how
|
|
regions and person_private work. `owner` is the same block children
|
|
already use — a foreign key column plus an optional kind discriminator
|
|
— and it exists because a timeline entry is keyed by (ref_kind,
|
|
ref_id) rather than by the event's own slug. Same upsert either way;
|
|
only the WHERE differs. */
|
|
function extensionWhere(ext, id) {
|
|
if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] };
|
|
const where = [`${ext.owner.column} = ?`];
|
|
const params = [id];
|
|
if (ext.owner.kindColumn) {
|
|
where.push(`${ext.owner.kindColumn} = ?`);
|
|
params.push(ext.owner.kindValue);
|
|
}
|
|
return { sql: where.join(" AND "), params };
|
|
}
|
|
|
|
function readExtension(db, ext, id) {
|
|
const { sql, params } = extensionWhere(ext, id);
|
|
return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null;
|
|
}
|
|
|
|
function readChildren(db, child, ownerId) {
|
|
const where = [`${child.owner.column} = ?`];
|
|
const params = [ownerId];
|
|
|
|
if (child.owner.kindColumn) {
|
|
where.push(`${child.owner.kindColumn} = ?`);
|
|
params.push(child.owner.kindValue);
|
|
}
|
|
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT * FROM ${child.table}
|
|
WHERE ${where.join(" AND ")}
|
|
ORDER BY ${child.order}`,
|
|
)
|
|
.all(...params);
|
|
|
|
for (const child2 of child.children ?? []) {
|
|
for (const row of rows) {
|
|
row[child2.key] = db
|
|
.prepare(
|
|
`SELECT * FROM ${child2.table}
|
|
WHERE ${child2.owner.column} = ?
|
|
ORDER BY ${child2.order}`,
|
|
)
|
|
.all(row.id);
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/* ── Write ───────────────────────────────────────────────────── */
|
|
|
|
/* An entity whose id is an autoincrement integer is addressed by a
|
|
number, and a number arriving from a URL segment is a string. Every
|
|
comparison against the id column goes through here so the two can't
|
|
drift apart. */
|
|
export function normalizeId(entity, id) {
|
|
if (entity.idKind !== "auto") return id;
|
|
const n = Number(id);
|
|
if (!Number.isInteger(n)) throw new HttpError(404, "Not found.");
|
|
return n;
|
|
}
|
|
|
|
export function createRow(db, entity, payload) {
|
|
// A singleton's one row comes from its migration. There is no
|
|
// second one to create, and the CHECK on its id would refuse it.
|
|
if (entity.singleton) {
|
|
throw new HttpError(405, "There is only one of these; edit it instead.");
|
|
}
|
|
|
|
// idKind "auto": the table assigns the id, so there is nothing to
|
|
// validate, nothing to check for collisions, and nothing for the
|
|
// client to have sent. Timeline entries use this — they have no
|
|
// natural name to slug, and one gets created every time somebody
|
|
// ticks a checkbox on an event.
|
|
const auto = entity.idKind === "auto";
|
|
const id = auto
|
|
? null
|
|
: String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
|
|
|
|
if (!auto) {
|
|
if (entity.idKind === "slug" && !SLUG.test(id)) {
|
|
throw new HttpError(422, "Validation failed", {
|
|
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
|
|
});
|
|
}
|
|
|
|
const exists = db
|
|
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
|
.get(id);
|
|
if (exists) {
|
|
throw new HttpError(422, "Validation failed", {
|
|
[entity.idColumn]: "Already taken.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const { values, errors } = coerceRow(entity.columns, payload);
|
|
if (Object.keys(errors).length) {
|
|
throw new HttpError(422, "Validation failed", errors);
|
|
}
|
|
|
|
let newId = id;
|
|
|
|
wrapDbErrors(() =>
|
|
tx(db, () => {
|
|
if (auto) {
|
|
const names = Object.keys(values);
|
|
// Every column omitted is legitimate here: a blank entry that
|
|
// takes all its defaults. INSERT INTO t () VALUES () is not
|
|
// valid SQL, so that case needs DEFAULT VALUES.
|
|
const result = names.length
|
|
? db
|
|
.prepare(
|
|
`INSERT INTO ${entity.table} (${names.join(", ")})
|
|
VALUES (${names.map(() => "?").join(", ")})`,
|
|
)
|
|
.run(...Object.values(values))
|
|
: db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run();
|
|
// better-sqlite3 and node:sqlite disagree about BigInt here.
|
|
newId = Number(result.lastInsertRowid);
|
|
} else {
|
|
const names = [entity.idColumn, ...Object.keys(values)];
|
|
db.prepare(
|
|
`INSERT INTO ${entity.table} (${names.join(", ")})
|
|
VALUES (${names.map(() => "?").join(", ")})`,
|
|
).run(id, ...Object.values(values));
|
|
}
|
|
|
|
writeExtensions(db, entity, newId, payload, values);
|
|
writeChildren(db, entity, newId, payload, values);
|
|
}),
|
|
);
|
|
|
|
return readRow(db, entity, newId);
|
|
}
|
|
|
|
export function updateRow(db, entity, rawId, payload) {
|
|
const id = normalizeId(entity, rawId);
|
|
const current = db
|
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
|
.get(id);
|
|
if (!current) throw new HttpError(404, "Not found.");
|
|
|
|
// Optimistic concurrency. The client echoes back the updated_at
|
|
// it loaded; anything else means someone saved in between.
|
|
if (entity.concurrency) {
|
|
const seen = payload?.[entity.concurrency];
|
|
if (!seen) {
|
|
throw new HttpError(400, `Missing ${entity.concurrency}.`);
|
|
}
|
|
if (seen !== current[entity.concurrency]) {
|
|
throw new HttpError(
|
|
409,
|
|
"Someone else saved this while you were editing. Reload to see their version.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const { values, errors } = coerceRow(entity.columns, payload);
|
|
if (Object.keys(errors).length) {
|
|
throw new HttpError(422, "Validation failed", errors);
|
|
}
|
|
|
|
// An unsent column keeps its stored value, so the gates below
|
|
// have to read the merged row, not just what came in.
|
|
const merged = { ...current, ...values };
|
|
|
|
wrapDbErrors(() =>
|
|
tx(db, () => {
|
|
const sets = Object.keys(values).map((name) => `${name} = ?`);
|
|
if (sets.length) {
|
|
db.prepare(
|
|
`UPDATE ${entity.table} SET ${sets.join(", ")}
|
|
WHERE ${entity.idColumn} = ?`,
|
|
).run(...Object.values(values), id);
|
|
}
|
|
|
|
writeExtensions(db, entity, id, payload, merged);
|
|
writeChildren(db, entity, id, payload, merged);
|
|
}),
|
|
);
|
|
|
|
return readRow(db, entity, id);
|
|
}
|
|
|
|
export function deleteRow(db, entity, rawId) {
|
|
// Deleting a singleton would leave the page it drives with nothing
|
|
// to read, and the admin with no way to make another.
|
|
if (entity.singleton) {
|
|
throw new HttpError(405, "This can't be deleted, only edited.");
|
|
}
|
|
|
|
const id = normalizeId(entity, rawId);
|
|
const result = wrapDbErrors(() =>
|
|
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
|
);
|
|
if (result.changes === 0) throw new HttpError(404, "Not found.");
|
|
}
|
|
|
|
/* ── Write helpers ───────────────────────────────────────────── */
|
|
|
|
function writeExtensions(db, entity, id, payload, parentValues) {
|
|
for (const ext of entity.extensions ?? []) {
|
|
if (!applies(ext.when, parentValues)) {
|
|
// The gate closed — the kind changed away from this side
|
|
// table, or a checkbox was unticked — so its row (and anything
|
|
// cascading off it) goes.
|
|
const gone = extensionWhere(ext, id);
|
|
db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
|
|
continue;
|
|
}
|
|
|
|
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
|
|
prefix: `${ext.key}.`,
|
|
});
|
|
if (Object.keys(errors).length) {
|
|
throw new HttpError(422, "Validation failed", errors);
|
|
}
|
|
|
|
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
|
|
// The owning columns come first, then whatever the form sent.
|
|
const ownNames = [];
|
|
const ownParams = [];
|
|
if (ext.owner) {
|
|
ownNames.push(ext.owner.column);
|
|
ownParams.push(id);
|
|
if (ext.owner.kindColumn) {
|
|
ownNames.push(ext.owner.kindColumn);
|
|
ownParams.push(ext.owner.kindValue);
|
|
}
|
|
} else {
|
|
ownNames.push(ext.idColumn);
|
|
ownParams.push(id);
|
|
}
|
|
|
|
const names = [...ownNames, ...Object.keys(values)];
|
|
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
|
|
|
// What makes this row the same row on a second save. Defaults to
|
|
// the id column; an owned extension declares the unique index its
|
|
// owning columns form.
|
|
const conflict = ext.conflict ?? ownNames;
|
|
|
|
// Upsert rather than delete-and-insert: deleting a regions row
|
|
// would cascade its region_areas away underneath us, and deleting
|
|
// a timeline row would take its people with it. With every
|
|
// optional column omitted there is nothing to set, so the
|
|
// conflict clause has to degrade to DO NOTHING or the SQL is
|
|
// syntactically invalid.
|
|
db.prepare(
|
|
`INSERT INTO ${ext.table} (${names.join(", ")})
|
|
VALUES (${names.map(() => "?").join(", ")})
|
|
ON CONFLICT(${conflict.join(", ")}) ${
|
|
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
|
}`,
|
|
).run(...ownParams, ...Object.values(values));
|
|
}
|
|
}
|
|
|
|
function isBlankChildRow(child, raw) {
|
|
if (!raw || typeof raw !== "object") return true;
|
|
|
|
for (const column of child.columns) {
|
|
const value = raw[column.name];
|
|
if (value === undefined || value === null || value === "") continue;
|
|
// An unchecked box is the default state of a new row, not input.
|
|
if (
|
|
column.type === "bool" &&
|
|
(value === false || value === 0 || value === "0" || value === "false")
|
|
) {
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
for (const child2 of child.children ?? []) {
|
|
const nested = raw[child2.key];
|
|
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function keyedDbErrors(prefix, fn) {
|
|
try {
|
|
return fn();
|
|
} catch (err) {
|
|
if (err instanceof HttpError) throw err;
|
|
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
|
|
if (notNull) {
|
|
throw new HttpError(422, "Validation failed", {
|
|
[`${prefix}${notNull[1]}`]: "Required.",
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function writeChildren(db, entity, id, payload, parentValues) {
|
|
for (const child of entity.children ?? []) {
|
|
if (!applies(child.when, parentValues)) {
|
|
deleteChildren(db, child, id);
|
|
continue;
|
|
}
|
|
if (payload[child.key] === undefined) continue; // not sent, not touched
|
|
|
|
deleteChildren(db, child, id);
|
|
|
|
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
|
|
|
|
// index is the row's place in what the client sent, so a
|
|
// validation error still points at the row the user is looking
|
|
// at. position counts only the rows that survive, so dropping a
|
|
// blank in the middle doesn't leave a gap in sort_order.
|
|
const rows = incoming
|
|
.map((raw, index) => ({ raw, index }))
|
|
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
|
|
|
|
rows.forEach(({ raw, index }, position) => {
|
|
const { values, errors } = coerceRow(child.columns, raw, {
|
|
prefix: `${child.key}.${index}.`,
|
|
});
|
|
if (Object.keys(errors).length) {
|
|
throw new HttpError(422, "Validation failed", errors);
|
|
}
|
|
|
|
const names = [child.owner.column, ...Object.keys(values)];
|
|
const params = [id, ...Object.values(values)];
|
|
|
|
if (child.owner.kindColumn) {
|
|
names.push(child.owner.kindColumn);
|
|
params.push(child.owner.kindValue);
|
|
}
|
|
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
|
|
if (names.includes(column)) continue;
|
|
names.push(column);
|
|
params.push(parentValues[from] ?? id);
|
|
}
|
|
if (child.order === "sort_order" && !names.includes("sort_order")) {
|
|
names.push("sort_order");
|
|
params.push(position);
|
|
}
|
|
|
|
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
|
|
db
|
|
.prepare(
|
|
`INSERT INTO ${child.table} (${names.join(", ")})
|
|
VALUES (${names.map(() => "?").join(", ")})`,
|
|
)
|
|
.run(...params),
|
|
);
|
|
|
|
for (const child2 of child.children ?? []) {
|
|
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
|
|
const nested = incomingNested
|
|
.map((rawItem, i) => ({ rawItem, i }))
|
|
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
|
|
|
|
nested.forEach(({ rawItem, i }, nestedPosition) => {
|
|
const item = coerceRow(child2.columns, rawItem, {
|
|
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
|
|
});
|
|
if (Object.keys(item.errors).length) {
|
|
throw new HttpError(422, "Validation failed", item.errors);
|
|
}
|
|
|
|
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
|
|
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
|
|
if (!itemNames.includes("sort_order")) {
|
|
itemNames.push("sort_order");
|
|
itemParams.push(nestedPosition);
|
|
}
|
|
|
|
db.prepare(
|
|
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
|
|
VALUES (${itemNames.map(() => "?").join(", ")})`,
|
|
).run(...itemParams);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function deleteChildren(db, child, ownerId) {
|
|
if (child.owner.kindColumn) {
|
|
db.prepare(
|
|
`DELETE FROM ${child.table}
|
|
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
|
|
).run(ownerId, child.owner.kindValue);
|
|
} else {
|
|
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
|
|
ownerId,
|
|
);
|
|
}
|
|
}
|
|
|
|
/* SQLite's constraint messages are accurate and unreadable. Turn
|
|
the ones that users actually cause into something actionable,
|
|
and name the column wherever the message carries it — a missing
|
|
field is the client's problem to fix, not a 500.
|
|
|
|
The descriptor and the schema agreeing (see admin-schema-sync.js)
|
|
should stop most of these arriving. This is the backstop for the
|
|
cases it can't see: partial indexes, triggers, CHECK constraints. */
|
|
function wrapDbErrors(fn) {
|
|
try {
|
|
return fn();
|
|
} catch (err) {
|
|
if (err instanceof HttpError) throw err;
|
|
const message = String(err.message ?? "");
|
|
|
|
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
|
|
if (notNull) {
|
|
throw new HttpError(422, "Validation failed", {
|
|
[notNull[1]]: "Required.",
|
|
});
|
|
}
|
|
|
|
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
|
|
if (unique) {
|
|
const columns = unique[1]
|
|
.split(",")
|
|
.map((part) => part.trim().split(".")[1])
|
|
.filter(Boolean);
|
|
if (columns.length === 1) {
|
|
throw new HttpError(422, "Validation failed", {
|
|
[columns[0]]: "Already taken.",
|
|
});
|
|
}
|
|
throw new HttpError(422, "That combination already exists.");
|
|
}
|
|
|
|
if (message.includes("FOREIGN KEY")) {
|
|
throw new HttpError(
|
|
422,
|
|
"Something references a row that doesn't exist, or is still referenced elsewhere.",
|
|
);
|
|
}
|
|
|
|
const check = /CHECK constraint failed: (\w+)/.exec(message);
|
|
if (check) {
|
|
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
|
|
}
|
|
|
|
// The polymorphic tables stand in for a foreign key with a
|
|
// BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none
|
|
// of the patterns above — so without this, pointing a content
|
|
// block, link or timeline entry at a row that isn't there is a 500
|
|
// rather than something the form can show.
|
|
const ghost = /^(\w+): no such (\w+)$/.exec(message);
|
|
if (ghost) {
|
|
throw new HttpError(
|
|
422,
|
|
`That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`,
|
|
);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|