@everystack/cli 0.3.0 → 0.3.4

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.
@@ -86,6 +86,9 @@ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapsh
86
86
  */
87
87
  export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
88
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;
89
92
  const desiredEnums = compileEnums(models);
90
93
  const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
91
94
  const declaredTables = new Set(desired.tables.map((t) => t.table));
@@ -99,7 +102,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
99
102
  const renames = compileRenames(models, { schema });
100
103
  const changes = diffSchema(desired, scopedCurrent, renames);
101
104
 
102
- const modelByTable = new Map(models.map((m) => [`${schema}.${m.table}`, m]));
105
+ const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
103
106
  const emitted = emitSchemaSql(changes, {
104
107
  createTable: (change) => {
105
108
  const model = modelByTable.get(change.table);
@@ -18,6 +18,7 @@
18
18
  export * from './schema-compile.js';
19
19
  export * from './schema-introspect.js';
20
20
  export * from './schema-diff.js';
21
+ export * from './schema-source.js';
21
22
  export * from './model-render.js';
22
23
  export * from './migration-compile.js';
23
24
  export * from './migration-generate.js';
@@ -40,6 +40,7 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
40
40
  const checks: CheckConstraint[] = [];
41
41
  const indexes: IndexSchema[] = [];
42
42
  let checkIndex = 0;
43
+ const usedIndexNames = new Set<string>();
43
44
  for (const con of model.constraints) {
44
45
  if (con.kind === 'unique') {
45
46
  const cols = con.columns.map(toSnakeCase);
@@ -48,8 +49,16 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
48
49
  checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
49
50
  } else if (con.kind === 'index') {
50
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);
51
60
  indexes.push({
52
- name: `${model.table}_${cols.join('_')}_index`,
61
+ name,
53
62
  columns: cols,
54
63
  unique: con.isUnique,
55
64
  ...(con.predicate ? { where: con.predicate } : {}),
@@ -77,6 +86,12 @@ const PG_TYPE: Record<string, string> = {
77
86
  };
78
87
 
79
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 {
80
95
  // Parameterized types build their own canonical string — exactly as `format_type`
81
96
  // spells it (no space after the comma; `varchar` is `character varying`; a
82
97
  // precision-only numeric carries scale 0), so an unchanged column round-trips clean.
@@ -132,7 +147,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
132
147
  out.push({
133
148
  name: `${model.table}_${col}_${target.table}_${targetPk}_fk`,
134
149
  columns: [col],
135
- refTable: target.table,
150
+ refTable: refTableName(target),
136
151
  refColumns: [targetPk],
137
152
  ...refActions(field.spec),
138
153
  });
@@ -146,7 +161,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
146
161
  out.push({
147
162
  name: `${model.table}_${cols.join('_')}_${target.table}_${refCols.join('_')}_fk`,
148
163
  columns: cols,
149
- refTable: target.table,
164
+ refTable: refTableName(target),
150
165
  refColumns: refCols,
151
166
  ...refActions(con),
152
167
  });
@@ -155,6 +170,22 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
155
170
  return out;
156
171
  }
157
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
+
158
189
  /** The ` ON DELETE … ON UPDATE …` clause for a FK, or '' when both are the default. */
159
190
  function refActionSql(fk: { onDelete?: string; onUpdate?: string }): string {
160
191
  let s = '';
@@ -168,11 +199,22 @@ function defaultExpr(spec: FieldSpec): string | null {
168
199
  switch (spec.defaultKind) {
169
200
  case 'now': return 'now()';
170
201
  case 'random': return 'gen_random_uuid()';
171
- case 'value': return literal(spec.default);
202
+ case 'value':
203
+ return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
172
204
  default: return null;
173
205
  }
174
206
  }
175
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
+
176
218
  function literal(value: unknown): string {
177
219
  if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
178
220
  if (typeof value === 'boolean') return value ? 'true' : 'false';
@@ -278,6 +320,18 @@ export interface CompileTableOptions {
278
320
  indent?: string;
279
321
  }
280
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
+
281
335
  /**
282
336
  * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
283
337
  * composite keys and uniques become table constraints, named the way drizzle
@@ -322,7 +376,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
322
376
  lines.push(`${indent}CONSTRAINT "${ck.name}" CHECK (${ck.expr})`);
323
377
  }
324
378
 
325
- return `CREATE TABLE "${model.table}" (\n${lines.join(',\n')}\n);`;
379
+ return `CREATE TABLE ${qualifiedTable(model)} (\n${lines.join(',\n')}\n);`;
326
380
  }
327
381
 
328
382
  /**
@@ -337,7 +391,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
337
391
  * helpers, so they never disagree.)
338
392
  */
339
393
  export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
340
- const schema = opts.schema ?? 'public';
394
+ const schema = model.schema ?? opts.schema ?? 'public';
341
395
  const entries = Object.entries(model.fields);
342
396
 
343
397
  const columns = entries.map(([name, field]) => ({
@@ -410,9 +464,12 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
410
464
  return modelForeignKeys(model).map((fk) => {
411
465
  const cols = fk.columns.map((c) => `"${c}"`).join(', ');
412
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.
413
470
  return (
414
- `ALTER TABLE "${model.table}" ADD CONSTRAINT "${fk.name}" ` +
415
- `FOREIGN KEY (${cols}) REFERENCES "${fk.refTable}"(${refCols})${refActionSql(fk)};`
471
+ `ALTER TABLE ${qualifiedTable(model)} ADD CONSTRAINT "${fk.name}" ` +
472
+ `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)};`
416
473
  );
417
474
  });
418
475
  }
@@ -425,9 +482,9 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
425
482
  * this map to turn a drop+add into a `RENAME COLUMN`.
426
483
  */
427
484
  export function compileRenames(models: ModelDescriptor[], opts: { schema?: string } = {}): RenameMap {
428
- const schema = opts.schema ?? 'public';
429
485
  const map: RenameMap = {};
430
486
  for (const model of models) {
487
+ const schema = model.schema ?? opts.schema ?? 'public';
431
488
  for (const [name, field] of Object.entries(model.fields)) {
432
489
  const from = field.spec.renamedFrom;
433
490
  if (!from) continue;
@@ -509,7 +509,10 @@ function addConstraintSql(table: string, c: ConstraintSpec): string {
509
509
  case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
510
510
  case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
511
511
  case 'foreignKey': {
512
- let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${quote(c.refTable)}(${colList(c.refColumns)})`;
512
+ // refTable may be schema-qualified (`auth.users`) for a cross-schema FK — quote each part,
513
+ // not the whole string, or `"auth.users"` reads as one identifier.
514
+ const refTable = c.refTable.split('.').map(quote).join('.');
515
+ let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
513
516
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
514
517
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
515
518
  return `${s};`;
@@ -0,0 +1,416 @@
1
+ /**
2
+ * `compileDrizzleSource` — the typed-runtime-table slice of the one generator.
3
+ *
4
+ * The sibling of `compileTableSchema` (SQL DDL) and `compileTableContract` (RLS):
5
+ * those compile a {@link ModelDescriptor} to a migration target; this compiles it
6
+ * to drizzle-orm TypeScript SOURCE TEXT. The emitted `schema.generated.ts`, when
7
+ * imported, yields the SAME runtime table `toDrizzleTable(model)` builds — but as
8
+ * literal, fully-typed declarations, so the SSR loaders keep their precise column
9
+ * types (`toDrizzleTable`'s loop-built table infers `<any>`; literal source does
10
+ * not). One hand-authored source (`models/*.ts`), the drizzle schema a generated,
11
+ * drift-guarded artifact.
12
+ *
13
+ * The column mapping mirrors `toDrizzleTable` exactly, expressed as text. The two
14
+ * documented divergences from a hand-written schema (see docs/plans/model-drizzle-codegen.md):
15
+ *
16
+ * - Cross-schema FKs (e.g. `profiles.id → auth.users.id`) are OMITTED — the model
17
+ * can't express a reference to a non-Model table, so neither can the emitter.
18
+ * - `relationName` strings are synthesized deterministically (the `Relation` type
19
+ * carries no name); only the pairing of a belongsTo with its inverse hasMany
20
+ * matters, which the synthesis guarantees.
21
+ *
22
+ * Pure and deterministic: imports sorted, declarations in model order, relation
23
+ * keys in declared order — re-running on the same models is byte-identical.
24
+ *
25
+ * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
26
+ */
27
+
28
+ import type { ModelDescriptor, FieldSpec } from '@everystack/model';
29
+
30
+ /** `author_id` is the SQL identifier; `authorId` the field key. The single snake-case rule. */
31
+ function toSnakeCase(name: string): string {
32
+ return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
33
+ }
34
+
35
+ /** `image_variants` → `imageVariants` (a snake table name to its camelCase drizzle export var). */
36
+ function toCamelCase(name: string): string {
37
+ return name.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
38
+ }
39
+
40
+ /** A JS/TS string literal — single-quoted, the example's style. */
41
+ function strLiteral(value: string): string {
42
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
43
+ }
44
+
45
+ /** A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts). */
46
+ function defaultValueLiteral(value: unknown): string {
47
+ if (typeof value === 'string') return strLiteral(value);
48
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
49
+ if (value === null) return 'null';
50
+ if (typeof value === 'number') return String(value);
51
+ // Arrays / objects: a JSON literal is valid TS for jsonb defaults.
52
+ return JSON.stringify(value);
53
+ }
54
+
55
+ /**
56
+ * The drizzle pg-core column builder *call* for a field — `text('bio')`,
57
+ * `timestamp('created_at', { withTimezone: true })`, `uuid('id')` — exactly the
58
+ * type + args `toDrizzleTable`'s `baseColumn` produces, as source text. Returns
59
+ * the call and the builder name it used (so the import set can be collected).
60
+ */
61
+ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string; builder: string } {
62
+ const n = strLiteral(columnName);
63
+ switch (spec.type) {
64
+ case 'text':
65
+ return { call: `text(${n})`, builder: 'text' };
66
+ case 'uuid':
67
+ return { call: `uuid(${n})`, builder: 'uuid' };
68
+ case 'integer':
69
+ return { call: `integer(${n})`, builder: 'integer' };
70
+ case 'bigint':
71
+ return { call: `bigint(${n}, { mode: 'number' })`, builder: 'bigint' };
72
+ case 'bigserial':
73
+ return { call: `bigserial(${n}, { mode: 'number' })`, builder: 'bigserial' };
74
+ case 'boolean':
75
+ return { call: `boolean(${n})`, builder: 'boolean' };
76
+ case 'timestamptz':
77
+ return { call: `timestamp(${n}, { withTimezone: true })`, builder: 'timestamp' };
78
+ case 'timestamp':
79
+ return { call: `timestamp(${n})`, builder: 'timestamp' };
80
+ case 'date':
81
+ return { call: `date(${n})`, builder: 'date' };
82
+ case 'numeric':
83
+ return spec.precision !== undefined
84
+ ? { call: `numeric(${n}, { precision: ${spec.precision}, scale: ${spec.scale} })`, builder: 'numeric' }
85
+ : { call: `numeric(${n})`, builder: 'numeric' };
86
+ case 'varchar':
87
+ return spec.length !== undefined
88
+ ? { call: `varchar(${n}, { length: ${spec.length} })`, builder: 'varchar' }
89
+ : { call: `varchar(${n})`, builder: 'varchar' };
90
+ case 'jsonb':
91
+ return { call: `jsonb(${n})`, builder: 'jsonb' };
92
+ case 'enum': {
93
+ if (!spec.enumName) throw new Error('enum field is missing enumName');
94
+ return { call: `${enumConstName(spec.enumName)}(${n})`, builder: enumConstName(spec.enumName) };
95
+ }
96
+ default: {
97
+ const exhaustive: never = spec.type;
98
+ throw new Error(`Unsupported field type: ${String(exhaustive)}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ /** The hoisted const name for an enum type. `status` → `statusEnum`. */
104
+ function enumConstName(enumName: string): string {
105
+ return `${toCamelCase(enumName)}Enum`;
106
+ }
107
+
108
+ /** The single primary-key field key for a model (FK targets reference its column). */
109
+ function primaryKeyKey(model: ModelDescriptor): string {
110
+ const pk = model.primaryKey[0];
111
+ if (!pk) throw new Error(`Referenced model "${model.table}" has no primary key`);
112
+ return pk;
113
+ }
114
+
115
+ /** A model's `field key → snake column name` map (for relation `fields:`/`references:` text). */
116
+ function columnKeys(model: ModelDescriptor): Map<string, string> {
117
+ const m = new Map<string, string>();
118
+ for (const key of Object.keys(model.fields)) m.set(key, toSnakeCase(key));
119
+ return m;
120
+ }
121
+
122
+ /**
123
+ * The modifier chain for a column, in `toDrizzleTable` order: notNull, unique,
124
+ * default, primaryKey (single-PK only), references. Cross-schema references are
125
+ * impossible to model, so only in-set targets are emitted. `usedBuilders` collects
126
+ * the table-extra/import builders the references clause needs (none today).
127
+ */
128
+ function columnModifiers(
129
+ spec: FieldSpec,
130
+ isComposite: boolean,
131
+ modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
132
+ ): string {
133
+ let s = '';
134
+ if (spec.isArray) s += '.array()';
135
+ if (spec.isNotNull) s += '.notNull()';
136
+ if (spec.isUnique) s += '.unique()';
137
+
138
+ if (spec.defaultKind === 'now') s += '.defaultNow()';
139
+ else if (spec.defaultKind === 'random') s += '.defaultRandom()';
140
+ else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
141
+
142
+ if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
143
+
144
+ if (spec.references) {
145
+ const target = (spec.references as () => ModelDescriptor)();
146
+ const entry = modelsByDescriptor.get(target);
147
+ if (entry) {
148
+ const parentPk = primaryKeyKey(target);
149
+ const opts: string[] = [];
150
+ if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
151
+ if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
152
+ const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
153
+ s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
154
+ }
155
+ // A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
156
+ // the runtime + relations never needed it; the DB constraint stays unmanaged.
157
+ }
158
+
159
+ return s;
160
+ }
161
+
162
+ /** An unordered table-pair key, so `(follows, profiles)` and `(profiles, follows)` collide. */
163
+ function pairKey(a: string, b: string): string {
164
+ return a < b ? `${a}\x00${b}` : `${b}\x00${a}`;
165
+ }
166
+
167
+ /**
168
+ * A relation that needs a `relationName` carries one keyed by `(childTable,
169
+ * fkColumnKey)` — the same string on the belongsTo (child) and the inverse hasMany
170
+ * (parent), so drizzle pairs them. Derived from the model's OWN relation graph:
171
+ * a belongsTo names `(thisTable, column)`; a hasMany names `(targetTable, column)`
172
+ * — both resolve to the child table holding the FK and the FK column key, so the
173
+ * two sides agree without coordination.
174
+ */
175
+ interface RelationNaming {
176
+ /** All synthesized names, keyed by `(childTable, fkColumnKey)`. */
177
+ needsName: Set<string>;
178
+ }
179
+
180
+ function relationPairId(childTable: string, fkColumnKey: string): string {
181
+ return `${childTable}\x00${fkColumnKey}`;
182
+ }
183
+
184
+ function synthesizedName(childTable: string, fkColumnKey: string): string {
185
+ return `${childTable}_${toSnakeCase(fkColumnKey)}`;
186
+ }
187
+
188
+ /**
189
+ * Decide which relations need a `relationName`: only pairs of tables connected by
190
+ * MORE than one relation. Counts every relation (both sides) per unordered table
191
+ * pair; a pair counted more than once flags every relation between those two tables.
192
+ */
193
+ function planRelationNames(models: ModelDescriptor[]): RelationNaming {
194
+ // Ambiguity is per DISTINCT FK column connecting a table pair — NOT per relation
195
+ // declaration. profiles<->posts is one FK (author_id) declared from both sides
196
+ // (belongsTo + its inverse hasMany): drizzle pairs them by table, no name needed.
197
+ // profiles<->follows is TWO FKs (follower_id, following_id): drizzle cannot tell
198
+ // which hasMany pairs which belongsTo, so every relation on that pair needs a name.
199
+ //
200
+ // Each relation resolves to a (childTable, fkColumnKey) — the child table holds the
201
+ // FK column. Count distinct such pairs per unordered table pair.
202
+ const fkColumnsByPair = new Map<string, Set<string>>();
203
+ for (const model of models) {
204
+ for (const rel of Object.values(model.relations)) {
205
+ const other = rel.target().table;
206
+ const childTable = rel.kind === 'belongsTo' ? model.table : other;
207
+ const k = pairKey(model.table, other);
208
+ let set = fkColumnsByPair.get(k);
209
+ if (!set) {
210
+ set = new Set<string>();
211
+ fkColumnsByPair.set(k, set);
212
+ }
213
+ set.add(relationPairId(childTable, rel.column));
214
+ }
215
+ }
216
+
217
+ const needsName = new Set<string>();
218
+ for (const model of models) {
219
+ for (const rel of Object.values(model.relations)) {
220
+ const other = rel.target().table;
221
+ const k = pairKey(model.table, other);
222
+ // Ambiguous when more than one distinct FK column connects the two tables.
223
+ if ((fkColumnsByPair.get(k)?.size ?? 0) <= 1) continue;
224
+ const childTable = rel.kind === 'belongsTo' ? model.table : other;
225
+ needsName.add(relationPairId(childTable, rel.column));
226
+ }
227
+ }
228
+ return { needsName };
229
+ }
230
+
231
+ /** The `relationName: '…'` fragment for a relation, or '' when the pair is unambiguous. */
232
+ function relationNameFragment(
233
+ model: ModelDescriptor,
234
+ rel: { kind: 'belongsTo' | 'hasMany'; column: string; target: () => ModelDescriptor },
235
+ naming: RelationNaming,
236
+ ): string {
237
+ const other = rel.target().table;
238
+ const childTable = rel.kind === 'belongsTo' ? model.table : other;
239
+ const id = relationPairId(childTable, rel.column);
240
+ if (!naming.needsName.has(id)) return '';
241
+ return `, relationName: ${strLiteral(synthesizedName(childTable, rel.column))}`;
242
+ }
243
+
244
+ export interface CompileDrizzleSourceOptions {
245
+ /** Reserved for parity with the DDL compiler's `schema` option. Unused in the emit. */
246
+ schema?: string;
247
+ }
248
+
249
+ /**
250
+ * Emit the full text of a `schema.generated.ts` from a set of Models: hoisted
251
+ * `pgEnum`s, one `pgTable` per model (columns + table-extras), then a
252
+ * `relations()` block per model that declares any. The output is what an app's
253
+ * hand-written `db/schema.ts` is replaced by — fully-typed, derived, drift-guarded.
254
+ */
255
+ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: CompileDrizzleSourceOptions = {}): string {
256
+ // A private model is package-owned — its drizzle runtime table ships with the
257
+ // package (e.g. @everystack/storage/schema), so it never appears in the app's
258
+ // generated schema. (The migration generator still emits its SQL + authz.) The
259
+ // command and the drift guard both call this, so filtering here keeps them in sync.
260
+ const models = allModels.filter((m) => !m.private);
261
+
262
+ const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
263
+ for (const model of models) modelsByDescriptor.set(model, { camel: toCamelCase(model.table) });
264
+
265
+ // --- Collect enums (deduped by name, sorted) -----------------------------
266
+ const enumsByName = new Map<string, readonly string[]>();
267
+ for (const model of models) {
268
+ for (const f of Object.values(model.fields)) {
269
+ const { type, enumName, enumValues } = f.spec;
270
+ if (type !== 'enum' || !enumName) continue;
271
+ const values = enumValues ?? [];
272
+ const existing = enumsByName.get(enumName);
273
+ if (existing && existing.join(',') !== values.join(',')) {
274
+ throw new Error(`enum '${enumName}' is declared with conflicting values: [${existing}] vs [${values}]`);
275
+ }
276
+ if (!existing) enumsByName.set(enumName, values);
277
+ }
278
+ }
279
+ const enumNames = [...enumsByName.keys()].sort((a, b) => a.localeCompare(b));
280
+
281
+ // --- Track which pg-core builders + drizzle-orm symbols are used ----------
282
+ const pgCoreBuilders = new Set<string>();
283
+ pgCoreBuilders.add('pgTable');
284
+ if (enumNames.length) pgCoreBuilders.add('pgEnum');
285
+
286
+ const relationNaming = planRelationNames(models);
287
+ const anyRelations = models.some((m) => Object.keys(m.relations).length > 0);
288
+
289
+ // --- Emit each table -------------------------------------------------------
290
+ const tableBlocks: string[] = [];
291
+ for (const model of models) {
292
+ const camel = toCamelCase(model.table);
293
+ const isComposite = model.primaryKey.length > 1;
294
+
295
+ const colLines: string[] = [];
296
+ for (const [key, builder] of Object.entries(model.fields)) {
297
+ const spec = builder.spec;
298
+ const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
299
+ pgCoreBuilders.add(pgBuilder);
300
+ const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
301
+ colLines.push(` ${key}: ${call}${mods},`);
302
+ }
303
+
304
+ // Table-extras: composite PK, then table-level unique/check constraints.
305
+ const extras: string[] = [];
306
+ if (isComposite) {
307
+ pgCoreBuilders.add('primaryKey');
308
+ const cols = model.primaryKey.map((k) => `t.${k}`).join(', ');
309
+ extras.push(` primaryKey({ columns: [${cols}] }),`);
310
+ }
311
+ for (const con of model.constraints) {
312
+ if (con.kind === 'unique') {
313
+ pgCoreBuilders.add('unique');
314
+ const cols = con.columns.map((k) => `t.${k}`).join(', ');
315
+ extras.push(` unique().on(${cols}),`);
316
+ } else if (con.kind === 'check') {
317
+ pgCoreBuilders.add('check');
318
+ pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
319
+ extras.push(` check(${strLiteral(`${model.table}_check`)}, sql.raw(${strLiteral(con.predicate)})),`);
320
+ }
321
+ // 'index' / 'foreignKey' table constraints are not materialized on the runtime
322
+ // drizzle table object (mirrors toDrizzleTable), so they are omitted here.
323
+ }
324
+
325
+ const cols = `{\n${colLines.join('\n')}\n}`;
326
+ let block: string;
327
+ if (extras.length) {
328
+ block = `export const ${camel} = pgTable(${strLiteral(model.table)}, ${cols}, (t) => [\n${extras.join('\n')}\n]);`;
329
+ } else {
330
+ block = `export const ${camel} = pgTable(${strLiteral(model.table)}, ${cols});`;
331
+ }
332
+ tableBlocks.push(block);
333
+ }
334
+
335
+ // --- Emit relations() blocks ----------------------------------------------
336
+ const relationBlocks: string[] = [];
337
+ for (const model of models) {
338
+ const relEntries = Object.entries(model.relations);
339
+ if (relEntries.length === 0) continue;
340
+ const camel = toCamelCase(model.table);
341
+
342
+ const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo');
343
+ const usesMany = relEntries.some(([, r]) => r.kind === 'hasMany');
344
+ const helpers = [usesOne ? 'one' : null, usesMany ? 'many' : null].filter(Boolean).join(', ');
345
+
346
+ const lines: string[] = [];
347
+ for (const [key, rel] of relEntries) {
348
+ const target = rel.target();
349
+ const targetCamel = toCamelCase(target.table);
350
+ const nameFrag = relationNameFragment(model, rel, relationNaming);
351
+ if (rel.kind === 'belongsTo') {
352
+ // belongsTo(() => Target, column, references?) -> one(...)
353
+ const thisCol = rel.column; // FK FIELD KEY on this model
354
+ const targetPk = rel.references ?? primaryKeyKey(target);
355
+ lines.push(
356
+ ` ${key}: one(${targetCamel}, { fields: [${camel}.${thisCol}], references: [${targetCamel}.${targetPk}]${nameFrag} }),`,
357
+ );
358
+ } else {
359
+ // hasMany(() => Target, column, references?) -> many(...)
360
+ if (nameFrag) {
361
+ lines.push(` ${key}: many(${targetCamel}, {${nameFrag.replace(/^, /, ' ')} }),`);
362
+ } else {
363
+ lines.push(` ${key}: many(${targetCamel}),`);
364
+ }
365
+ }
366
+ }
367
+
368
+ relationBlocks.push(
369
+ `export const ${camel}Relations = relations(${camel}, ({ ${helpers} }) => ({\n${lines.join('\n')}\n}));`,
370
+ );
371
+ }
372
+
373
+ // --- Header ----------------------------------------------------------------
374
+ const header = [
375
+ '// GENERATED by `everystack db:generate` from models/. Do not edit.',
376
+ '//',
377
+ '// Cross-schema foreign keys (e.g. profiles.id -> auth.users.id) are intentionally',
378
+ '// omitted: a Model cannot reference a table outside the model set, so the runtime',
379
+ '// table and its relations() never carry that FK. The DB constraint stays untouched.',
380
+ ].join('\n');
381
+
382
+ // --- Imports ---------------------------------------------------------------
383
+ // pg-core: sorted, enum consts are not imports (they're declared below), `sql`
384
+ // comes from 'drizzle-orm', not pg-core — strip both from the pg-core set.
385
+ const enumConsts = new Set(enumNames.map(enumConstName));
386
+ const usesSqlRaw = pgCoreBuilders.has('sql');
387
+ const pgCoreNames = [...pgCoreBuilders].filter((b) => !enumConsts.has(b) && b !== 'sql').sort((a, b) => a.localeCompare(b));
388
+
389
+ const importLines: string[] = [];
390
+ importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
391
+
392
+ const drizzleOrmNames: string[] = [];
393
+ if (anyRelations) drizzleOrmNames.push('relations');
394
+ if (usesSqlRaw) drizzleOrmNames.push('sql');
395
+ drizzleOrmNames.sort((a, b) => a.localeCompare(b));
396
+ if (drizzleOrmNames.length) {
397
+ importLines.push(`import { ${drizzleOrmNames.join(', ')} } from 'drizzle-orm';`);
398
+ }
399
+
400
+ // --- Enum declarations -----------------------------------------------------
401
+ const enumDecls = enumNames.map((name) => {
402
+ const values = enumsByName.get(name) ?? [];
403
+ const valsText = values.map((v) => strLiteral(v)).join(', ');
404
+ return `export const ${enumConstName(name)} = pgEnum(${strLiteral(name)}, [${valsText}]);`;
405
+ });
406
+
407
+ // --- Assemble --------------------------------------------------------------
408
+ const sections: string[] = [];
409
+ sections.push(header);
410
+ sections.push(importLines.join('\n'));
411
+ if (enumDecls.length) sections.push(enumDecls.join('\n'));
412
+ for (const block of tableBlocks) sections.push(block);
413
+ for (const block of relationBlocks) sections.push(block);
414
+
415
+ return sections.join('\n\n') + '\n';
416
+ }