Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.3 kB Ruw bestand
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;
  });
}