@everystack/cli 0.2.39 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The one generator — compose the Model into a single ordered migration.
3
+ *
4
+ * This is the unified-migration thesis made concrete: a set of Models compiles to
5
+ * ONE ordered list of SQL statements covering *both* layers, in dependency order:
6
+ *
7
+ * 1. CREATE TABLE … (data — fields → DDL + CHECK)
8
+ * 2. ALTER TABLE … FOREIGN KEY (data — references, after every table exists)
9
+ * 3. ENABLE/FORCE RLS · policies · grants (authz — abilities, after the
10
+ * columns they reference exist)
11
+ *
12
+ * It is not two pipelines: the authz statements reference columns the DDL just
13
+ * created, so they live in the same ordered migration. This composes the four
14
+ * compilers already built — compileCreateTable + foreignKeySql (data) and
15
+ * compileTableContract + emitReconcileSql (authz) — with no new SQL emission.
16
+ *
17
+ * This is the greenfield form (empty database → the whole schema). The brownfield
18
+ * form diffs against an introspected snapshot instead of the empty baseline; that
19
+ * lands with whole-DB introspection. The emission is identical either way.
20
+ */
21
+
22
+ import type { ModelDescriptor, Module } from '@everystack/model';
23
+ import type { AuthzContract, TableContract } from './authz-contract.js';
24
+ import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
25
+ import { compileTableContract } from './authz-compile.js';
26
+ import { emitReconcileSql } from './authz-reconcile.js';
27
+ import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
28
+
29
+ /** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
30
+ function emptyTable(table: string): TableContract {
31
+ return { table, rls: { enabled: false, forced: false }, grants: {}, policies: [] };
32
+ }
33
+
34
+ /**
35
+ * Compile a set of Models into one ordered migration (greenfield: empty DB → full
36
+ * schema). Tables, then foreign keys, then authz — the order a fresh database must
37
+ * apply them in.
38
+ */
39
+ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
40
+ const sql: string[] = [];
41
+ const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
42
+
43
+ // 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
44
+ // before any table is created in it. public is the implicit default, never emitted.
45
+ const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
46
+ sql.push(...schemas.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}";`));
47
+
48
+ // The authz contracts — computed once, used for the schema USAGE grants here and the
49
+ // table-level reconcile below.
50
+ const contracts = models.map((m) => compileTableContract(m));
51
+
52
+ // 0a-bis. Schema USAGE — a role can't reach a table in a non-public schema without USAGE on
53
+ // it, so a table/column grant there is dead without this. Derived from the grants: every
54
+ // role that holds any privilege on a table in a non-public schema gets USAGE on that schema
55
+ // (public USAGE is granted to PUBLIC by default, so it is never emitted). This replaces the
56
+ // hand-written `GRANT USAGE ON SCHEMA auth/ops/dist …` the old bootstrap carried.
57
+ for (const schema of schemas) {
58
+ const roles = new Set<string>();
59
+ for (const c of contracts) {
60
+ if (!c.table.startsWith(`${schema}.`)) continue;
61
+ for (const r of Object.keys(c.grants)) roles.add(r);
62
+ for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
63
+ }
64
+ if (roles.size) {
65
+ const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
66
+ sql.push(`GRANT USAGE ON SCHEMA "${schema}" TO ${targets};`);
67
+ }
68
+ }
69
+
70
+ // 0b. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
71
+ const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
72
+ sql.push(...emitSchemaSql(enumCreates));
73
+
74
+ // 1. Tables (with columns, CHECKs, single/composite PK, uniques).
75
+ for (const model of models) sql.push(compileCreateTable(model, opts));
76
+
77
+ // 2. Foreign keys — after every table exists.
78
+ for (const model of models) sql.push(...foreignKeySql(model));
79
+
80
+ // 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
81
+ // schema-qualified table, so a non-public index targets the right table.
82
+ const indexCreates: SchemaChange[] = [];
83
+ for (const model of models) {
84
+ for (const ix of modelConstraintSpecs(model).indexes) {
85
+ indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
86
+ }
87
+ }
88
+ sql.push(...emitSchemaSql(indexCreates));
89
+
90
+ // 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
91
+ // the reconcile emitter against the empty baseline: every policy/grant is a
92
+ // pure creation, in the same dependency-correct order reconcile already uses.
93
+ // The baseline keys on each model's resolved schema, so a non-public table
94
+ // diffs against its own empty state, not a phantom public one.
95
+ const desired: AuthzContract = { tables: contracts, functions: [] };
96
+ const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`${schemaOf(m)}.${m.table}`)), functions: [] };
97
+ sql.push(...emitReconcileSql(desired, baseline));
98
+
99
+ return sql;
100
+ }
101
+
102
+ /**
103
+ * Compile a set of Modules into one COMPLETE greenfield migration — the whole deploy, no
104
+ * hand-written bootstrap left:
105
+ *
106
+ * 1. CREATE EXTENSION (deduped, sorted) — the bootstrap a package's SQL needs
107
+ * 2. compileMigration(flattened models) — schemas, tables, FKs, indexes, RLS, authz
108
+ * 3. package functions/triggers (each thunk) — imperative package SQL, AFTER the tables
109
+ *
110
+ * Roles are NOT emitted — they are cluster-global (`db:provision`), and the GRANTs in step 2
111
+ * assume they exist (as the dogfood pre-creates them). `compileMigration` is left untouched as
112
+ * the schema+authz core the contract round-trips through; the functions are emitted verbatim
113
+ * here, outside that round-trip (they are imperative, package-owned — not modeled).
114
+ */
115
+ export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
116
+ const sql: string[] = [];
117
+
118
+ // 1. Extensions — deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
119
+ const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
120
+ sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
121
+
122
+ // 2. Schema + authz for every modeled table.
123
+ sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
124
+
125
+ // 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
126
+ // output is a self-contained multi-statement block applied as one unit.
127
+ for (const fn of modules.flatMap((m) => m.functions)) {
128
+ const text = fn().trim();
129
+ if (text) sql.push(text);
130
+ }
131
+
132
+ return sql;
133
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `db:generate`'s pure core — Models + live snapshot → a migration file.
3
+ *
4
+ * The brownfield generator, composed from the pieces already built and proven:
5
+ *
6
+ * compileTableSchema + compileRenames (the desired side, from the Models)
7
+ * → diffSchema (vs the introspected `current`)
8
+ * → emitSchemaSql (the ALTER/CREATE/RENAME DDL)
9
+ *
10
+ * A whole *new* table renders through the greenfield `compileCreateTable` (byte-identical
11
+ * to what the app ships) rather than the IR renderer; its foreign keys still arrive as the
12
+ * diff's `addConstraint` changes, so they land after every table exists.
13
+ *
14
+ * Everything here is pure and deterministic — the clock (`when`) is injected, never read —
15
+ * so the command shell (commands/db-generate.ts) is a thin layer of IO over it.
16
+ */
17
+
18
+ import type { ModelDescriptor } from '@everystack/model';
19
+ import type { SchemaSnapshot } from './schema-introspect.js';
20
+ import type { AuthzContract } from './authz-contract.js';
21
+ import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
22
+ import { compileTableContract } from './authz-compile.js';
23
+ import { emitReconcileSql } from './authz-reconcile.js';
24
+ import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
25
+
26
+ export interface GenerateOptions {
27
+ /** The Postgres schema the Models live in. Default: `public`. */
28
+ schema?: string;
29
+ /**
30
+ * Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
31
+ * database but absent from the Models. Default `false` — those are held back as
32
+ * commented notices (see {@link HELD_DROP_PREFIX}), so a partial or lossy Model set
33
+ * never silently loses data. Destruction needs intent, the same rule renames are built on.
34
+ */
35
+ allowDrops?: boolean;
36
+ /**
37
+ * The live authorization contract (`introspectContract`). When provided, the migration
38
+ * carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
39
+ * diffed against this live state, appended after the data phase as one ordered file (the
40
+ * unified-migration thesis: declare once, generate both layers). Omitted → data-only, the
41
+ * backward-compatible default. Authz drops (`DROP POLICY`/`REVOKE`) are data-safe, so they
42
+ * are emitted regardless of `allowDrops` — `allowDrops` gates only data destruction.
43
+ */
44
+ liveAuthz?: AuthzContract;
45
+ }
46
+
47
+ /** The comment prefix a held-back destructive change carries (the command greps it to count them). */
48
+ export const HELD_DROP_PREFIX = '-- DROP held back';
49
+
50
+ /**
51
+ * A destructive change that removes data or an integrity guard with no re-creation —
52
+ * the set held back unless `allowDrops`. A constraint *replace* (a `dropConstraint`
53
+ * paired with an `addConstraint` of the same name) is NOT a removal: it loses no data
54
+ * and its add depends on the drop, so it is kept intact.
55
+ */
56
+ function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
57
+ if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
58
+ if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
59
+ return false;
60
+ }
61
+
62
+ /** Comment out a destructive statement, showing the exact DDL to run by hand or with `--allow-drops`. */
63
+ function holdDrop(sql: string): string {
64
+ return `${HELD_DROP_PREFIX} (destructive — re-run db:generate with --allow-drops to emit it, or run it by hand):\n-- ${sql}`;
65
+ }
66
+
67
+ /**
68
+ * Tables present in the live database that no model declares. `db:generate` leaves these
69
+ * untouched — they belong to another concern (auth, jobs, observability all ship their own
70
+ * migrations), and dropping a table must be deliberate, never a side effect of a missing
71
+ * model. The command reports the count so the omission is visible, not silent.
72
+ */
73
+ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
74
+ const schema = opts.schema ?? 'public';
75
+ const declared = new Set(models.map((m) => `${schema}.${m.table}`));
76
+ return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
77
+ }
78
+
79
+ /**
80
+ * Compile the Models into the ordered SQL that evolves `current` to match them. Empty when
81
+ * the database already matches (the no-op generate).
82
+ *
83
+ * Scoped to the tables the models DECLARE: the diff never sees a table no model owns, so it
84
+ * cannot propose dropping one. db:generate manages your declared schema; everything else in
85
+ * the database (auth, jobs, observability) is left to its own migrations.
86
+ */
87
+ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
88
+ const schema = opts.schema ?? 'public';
89
+ // A table's schema comes from the model first (package tables live in dist/ops/auth),
90
+ // then the call default — so one run spans schemas.
91
+ const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
92
+ const desiredEnums = compileEnums(models);
93
+ const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
94
+ const declaredTables = new Set(desired.tables.map((t) => t.table));
95
+ const declaredEnums = new Set(desiredEnums.map((e) => e.name));
96
+ // Scope both tables and enums to what the Models declare — an undeclared table OR enum
97
+ // (auth, jobs, observability ship their own) is invisible to the diff, so it is never dropped.
98
+ const scopedCurrent: SchemaSnapshot = {
99
+ tables: current.tables.filter((t) => declaredTables.has(t.table)),
100
+ enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
101
+ };
102
+ const renames = compileRenames(models, { schema });
103
+ const changes = diffSchema(desired, scopedCurrent, renames);
104
+
105
+ const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
106
+ const emitted = emitSchemaSql(changes, {
107
+ createTable: (change) => {
108
+ const model = modelByTable.get(change.table);
109
+ if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
110
+ return compileCreateTable(model);
111
+ },
112
+ });
113
+
114
+ // Non-destructive by default: a column/constraint/table in the database but absent
115
+ // from the Models would otherwise become a DROP — silent data loss for a partial or
116
+ // lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
117
+ // returns one statement per change, index-aligned, so the gate stays a pure
118
+ // post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
119
+ let dataPhase = emitted;
120
+ if (!opts.allowDrops) {
121
+ const replacedConstraints = new Set(
122
+ changes
123
+ .filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
124
+ .map((c) => `${c.table}::${c.constraint.name}`),
125
+ );
126
+ dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
127
+ }
128
+
129
+ // Authz phase — appended after the data phase so a policy/grant references columns the
130
+ // data DDL just created. Emitted only when the live contract is supplied; the Models'
131
+ // `abilities` compile to the same contract shape introspection reads, so the two diff.
132
+ // Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
133
+ const authzPhase = opts.liveAuthz
134
+ ? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, opts.liveAuthz)
135
+ : [];
136
+
137
+ return [...dataPhase, ...authzPhase];
138
+ }
139
+
140
+ /** The marker drizzle migration files put between statements. */
141
+ const BREAKPOINT = '\n--> statement-breakpoint\n';
142
+
143
+ /** Join statements into a drizzle-compatible migration file body (trailing newline). */
144
+ export function formatMigrationFile(statements: string[]): string {
145
+ return statements.join(BREAKPOINT) + '\n';
146
+ }
147
+
148
+ export interface JournalEntry {
149
+ idx: number;
150
+ version: string;
151
+ when: number;
152
+ tag: string;
153
+ breakpoints: boolean;
154
+ }
155
+
156
+ export interface Journal {
157
+ version: string;
158
+ dialect: string;
159
+ entries: JournalEntry[];
160
+ }
161
+
162
+ /** Sanitize a migration name into the snake_case the `NNNN_<tag>.sql` convention uses. */
163
+ function sanitizeName(name: string): string {
164
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'generated';
165
+ }
166
+
167
+ /**
168
+ * Plan the next migration file: its tag, filename, and the journal with the new entry
169
+ * appended. Pure — `when` is passed in (the command stamps it with the clock), so the same
170
+ * inputs always produce the same plan. The next index is one past the highest existing
171
+ * `idx`, matching how the runner and drizzle order migrations.
172
+ */
173
+ export function planMigrationFile(journal: Journal, name: string, when: number): { tag: string; filename: string; journal: Journal } {
174
+ const idx = journal.entries.reduce((max, e) => Math.max(max, e.idx + 1), 0);
175
+ const tag = `${String(idx).padStart(4, '0')}_${sanitizeName(name)}`;
176
+ const entry: JournalEntry = { idx, version: journal.version || '7', when, tag, breakpoints: true };
177
+ return { tag, filename: `${tag}.sql`, journal: { ...journal, entries: [...journal.entries, entry] } };
178
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@everystack/cli/model` — the programmatic surface of the v3 Model generator and red-team.
3
+ *
4
+ * Everything the CLI commands do (`db:generate`, `db:pull`, `db:authz:*`) is pure logic over a
5
+ * `QueryRunner`; this barrel exposes it so a consumer can generate and VERIFY their schema in
6
+ * their own tests/CI without shelling out to the CLI. The reference app dogfoods exactly this
7
+ * surface — its real Models are round-tripped through `compileMigration` / `generateMigrationSql`
8
+ * and red-teamed with the owner probe, against a real Postgres, proving the generator on a real app.
9
+ *
10
+ * The two entry shapes:
11
+ * - GENERATE: `compileMigration(models)` (greenfield) or `generateMigrationSql(models, snapshot,
12
+ * { liveAuthz })` (brownfield, both layers) → ordered SQL.
13
+ * - VERIFY: `introspectSchema(run)` / `introspectContract(run, …)` → diff against the Models, and
14
+ * `buildOwnerProbeSql` / `evaluateOwnerProbe` for behavioural owner-isolation checks.
15
+ */
16
+
17
+ // --- data layer: compile, introspect, diff, render -------------------------
18
+ export * from './schema-compile.js';
19
+ export * from './schema-introspect.js';
20
+ export * from './schema-diff.js';
21
+ export * from './schema-source.js';
22
+ export * from './model-render.js';
23
+ export * from './migration-compile.js';
24
+ export * from './migration-generate.js';
25
+
26
+ // --- authz layer: contract compile/introspect/diff/reconcile ---------------
27
+ export * from './authz-compile.js';
28
+ export * from './authz-contract.js';
29
+ export * from './authz-reconcile.js';
30
+
31
+ // --- red-team: grant enforcement + owner isolation -------------------------
32
+ export * from './authz-redteam.js';
33
+ export * from './authz-owner-probe.js';
34
+
35
+ // --- the SECDEF function catalog the contract introspection needs -----------
36
+ export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
@@ -0,0 +1,262 @@
1
+ /**
2
+ * `db:pull`'s pure core — an introspected SchemaSnapshot back into `field()` model source.
3
+ *
4
+ * The inverse of compileTableSchema: that turns a Model into the structured schema; this
5
+ * turns the structured schema (read from a live database) into the TypeScript a Model is
6
+ * written in. The brownfield on-ramp — point at an existing database, get reviewable Models
7
+ * to start from.
8
+ *
9
+ * It renders what the field builder can express today (type, primary key, not-null,
10
+ * single-column unique, single-column references, the common defaults) and SURFACES what it
11
+ * can't as inline comments rather than faking it: an unmapped column type, a composite
12
+ * unique, a CHECK that needs a manual `.validate()`, a foreign key to a table outside the
13
+ * pulled set. Reversing a CHECK back to its Zod refinement is a later increment.
14
+ *
15
+ * Pure and deterministic — the command shell (commands/db-pull.ts) does the IO.
16
+ */
17
+
18
+ import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
19
+ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
20
+
21
+ /**
22
+ * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
23
+ * `fieldCheckPredicate`. Only the stable, SQL-expressible shapes that compiler emits round-trip;
24
+ * anything else returns null and stays a raw `check(sql\`…\`)`. Returns the single column the
25
+ * check is on and the Zod expression, so the renderer can hang `.validate()` on that field.
26
+ */
27
+ export function checkToValidate(expr: string): { column: string; zod: string } | null {
28
+ const s = normalizeCheck(expr);
29
+ let m: RegExpMatchArray | null;
30
+
31
+ if ((m = s.match(/^char_length\((\w+)\) = (\d+)$/))) return { column: m[1], zod: `z.string().length(${m[2]})` };
32
+ if ((m = s.match(/^char_length\((\w+)\) BETWEEN (\d+) AND (\d+)$/))) return { column: m[1], zod: `z.string().min(${m[2]}).max(${m[3]})` };
33
+ if ((m = s.match(/^char_length\((\w+)\) >= (\d+)$/))) return { column: m[1], zod: `z.string().min(${m[2]})` };
34
+ if ((m = s.match(/^char_length\((\w+)\) <= (\d+)$/))) return { column: m[1], zod: `z.string().max(${m[2]})` };
35
+
36
+ if ((m = s.match(/^(\w+) BETWEEN (-?\d+(?:\.\d+)?) AND (-?\d+(?:\.\d+)?)$/))) return { column: m[1], zod: `z.number().min(${m[2]}).max(${m[3]})` };
37
+
38
+ if ((m = s.match(/^(\w+) IN \((.+)\)$/))) {
39
+ const vals = m[2].split(',').map((v) => v.trim());
40
+ if (vals.length && vals.every((v) => /^'.*'$/.test(v))) {
41
+ const items = vals.map((v) => v.slice(1, -1).replace(/''/g, "'"));
42
+ return { column: m[1], zod: `z.enum([${items.map((i) => JSON.stringify(i)).join(', ')}])` };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // One or more numeric comparisons on the same column (`x >= 1`, `x > 1 AND x < 10`).
48
+ let column: string | null = null;
49
+ const chain: string[] = [];
50
+ for (const part of s.split(' AND ')) {
51
+ const c = part.match(/^(\w+) (>=|>|<=|<) (-?\d+(?:\.\d+)?)$/);
52
+ if (!c || (column && column !== c[1])) return null;
53
+ column = c[1];
54
+ chain.push(c[2] === '>=' ? `min(${c[3]})` : c[2] === '>' ? `gt(${c[3]})` : c[2] === '<=' ? `max(${c[3]})` : `lt(${c[3]})`);
55
+ }
56
+ return column ? { column, zod: `z.number().${chain.join('.')}` } : null;
57
+ }
58
+
59
+ export interface RenderOptions {
60
+ /** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
61
+ schema?: string;
62
+ }
63
+
64
+ /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
65
+ const FIELD_FACTORY: Record<string, string> = {
66
+ uuid: 'uuid',
67
+ text: 'text',
68
+ integer: 'integer',
69
+ bigint: 'bigint',
70
+ boolean: 'boolean',
71
+ jsonb: 'jsonb',
72
+ date: 'date',
73
+ 'timestamp with time zone': 'timestamptz',
74
+ 'timestamp without time zone': 'timestamp',
75
+ timestamp: 'timestamp',
76
+ };
77
+
78
+ /**
79
+ * The full `field.*()` call for a `format_type` string, or null when no field maps it.
80
+ * Handles the parameterized types `format_type` spells out — `numeric(p,s)` and
81
+ * `character varying(n)` — so a pulled column round-trips back to the same DDL. A
82
+ * scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
83
+ */
84
+ export function fieldFactoryCall(type: string): string | null {
85
+ const direct = FIELD_FACTORY[type];
86
+ if (direct) return `field.${direct}()`;
87
+
88
+ const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
89
+ if (num) {
90
+ if (num[1] == null) return 'field.numeric()';
91
+ return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
92
+ }
93
+
94
+ const vc = type.match(/^character varying(?:\((\d+)\))?$/);
95
+ if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
96
+
97
+ return null;
98
+ }
99
+
100
+ /** `public.image_variants` / `image_variants` → `image_variants` (the bare table name). */
101
+ function bareName(table: string): string {
102
+ return table.replace(/^[^.]+\./, '');
103
+ }
104
+
105
+ /** A table name → its model variable: PascalCase, naively singularized. `image_variants` → `ImageVariant`. */
106
+ export function modelVarName(table: string): string {
107
+ const bare = bareName(table);
108
+ const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
109
+ return singular.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
110
+ }
111
+
112
+ /** `author_id` → `authorId` (a SQL column to its camelCase field key — the inverse of the compiler's snake_case). */
113
+ function toCamelCase(name: string): string {
114
+ return name.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
115
+ }
116
+
117
+ /** The `.default*()` modifier for a column default, or null when the expression isn't one we map. */
118
+ function renderDefault(expr: string): string | null {
119
+ const n = normalizeDefault(expr);
120
+ if (n == null) return null;
121
+ if (n === 'now()') return '.defaultNow()';
122
+ if (n === 'gen_random_uuid()') return '.defaultRandom()';
123
+ const str = n.match(/^'([\s\S]*)'$/);
124
+ if (str) return `.default(${JSON.stringify(str[1].replace(/''/g, "'"))})`;
125
+ if (n === 'true' || n === 'false') return `.default(${n})`;
126
+ if (/^-?\d+(\.\d+)?$/.test(n)) return `.default(${n})`;
127
+ return null;
128
+ }
129
+
130
+ /** A single-quoted TS string literal (the model idiom), escaping embedded quotes. */
131
+ function tsLiteral(value: string): string {
132
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
133
+ }
134
+
135
+ /** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
136
+ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>): string {
137
+ // An enum column's type is the enum's name; render the explicit-name field.enum with its
138
+ // values (the always-explicit form round-trips the name and the value order).
139
+ const enumValues = enums.get(col.type);
140
+ const call = enumValues
141
+ ? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
142
+ : fieldFactoryCall(col.type);
143
+ let expr = call ?? 'field.text()';
144
+ let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
145
+
146
+ const isPk = table.primaryKey.includes(col.name);
147
+ if (isPk) expr += '.primaryKey()';
148
+ else if (col.notNull) expr += '.notNull()';
149
+
150
+ if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
151
+
152
+ const fk = table.foreignKeys.find((f) => f.columns.length === 1 && f.columns[0] === col.name);
153
+ if (fk) {
154
+ if (known.has(bareName(fk.refTable))) {
155
+ const actions = [
156
+ fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
157
+ fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
158
+ ].filter(Boolean);
159
+ const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
160
+ expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
161
+ } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
162
+ }
163
+
164
+ if (col.default != null) {
165
+ const d = renderDefault(col.default);
166
+ if (d) expr += d;
167
+ else comment += ` // TODO: unmapped default ${col.default}`;
168
+ }
169
+
170
+ const validate = validates.get(col.name);
171
+ if (validate) expr += `.validate(${validate})`;
172
+
173
+ return ` ${toCamelCase(col.name)}: ${expr},${comment}`;
174
+ }
175
+
176
+ /**
177
+ * The `constraints: [...]` block for a table's composite uniques, checks, indexes, and
178
+ * multi-column foreign keys — the table-level surface a column-scoped `field()` can't carry.
179
+ * Single-column uniques and FKs are rendered on the field (`.unique()` / `.references()`), so
180
+ * only composite ones land here. A composite FK whose target isn't in the pulled set is
181
+ * surfaced as a comment, not faked. Returns '' when there are none.
182
+ */
183
+ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string>): string {
184
+ const items: string[] = [];
185
+ for (const u of table.uniques) {
186
+ if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
187
+ }
188
+ for (const ck of checks) {
189
+ items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
190
+ }
191
+ for (const ix of table.indexes) {
192
+ let s = `index(${ix.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
193
+ if (ix.unique) s += '.unique()';
194
+ if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
195
+ items.push(s);
196
+ }
197
+ for (const fk of table.foreignKeys) {
198
+ if (fk.columns.length <= 1) continue; // single-column FKs are rendered on the field
199
+ const cols = `[${fk.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
200
+ const refCols = `[${fk.refColumns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
201
+ if (!known.has(bareName(fk.refTable))) {
202
+ items.push(`// FIXME: composite FK ${cols} → ${fk.refTable} ${refCols} (target not in the pulled set)`);
203
+ continue;
204
+ }
205
+ const actions = [
206
+ fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
207
+ fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
208
+ ].filter(Boolean);
209
+ const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
210
+ items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
211
+ }
212
+ if (!items.length) return '';
213
+ return `\n constraints: [\n${items.map((i) => ` ${i},`).join('\n')}\n ],`;
214
+ }
215
+
216
+ /** One `export const X = defineModel(...)` block for a table. */
217
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
218
+ // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
219
+ // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
220
+ const validates = new Map<string, string>();
221
+ const columnNames = new Set(table.columns.map((c) => c.name));
222
+ const tableChecks: CheckConstraint[] = [];
223
+ for (const ck of table.checks) {
224
+ const v = checkToValidate(ck.expr);
225
+ if (v && columnNames.has(v.column) && !validates.has(v.column)) validates.set(v.column, v.zod);
226
+ else tableChecks.push(ck);
227
+ }
228
+
229
+ const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
230
+ const constraints = renderConstraintsBlock(table, tableChecks, known);
231
+
232
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
233
+ }
234
+
235
+ /**
236
+ * Render a whole snapshot as a single Models module: the import, one block per table (scoped
237
+ * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
238
+ * come pre-sorted from assembleSchema, so the output is stable.
239
+ */
240
+ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
241
+ const schema = opts.schema ?? 'public';
242
+ const tables = snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`));
243
+ const known = new Set(tables.map((t) => bareName(t.table)));
244
+ const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
245
+
246
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums));
247
+ const body = blocks.join('\n\n');
248
+
249
+ // Import only what the rendered models actually use, so the file reads clean.
250
+ const symbols = ['defineModel', 'field'];
251
+ if (/\bunique\(/.test(body)) symbols.push('unique');
252
+ if (/\bcheck\(/.test(body)) symbols.push('check');
253
+ if (/\bindex\(/.test(body)) symbols.push('index');
254
+ if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
255
+ if (/\bsql`/.test(body)) symbols.push('sql');
256
+ const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
257
+ if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
258
+ const header = imports.join('\n');
259
+ const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
260
+
261
+ return [header, ...blocks, footer].join('\n\n') + '\n';
262
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Postgres identifier quoting for emitted DDL.
3
+ *
4
+ * A bare identifier that collides with a reserved keyword (`user`, `order`, `group`,
5
+ * `table`, …) is a syntax error: `GRANT … ON public.user` fails to parse. The data layer
6
+ * already quotes every table name unconditionally (`compileCreateTable` emits
7
+ * `CREATE TABLE "user"`); the authz layer emitted bare names and so broke on a reserved
8
+ * table name. This is the shared quoter that closes that gap.
9
+ *
10
+ * The rule mirrors what drizzle/pg_dump do: a simple lowercase identifier that is NOT a
11
+ * reserved word is left bare (so `public.posts` stays `public.posts` — the proven output is
12
+ * unchanged), and anything else — a reserved word, a mixed-case or special-character name,
13
+ * or an already-quoted token — is double-quoted. Quoting preserves the exact stored name,
14
+ * which is correct because that is the name the table was created under.
15
+ */
16
+
17
+ /**
18
+ * Postgres *reserved* keywords — the set that cannot appear as a table/column identifier
19
+ * without quoting (the "reserved" column of the keyword table; the "reserved (can be
20
+ * function or type)" words are usable as table names and are deliberately omitted, so we
21
+ * never over-quote). Lowercased for a case-insensitive lookup.
22
+ */
23
+ const RESERVED = new Set([
24
+ 'all', 'analyse', 'analyze', 'and', 'any', 'array', 'as', 'asc', 'asymmetric',
25
+ 'both', 'case', 'cast', 'check', 'collate', 'column', 'constraint', 'create',
26
+ 'current_catalog', 'current_date', 'current_role', 'current_time', 'current_timestamp',
27
+ 'current_user', 'default', 'deferrable', 'desc', 'distinct', 'do', 'else', 'end',
28
+ 'except', 'false', 'fetch', 'for', 'foreign', 'from', 'grant', 'group', 'having',
29
+ 'in', 'initially', 'intersect', 'into', 'lateral', 'leading', 'limit', 'localtime',
30
+ 'localtimestamp', 'not', 'null', 'offset', 'on', 'only', 'or', 'order', 'placing',
31
+ 'primary', 'references', 'returning', 'select', 'session_user', 'some', 'symmetric',
32
+ 'table', 'then', 'to', 'trailing', 'true', 'union', 'unique', 'user', 'using',
33
+ 'variadic', 'when', 'where', 'window', 'with',
34
+ ]);
35
+
36
+ /** A bare-safe identifier: a simple lowercase name that is not a reserved keyword. */
37
+ function isBareSafe(id: string): boolean {
38
+ return /^[a-z_][a-z0-9_]*$/.test(id) && !RESERVED.has(id);
39
+ }
40
+
41
+ /**
42
+ * Quote a single identifier when it needs it. A bare-safe name is returned unchanged
43
+ * (preserving the existing output for normal tables); an already-quoted token is passed
44
+ * through; anything else is double-quoted with embedded quotes doubled.
45
+ */
46
+ export function quoteIdent(id: string): string {
47
+ if (/^".*"$/.test(id)) return id;
48
+ if (isBareSafe(id)) return id;
49
+ return `"${id.replace(/"/g, '""')}"`;
50
+ }
51
+
52
+ /**
53
+ * Quote each part of a (possibly) schema-qualified name independently:
54
+ * `public.user` → `public."user"`, `public.posts` → `public.posts`. The contract's table
55
+ * names are `<schema>.<table>` with simple parts, so a plain dot split is exact.
56
+ */
57
+ export function quoteQualified(qualified: string): string {
58
+ return qualified.split('.').map(quoteIdent).join('.');
59
+ }