/* ═══════════════════════════════════════════════════════════════ 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": { // 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; } }