Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
aggLabel,
isAggAllowed,
slugifyTableName,
type ColumnMeta,
type MetaResponse,
type Summaries,
} from '@wellformed/shared';
import { badRequest, conflict, notFound } from '../errors.js';
import { invalidateMeta, transact } from '../db/driver.js';
import { getTable, tablesWithCounts } from '../db/meta.js';
import { assertRoomForTable } from '../billing/limits.js';
import { recordAudit } from '../engine/audit.js';
import { validateExpression } from '../engine/compile.js';
import { newId } from '../engine/ids.js';
import { viewsWithCounts } from '../engine/query.js';
import {
addColumn,
availableTableName,
createTable,
defaultDisplayLabel,
defaultViewName,
dropColumn,
dropTable,
renameColumn,
reorderColumns,
reorderTables,
setDisplayColumn,
updateColumn,
} from '../engine/schema.js';
import { applyNormalize, planNormalize } from '../engine/normalize.js';
import { applyTighten, planTighten } from '../engine/tighten.js';
import { planWalk } from '../engine/walk.js';
import {
addColumnSchema,
columnNameSchema,
createTableSchema,
normalizeSchema,
patchViewSchema,
reorderColumnsSchema,
reorderTablesSchema,
ruleSchema,
tableNameSchema,
tightenSchema,
updateColumnSchema,
upsertViewSchema,
validateExprSchema,
} from './schemas.js';
const tableParams = z.object({ table: tableNameSchema });
const columnParams = z.object({ table: tableNameSchema, column: columnNameSchema });
const idParams = z.object({ id: z.string().min(1).max(64) });
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
/** Everything the client needs to render the shell in one round trip. */
app.get('/meta', async (request): Promise<MetaResponse> => {
request.ws.require('viewer');
const meta = request.ws.meta();
return {
tables: tablesWithCounts(request.ws.db, meta),
views: viewsWithCounts(request.ws.db, meta),
rules: meta.rules,
};
});
app.post('/formula/validate', async (request) => {
request.ws.require('viewer');
const body = validateExprSchema.parse(request.body);
const meta = request.ws.meta();
getTable(meta, body.table);
const error = validateExpression(meta, body.table, body.expr);
return { ok: error === null, error };
});
// ---- tables -------------------------------------------------------------
app.post('/tables', async (request, reply) => {
request.ws.require('admin');
const body = createTableSchema.parse(request.body);
const meta = request.ws.meta();
assertRoomForTable(request.ws.plan(), meta);
const name = createTable(
request.ws.db,
{
...body,
name: body.name ?? availableTableName(meta, slugifyTableName(body.label)),
displayLabel: body.displayLabel ?? defaultDisplayLabel(request.ws.workspace.locale),
defaultViewName: defaultViewName(body.label, request.ws.workspace.locale),
},
request.ws.actor(),
);
reply.code(201);
return { table: request.ws.meta().tables[name] };
});
app.delete('/tables/:table', async (request) => {
request.ws.require('admin');
const { table } = tableParams.parse(request.params);
dropTable(request.ws.db, table, request.ws.actor());
return { ok: true };
});
// Editor like column reorder: it moves furniture, it changes no data.
app.post('/tables/reorder', async (request) => {
request.ws.require('editor');
const { order } = reorderTablesSchema.parse(request.body);
reorderTables(request.ws.db, order, request.ws.actor());
return { ok: true };
});
app.patch('/tables/:table', async (request) => {
request.ws.require('admin');
const { table } = tableParams.parse(request.params);
const body = z
.object({
label: z.string().min(1).max(60).optional(),
display: columnNameSchema.optional(),
/** The sidebar heading; null takes the table off its shelf. */
group: z.string().trim().min(1).max(60).nullable().optional(),
})
.strict()
.parse(request.body);
const meta = request.ws.meta();
const schema = getTable(meta, table);
const actor = request.ws.actor();
if (body.label && body.label !== schema.label) {
transact(request.ws.db, () => {
request.ws.db.sqlite.prepare('UPDATE _wellformed_table SET label = ? WHERE name = ?').run(body.label, table);
recordAudit(request.ws.db, actor, {
kind: 'schema',
table,
old: schema.label,
nw: `RENAME TABLE LABEL → ${body.label}`,
});
});
invalidateMeta(request.ws.db);
}
if (body.display && body.display !== schema.display) {
setDisplayColumn(request.ws.db, table, body.display, actor);
}
if (body.group !== undefined && body.group !== schema.group) {
transact(request.ws.db, () => {
request.ws.db.sqlite
.prepare('UPDATE _wellformed_table SET group_name = ? WHERE name = ?')
.run(body.group, table);
recordAudit(request.ws.db, actor, {
kind: 'schema',
table,
old: schema.group ?? undefined,
nw: body.group ? `GROUP → ${body.group}` : 'GROUP CLEARED',
});
});
invalidateMeta(request.ws.db);
}
return { table: request.ws.meta().tables[table] };
});
// ---- columns ------------------------------------------------------------
app.post('/tables/:table/columns', async (request, reply) => {
request.ws.require('admin');
const { table } = tableParams.parse(request.params);
const body = addColumnSchema.parse(request.body);
const column = addColumn(request.ws.db, table, body, request.ws.actor());
reply.code(201);
return { column };
});
app.patch('/tables/:table/columns/:column', async (request) => {
request.ws.require('admin');
const { table, column } = columnParams.parse(request.params);
const body = updateColumnSchema.parse(request.body);
const actor = request.ws.actor();
const { rename, ...rest } = body;
if (Object.keys(rest).length) updateColumn(request.ws.db, table, column, rest, actor);
if (rename) renameColumn(request.ws.db, table, column, rename, actor);
const meta = request.ws.meta();
const updated = getTable(meta, table).columns.find((c) => c.name === (rename ?? column));
if (!updated) throw notFound(`Column "${rename ?? column}" disappeared.`);
return { column: updated };
});
/**
* The dry run. Read-only, and the only way to find out what a constraint
* would cost before it binds — so it answers for anyone who may edit, not
* only for whoever is allowed to apply it.
*/
app.post('/tables/:table/columns/:column/tighten/plan', async (request) => {
request.ws.require('editor');
const { table, column } = columnParams.parse(request.params);
const body = tightenSchema.parse(request.body);
return { plan: planTighten(request.ws.db, table, column, body) };
});
app.post('/tables/:table/columns/:column/tighten', async (request) => {
request.ws.require('admin');
const { table, column } = columnParams.parse(request.params);
const body = tightenSchema.parse(request.body);
const result = applyTighten(request.ws.db, table, column, body, request.ws.actor());
return { column: result.column, plan: result.plan, cleared: result.cleared };
});
/**
* The tighten walk: every stored column looked at once, each with what its
* values say it really is — a detected type, spellings to merge, a select
* in waiting. Read-only; each step is applied through normalize and
* tighten, one column at a time.
*/
app.post('/tables/:table/tighten/walk', async (request) => {
request.ws.require('editor');
const { table } = tableParams.parse(request.params);
return { plan: planWalk(request.ws.db, table) };
});
/**
* The scan behind the walk's merge step: every text and select column, read
* for spellings that are the same answer written differently. Read-only,
* like the tighten plan, and answering for anyone who may edit.
*/
app.post('/tables/:table/normalize/plan', async (request) => {
request.ws.require('editor');
const { table } = tableParams.parse(request.params);
return { plan: planNormalize(request.ws.db, table) };
});
/**
* Editor, not admin: merging spellings is editing cell values, which an
* editor may already do row by row. A select's option list follows the
* values it describes — that is the merge finishing, not the list being
* redesigned.
*/
app.post('/tables/:table/columns/:column/normalize', async (request) => {
request.ws.require('editor');
const { table, column } = columnParams.parse(request.params);
const body = normalizeSchema.parse(request.body);
const result = applyNormalize(request.ws.db, table, column, body.merges, request.ws.actor());
return { column: result.column, changed: result.changed };
});
app.post('/tables/:table/columns/reorder', async (request) => {
request.ws.require('editor');
const { table } = tableParams.parse(request.params);
const { order } = reorderColumnsSchema.parse(request.body);
reorderColumns(request.ws.db, table, order, request.ws.actor());
return { table: request.ws.meta().tables[table] };
});
app.delete('/tables/:table/columns/:column', async (request) => {
request.ws.require('admin');
const { table, column } = columnParams.parse(request.params);
dropColumn(request.ws.db, table, column, request.ws.actor());
return { ok: true };
});
// ---- views --------------------------------------------------------------
app.post('/tables/:table/views', async (request, reply) => {
request.ws.require('editor');
const { table } = tableParams.parse(request.params);
const body = upsertViewSchema.parse(request.body);
const meta = request.ws.meta();
const schema = getTable(meta, table);
validateViewColumns(schema.columns, body);
if (meta.views.some((v) => v.table === table && v.name === body.name)) {
throw conflict(`"${table}" already has a view called "${body.name}".`);
}
const id = newId('vw');
const position =
Math.max(-1, ...meta.views.filter((v) => v.table === table).map((v) => v.position)) + 1;
transact(request.ws.db, () => {
request.ws.db.sqlite
.prepare(
'INSERT INTO _wellformed_view (id, table_name, name, filters, sort, summaries, hidden, position, system) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)',
)
.run(
id,
table,
body.name,
JSON.stringify(body.filters),
body.sort ? JSON.stringify(body.sort) : null,
JSON.stringify(body.summaries),
JSON.stringify(body.hidden),
position,
);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'view',
table,
nw: `CREATE VIEW ${body.name}`,
});
});
invalidateMeta(request.ws.db);
reply.code(201);
return { view: request.ws.meta().views.find((v) => v.id === id) };
});
app.patch('/views/:id', async (request) => {
request.ws.require('editor');
const { id } = idParams.parse(request.params);
const body = patchViewSchema.parse(request.body);
const meta = request.ws.meta();
const view = meta.views.find((v) => v.id === id);
if (!view) throw notFound(`No view "${id}".`);
const schema = getTable(meta, view.table);
const next = {
name: body.name ?? view.name,
filters: body.filters ?? view.filters,
sort: body.sort === undefined ? view.sort : body.sort,
summaries: body.summaries ?? view.summaries,
hidden: body.hidden ?? view.hidden,
};
validateViewColumns(schema.columns, next);
transact(request.ws.db, () => {
request.ws.db.sqlite
.prepare('UPDATE _wellformed_view SET name = ?, filters = ?, sort = ?, summaries = ?, hidden = ? WHERE id = ?')
.run(
next.name,
JSON.stringify(next.filters),
next.sort ? JSON.stringify(next.sort) : null,
JSON.stringify(next.summaries),
JSON.stringify(next.hidden),
id,
);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'view',
table: view.table,
old: view.name,
nw: `UPDATE VIEW ${next.name}`,
});
});
invalidateMeta(request.ws.db);
return { view: request.ws.meta().views.find((v) => v.id === id) };
});
app.delete('/views/:id', async (request) => {
request.ws.require('editor');
const { id } = idParams.parse(request.params);
const view = request.ws.meta().views.find((v) => v.id === id);
if (!view) throw notFound(`No view "${id}".`);
if (view.system) throw conflict(`"${view.name}" is a built-in view and cannot be deleted.`);
transact(request.ws.db, () => {
request.ws.db.sqlite.prepare('DELETE FROM _wellformed_view WHERE id = ?').run(id);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'view',
table: view.table,
nw: `DROP VIEW ${view.name}`,
});
});
invalidateMeta(request.ws.db);
return { ok: true };
});
// ---- workflow rules -----------------------------------------------------
app.post('/rules', async (request, reply) => {
request.ws.require('admin');
const body = ruleSchema.parse(request.body);
const meta = request.ws.meta();
const schema = getTable(meta, body.table);
const trigger = schema.columns.find((c) => c.name === body.column);
if (!trigger) throw notFound(`"${body.table}" has no column "${body.column}".`);
for (const action of body.actions) {
const target = schema.columns.find((c) => c.name === action.column);
if (!target) throw notFound(`"${body.table}" has no column "${action.column}".`);
if (target.type === 'formula' || target.type === 'rollup') {
throw badRequest(`"${action.column}" is computed; a rule cannot set it.`);
}
}
const id = newId('rl');
transact(request.ws.db, () => {
request.ws.db.sqlite
.prepare(
'INSERT INTO _wellformed_rule (id, table_name, column_name, equals, label, actions, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
.run(id, body.table, body.column, body.equals, body.label, JSON.stringify(body.actions), body.enabled ? 1 : 0);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'schema',
table: body.table,
column: body.column,
nw: `CREATE RULE ${body.label}`,
});
});
invalidateMeta(request.ws.db);
reply.code(201);
return { rule: request.ws.meta().rules.find((r) => r.id === id) };
});
app.patch('/rules/:id', async (request) => {
request.ws.require('admin');
const { id } = idParams.parse(request.params);
const body = z.object({ enabled: z.boolean() }).strict().parse(request.body);
const rule = request.ws.meta().rules.find((r) => r.id === id);
if (!rule) throw notFound(`No rule "${id}".`);
transact(request.ws.db, () => {
request.ws.db.sqlite.prepare('UPDATE _wellformed_rule SET enabled = ? WHERE id = ?').run(body.enabled ? 1 : 0, id);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'schema',
table: rule.table,
nw: `${body.enabled ? 'ENABLE' : 'DISABLE'} RULE ${rule.label}`,
});
});
invalidateMeta(request.ws.db);
return { rule: request.ws.meta().rules.find((r) => r.id === id) };
});
app.delete('/rules/:id', async (request) => {
request.ws.require('admin');
const { id } = idParams.parse(request.params);
const rule = request.ws.meta().rules.find((r) => r.id === id);
if (!rule) throw notFound(`No rule "${id}".`);
transact(request.ws.db, () => {
request.ws.db.sqlite.prepare('DELETE FROM _wellformed_rule WHERE id = ?').run(id);
recordAudit(request.ws.db, request.ws.actor(), {
kind: 'schema',
table: rule.table,
nw: `DROP RULE ${rule.label}`,
});
});
invalidateMeta(request.ws.db);
return { ok: true };
});
}
function validateViewColumns(
known: ColumnMeta[],
view: {
filters?: { col: string }[];
sort?: { col: string } | null;
summaries?: Summaries;
hidden?: string[];
},
): void {
const byName = new Map(known.map((c) => [c.name, c]));
for (const f of view.filters ?? []) {
if (!byName.has(f.col)) throw badRequest(`This view filters on "${f.col}", which does not exist.`);
}
if (view.sort && !byName.has(view.sort.col)) {
throw badRequest(`This view sorts by "${view.sort.col}", which does not exist.`);
}
for (const name of view.hidden ?? []) {
if (!byName.has(name)) throw badRequest(`This view hides "${name}", which does not exist.`);
}
if (view.hidden && view.hidden.length >= known.length) {
throw badRequest('A view must show at least one column.');
}
for (const [name, fn] of Object.entries(view.summaries ?? {})) {
const col = byName.get(name);
if (!col) throw badRequest(`This view summarises "${name}", which does not exist.`);
// Refused here rather than stored and reported forever after: a saved view
// should not be able to hold a summary the database would decline to take.
if (!isAggAllowed(col, fn)) {
throw badRequest(`${aggLabel(fn, col)} does not apply to the ${col.type} column "${name}".`);
}
}
}