v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
533
server/src/admin-crud.js
Normal file
533
server/src/admin-crud.js
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
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}$/;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
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, id) {
|
||||
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] =
|
||||
db
|
||||
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
|
||||
.get(id) ?? null;
|
||||
}
|
||||
|
||||
for (const child of entity.children ?? []) {
|
||||
row[child.key] = readChildren(db, child, id);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────── */
|
||||
|
||||
export function createRow(db, entity, payload) {
|
||||
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const names = [entity.idColumn, ...Object.keys(values)];
|
||||
|
||||
wrapDbErrors(() =>
|
||||
tx(db, () => {
|
||||
db.prepare(
|
||||
`INSERT INTO ${entity.table} (${names.join(", ")})
|
||||
VALUES (${names.map(() => "?").join(", ")})`,
|
||||
).run(id, ...Object.values(values));
|
||||
|
||||
writeExtensions(db, entity, id, payload, values);
|
||||
writeChildren(db, entity, id, payload, values);
|
||||
}),
|
||||
);
|
||||
|
||||
return readRow(db, entity, id);
|
||||
}
|
||||
|
||||
export function updateRow(db, entity, id, payload) {
|
||||
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, id) {
|
||||
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, so its row (and anything cascading off it) goes.
|
||||
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
|
||||
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);
|
||||
|
||||
const names = [ext.idColumn, ...Object.keys(values)];
|
||||
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
||||
|
||||
// Upsert rather than delete-and-insert: deleting a regions row
|
||||
// would cascade its region_areas away underneath us. 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(${ext.idColumn}) ${
|
||||
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
||||
}`,
|
||||
).run(id, ...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.`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue