Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import {
isComputed,
nextSequenceValue,
nowISO,
q,
todayISO,
type CellValue,
type ColumnMeta,
type Row,
type Rule,
} from '@wellformed/shared';
import { badRequest, conflict, notFound } from '../errors.js';
import { transact, type Db } from '../db/driver.js';
import { getTable, incomingLinks, requireColumn, storedColumns, type MetaSnapshot } from '../db/meta.js';
import { recordAudit, SYSTEM_ACTOR, type Actor } from './audit.js';
import { fetchRow, rowLabel } from './query.js';
import { newId, isValidId } from './ids.js';
import { coerceForColumn, describeValue } from './values.js';
export interface WriteResult {
row: Row;
/** Labels of the workflow rules that fired, in order. */
workflows: string[];
}
/**
* Apply the workflow rules that match a set of pending changes.
*
* A rule fires when its column is being set to its trigger value. Rule-applied
* changes do not themselves re-trigger rules; one pass keeps the outcome of an
* edit predictable and prevents two rules from ping-ponging.
*/
function applyRules(
db: Db,
meta: MetaSnapshot,
tableName: string,
patch: Map<string, CellValue>,
actor: Actor,
): string[] {
const table = getTable(meta, tableName);
const fired: string[] = [];
const matching = meta.rules.filter(
(r) => r.enabled && r.table === tableName && patch.has(r.column),
);
for (const rule of matching) {
const value = patch.get(rule.column);
if (String(value ?? '') !== rule.equals) continue;
for (const action of rule.actions) {
if (action.type !== 'set') continue;
const col = table.columns.find((c) => c.name === action.column);
if (!col || isComputed(col.type)) continue;
patch.set(col.name, coerceForColumn(db, col, resolveActionValue(action.value, actor)));
}
fired.push(rule.label);
}
return fired;
}
function resolveActionValue(value: string, actor: Actor): string {
switch (value) {
case '@today':
return todayISO();
case '@now':
return nowISO();
case '@user':
return actor.name;
case '@empty':
return '';
default:
return value;
}
}
export function insertRow(
db: Db,
meta: MetaSnapshot,
tableName: string,
input: Record<string, unknown>,
actor: Actor,
explicitId?: string,
): WriteResult {
const table = getTable(meta, tableName);
if (explicitId !== undefined && !isValidId(explicitId)) {
throw badRequest(`"${explicitId}" is not a valid row id.`);
}
const id = explicitId ?? newId(tableName.slice(0, 3));
const patch = new Map<string, CellValue>();
for (const [key, raw] of Object.entries(input)) {
if (key === 'id') continue;
const col = requireColumn(table, key);
if (isComputed(col.type)) continue;
// An empty cell in a self-numbering column is a request for the next
// number, not a value — it falls through to the fill below, where a
// required one would otherwise be refused as blank.
if (col.sequence && (raw === null || raw === undefined || raw === '')) continue;
patch.set(col.name, coerceForColumn(db, col, raw));
}
// Self-numbering columns fill themselves when the row arrives without them.
// The counter is read off the rows already in the column, scoped to the
// pattern's rendered date parts — `INV-{yyyy}-{n}` starts over each January
// because nothing from last year matches it any more. A typed value stands;
// the pattern only speaks for cells left empty.
for (const col of storedColumns(table)) {
if (col.type !== 'text' || !col.sequence || patch.has(col.name)) continue;
const existing = db.sqlite
.prepare<[], { v: string }>(
`SELECT ${q(col.name)} AS v FROM ${q(tableName)} WHERE ${q(col.name)} IS NOT NULL`,
)
.all()
.map((r) => String(r.v));
patch.set(col.name, nextSequenceValue(col.sequence, existing, new Date()));
}
// Required columns must be present even when the client omitted them.
for (const col of storedColumns(table)) {
if (col.required && !patch.has(col.name)) {
throw badRequest(`"${col.label}" is required.`);
}
}
const workflows = applyRules(db, meta, tableName, patch, actor);
return transact(db, () => {
const existing = db.sqlite
.prepare<[string], { id: string }>(`SELECT id FROM ${q(tableName)} WHERE id = ?`)
.get(id);
if (existing) throw conflict(`A row with id "${id}" already exists in "${tableName}".`);
const names = ['id', ...patch.keys()];
const values = [id, ...patch.values()];
const placeholders = names.map(() => '?').join(', ');
db.sqlite
.prepare(`INSERT INTO ${q(tableName)} (${names.map(q).join(', ')}) VALUES (${placeholders})`)
.run(...values);
const row = fetchRow(db, meta, tableName, id);
if (!row) throw notFound(`Row "${id}" vanished immediately after insert.`);
const label = rowLabel(row, table);
recordAudit(db, actor, {
kind: 'insert',
table: tableName,
rowId: id,
rowLabel: label,
// The badge already says "insert" and the location line already names the
// table, so echoing the statement here only repeated both.
nw: 'created',
});
for (const w of workflows) {
recordAudit(db, SYSTEM_ACTOR, {
kind: 'workflow',
table: tableName,
rowId: id,
rowLabel: label,
nw: w,
});
}
return { row, workflows };
});
}
export function updateRow(
db: Db,
meta: MetaSnapshot,
tableName: string,
id: string,
input: Record<string, unknown>,
actor: Actor,
): WriteResult {
const table = getTable(meta, tableName);
const before = fetchRow(db, meta, tableName, id);
if (!before) throw notFound(`No row "${id}" in "${tableName}".`);
const patch = new Map<string, CellValue>();
const touched: ColumnMeta[] = [];
for (const [key, raw] of Object.entries(input)) {
if (key === 'id') throw badRequest('Row ids cannot be changed.');
const col = requireColumn(table, key);
if (isComputed(col.type)) {
throw badRequest(`"${col.label}" is a computed column and cannot be written to.`);
}
const next = coerceForColumn(db, col, raw);
if (sameValue(before.values[col.name] ?? null, next)) continue;
patch.set(col.name, next);
touched.push(col);
}
if (patch.size === 0) return { row: before, workflows: [] };
const workflows = applyRules(db, meta, tableName, patch, actor);
return transact(db, () => {
const assignments = [...patch.keys()].map((name) => `${q(name)} = ?`).join(', ');
const info = db.sqlite
.prepare(`UPDATE ${q(tableName)} SET ${assignments} WHERE id = ?`)
.run(...patch.values(), id);
if (info.changes === 0) throw notFound(`No row "${id}" in "${tableName}".`);
const after = fetchRow(db, meta, tableName, id);
if (!after) throw notFound(`Row "${id}" vanished during update.`);
const label = rowLabel(after, table);
for (const [name, next] of patch) {
const col = table.columns.find((c) => c.name === name)!;
recordAudit(db, actor, {
kind: 'edit',
table: tableName,
rowId: id,
rowLabel: label,
column: name,
old: describeValue(before.values[name] ?? null, col, before.labels[name]),
nw: describeValue(next, col, after.labels[name]),
});
}
for (const w of workflows) {
recordAudit(db, SYSTEM_ACTOR, {
kind: 'workflow',
table: tableName,
rowId: id,
rowLabel: label,
nw: w,
});
}
return { row: after, workflows };
});
}
export interface DeleteResult {
/** Stored values of the deleted row, enough to recreate it for an undo. */
restore: Record<string, CellValue>;
label: string;
}
export function deleteRow(
db: Db,
meta: MetaSnapshot,
tableName: string,
id: string,
actor: Actor,
): DeleteResult {
const table = getTable(meta, tableName);
const before = fetchRow(db, meta, tableName, id);
if (!before) throw notFound(`No row "${id}" in "${tableName}".`);
const label = rowLabel(before, table);
const restore: Record<string, CellValue> = { id };
for (const col of storedColumns(table)) {
restore[col.name] = before.values[col.name] ?? null;
}
// Foreign keys are ON DELETE SET NULL, so rows elsewhere quietly lose their
// link. Count them first and say so in the trail rather than let it pass.
const orphaned = incomingLinks(meta, tableName).reduce((sum, ref) => {
const row = db.sqlite
.prepare<[string], { n: number }>(
`SELECT COUNT(*) AS n FROM ${q(ref.table)} WHERE ${q(ref.column.name)} = ?`,
)
.get(id);
return sum + (row?.n ?? 0);
}, 0);
return transact(db, () => {
const info = db.sqlite.prepare(`DELETE FROM ${q(tableName)} WHERE id = ?`).run(id);
if (info.changes === 0) throw notFound(`No row "${id}" in "${tableName}".`);
recordAudit(db, actor, {
kind: 'delete',
table: tableName,
rowId: id,
rowLabel: label,
old: label,
nw:
orphaned > 0
? `deleted — cleared ${orphaned} link${orphaned === 1 ? '' : 's'}`
: 'deleted',
});
return { restore, label };
});
}
function sameValue(a: CellValue, b: CellValue): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a === 'boolean' || typeof b === 'boolean') return Boolean(a) === Boolean(b);
return String(a) === String(b);
}
/** Rules attached to a table, for the sidebar. */
export function rulesFor(meta: MetaSnapshot, tableName: string): Rule[] {
return meta.rules.filter((r) => r.table === tableName);
}