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 type { Filter, RowsResponse, Sort, Summaries, SummaryResponse } from '@wellformed/shared';
import { badRequest, notFound } from '../errors.js';
import { getTable, type MetaSnapshot } from '../db/meta.js';
import { assertRoomForRows } from '../billing/limits.js';
import { deleteRow, insertRow, updateRow } from '../engine/mutate.js';
import { fetchRow, runQuery, runSummary } from '../engine/query.js';
import {
filterSchema,
rowIdSchema,
rowsQuerySchema,
sortSchema,
summariesSchema,
summaryQuerySchema,
tableNameSchema,
} from './schemas.js';
const tableParams = z.object({ table: tableNameSchema });
const rowParams = z.object({ table: tableNameSchema, id: rowIdSchema });
const writeBodySchema = z.object({
values: z.record(z.string(), z.unknown()),
/** Only honoured on insert, and only to restore a row an undo removed. */
id: rowIdSchema.optional(),
});
function parseJsonParam<T>(raw: string | undefined, schema: z.ZodType<T>, what: string): T | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw badRequest(`The "${what}" parameter is not valid JSON.`);
}
const result = schema.safeParse(parsed);
if (!result.success) {
throw badRequest(`The "${what}" parameter is malformed.`, result.error.issues);
}
return result.data;
}
/**
* The rows a request is about: the saved view's filters with the client's
* ad-hoc ones layered on top, exactly as the filter chips in the view bar
* suggest. Shared so the aggregate row summarises the rows the grid is showing
* and not some near-miss of them.
*/
function resolveScope(
meta: MetaSnapshot,
table: string,
query: { view?: string; filters?: string; hidden?: string },
): { filters: Filter[]; sort: Sort | null; hidden: string[] } {
let filters: Filter[] = [];
let sort: Sort | null = null;
let hidden: string[] = [];
if (query.view) {
const view = meta.views.find((v) => v.id === query.view && v.table === table);
if (!view) throw notFound(`No view "${query.view}" on "${table}".`);
filters = [...view.filters];
sort = view.sort;
hidden = [...view.hidden];
}
const extra = parseJsonParam(query.filters, z.array(filterSchema).max(20), 'filters');
if (extra) filters = [...filters, ...extra];
// Unlike filters, hidden replaces rather than layers: the client sends the
// whole set it wants left out, or no parameter to take the view's word for it.
if (query.hidden !== undefined) {
hidden = parseJsonParam(query.hidden, z.array(z.string()).max(500), 'hidden') ?? [];
}
return { filters, sort, hidden };
}
export async function rowRoutes(app: FastifyInstance): Promise<void> {
app.get('/tables/:table/rows', async (request): Promise<RowsResponse> => {
request.ws.require('viewer');
const { table } = tableParams.parse(request.params);
const query = rowsQuerySchema.parse(request.query);
const meta = request.ws.meta();
getTable(meta, table);
const scope = resolveScope(meta, table, query);
const filters = scope.filters;
let sort = scope.sort;
const sortOverride = parseJsonParam(query.sort, sortSchema.nullable(), 'sort');
if (query.sort !== undefined) sort = sortOverride;
const result = runQuery(request.ws.db, meta, {
table,
filters,
sort,
search: query.q,
hidden: scope.hidden,
limit: query.limit,
offset: query.offset,
});
return {
rows: result.rows,
total: result.total,
limit: query.limit,
offset: query.offset,
sql: result.sql,
tokens: result.tokens,
columnErrors: result.columnErrors,
};
});
/**
* The aggregate row under the grid.
*
* A separate call rather than a field on the rows response: the grid pages,
* and the summary does not — hanging it off page one would recompute it on
* every scroll and leave it stale on every other page. Which functions to
* compute come from the request, so the footer answers while the choice is
* still unsaved, and only becomes the view's when the view is saved.
*/
app.get('/tables/:table/summary', async (request): Promise<SummaryResponse> => {
request.ws.require('viewer');
const { table } = tableParams.parse(request.params);
const query = summaryQuerySchema.parse(request.query);
const meta = request.ws.meta();
getTable(meta, table);
const { filters, hidden } = resolveScope(meta, table, query);
const requested = parseJsonParam(query.summaries, summariesSchema, 'summaries');
const view = query.view ? meta.views.find((v) => v.id === query.view) : undefined;
const summaries: Summaries = requested ?? view?.summaries ?? {};
return runSummary(request.ws.db, meta, { table, filters, search: query.q, hidden, summaries });
});
app.get('/tables/:table/rows/:id', async (request) => {
request.ws.require('viewer');
const { table, id } = rowParams.parse(request.params);
const meta = request.ws.meta();
getTable(meta, table);
const row = fetchRow(request.ws.db, meta, table, id);
if (!row) throw notFound(`No row "${id}" in "${table}".`);
return { row };
});
app.post('/tables/:table/rows', async (request, reply) => {
request.ws.require('editor');
const { table } = tableParams.parse(request.params);
const body = writeBodySchema.parse(request.body);
const meta = request.ws.meta();
// The one place a workspace grows a row. Editing and deleting are never
// gated on the plan — only adding is.
assertRoomForRows(request.ws.plan(), request.ws.db, meta);
const result = insertRow(
request.ws.db,
meta,
table,
body.values,
request.ws.actor(),
body.id,
);
reply.code(201);
return result;
});
app.patch('/tables/:table/rows/:id', async (request) => {
request.ws.require('editor');
const { table, id } = rowParams.parse(request.params);
const body = writeBodySchema.omit({ id: true }).parse(request.body);
return updateRow(request.ws.db, request.ws.meta(), table, id, body.values, request.ws.actor());
});
app.delete('/tables/:table/rows/:id', async (request) => {
request.ws.require('editor');
const { table, id } = rowParams.parse(request.params);
const result = deleteRow(request.ws.db, request.ws.meta(), table, id, request.ws.actor());
return result;
});
}