table.ts84 lines · main
1import { ColumnBuilder, type ColumnDef } from './columns.js';
2
3export interface IndexDef {
4 readonly columns: readonly string[];
5 readonly unique: boolean;
6}
7
8export interface AccessRules {
9 readonly read?: string;
10 readonly write?: string;
11}
12
13export interface TableDef {
14 readonly columns: Readonly<Record<string, ColumnDef>>;
15 readonly indexes: readonly IndexDef[];
16 readonly access?: AccessRules;
17}
18
19export interface TableInput {
20 columns: Record<string, ColumnBuilder>;
21 indexes?: Array<{ columns: readonly string[]; unique?: boolean }>;
22 access?: AccessRules;
23}
24
25/**
26 * Declare a table. The `columns` record is the core input; `indexes` and
27 * `access` are optional refinements. The returned value is an immutable
28 * `TableDef` suitable for serialisation.
29 */
30export function table(input: TableInput | Record<string, ColumnBuilder>): TableDef {
31 const normalised: TableInput = isTableInput(input) ? input : { columns: input };
32
33 const columns: Record<string, ColumnDef> = {};
34 for (const [name, builder] of Object.entries(normalised.columns)) {
35 if (!isIdentifier(name)) {
36 throw new Error(`invalid column name: ${JSON.stringify(name)}`);
37 }
38 columns[name] = builder.def;
39 }
40
41 // At least one primary key per table. 2+ marked columns produce a
42 // composite key — rendered as a single PRIMARY KEY (col, col) constraint
43 // in sql.ts so Postgres treats them as one key.
44 const primaryKeys = Object.values(columns).filter((c) => c.primaryKey);
45 if (primaryKeys.length === 0) {
46 throw new Error('table requires at least one primaryKey() column');
47 }
48
49 const indexes: IndexDef[] = (normalised.indexes ?? []).map((idx) => {
50 for (const col of idx.columns) {
51 if (!(col in columns)) {
52 throw new Error(`index references unknown column '${col}'`);
53 }
54 }
55 return { columns: [...idx.columns], unique: idx.unique ?? false };
56 });
57
58 return Object.freeze({
59 columns: Object.freeze(columns),
60 indexes: Object.freeze(indexes),
61 access: normalised.access,
62 });
63}
64
65function isTableInput(v: unknown): v is TableInput {
66 return (
67 typeof v === 'object' &&
68 v !== null &&
69 'columns' in v &&
70 typeof (v as { columns: unknown }).columns === 'object'
71 );
72}
73
74const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
75
76/**
77 * Permissive identifier check — allows camelCase and snake_case so authors
78 * can stay idiomatic in TypeScript while also matching CLAUDE.md §6.1's
79 * snake_case recommendation where they prefer. The SQL renderer quotes
80 * identifiers exactly as given.
81 */
82export function isIdentifier(s: string): boolean {
83 return IDENTIFIER_RE.test(s);
84}