A Series checkbox under When opens a panel for the schedule: weekly on chosen weekdays, monthly by date, or monthly by weekday position, every N weeks or months, with meeting times and an optional meeting count. starts_on anchors the schedule and ends_on bounds it, so effective_status needs no change. Occurrences are derived, not stored. src/lib/eventSeries.ts builds the schedule label shown on cards and the event page, and the upcoming dates listed on the event page. Migration 016 adds the columns; the CRUD engine gains a time type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
639 lines
22 KiB
JavaScript
639 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) {
|
|
// 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) {
|
|
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;
|
|
}
|
|
}
|