v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue