Spreadsheet-UX op een echte relationele engine

main

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

7.7 kB Ruw bestand
/**
 * Identifier rules.
 *
 * Table and column names reach SQLite as quoted identifiers, and quoting alone
 * is not a policy — these rules are. They keep names safe to interpolate, safe
 * to reference from formula paths (which split on `.`), and free of collisions
 * with the internal `_wellformed_*` namespace.
 */

const TABLE_RE = /^[a-z][a-z0-9_]{0,47}$/;
const COLUMN_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/;

/** Names Wellformed owns on every user table. */
export const RESERVED_COLUMN_NAMES = new Set(['id', 'rowid', 'oid', '_rowid_']);

export const INTERNAL_PREFIX = '_wellformed_';

export class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

export function assertTableName(name: string): string {
  if (!TABLE_RE.test(name)) {
    throw new ValidationError(
      `Invalid table name "${name}". Use lowercase letters, digits and underscores, starting with a letter (max 48 characters).`,
    );
  }
  if (name.startsWith(INTERNAL_PREFIX)) {
    throw new ValidationError(`Table names cannot start with "${INTERNAL_PREFIX}" — that prefix is reserved.`);
  }
  if (SQL_KEYWORDS.has(name.toUpperCase())) {
    throw new ValidationError(`"${name}" is a SQL keyword and cannot be used as a table name.`);
  }
  return name;
}

export function assertColumnName(name: string): string {
  if (!COLUMN_RE.test(name)) {
    throw new ValidationError(
      `Invalid column name "${name}". Use letters, digits and underscores, starting with a letter (max 48 characters). Spaces are not allowed because formulas address columns with dots.`,
    );
  }
  if (RESERVED_COLUMN_NAMES.has(name.toLowerCase())) {
    throw new ValidationError(`"${name}" is reserved by Wellformed and cannot be used as a column name.`);
  }
  if (SQL_KEYWORDS.has(name.toUpperCase())) {
    throw new ValidationError(`"${name}" is a SQL keyword and cannot be used as a column name.`);
  }
  return name;
}

export function isValidTableName(name: string): boolean {
  try {
    assertTableName(name);
    return true;
  } catch {
    return false;
  }
}

export function isValidColumnName(name: string): boolean {
  try {
    assertColumnName(name);
    return true;
  } catch {
    return false;
  }
}

/**
 * Quote an identifier for SQLite. Only ever called with names that already
 * passed the asserts above; the doubling is belt-and-braces.
 */
export function q(identifier: string): string {
  return `"${identifier.replace(/"/g, '""')}"`;
}

/** Render a value as a SQL literal. Display only — execution always binds. */
export function literal(value: unknown): string {
  if (value === null || value === undefined) return 'NULL';
  if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
  if (typeof value === 'boolean') return value ? '1' : '0';
  return `'${String(value).replace(/'/g, "''")}'`;
}

/**
 * Workspace slugs.
 *
 * A slug is the `/w/<slug>` URL segment, so it must survive a URL untouched and
 * must not collide with a top-level client route. It never reaches SQL as an
 * identifier and never names a file — the workspace id does both of those — so
 * these rules are about addressability, not injection.
 */
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;

/** Words the router owns; a workspace may not take one. */
export const RESERVED_SLUGS = new Set([
  'api',
  'w',
  'new',
  'login',
  'logout',
  'signup',
  'signin',
  'invite',
  'invites',
  'account',
  'accounts',
  'admin',
  'settings',
  'workspace',
  'workspaces',
  'health',
  'assets',
  'static',
  'public',
]);

export function assertWorkspaceSlug(slug: string): string {
  if (!SLUG_RE.test(slug)) {
    throw new ValidationError(
      `Invalid workspace address "${slug}". Use lowercase letters, digits and hyphens, starting and ending with a letter or digit (max 40 characters).`,
    );
  }
  if (RESERVED_SLUGS.has(slug)) {
    throw new ValidationError(`"${slug}" is reserved by Wellformed. Pick another workspace address.`);
  }
  return slug;
}

export function isValidWorkspaceSlug(slug: string): boolean {
  try {
    assertWorkspaceSlug(slug);
    return true;
  } catch {
    return false;
  }
}

