@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,47 @@
1
+ /**
2
+ * db:snapshot — the pure core for RDS *physical* snapshots (the thin `aws rds` wrapper).
3
+ *
4
+ * RDS snapshot identifiers are far stricter than our backup ids: 1–255 chars, must start with a
5
+ * letter, letters/digits/hyphens only, no two consecutive hyphens, no trailing hyphen, stored
6
+ * lowercase. (Underscores, dots, colons — all rejected by RDS.) So our `YYYYMMDDTHHMMSSZ` stamp
7
+ * can't be used verbatim; this module builds a guaranteed-valid identifier and a validator that
8
+ * pins the contract. No IO — the clock is passed in. See docs/plans/db-backup-restore.md.
9
+ */
10
+
11
+ /**
12
+ * RDS DBSnapshotIdentifier validity (per the AWS API reference):
13
+ * - 1 to 255 letters, numbers, or hyphens
14
+ * - first character must be a letter
15
+ * - can't end with a hyphen or contain two consecutive hyphens
16
+ */
17
+ const RDS_SNAPSHOT_ID = /^[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*$/;
18
+
19
+ /** Whether `id` satisfies RDS's DBSnapshotIdentifier constraints. */
20
+ export function isValidRdsSnapshotId(id: string): boolean {
21
+ return id.length >= 1 && id.length <= 255 && RDS_SNAPSHOT_ID.test(id);
22
+ }
23
+
24
+ /**
25
+ * Sanitize a stage name into the leading segment of an RDS identifier: lowercase, non-alphanumeric
26
+ * runs collapse to a single hyphen, and it is forced to start with a letter (a `db` prefix is
27
+ * prepended when the stage starts with a digit or is empty). Never emits a trailing hyphen.
28
+ */
29
+ function sanitizeStage(stage: string): string {
30
+ let s = stage.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
31
+ if (s === '' || !/^[a-z]/.test(s)) s = `db-${s}`.replace(/-+$/g, '');
32
+ return s;
33
+ }
34
+
35
+ /**
36
+ * Build a valid, deterministic RDS snapshot identifier from a stage and a `YYYYMMDDTHHMMSSZ` stamp.
37
+ * The stamp is lowercased (its `T`/`Z` become valid letters); the result is
38
+ * `<sanitized-stage>-<stamp>`, e.g. `dev-20260628t120000z`. Throws if — against expectation — the
39
+ * composed id is still invalid, so a malformed id can never reach the AWS API.
40
+ */
41
+ export function rdsSnapshotIdentifier(stage: string, stamp: string): string {
42
+ const id = `${sanitizeStage(stage)}-${stamp.toLowerCase()}`;
43
+ if (!isValidRdsSnapshotId(id)) {
44
+ throw new Error(`Could not build a valid RDS snapshot identifier from stage="${stage}", stamp="${stamp}" (got "${id}")`);
45
+ }
46
+ return id;
47
+ }
@@ -0,0 +1,496 @@
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
+ const usedIndexNames = new Set<string>();
44
+ for (const con of model.constraints) {
45
+ if (con.kind === 'unique') {
46
+ const cols = con.columns.map(toSnakeCase);
47
+ uniques.push({ name: `${model.table}_${cols.join('_')}_unique`, columns: cols });
48
+ } else if (con.kind === 'check') {
49
+ checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
50
+ } else if (con.kind === 'index') {
51
+ const cols = con.columns.map(toSnakeCase);
52
+ // Disambiguate multiple indexes over the same columns — e.g. a full index plus a
53
+ // partial one (`familyId` and `familyId WHERE status='active'`): the name must be
54
+ // unique within the table for CREATE to succeed. The diff matches indexes by content
55
+ // (columns + uniqueness + predicate), never by name, so the suffix never affects the
56
+ // round-trip. Declaration order is deterministic, so the name is stable across calls.
57
+ let name = `${model.table}_${cols.join('_')}_index`;
58
+ for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${cols.join('_')}_index_${n}`;
59
+ usedIndexNames.add(name);
60
+ indexes.push({
61
+ name,
62
+ columns: cols,
63
+ unique: con.isUnique,
64
+ ...(con.predicate ? { where: con.predicate } : {}),
65
+ });
66
+ }
67
+ }
68
+ return { uniques, checks, indexes };
69
+ }
70
+
71
+ /** Postgres type for a field, with the precision drizzle-kit would emit. */
72
+ const PG_TYPE: Record<string, string> = {
73
+ text: 'text',
74
+ uuid: 'uuid',
75
+ integer: 'integer',
76
+ boolean: 'boolean',
77
+ jsonb: 'jsonb',
78
+ bigint: 'bigint',
79
+ bigserial: 'bigserial',
80
+ timestamptz: 'timestamp with time zone',
81
+ // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
82
+ // introspection compares against it — so the compiler must emit the same, or an
83
+ // unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
84
+ timestamp: 'timestamp without time zone',
85
+ date: 'date',
86
+ };
87
+
88
+ function pgType(spec: FieldSpec): string {
89
+ // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
90
+ // `format_type` spells it.
91
+ return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
92
+ }
93
+
94
+ function scalarPgType(spec: FieldSpec): string {
95
+ // Parameterized types build their own canonical string — exactly as `format_type`
96
+ // spells it (no space after the comma; `varchar` is `character varying`; a
97
+ // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
98
+ if (spec.type === 'numeric') {
99
+ if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
100
+ return 'numeric';
101
+ }
102
+ if (spec.type === 'varchar') {
103
+ return spec.length != null ? `character varying(${spec.length})` : 'character varying';
104
+ }
105
+ if (spec.type === 'enum') {
106
+ // The column's type is the enum's own type name; `format_type` reports the same bare
107
+ // name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
108
+ if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
109
+ return spec.enumName;
110
+ }
111
+ const t = PG_TYPE[spec.type];
112
+ if (!t) {
113
+ // geometry needs PostGIS — a deliberate follow-up, not a silent wrong column.
114
+ throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
115
+ }
116
+ return t;
117
+ }
118
+
119
+ /**
120
+ * The FK referential actions declared on a field or a table-level `foreignKey(...)`, dropping
121
+ * the Postgres default (`no action`, which `pg_get_constraintdef` also omits) so the desired
122
+ * side matches what introspection reports — otherwise an unchanged FK would read as drift.
123
+ */
124
+ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?: string; onUpdate?: string } {
125
+ const out: { onDelete?: string; onUpdate?: string } = {};
126
+ if (spec.onDelete && spec.onDelete !== 'no action') out.onDelete = spec.onDelete;
127
+ if (spec.onUpdate && spec.onUpdate !== 'no action') out.onUpdate = spec.onUpdate;
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * Every foreign key a model declares — the single source both the structured IR
133
+ * (`compileTableSchema`) and the greenfield DDL (`foreignKeySql`) read from, so they never
134
+ * disagree. Single-column FKs come from each field's `.references()`; composite FKs come from
135
+ * the table-level `foreignKey([...], () => Other, [...])` constraint. Both name themselves the
136
+ * way drizzle does — `<table>_<cols>_<target>_<refcols>_fk` — so a brownfield FK round-trips.
137
+ */
138
+ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
139
+ const out: ForeignKey[] = [];
140
+
141
+ for (const [name, field] of Object.entries(model.fields)) {
142
+ const ref = field.spec.references;
143
+ if (!ref) continue;
144
+ const target = ref() as ModelDescriptor;
145
+ const col = toSnakeCase(name);
146
+ const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
147
+ out.push({
148
+ name: `${model.table}_${col}_${target.table}_${targetPk}_fk`,
149
+ columns: [col],
150
+ refTable: refTableName(target),
151
+ refColumns: [targetPk],
152
+ ...refActions(field.spec),
153
+ });
154
+ }
155
+
156
+ for (const con of model.constraints) {
157
+ if (con.kind !== 'foreignKey') continue;
158
+ const target = con.references() as ModelDescriptor;
159
+ const cols = con.columns.map(toSnakeCase);
160
+ const refCols = con.refColumns.map(toSnakeCase);
161
+ out.push({
162
+ name: `${model.table}_${cols.join('_')}_${target.table}_${refCols.join('_')}_fk`,
163
+ columns: cols,
164
+ refTable: refTableName(target),
165
+ refColumns: refCols,
166
+ ...refActions(con),
167
+ });
168
+ }
169
+
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * The referenced table's name as the FK should carry it — schema-qualified for a non-public
175
+ * target (`auth.users`), bare for a public one (`uploads`). A cross-schema FK (auth → auth, or
176
+ * public → auth) must name the schema, and `pg_get_constraintdef` deparses it qualified when the
177
+ * target schema is off the search_path — so the desired side must match, or an unchanged auth FK
178
+ * would read as drift. public stays bare because it is on the search_path (the proven output).
179
+ */
180
+ function refTableName(target: ModelDescriptor): string {
181
+ return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
182
+ }
183
+
184
+ /** Quote each dot-separated part of a (possibly schema-qualified) name: `auth.users` → `"auth"."users"`. */
185
+ function quoteParts(name: string): string {
186
+ return name.split('.').map((p) => `"${p}"`).join('.');
187
+ }
188
+
189
+ /** The ` ON DELETE … ON UPDATE …` clause for a FK, or '' when both are the default. */
190
+ function refActionSql(fk: { onDelete?: string; onUpdate?: string }): string {
191
+ let s = '';
192
+ if (fk.onDelete) s += ` ON DELETE ${fk.onDelete.toUpperCase()}`;
193
+ if (fk.onUpdate) s += ` ON UPDATE ${fk.onUpdate.toUpperCase()}`;
194
+ return s;
195
+ }
196
+
197
+ /** A column's DEFAULT expression, or null when it has none. */
198
+ function defaultExpr(spec: FieldSpec): string | null {
199
+ switch (spec.defaultKind) {
200
+ case 'now': return 'now()';
201
+ case 'random': return 'gen_random_uuid()';
202
+ case 'value':
203
+ return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
204
+ default: return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * A Postgres array literal for a default — `[]` → `'{}'`, `['a','b']` → `'{"a","b"}'`.
210
+ * The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
211
+ * strips, so the bare literal round-trips.
212
+ */
213
+ function arrayLiteral(arr: unknown[]): string {
214
+ const elems = arr.map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : String(v)));
215
+ return `'{${elems.join(',')}}'`;
216
+ }
217
+
218
+ function literal(value: unknown): string {
219
+ if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
220
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
221
+ return String(value);
222
+ }
223
+
224
+ /**
225
+ * One column definition (no leading indent). Clause order matches drizzle's:
226
+ * type, PRIMARY KEY (single-column only), DEFAULT, NOT NULL. `.references()`,
227
+ * `.unique()`, and composite PKs are table-level, emitted by compileCreateTable.
228
+ */
229
+ export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
230
+ let s = `"${sqlName}" ${pgType(spec)}`;
231
+ if (inlinePrimaryKey) s += ' PRIMARY KEY';
232
+ const def = defaultExpr(spec);
233
+ if (def) s += ` DEFAULT ${def}`;
234
+ if (spec.isNotNull) s += ' NOT NULL';
235
+ return s;
236
+ }
237
+
238
+ // --- CHECK constraints from .validate() Zod refinements --------------------
239
+
240
+ interface ZodDefLike {
241
+ typeName?: string;
242
+ checks?: Array<{ kind: string; value?: unknown; inclusive?: boolean }>;
243
+ values?: readonly string[];
244
+ innerType?: { _def?: ZodDefLike };
245
+ }
246
+
247
+ function zodDef(schema: unknown): ZodDefLike | null {
248
+ const d = (schema as { _def?: unknown } | null | undefined)?._def;
249
+ return d && typeof d === 'object' ? (d as ZodDefLike) : null;
250
+ }
251
+
252
+ /** Unwrap ZodOptional / ZodNullable / ZodDefault to the base type. */
253
+ function unwrapZod(def: ZodDefLike): ZodDefLike {
254
+ let cur = def;
255
+ while (cur.innerType?._def) cur = cur.innerType._def;
256
+ return cur;
257
+ }
258
+
259
+ /**
260
+ * The SQL CHECK predicate for a field's `.validate()` schema, or null when none
261
+ * of its refinements are SQL-expressible.
262
+ *
263
+ * The two-paths split (the field bridge's point): `min`/`max`/`length`/`enum`/
264
+ * range become a DB CHECK — the backstop that covers the SSR-direct write path,
265
+ * which never runs the app-layer Zod. Rich refinements (regex, email, cross-field
266
+ * `.refine()`) are NOT SQL-expressible and stay app-layer Zod only; they are
267
+ * intentionally skipped here, never half-emitted.
268
+ *
269
+ * Reads Zod's internal `_def` (pinned to zod 3.x — the same coupling drizzle-zod
270
+ * accepts). An unrecognized shape yields null, never a wrong constraint.
271
+ */
272
+ export function fieldCheckPredicate(sqlName: string, spec: FieldSpec): string | null {
273
+ const top = zodDef(spec.validation);
274
+ if (!top) return null;
275
+ const def = unwrapZod(top);
276
+
277
+ if (def.typeName === 'ZodString') {
278
+ let min: number | undefined;
279
+ let max: number | undefined;
280
+ let exact: number | undefined;
281
+ for (const c of def.checks ?? []) {
282
+ if (c.kind === 'min') min = c.value as number;
283
+ else if (c.kind === 'max') max = c.value as number;
284
+ else if (c.kind === 'length') exact = c.value as number;
285
+ }
286
+ if (exact != null) return `char_length(${sqlName}) = ${exact}`;
287
+ if (min != null && max != null) return `char_length(${sqlName}) BETWEEN ${min} AND ${max}`;
288
+ if (min != null) return `char_length(${sqlName}) >= ${min}`;
289
+ if (max != null) return `char_length(${sqlName}) <= ${max}`;
290
+ return null;
291
+ }
292
+
293
+ if (def.typeName === 'ZodNumber') {
294
+ let min: number | undefined;
295
+ let max: number | undefined;
296
+ let minIncl = true;
297
+ let maxIncl = true;
298
+ for (const c of def.checks ?? []) {
299
+ if (c.kind === 'min') { min = c.value as number; minIncl = c.inclusive !== false; }
300
+ else if (c.kind === 'max') { max = c.value as number; maxIncl = c.inclusive !== false; }
301
+ }
302
+ if (min != null && max != null && minIncl && maxIncl) return `${sqlName} BETWEEN ${min} AND ${max}`;
303
+ const parts: string[] = [];
304
+ if (min != null) parts.push(`${sqlName} ${minIncl ? '>=' : '>'} ${min}`);
305
+ if (max != null) parts.push(`${sqlName} ${maxIncl ? '<=' : '<'} ${max}`);
306
+ return parts.length ? parts.join(' AND ') : null;
307
+ }
308
+
309
+ if (def.typeName === 'ZodEnum') {
310
+ const vals = def.values ?? [];
311
+ if (!vals.length) return null;
312
+ return `${sqlName} IN (${vals.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(', ')})`;
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ export interface CompileTableOptions {
319
+ /** Indent for column/constraint lines. Default: a tab (matches drizzle's output). */
320
+ indent?: string;
321
+ }
322
+
323
+ /**
324
+ * A model's table as a quoted SQL name — schema-qualified only for a non-public
325
+ * schema. Drizzle leaves `public` tables bare (`"posts"`), and the example's
326
+ * brownfield DDL matches that, so a public model stays unqualified; a package
327
+ * table in its own schema becomes `"dist"."blobs"`.
328
+ */
329
+ export function qualifiedTable(model: ModelDescriptor): string {
330
+ return model.schema && model.schema !== 'public'
331
+ ? `"${model.schema}"."${model.table}"`
332
+ : `"${model.table}"`;
333
+ }
334
+
335
+ /**
336
+ * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
337
+ * composite keys and uniques become table constraints, named the way drizzle
338
+ * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`).
339
+ */
340
+ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
341
+ const indent = opts.indent ?? '\t';
342
+ const entries = Object.entries(model.fields);
343
+ const pkFields = model.primaryKey;
344
+ const singlePk = pkFields.length === 1 ? pkFields[0] : null;
345
+
346
+ const lines: string[] = [];
347
+ for (const [name, field] of entries) {
348
+ lines.push(indent + fieldColumnSql(toSnakeCase(name), field.spec, name === singlePk));
349
+ }
350
+
351
+ if (pkFields.length > 1) {
352
+ const cols = pkFields.map(toSnakeCase);
353
+ const quoted = cols.map((c) => `"${c}"`).join(',');
354
+ lines.push(`${indent}CONSTRAINT "${model.table}_${cols.join('_')}_pk" PRIMARY KEY(${quoted})`);
355
+ }
356
+
357
+ for (const [name, field] of entries) {
358
+ if (field.spec.isUnique) {
359
+ const col = toSnakeCase(name);
360
+ lines.push(`${indent}CONSTRAINT "${model.table}_${col}_unique" UNIQUE("${col}")`);
361
+ }
362
+ }
363
+
364
+ for (const [name, field] of entries) {
365
+ const col = toSnakeCase(name);
366
+ const pred = fieldCheckPredicate(col, field.spec);
367
+ if (pred) lines.push(`${indent}CONSTRAINT "${model.table}_${col}_check" CHECK (${pred})`);
368
+ }
369
+
370
+ // Table-level constraints (composite unique, multi-column check) after the field-derived ones.
371
+ const tableConstraints = modelConstraintSpecs(model);
372
+ for (const u of tableConstraints.uniques) {
373
+ lines.push(`${indent}CONSTRAINT "${u.name}" UNIQUE(${u.columns.map((c) => `"${c}"`).join(',')})`);
374
+ }
375
+ for (const ck of tableConstraints.checks) {
376
+ lines.push(`${indent}CONSTRAINT "${ck.name}" CHECK (${ck.expr})`);
377
+ }
378
+
379
+ return `CREATE TABLE ${qualifiedTable(model)} (\n${lines.join(',\n')}\n);`;
380
+ }
381
+
382
+ /**
383
+ * Compile a model's `fields` into the structured TableSchema — the same shape
384
+ * whole-DB introspection produces. This is the data layer's second producer (the
385
+ * counterpart to compileTableContract for authz): the Model and the live database
386
+ * meet at this IR, so `db:generate` can diff them. Types come from `format_type`'s
387
+ * vocabulary on both sides, so they compare.
388
+ *
389
+ * (compileCreateTable renders SQL directly for the greenfield emit; this is the
390
+ * structured form for the brownfield diff. They share the field→type/default/check
391
+ * helpers, so they never disagree.)
392
+ */
393
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
394
+ const schema = model.schema ?? opts.schema ?? 'public';
395
+ const entries = Object.entries(model.fields);
396
+
397
+ const columns = entries.map(([name, field]) => ({
398
+ name: toSnakeCase(name),
399
+ type: pgType(field.spec),
400
+ notNull: field.spec.isNotNull,
401
+ default: defaultExpr(field.spec),
402
+ }));
403
+
404
+ const tableConstraints = modelConstraintSpecs(model);
405
+
406
+ const fieldUniques = entries
407
+ .filter(([, field]) => field.spec.isUnique)
408
+ .map(([name]) => {
409
+ const col = toSnakeCase(name);
410
+ return { name: `${model.table}_${col}_unique`, columns: [col] };
411
+ });
412
+ const uniques = [...fieldUniques, ...tableConstraints.uniques];
413
+
414
+ const fieldChecks = entries.flatMap(([name, field]) => {
415
+ const col = toSnakeCase(name);
416
+ const pred = fieldCheckPredicate(col, field.spec);
417
+ return pred ? [{ name: `${model.table}_${col}_check`, expr: pred }] : [];
418
+ });
419
+ const checks = [...fieldChecks, ...tableConstraints.checks];
420
+
421
+ return {
422
+ table: `${schema}.${model.table}`,
423
+ columns,
424
+ primaryKey: model.primaryKey.map(toSnakeCase),
425
+ uniques,
426
+ checks,
427
+ foreignKeys: modelForeignKeys(model),
428
+ indexes: tableConstraints.indexes,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The enum types a set of Models declares — the desired-side counterpart to the
434
+ * `enums` whole-DB introspection folds. Collected across every `field.enum(name, values)`,
435
+ * de-duplicated by name (the same enum may back columns on several tables), sorted to
436
+ * match `assembleSchema`'s order so the two sides diff. A name reused with *different*
437
+ * values is a model error surfaced here, not a silently-merged type.
438
+ */
439
+ export function compileEnums(models: ModelDescriptor[]): EnumType[] {
440
+ const byName = new Map<string, EnumType>();
441
+ for (const model of models) {
442
+ for (const field of Object.values(model.fields)) {
443
+ const { type, enumName, enumValues } = field.spec;
444
+ if (type !== 'enum' || !enumName) continue;
445
+ const values = [...(enumValues ?? [])];
446
+ const existing = byName.get(enumName);
447
+ if (existing && existing.values.join(',') !== values.join(',')) {
448
+ throw new Error(`enum '${enumName}' is declared with conflicting values: [${existing.values}] vs [${values}]`);
449
+ }
450
+ if (!existing) byName.set(enumName, { name: enumName, values });
451
+ }
452
+ }
453
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
454
+ }
455
+
456
+ /**
457
+ * Foreign-key constraints for a model — its `.references()` fields and its table-level
458
+ * `foreignKey(...)` constraints, via `modelForeignKeys` — as `ALTER TABLE` statements. These
459
+ * are emitted *after* every `CREATE TABLE` (a FK's target table must already exist), so they
460
+ * are separate from the table DDL — which is why FKs are not rendered inline by
461
+ * compileCreateTable. Named the way drizzle names them: `<table>_<cols>_<target>_<refcols>_fk`.
462
+ */
463
+ export function foreignKeySql(model: ModelDescriptor): string[] {
464
+ return modelForeignKeys(model).map((fk) => {
465
+ const cols = fk.columns.map((c) => `"${c}"`).join(', ');
466
+ const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
467
+ // Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`
468
+ // and `REFERENCES "auth"."users"` — so a cross-schema FK targets the right table. public
469
+ // stays bare-but-quoted (`"uploads"`), preserving the proven output.
470
+ return (
471
+ `ALTER TABLE ${qualifiedTable(model)} ADD CONSTRAINT "${fk.name}" ` +
472
+ `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)};`
473
+ );
474
+ });
475
+ }
476
+
477
+ /**
478
+ * Collect the `renamedFrom` markers off a set of Models into a rename map keyed the same
479
+ * way the compiled snapshot keys tables (`public.posts`). This is the rename *intent*,
480
+ * carried separately from the TableSchema IR — introspection cannot produce it (the
481
+ * database has no rename history), so it never belongs on a column. `diffSchema` reads
482
+ * this map to turn a drop+add into a `RENAME COLUMN`.
483
+ */
484
+ export function compileRenames(models: ModelDescriptor[], opts: { schema?: string } = {}): RenameMap {
485
+ const map: RenameMap = {};
486
+ for (const model of models) {
487
+ const schema = model.schema ?? opts.schema ?? 'public';
488
+ for (const [name, field] of Object.entries(model.fields)) {
489
+ const from = field.spec.renamedFrom;
490
+ if (!from) continue;
491
+ const table = `${schema}.${model.table}`;
492
+ (map[table] ??= {})[toSnakeCase(name)] = toSnakeCase(from);
493
+ }
494
+ }
495
+ return map;
496
+ }