parse-table-schema.ts6.6 KBView on GitHub
/**
 * Strict client-side validation of a table document's schema map.
 *
 * The server's `readTableSchema` is deliberately TOLERANT — it runs inside the save
 * pipeline on every write, where a throw would fail the user's edit, so it silently
 * drops anything malformed. That is the wrong contract for the grid: a schema whose
 * `columns` is a string, or which declares two columns under one key, would render as
 * a table that quietly disagrees with what the agent believes it wrote. Better to show
 * the user an error card and leave the bytes untouched.
 *
 * So this is a second, stricter reader over the same map — narrow on purpose, and the
 * only thing between a corrupt schema and a thrown component.
 */

import {
  RESERVED_COLUMN_KEYS,
  TABLE_COLUMN_TYPE,
  coerceTableSort,
  emptyTableSchema,
  isTableCellWrap,
  isTableColumnType,
  isTableOutputKind,
  type TableColumn,
  type TableSchema,
} from '@zero/server/table';

export type ParsedTableSchema =
  | { ok: true; schema: TableSchema }
  | { ok: false; error: string };

/**
 * Validate a `tableSchema` Y.Map snapshot (`map.toJSON()`).
 *
 * An empty map is VALID and yields the empty schema — that is a brand-new table whose
 * `create` hasn't landed yet, not corruption.
 */
export function parseTableSchema(raw: unknown): ParsedTableSchema {
  if (raw === null || raw === undefined) return { ok: true, schema: emptyTableSchema() };
  if (!isPlainObject(raw)) {
    return { ok: false, error: `Expected the schema to be an object, got ${describe(raw)}.` };
  }
  if (Object.keys(raw).length === 0) return { ok: true, schema: emptyTableSchema() };

  if (raw.version !== undefined && raw.version !== 1) {
    return {
      ok: false,
      error: `Unsupported schema version ${describe(raw.version)}. This client understands version 1.`,
    };
  }

  if (raw.columns !== undefined && !Array.isArray(raw.columns)) {
    return { ok: false, error: `Expected "columns" to be an array, got ${describe(raw.columns)}.` };
  }
  const rawColumns: unknown[] = Array.isArray(raw.columns) ? raw.columns : [];

  const columns: TableColumn[] = [];
  const seen = new Set<string>();
  for (const [index, rawColumn] of rawColumns.entries()) {
    const parsed = parseColumn(rawColumn, index);
    if (!parsed.ok) return parsed;
    if (seen.has(parsed.column.key)) {
      return {
        ok: false,
        error: `Two columns share the key "${parsed.column.key}". Column keys address cells, so they must be unique.`,
      };
    }
    seen.add(parsed.column.key);
    columns.push(parsed.column);
  }

  if (raw.titleColumn !== undefined && typeof raw.titleColumn !== 'string') {
    return {
      ok: false,
      error: `Expected "titleColumn" to be a column key, got ${describe(raw.titleColumn)}.`,
    };
  }
  if (raw.purpose !== undefined && typeof raw.purpose !== 'string') {
    return { ok: false, error: `Expected "purpose" to be a string, got ${describe(raw.purpose)}.` };
  }

  const schema: TableSchema = {
    version: 1,
    columns,
    titleColumn: typeof raw.titleColumn === 'string' ? raw.titleColumn : '',
  };
  if (raw.purpose) schema.purpose = raw.purpose;
  /**
   * The sort is READ BACK, unlike `triggers` — and it has to be.
   *
   * Every header edit round-trips the schema through this parser and back out through
   * `setSchema`, where `writeTableSchema` treats an absent `sort` as "cleared". A parser that
   * dropped it would therefore make resizing a column silently un-sort the table. `triggers`
   * gets away with being dropped only because its writer leaves an unmentioned value alone.
   *
   * Tolerated rather than rejected, like `wrap`: a malformed sort is a view that renders in
   * document order, not a reason to put an error card over a table whose data is fine.
   */
  const sort = coerceTableSort(raw.sort);
  if (sort) schema.sort = sort;
  return { ok: true, schema };
}

type ParsedColumn = { ok: true; column: TableColumn } | { ok: false; error: string };

function parseColumn(raw: unknown, index: number): ParsedColumn {
  const at = `Column ${index + 1}`;
  if (!isPlainObject(raw)) return { ok: false, error: `${at} is ${describe(raw)}, not an object.` };

  const key=[redacted] raw.key === 'string' ? raw.key.trim() : '';
  const label = typeof raw.label === 'string' ? raw.label.trim() : '';
  if (!key) return { ok: false, error: `${at} has no "key". A column with no key is unaddressable.` };
  if (!label) return { ok: false, error: `${at} ("${key}") has no "label", so it has no header.` };
  if (RESERVED_COLUMN_KEYS.has(key)) {
    return { ok: false, error: `${at} uses the reserved key "${key}", which is never declared.` };
  }
  if (raw.type !== undefined && !isTableColumnType(raw.type)) {
    return { ok: false, error: `${at} ("${key}") has unknown type ${describe(raw.type)}.` };
  }

  const column: TableColumn = {
    key,
    label,
    type: isTableColumnType(raw.type) ? raw.type : TABLE_COLUMN_TYPE.TEXT,
  };
  if (typeof raw.description === 'string' && raw.description) column.description = raw.description;
  if (typeof raw.fillInstruction === 'string' && raw.fillInstruction) {
    column.fillInstruction = raw.fillInstruction;
  }
  if (raw.required === true) column.required = true;
  if (typeof raw.binding === 'string' && raw.binding) column.binding = raw.binding;
  if (Array.isArray(raw.options)) {
    column.options = raw.options.filter((o): o is string => typeof o === 'string');
  }
  if (typeof raw.currency === 'string' && raw.currency) column.currency = raw.currency;
  if (typeof raw.format === 'string' && raw.format) column.format = raw.format;
  if (typeof raw.width === 'number' && Number.isFinite(raw.width)) column.width = raw.width;
  // An unknown `wrap` is dropped rather than rejected: it is presentation, so the honest
  // failure is "renders with the default", not an error card over a table whose data is fine.
  if (isTableCellWrap(raw.wrap)) column.wrap = raw.wrap;
  // Same tolerance as `wrap`: an unknown kind renders as an undeclared output column rather
  // than taking the whole table down over one bad enum.
  if (isTableOutputKind(raw.outputKind)) column.outputKind = raw.outputKind;
  if (raw.hidden === true) column.hidden = true;
  return { ok: true, column };
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Short, quotable rendering of a bad value for the error card. */
function describe(value: unknown): string {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'an array';
  if (typeof value === 'string') return `"${value.slice(0, 40)}"`;
  if (typeof value === 'object') return 'an object';
  return String(value);
}