v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -153,7 +153,8 @@ export function listRows(db, entity, query = {}) {
return { rows, total: rows.length };
}
export function readRow(db, entity, id) {
export function readRow(db, entity, rawId) {
const id = normalizeId(entity, rawId);
const row = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
@ -161,10 +162,7 @@ export function readRow(db, entity, 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;
row[ext.key] = readExtension(db, ext, id);
}
for (const child of entity.children ?? []) {
@ -174,6 +172,29 @@ export function readRow(db, entity, 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];
@ -208,22 +229,43 @@ function readChildren(db, child, ownerId) {
/* ── 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) {
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
// 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 (entity.idKind === "slug" && !SLUG.test(id)) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
});
}
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 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);
@ -231,24 +273,43 @@ export function createRow(db, entity, payload) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [entity.idColumn, ...Object.keys(values)];
let newId = id;
wrapDbErrors(() =>
tx(db, () => {
db.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
).run(id, ...Object.values(values));
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, id, payload, values);
writeChildren(db, entity, id, payload, values);
writeExtensions(db, entity, newId, payload, values);
writeChildren(db, entity, newId, payload, values);
}),
);
return readRow(db, entity, id);
return readRow(db, entity, newId);
}
export function updateRow(db, entity, id, payload) {
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);
@ -296,7 +357,8 @@ export function updateRow(db, entity, id, payload) {
return readRow(db, entity, id);
}
export function deleteRow(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),
);
@ -309,8 +371,10 @@ 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);
// 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;
}
@ -323,21 +387,42 @@ function writeExtensions(db, entity, id, payload, parentValues) {
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
const names = [ext.idColumn, ...Object.keys(values)];
// 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. With every
// 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(${ext.idColumn}) ${
ON CONFLICT(${conflict.join(", ")}) ${
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
}`,
).run(id, ...Object.values(values));
).run(...ownParams, ...Object.values(values));
}
}
@ -528,6 +613,19 @@ function wrapDbErrors(fn) {
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;
}
}