@everystack/cli 0.2.39 → 0.3.0

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,439 @@
1
+ /**
2
+ * The Model -> table DDL compiler — the data slice of the one generator.
3
+ *
4
+ * The counterpart to authz-compile.ts: that turns `abilities` into RLS/grants;
5
+ * this turns `fields` into `CREATE TABLE`. Both feed the single migration the
6
+ * generator emits (tables first, then the authz that depends on them).
7
+ *
8
+ * It is the field bridge's generation half (architecture Layer 1): one `field()`
9
+ * carries the pg-precise type + the modifiers (NOT NULL, default, primary key,
10
+ * unique), and this emits the column DDL from them. drizzle-kit's generation job,
11
+ * owned. The Zod marriage (CHECK constraints from `.validate()` refinements, the
12
+ * runtime schema, the inferred TS type) is the next increment; this is structure.
13
+ *
14
+ * Output matches the clean drizzle DDL shape the example already ships, so a
15
+ * brownfield app's tables regenerate byte-for-byte. FK constraints are emitted
16
+ * separately by the generator (they must follow every table), so `.references()`
17
+ * is not rendered inline here.
18
+ *
19
+ * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
20
+ */
21
+
22
+ import type { ModelDescriptor, FieldSpec } from '@everystack/model';
23
+ import type { TableSchema, EnumType, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
24
+ import type { RenameMap } from './schema-diff.js';
25
+
26
+ /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
27
+ function toSnakeCase(name: string): string {
28
+ return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
29
+ }
30
+
31
+ /**
32
+ * The table-level constraints a Model's `constraints: [...]` block declares — composite
33
+ * `unique(...)` and multi-column `check(...)`, folded into the structured form both
34
+ * `compileTableSchema` (the diff IR) and `compileCreateTable` (the DDL) render from, so the
35
+ * two never disagree. Field keys are snake_cased to SQL columns; names are deterministic
36
+ * (a composite unique by its columns, a check by its position) so they round-trip.
37
+ */
38
+ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
39
+ const uniques: UniqueConstraint[] = [];
40
+ const checks: CheckConstraint[] = [];
41
+ const indexes: IndexSchema[] = [];
42
+ let checkIndex = 0;
43
+ for (const con of model.constraints) {
44
+ if (con.kind === 'unique') {
45
+ const cols = con.columns.map(toSnakeCase);
46
+ uniques.push({ name: `${model.table}_${cols.join('_')}_unique`, columns: cols });
47
+ } else if (con.kind === 'check') {
48
+ checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
49
+ } else if (con.kind === 'index') {
50
+ const cols = con.columns.map(toSnakeCase);
51
+ indexes.push({
52
+ name: `${model.table}_${cols.join('_')}_index`,
53
+ columns: cols,
54
+ unique: con.isUnique,
55
+ ...(con.predicate ? { where: con.predicate } : {}),
56
+ });
57
+ }
58
+ }
59
+ return { uniques, checks, indexes };
60
+ }
61
+
62
+ /** Postgres type for a field, with the precision drizzle-kit would emit. */
63
+ const PG_TYPE: Record<string, string> = {
64
+ text: 'text',
65
+ uuid: 'uuid',
66
+ integer: 'integer',
67
+ boolean: 'boolean',
68
+ jsonb: 'jsonb',
69
+ bigint: 'bigint',
70
+ bigserial: 'bigserial',
71
+ timestamptz: 'timestamp with time zone',
72
+ // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
73
+ // introspection compares against it — so the compiler must emit the same, or an
74
+ // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
75
+ timestamp: 'timestamp without time zone',
76
+ date: 'date',
77
+ };
78
+
79
+ function pgType(spec: FieldSpec): string {
80
+ // Parameterized types build their own canonical string — exactly as `format_type`
81
+ // spells it (no space after the comma; `varchar` is `character varying`; a
82
+ // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
83
+ if (spec.type === 'numeric') {
84
+ if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
85
+ return 'numeric';
86
+ }
87
+ if (spec.type === 'varchar') {
88
+ return spec.length != null ? `character varying(${spec.length})` : 'character varying';
89
+ }
90
+ if (spec.type === 'enum') {
91
+ // The column's type is the enum's own type name; `format_type` reports the same bare
92
+ // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
93
+ if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
94
+ return spec.enumName;
95
+ }
96
+ const t = PG_TYPE[spec.type];
97
+ if (!t) {
98
+ // geometry needs PostGIS — a deliberate follow-up, not a silent wrong column.
99
+ throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
100
+ }
101
+ return t;
102
+ }
103
+
104
+ /**
105
+ * The FK referential actions declared on a field or a table-level `foreignKey(...)`, dropping
106
+ * the Postgres default (`no action`, which `pg_get_constraintdef` also omits) so the desired
107
+ * side matches what introspection reports — otherwise an unchanged FK would read as drift.
108
+ */
109
+ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?: string; onUpdate?: string } {
110
+ const out: { onDelete?: string; onUpdate?: string } = {};
111
+ if (spec.onDelete && spec.onDelete !== 'no action') out.onDelete = spec.onDelete;
112
+ if (spec.onUpdate && spec.onUpdate !== 'no action') out.onUpdate = spec.onUpdate;
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Every foreign key a model declares — the single source both the structured IR
118
+ * (`compileTableSchema`) and the greenfield DDL (`foreignKeySql`) read from, so they never
119
+ * disagree. Single-column FKs come from each field's `.references()`; composite FKs come from
120
+ * the table-level `foreignKey([...], () => Other, [...])` constraint. Both name themselves the
121
+ * way drizzle does — `<table>_<cols>_<target>_<refcols>_fk` — so a brownfield FK round-trips.
122
+ */
123
+ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
124
+ const out: ForeignKey[] = [];
125
+
126
+ for (const [name, field] of Object.entries(model.fields)) {
127
+ const ref = field.spec.references;
128
+ if (!ref) continue;
129
+ const target = ref() as ModelDescriptor;
130
+ const col = toSnakeCase(name);
131
+ const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
132
+ out.push({
133
+ name: `${model.table}_${col}_${target.table}_${targetPk}_fk`,
134
+ columns: [col],
135
+ refTable: target.table,
136
+ refColumns: [targetPk],
137
+ ...refActions(field.spec),
138
+ });
139
+ }
140
+
141
+ for (const con of model.constraints) {
142
+ if (con.kind !== 'foreignKey') continue;
143
+ const target = con.references() as ModelDescriptor;
144
+ const cols = con.columns.map(toSnakeCase);
145
+ const refCols = con.refColumns.map(toSnakeCase);
146
+ out.push({
147
+ name: `${model.table}_${cols.join('_')}_${target.table}_${refCols.join('_')}_fk`,
148
+ columns: cols,
149
+ refTable: target.table,
150
+ refColumns: refCols,
151
+ ...refActions(con),
152
+ });
153
+ }
154
+
155
+ return out;
156
+ }
157
+
158
+ /** The ` ON DELETE … ON UPDATE …` clause for a FK, or '' when both are the default. */
159
+ function refActionSql(fk: { onDelete?: string; onUpdate?: string }): string {
160
+ let s = '';
161
+ if (fk.onDelete) s += ` ON DELETE ${fk.onDelete.toUpperCase()}`;
162
+ if (fk.onUpdate) s += ` ON UPDATE ${fk.onUpdate.toUpperCase()}`;
163
+ return s;
164
+ }
165
+
166
+ /** A column's DEFAULT expression, or null when it has none. */
167
+ function defaultExpr(spec: FieldSpec): string | null {
168
+ switch (spec.defaultKind) {
169
+ case 'now': return 'now()';
170
+ case 'random': return 'gen_random_uuid()';
171
+ case 'value': return literal(spec.default);
172
+ default: return null;
173
+ }
174
+ }
175
+
176
+ function literal(value: unknown): string {
177
+ if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
178
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
179
+ return String(value);
180
+ }
181
+
182
+ /**
183
+ * One column definition (no leading indent). Clause order matches drizzle's:
184
+ * type, PRIMARY KEY (single-column only), DEFAULT, NOT NULL. `.references()`,
185
+ * `.unique()`, and composite PKs are table-level, emitted by compileCreateTable.
186
+ */
187
+ export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
188
+ let s = `"${sqlName}" ${pgType(spec)}`;
189
+ if (inlinePrimaryKey) s += ' PRIMARY KEY';
190
+ const def = defaultExpr(spec);
191
+ if (def) s += ` DEFAULT ${def}`;
192
+ if (spec.isNotNull) s += ' NOT NULL';
193
+ return s;
194
+ }
195
+
196
+ // --- CHECK constraints from .validate() Zod refinements --------------------
197
+
198
+ interface ZodDefLike {
199
+ typeName?: string;
200
+ checks?: Array<{ kind: string; value?: unknown; inclusive?: boolean }>;
201
+ values?: readonly string[];
202
+ innerType?: { _def?: ZodDefLike };
203
+ }
204
+
205
+ function zodDef(schema: unknown): ZodDefLike | null {
206
+ const d = (schema as { _def?: unknown } | null | undefined)?._def;
207
+ return d && typeof d === 'object' ? (d as ZodDefLike) : null;
208
+ }
209
+
210
+ /** Unwrap ZodOptional / ZodNullable / ZodDefault to the base type. */
211
+ function unwrapZod(def: ZodDefLike): ZodDefLike {
212
+ let cur = def;
213
+ while (cur.innerType?._def) cur = cur.innerType._def;
214
+ return cur;
215
+ }
216
+
217
+ /**
218
+ * The SQL CHECK predicate for a field's `.validate()` schema, or null when none
219
+ * of its refinements are SQL-expressible.
220
+ *
221
+ * The two-paths split (the field bridge's point): `min`/`max`/`length`/`enum`/
222
+ * range become a DB CHECK — the backstop that covers the SSR-direct write path,
223
+ * which never runs the app-layer Zod. Rich refinements (regex, email, cross-field
224
+ * `.refine()`) are NOT SQL-expressible and stay app-layer Zod only; they are
225
+ * intentionally skipped here, never half-emitted.
226
+ *
227
+ * Reads Zod's internal `_def` (pinned to zod 3.x — the same coupling drizzle-zod
228
+ * accepts). An unrecognized shape yields null, never a wrong constraint.
229
+ */
230
+ export function fieldCheckPredicate(sqlName: string, spec: FieldSpec): string | null {
231
+ const top = zodDef(spec.validation);
232
+ if (!top) return null;
233
+ const def = unwrapZod(top);
234
+
235
+ if (def.typeName === 'ZodString') {
236
+ let min: number | undefined;
237
+ let max: number | undefined;
238
+ let exact: number | undefined;
239
+ for (const c of def.checks ?? []) {
240
+ if (c.kind === 'min') min = c.value as number;
241
+ else if (c.kind === 'max') max = c.value as number;
242
+ else if (c.kind === 'length') exact = c.value as number;
243
+ }
244
+ if (exact != null) return `char_length(${sqlName}) = ${exact}`;
245
+ if (min != null && max != null) return `char_length(${sqlName}) BETWEEN ${min} AND ${max}`;
246
+ if (min != null) return `char_length(${sqlName}) >= ${min}`;
247
+ if (max != null) return `char_length(${sqlName}) <= ${max}`;
248
+ return null;
249
+ }
250
+
251
+ if (def.typeName === 'ZodNumber') {
252
+ let min: number | undefined;
253
+ let max: number | undefined;
254
+ let minIncl = true;
255
+ let maxIncl = true;
256
+ for (const c of def.checks ?? []) {
257
+ if (c.kind === 'min') { min = c.value as number; minIncl = c.inclusive !== false; }
258
+ else if (c.kind === 'max') { max = c.value as number; maxIncl = c.inclusive !== false; }
259
+ }
260
+ if (min != null && max != null && minIncl && maxIncl) return `${sqlName} BETWEEN ${min} AND ${max}`;
261
+ const parts: string[] = [];
262
+ if (min != null) parts.push(`${sqlName} ${minIncl ? '>=' : '>'} ${min}`);
263
+ if (max != null) parts.push(`${sqlName} ${maxIncl ? '<=' : '<'} ${max}`);
264
+ return parts.length ? parts.join(' AND ') : null;
265
+ }
266
+
267
+ if (def.typeName === 'ZodEnum') {
268
+ const vals = def.values ?? [];
269
+ if (!vals.length) return null;
270
+ return `${sqlName} IN (${vals.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(', ')})`;
271
+ }
272
+
273
+ return null;
274
+ }
275
+
276
+ export interface CompileTableOptions {
277
+ /** Indent for column/constraint lines. Default: a tab (matches drizzle's output). */
278
+ indent?: string;
279
+ }
280
+
281
+ /**
282
+ * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
283
+ * composite keys and uniques become table constraints, named the way drizzle
284
+ * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`).
285
+ */
286
+ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
287
+ const indent = opts.indent ?? '\t';
288
+ const entries = Object.entries(model.fields);
289
+ const pkFields = model.primaryKey;
290
+ const singlePk = pkFields.length === 1 ? pkFields[0] : null;
291
+
292
+ const lines: string[] = [];
293
+ for (const [name, field] of entries) {
294
+ lines.push(indent + fieldColumnSql(toSnakeCase(name), field.spec, name === singlePk));
295
+ }
296
+
297
+ if (pkFields.length > 1) {
298
+ const cols = pkFields.map(toSnakeCase);
299
+ const quoted = cols.map((c) => `"${c}"`).join(',');
300
+ lines.push(`${indent}CONSTRAINT "${model.table}_${cols.join('_')}_pk" PRIMARY KEY(${quoted})`);
301
+ }
302
+
303
+ for (const [name, field] of entries) {
304
+ if (field.spec.isUnique) {
305
+ const col = toSnakeCase(name);
306
+ lines.push(`${indent}CONSTRAINT "${model.table}_${col}_unique" UNIQUE("${col}")`);
307
+ }
308
+ }
309
+
310
+ for (const [name, field] of entries) {
311
+ const col = toSnakeCase(name);
312
+ const pred = fieldCheckPredicate(col, field.spec);
313
+ if (pred) lines.push(`${indent}CONSTRAINT "${model.table}_${col}_check" CHECK (${pred})`);
314
+ }
315
+
316
+ // Table-level constraints (composite unique, multi-column check) after the field-derived ones.
317
+ const tableConstraints = modelConstraintSpecs(model);
318
+ for (const u of tableConstraints.uniques) {
319
+ lines.push(`${indent}CONSTRAINT "${u.name}" UNIQUE(${u.columns.map((c) => `"${c}"`).join(',')})`);
320
+ }
321
+ for (const ck of tableConstraints.checks) {
322
+ lines.push(`${indent}CONSTRAINT "${ck.name}" CHECK (${ck.expr})`);
323
+ }
324
+
325
+ return `CREATE TABLE "${model.table}" (\n${lines.join(',\n')}\n);`;
326
+ }
327
+
328
+ /**
329
+ * Compile a model's `fields` into the structured TableSchema — the same shape
330
+ * whole-DB introspection produces. This is the data layer's second producer (the
331
+ * counterpart to compileTableContract for authz): the Model and the live database
332
+ * meet at this IR, so `db:generate` can diff them. Types come from `format_type`'s
333
+ * vocabulary on both sides, so they compare.
334
+ *
335
+ * (compileCreateTable renders SQL directly for the greenfield emit; this is the
336
+ * structured form for the brownfield diff. They share the field→type/default/check
337
+ * helpers, so they never disagree.)
338
+ */
339
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
340
+ const schema = opts.schema ?? 'public';
341
+ const entries = Object.entries(model.fields);
342
+
343
+ const columns = entries.map(([name, field]) => ({
344
+ name: toSnakeCase(name),
345
+ type: pgType(field.spec),
346
+ notNull: field.spec.isNotNull,
347
+ default: defaultExpr(field.spec),
348
+ }));
349
+
350
+ const tableConstraints = modelConstraintSpecs(model);
351
+
352
+ const fieldUniques = entries
353
+ .filter(([, field]) => field.spec.isUnique)
354
+ .map(([name]) => {
355
+ const col = toSnakeCase(name);
356
+ return { name: `${model.table}_${col}_unique`, columns: [col] };
357
+ });
358
+ const uniques = [...fieldUniques, ...tableConstraints.uniques];
359
+
360
+ const fieldChecks = entries.flatMap(([name, field]) => {
361
+ const col = toSnakeCase(name);
362
+ const pred = fieldCheckPredicate(col, field.spec);
363
+ return pred ? [{ name: `${model.table}_${col}_check`, expr: pred }] : [];
364
+ });
365
+ const checks = [...fieldChecks, ...tableConstraints.checks];
366
+
367
+ return {
368
+ table: `${schema}.${model.table}`,
369
+ columns,
370
+ primaryKey: model.primaryKey.map(toSnakeCase),
371
+ uniques,
372
+ checks,
373
+ foreignKeys: modelForeignKeys(model),
374
+ indexes: tableConstraints.indexes,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * The enum types a set of Models declares — the desired-side counterpart to the
380
+ * `enums` whole-DB introspection folds. Collected across every `field.enum(name, values)`,
381
+ * de-duplicated by name (the same enum may back columns on several tables), sorted to
382
+ * match `assembleSchema`'s order so the two sides diff. A name reused with *different*
383
+ * values is a model error surfaced here, not a silently-merged type.
384
+ */
385
+ export function compileEnums(models: ModelDescriptor[]): EnumType[] {
386
+ const byName = new Map<string, EnumType>();
387
+ for (const model of models) {
388
+ for (const field of Object.values(model.fields)) {
389
+ const { type, enumName, enumValues } = field.spec;
390
+ if (type !== 'enum' || !enumName) continue;
391
+ const values = [...(enumValues ?? [])];
392
+ const existing = byName.get(enumName);
393
+ if (existing && existing.values.join(',') !== values.join(',')) {
394
+ throw new Error(`enum '${enumName}' is declared with conflicting values: [${existing.values}] vs [${values}]`);
395
+ }
396
+ if (!existing) byName.set(enumName, { name: enumName, values });
397
+ }
398
+ }
399
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
400
+ }
401
+
402
+ /**
403
+ * Foreign-key constraints for a model — its `.references()` fields and its table-level
404
+ * `foreignKey(...)` constraints, via `modelForeignKeys` — as `ALTER TABLE` statements. These
405
+ * are emitted *after* every `CREATE TABLE` (a FK's target table must already exist), so they
406
+ * are separate from the table DDL — which is why FKs are not rendered inline by
407
+ * compileCreateTable. Named the way drizzle names them: `<table>_<cols>_<target>_<refcols>_fk`.
408
+ */
409
+ export function foreignKeySql(model: ModelDescriptor): string[] {
410
+ return modelForeignKeys(model).map((fk) => {
411
+ const cols = fk.columns.map((c) => `"${c}"`).join(', ');
412
+ const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
413
+ return (
414
+ `ALTER TABLE "${model.table}" ADD CONSTRAINT "${fk.name}" ` +
415
+ `FOREIGN KEY (${cols}) REFERENCES "${fk.refTable}"(${refCols})${refActionSql(fk)};`
416
+ );
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Collect the `renamedFrom` markers off a set of Models into a rename map keyed the same
422
+ * way the compiled snapshot keys tables (`public.posts`). This is the rename *intent*,
423
+ * carried separately from the TableSchema IR — introspection cannot produce it (the
424
+ * database has no rename history), so it never belongs on a column. `diffSchema` reads
425
+ * this map to turn a drop+add into a `RENAME COLUMN`.
426
+ */
427
+ export function compileRenames(models: ModelDescriptor[], opts: { schema?: string } = {}): RenameMap {
428
+ const schema = opts.schema ?? 'public';
429
+ const map: RenameMap = {};
430
+ for (const model of models) {
431
+ for (const [name, field] of Object.entries(model.fields)) {
432
+ const from = field.spec.renamedFrom;
433
+ if (!from) continue;
434
+ const table = `${schema}.${model.table}`;
435
+ (map[table] ??= {})[toSnakeCase(name)] = toSnakeCase(from);
436
+ }
437
+ }
438
+ return map;
439
+ }