/** Suggest a workspace slug from a human name: "Acme Marine BV" → "acme-marine-bv". */
export function slugifyWorkspaceName(name: string): string {
  const slug = name
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 40)
    .replace(/-+$/g, '');
  if (!slug) return 'workspace';
  return RESERVED_SLUGS.has(slug) ? `${slug}-1`.slice(0, 40) : slug;
}

/**
 * The table name a human label becomes: "Purchase Orders" → "purchase_orders".
 *
 * This is a derivation, not a suggestion — people name tables in their own
 * words and never see this field; the SQL bar is where the derived name
 * surfaces. It must therefore always produce something `assertTableName`
 * accepts: lowercased, diacritics folded, a `t_` in front of a leading digit,
 * and a `_t` behind the one label in a thousand that lands on a SQL keyword.
 * Collisions are not handled here — the server suffixes `_2` against what
 * actually exists.
 */
export function slugifyTableName(label: string): string {
  let slug = label
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '_')
    .replace(/^_+|_+$/g, '')
    .slice(0, 48)
    .replace(/_+$/, '');
  if (!/^[a-z]/.test(slug)) slug = `t${slug ? '_' : ''}${slug}`.slice(0, 48);
  if (SQL_KEYWORDS.has(slug.toUpperCase())) slug = `${slug.slice(0, 46)}_t`;
  return slug;
}

/**
 * The column name a human label becomes: "Unit price" → "unit_price".
 *
 * The same derivation tables get, with the same contract: people name columns
 * in their own words and never type this — it surfaces in the SQL bar and in
 * formulas, which address columns with dots. It must always produce something
 * `assertColumnName` accepts: lowercased, diacritics folded, a `c_` in front
 * of a leading digit, and a `_c` behind a label that lands on a SQL keyword or
 * a name Wellformed reserves. Collisions are the caller's affair — the server
 * steps past what actually exists with `_2`.
 */
export function slugifyColumnName(label: string): string {
  let slug = label
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '_')
    .replace(/^_+|_+$/g, '')
    .slice(0, 48)
    .replace(/_+$/, '');
  if (!/^[a-z]/.test(slug)) slug = `c${slug ? '_' : ''}${slug}`.slice(0, 48);
  if (SQL_KEYWORDS.has(slug.toUpperCase()) || RESERVED_COLUMN_NAMES.has(slug)) {
    slug = `${slug.slice(0, 46)}_c`;
  }
  return slug;
}

const SQL_KEYWORDS = new Set(
  `ABORT ACTION ADD AFTER ALL ALTER ALWAYS ANALYZE AND AS ASC ATTACH AUTOINCREMENT BEFORE BEGIN
   BETWEEN BY CASCADE CASE CAST CHECK COLLATE COLUMN COMMIT CONFLICT CONSTRAINT CREATE CROSS
   CURRENT CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP DATABASE DEFAULT DEFERRABLE DEFERRED DELETE
   DESC DETACH DISTINCT DO DROP EACH ELSE END ESCAPE EXCEPT EXCLUDE EXCLUSIVE EXISTS EXPLAIN FAIL
   FILTER FIRST FOLLOWING FOR FOREIGN FROM FULL GENERATED GLOB GROUP GROUPS HAVING IF IGNORE
   IMMEDIATE IN INDEX INDEXED INITIALLY INNER INSERT INSTEAD INTERSECT INTO IS ISNULL JOIN KEY LAST
   LEFT LIKE LIMIT MATCH MATERIALIZED NATURAL NO NOT NOTHING NOTNULL NULL NULLS OF OFFSET ON OR
   ORDER OTHERS OUTER OVER PARTITION PLAN PRAGMA PRECEDING PRIMARY QUERY RAISE RANGE RECURSIVE
   REFERENCES REGEXP REINDEX RELEASE RENAME REPLACE RESTRICT RETURNING RIGHT ROLLBACK ROW ROWS
   SAVEPOINT SELECT SET TABLE TEMP TEMPORARY THEN TIES TO TRANSACTION TRIGGER UNBOUNDED UNION
   UNIQUE UPDATE USING VACUUM VALUES VIEW VIRTUAL WHEN WHERE WINDOW WITH WITHOUT`
    .split(/\s+/)
    .filter(Boolean),
);

export { SQL_KEYWORDS };