@everystack/cli 0.3.9 → 0.3.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -74,7 +74,7 @@
74
74
  "structured-headers": "1.0.1",
75
75
  "tsx": "4.21.0",
76
76
  "typescript": "5.9.3",
77
- "@everystack/model": "0.3.3"
77
+ "@everystack/model": "0.3.4"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@everystack/server": ">=0.1.0",
@@ -48,7 +48,8 @@ function castForType(type: string): string {
48
48
  case 'uuid': return 'uuid';
49
49
  case 'bigint':
50
50
  case 'bigserial': return 'bigint';
51
- case 'integer': return 'integer';
51
+ case 'integer':
52
+ case 'serial': return 'integer';
52
53
  default: return type;
53
54
  }
54
55
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
- * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out models/index.ts]
4
+ * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
5
5
  *
6
6
  * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
7
  * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
@@ -11,15 +11,19 @@
11
11
  * starting point you review and refine (it flags, as inline comments, anything the field
12
12
  * builder can't yet express).
13
13
  *
14
- * The rendered source is the artifact: it goes to stdout (so `db:pull > models.ts` works) or
15
- * to `--out`. Every progress/diagnostic line goes to stderr, so the stdout pipe stays pure
16
- * source. The renderer (model-render.ts) is pure; this shell is the IO.
14
+ * The rendered source is the artifact. An `--out` that is a directory (anything not ending
15
+ * in `.ts` — `--out models` is the canonical form) is the DEFAULT shape: one file per model
16
+ * plus an `index.ts` assembling the `models` array, because a real schema pulled as a single
17
+ * module is unreviewable. An `--out` ending in `.ts` writes the single-module form; with no
18
+ * `--out` the single module goes to stdout (so `db:pull > models.ts` works). Every
19
+ * progress/diagnostic line goes to stderr, so the stdout pipe stays pure source. The
20
+ * renderers (model-render.ts) are pure; this shell is the IO.
17
21
  */
18
22
 
19
23
  import fs from 'node:fs/promises';
20
24
  import path from 'node:path';
21
25
  import { introspectSchema } from '../schema-introspect.js';
22
- import { renderModelSource } from '../model-render.js';
26
+ import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
23
27
  import type { QueryRunner } from '../authz-contract.js';
24
28
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
25
29
  import { resolveConfig, opsFunction } from '../config.js';
@@ -80,16 +84,34 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
80
84
  process.exit(1);
81
85
  }
82
86
 
83
- const pulled = current.tables.filter((t) => t.table.startsWith(`${schema}.`));
87
+ const pulled = pullableTables(current, schema);
84
88
  if (pulled.length === 0) {
85
89
  fail(`No tables found in schema "${schema}".`);
86
90
  process.exit(1);
87
91
  }
88
92
 
89
- const source = renderModelSource(current, { schema });
90
-
91
- if (flags.out) {
93
+ let source: string;
94
+ if (flags.out && !flags.out.endsWith('.ts')) {
95
+ // A directory: one file per model + index.ts — the default shape for a real app.
96
+ const dir = path.resolve(flags.out);
97
+ const files = renderModelFiles(current, { schema });
98
+ try {
99
+ await fs.mkdir(dir, { recursive: true });
100
+ const written = new Set(files.map((f) => f.file));
101
+ const existing = (await fs.readdir(dir)).filter((f) => f.endsWith('.ts') && !written.has(f));
102
+ for (const f of files) await fs.writeFile(path.join(dir, f.file), f.source, 'utf8');
103
+ if (existing.length) {
104
+ caution(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), dir)} were not part of this pull and were left untouched: ${existing.join(', ')}`);
105
+ }
106
+ } catch (err: any) {
107
+ fail(`Could not write ${flags.out}: ${err.message}`);
108
+ process.exit(1);
109
+ }
110
+ ok(`Wrote ${files.length - 1} model file(s) + index.ts to ${path.relative(process.cwd(), dir)}.`);
111
+ source = files.map((f) => f.source).join('\n');
112
+ } else if (flags.out) {
92
113
  const outPath = path.resolve(flags.out);
114
+ source = renderModelSource(current, { schema });
93
115
  try {
94
116
  await fs.mkdir(path.dirname(outPath), { recursive: true });
95
117
  await fs.writeFile(outPath, source, 'utf8');
@@ -99,6 +121,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
99
121
  }
100
122
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
101
123
  } else {
124
+ source = renderModelSource(current, { schema });
102
125
  process.stdout.write(source);
103
126
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
104
127
  }
package/src/cli/index.ts CHANGED
@@ -288,7 +288,7 @@ Usage:
288
288
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
289
289
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
290
290
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
291
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
291
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
292
292
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
293
293
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
294
294
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
@@ -67,6 +67,8 @@ const FIELD_FACTORY: Record<string, string> = {
67
67
  text: 'text',
68
68
  integer: 'integer',
69
69
  bigint: 'bigint',
70
+ real: 'real',
71
+ 'double precision': 'doublePrecision',
70
72
  boolean: 'boolean',
71
73
  jsonb: 'jsonb',
72
74
  date: 'date',
@@ -82,6 +84,13 @@ const FIELD_FACTORY: Record<string, string> = {
82
84
  * scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
83
85
  */
84
86
  export function fieldFactoryCall(type: string): string | null {
87
+ // An array is its base type + `.array()` — exactly how the compiler emits it
88
+ // (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
89
+ if (type.endsWith('[]')) {
90
+ const base = fieldFactoryCall(type.slice(0, -2));
91
+ return base ? `${base}.array()` : null;
92
+ }
93
+
85
94
  const direct = FIELD_FACTORY[type];
86
95
  if (direct) return `field.${direct}()`;
87
96
 
@@ -102,6 +111,19 @@ function bareName(table: string): string {
102
111
  return table.replace(/^[^.]+\./, '');
103
112
  }
104
113
 
114
+ /** Migration infrastructure a pull must never render as a model, whatever schema it landed in. */
115
+ const INFRASTRUCTURE_TABLES = new Set(['__drizzle_migrations']);
116
+
117
+ /**
118
+ * The tables a pull renders: the requested schema, minus migration infrastructure.
119
+ * drizzle-kit keeps its journal in the `drizzle` schema, but a restored or legacy
120
+ * database can carry `public.__drizzle_migrations` — the journal is tooling state,
121
+ * never an app model. Exported so the command shell counts the same set it writes.
122
+ */
123
+ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableSchema[] {
124
+ return snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`) && !INFRASTRUCTURE_TABLES.has(bareName(t.table)));
125
+ }
126
+
105
127
  /** A table name → its model variable: PascalCase, naively singularized. `image_variants` → `ImageVariant`. */
106
128
  export function modelVarName(table: string): string {
107
129
  const bare = bareName(table);
@@ -109,9 +131,17 @@ export function modelVarName(table: string): string {
109
131
  return singular.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
110
132
  }
111
133
 
112
- /** `author_id` → `authorId` (a SQL column to its camelCase field key — the inverse of the compiler's snake_case). */
134
+ /**
135
+ * `author_id` → `authorId` — a SQL column to its field key, LOSSLESSLY: the compiler's
136
+ * snake_case must reconstruct the exact column (`snake(key) === column`, always). Only a
137
+ * letter after `_` camelizes, because only a capital letter survives the trip back — an
138
+ * underscore before a digit is preserved (`yardline_100` stays `yardline_100`,
139
+ * `top_3_best` → `top_3Best`). Swallowing it (the old `_([a-z0-9])` rule) generated a
140
+ * phantom rename for every digit-boundary column and COLLIDED distinct columns
141
+ * (`a_1` and `a1`) onto one key.
142
+ */
113
143
  function toCamelCase(name: string): string {
114
- return name.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
144
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
115
145
  }
116
146
 
117
147
  /** The `.default*()` modifier for a column default, or null when the expression isn't one we map. */
@@ -132,20 +162,37 @@ function tsLiteral(value: string): string {
132
162
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
133
163
  }
134
164
 
165
+ /**
166
+ * The `field.serial()`/`field.bigserial()` factory for an integer/bigint column whose
167
+ * default is its own conventional sequence (`<table>_<column>_seq`, optionally
168
+ * schema-qualified — the deparser qualifies it when the schema is off the search_path).
169
+ * A nextval on any OTHER sequence is not a serial declaration and stays surfaced.
170
+ */
171
+ function serialFactoryCall(table: TableSchema, col: ColumnSchema): string | null {
172
+ const factory = col.type === 'integer' ? 'serial' : col.type === 'bigint' ? 'bigserial' : null;
173
+ if (!factory || col.default == null) return null;
174
+ const m = col.default.match(/^nextval\('(?:[^'.]+\.)?([^'.]+)'::regclass\)$/);
175
+ if (!m || m[1] !== `${bareName(table.table)}_${col.name}_seq`) return null;
176
+ return `field.${factory}()`;
177
+ }
178
+
135
179
  /** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
136
180
  function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>): string {
137
181
  // An enum column's type is the enum's name; render the explicit-name field.enum with its
138
182
  // values (the always-explicit form round-trips the name and the value order).
139
183
  const enumValues = enums.get(col.type);
140
- const call = enumValues
141
- ? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
142
- : fieldFactoryCall(col.type);
184
+ const serial = serialFactoryCall(table, col);
185
+ const call = serial
186
+ ?? (enumValues
187
+ ? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
188
+ : fieldFactoryCall(col.type));
143
189
  let expr = call ?? 'field.text()';
144
190
  let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
145
191
 
146
192
  const isPk = table.primaryKey.includes(col.name);
147
193
  if (isPk) expr += '.primaryKey()';
148
- else if (col.notNull) expr += '.notNull()';
194
+ // A serial field is NOT NULL by construction, so the modifier would be redundant.
195
+ else if (col.notNull && !serial) expr += '.notNull()';
149
196
 
150
197
  if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
151
198
 
@@ -161,7 +208,7 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
161
208
  } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
162
209
  }
163
210
 
164
- if (col.default != null) {
211
+ if (col.default != null && !serial) {
165
212
  const d = renderDefault(col.default);
166
213
  if (d) expr += d;
167
214
  else comment += ` // TODO: unmapped default ${col.default}`;
@@ -232,6 +279,19 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
232
279
  return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
233
280
  }
234
281
 
282
+ /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
283
+ function importHeader(body: string): string {
284
+ const symbols = ['defineModel', 'field'];
285
+ if (/\bunique\(/.test(body)) symbols.push('unique');
286
+ if (/\bcheck\(/.test(body)) symbols.push('check');
287
+ if (/\bindex\(/.test(body)) symbols.push('index');
288
+ if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
289
+ if (/\bsql`/.test(body)) symbols.push('sql');
290
+ const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
291
+ if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
292
+ return imports.join('\n');
293
+ }
294
+
235
295
  /**
236
296
  * Render a whole snapshot as a single Models module: the import, one block per table (scoped
237
297
  * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
@@ -239,24 +299,71 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
239
299
  */
240
300
  export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
241
301
  const schema = opts.schema ?? 'public';
242
- const tables = snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`));
302
+ const tables = pullableTables(snapshot, schema);
243
303
  const known = new Set(tables.map((t) => bareName(t.table)));
244
304
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
245
305
 
246
306
  const blocks = tables.map((t) => renderModelBlock(t, known, enums));
247
307
  const body = blocks.join('\n\n');
248
308
 
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');
309
+ const header = importHeader(body);
259
310
  const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
260
311
 
261
312
  return [header, ...blocks, footer].join('\n\n') + '\n';
262
313
  }
314
+
315
+ /** `image_variants` → `image-variants.ts` — the repo's kebab-case file convention. */
316
+ function modelFileName(table: string): string {
317
+ return `${bareName(table).replace(/_/g, '-')}.ts`;
318
+ }
319
+
320
+ /** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
321
+ function referencedTables(table: TableSchema, known: Set<string>): string[] {
322
+ const self = bareName(table.table);
323
+ const refs = new Set<string>();
324
+ for (const fk of table.foreignKeys) {
325
+ const target = bareName(fk.refTable);
326
+ if (target !== self && known.has(target)) refs.add(target);
327
+ }
328
+ return [...refs].sort();
329
+ }
330
+
331
+ export interface RenderedModelFile {
332
+ /** File name relative to the output directory (`posts.ts`, …, `index.ts` last). */
333
+ file: string;
334
+ source: string;
335
+ }
336
+
337
+ /**
338
+ * Render a snapshot as one file per model plus an `index.ts` — the shape a real app keeps
339
+ * its models in (a 200-table pull as a single module is unreviewable). Each model file
340
+ * imports its own `@everystack/model` symbols and its FK targets from their sibling files
341
+ * (extensionless specifiers — Metro and TS bundler resolution both take them; the
342
+ * `() => Model` thunks make circular FK imports safe). The index re-exports every model
343
+ * and assembles the `models` array the rest of the framework consumes.
344
+ */
345
+ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions = {}): RenderedModelFile[] {
346
+ const schema = opts.schema ?? 'public';
347
+ const tables = pullableTables(snapshot, schema);
348
+ const known = new Set(tables.map((t) => bareName(t.table)));
349
+ const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
350
+
351
+ const files: RenderedModelFile[] = tables.map((t) => {
352
+ const block = renderModelBlock(t, known, enums);
353
+ const crossImports = referencedTables(t, known).map(
354
+ (target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
355
+ );
356
+ const header = [importHeader(block), ...crossImports].join('\n');
357
+ return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
358
+ });
359
+
360
+ const names = tables.map((t) => modelVarName(t.table));
361
+ const index = [
362
+ names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`).join('\n'),
363
+ `export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
364
+ `export const models = [\n${names.map((n) => ` ${n},`).join('\n')}\n];`,
365
+ ].join('\n\n');
366
+ files.push({ file: 'index.ts', source: index + '\n' });
367
+
368
+ return files;
369
+ }
package/src/cli/output.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  * No spinner library — step markers work in CI/CD and terminals alike.
5
5
  */
6
6
 
7
+ // Progress is diagnostics, never data — stderr, so a codegen command (db:pull) keeps a
8
+ // pure stdout for redirects even when shared plumbing (resolveConfig discovery) reports.
7
9
  export function step(msg: string): void {
8
- console.log(` > ${msg}`);
10
+ console.error(` > ${msg}`);
9
11
  }
10
12
 
11
13
  export function success(msg: string): void {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * renderDataModelSection — the app's Models, rendered for the runbook.
3
+ *
4
+ * The one chapter no generic doc can carry: this app's tables, fields,
5
+ * relations, and declared abilities. Compiled from the same ModelDescriptors
6
+ * db:generate consumes, so it can never describe a schema the app doesn't
7
+ * have — and an ability change shows up as runbook drift (`runbook --check`).
8
+ */
9
+
10
+ import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
11
+
12
+ function typeLabel(spec: FieldSpec): string {
13
+ let base: string;
14
+ switch (spec.type) {
15
+ case 'varchar':
16
+ base = spec.length != null ? `varchar(${spec.length})` : 'varchar';
17
+ break;
18
+ case 'numeric':
19
+ base =
20
+ spec.precision != null
21
+ ? `numeric(${spec.precision}${spec.scale != null ? `, ${spec.scale}` : ''})`
22
+ : 'numeric';
23
+ break;
24
+ case 'enum':
25
+ base = spec.enumValues ? `enum(${spec.enumValues.join(' | ')})` : 'enum';
26
+ break;
27
+ default:
28
+ base = spec.type;
29
+ }
30
+ return spec.isArray ? `${base}[]` : base;
31
+ }
32
+
33
+ function referenceTarget(spec: FieldSpec): string | null {
34
+ if (!spec.references) return null;
35
+ try {
36
+ const target = spec.references() as { table?: string } | undefined;
37
+ return target?.table ?? 'unknown';
38
+ } catch {
39
+ return 'unknown';
40
+ }
41
+ }
42
+
43
+ function fieldLine(name: string, spec: FieldSpec): string {
44
+ const parts = [typeLabel(spec)];
45
+ if (spec.isPrimaryKey) parts.push('pk');
46
+ if (spec.isNotNull && !spec.isPrimaryKey) parts.push('not null');
47
+ if (spec.isUnique) parts.push('unique');
48
+ const target = referenceTarget(spec);
49
+ if (target) {
50
+ parts.push(`→ ${target}${spec.onDelete && spec.onDelete !== 'no action' ? ` (${spec.onDelete})` : ''}`);
51
+ }
52
+ if (spec.defaultKind === 'now') parts.push('default: now()');
53
+ else if (spec.defaultKind === 'random') parts.push('default: random');
54
+ else if (spec.defaultKind === 'value') parts.push(`default: ${JSON.stringify(spec.default)}`);
55
+ if (spec.isPrivate) parts.push('private (never serialized)');
56
+ if (spec.isReadonly) parts.push('readonly (never writable via the API)');
57
+ return `- \`${name}\` — ${parts.join(', ')}`;
58
+ }
59
+
60
+ function abilityLine(a: Ability): string {
61
+ const verb = a.action === 'manage' ? 'manage (all actions)' : a.action;
62
+ const c = a.condition ?? {};
63
+ const scope: string[] = [];
64
+ if (c.role) scope.push(`role \`${c.role}\``);
65
+ if (c.owner) scope.push(`owner (\`${c.owner}\`)`);
66
+ if (c.via) scope.push(`owner via \`${c.via}\``);
67
+ if (c.where || c.sql) scope.push('custom predicate');
68
+ const columns = c.columns ? ` — columns: ${c.columns.map((col) => `\`${col}\``).join(', ')}` : '';
69
+ return `- ${verb}: ${scope.length ? scope.join(', ') : 'anyone'}${columns}`;
70
+ }
71
+
72
+ function relationLine(name: string, rel: Relation): string {
73
+ let target = 'unknown';
74
+ try {
75
+ target = rel.target().table;
76
+ } catch {
77
+ /* forward reference that can't resolve here — label stays unknown */
78
+ }
79
+ const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
80
+ return `- \`${name}\` — ${shape} ${target} (via \`${rel.column}\`)`;
81
+ }
82
+
83
+ export function renderDataModelSection(models: ModelDescriptor[]): string {
84
+ const lines: string[] = [
85
+ '## Your data model',
86
+ '',
87
+ "Compiled from the app's Models — edit `models/` and regenerate; never edit this section.",
88
+ ];
89
+ for (const m of models) {
90
+ const heading = m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table;
91
+ lines.push('', `### ${heading}`, '');
92
+ const annotations: string[] = [];
93
+ if (m.private) annotations.push('not exposed via the generic API');
94
+ if (m.writtenBy !== 'app') annotations.push(`written by ${m.writtenBy}`);
95
+ if (annotations.length) lines.push(`_Operational table — ${annotations.join('; ')}._`, '');
96
+ for (const [name, builder] of Object.entries(m.fields)) {
97
+ lines.push(fieldLine(name, builder.spec));
98
+ }
99
+ const relations = Object.entries(m.relations ?? {});
100
+ if (relations.length) {
101
+ lines.push('', 'Relations:', '');
102
+ for (const [name, rel] of relations) lines.push(relationLine(name, rel));
103
+ }
104
+ lines.push('', 'Access:', '');
105
+ if (m.abilities.length === 0) {
106
+ lines.push('- no abilities declared — nobody can reach this table through the API');
107
+ } else {
108
+ for (const a of m.abilities) lines.push(abilityLine(a));
109
+ }
110
+ }
111
+ return lines.join('\n');
112
+ }
@@ -68,7 +68,14 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
68
68
  return { uniques, checks, indexes };
69
69
  }
70
70
 
71
- /** Postgres type for a field, with the precision drizzle-kit would emit. */
71
+ /**
72
+ * Postgres type for a field, with the precision drizzle-kit would emit — in
73
+ * `format_type`'s vocabulary, because this feeds the IR that diffs against
74
+ * introspection. A serial field's *column type* is integer/bigint (`serial` is
75
+ * DDL shorthand, not a type — `format_type` never reports it); the sequence
76
+ * default is carried separately (see compileTableSchema), and the DDL renderers
77
+ * spell the shorthand (see SERIAL_DDL).
78
+ */
72
79
  const PG_TYPE: Record<string, string> = {
73
80
  text: 'text',
74
81
  uuid: 'uuid',
@@ -76,7 +83,10 @@ const PG_TYPE: Record<string, string> = {
76
83
  boolean: 'boolean',
77
84
  jsonb: 'jsonb',
78
85
  bigint: 'bigint',
79
- bigserial: 'bigserial',
86
+ serial: 'integer',
87
+ bigserial: 'bigint',
88
+ real: 'real',
89
+ doublePrecision: 'double precision',
80
90
  timestamptz: 'timestamp with time zone',
81
91
  // `format_type` canonicalizes a bare `timestamp` column to this spelling, and
82
92
  // introspection compares against it — so the compiler must emit the same, or an
@@ -221,15 +231,26 @@ function literal(value: unknown): string {
221
231
  return String(value);
222
232
  }
223
233
 
234
+ /** The DDL shorthand for the sequence-backed types — CREATE/ADD COLUMN must use it (it creates the sequence). */
235
+ const SERIAL_DDL: Partial<Record<FieldSpec['type'], string>> = { serial: 'serial', bigserial: 'bigserial' };
236
+
237
+ /** The implicit sequence a serial column owns, as `pg_get_expr` deparses its default. */
238
+ function serialDefault(table: string, sqlName: string): string {
239
+ return `nextval('${table}_${sqlName}_seq'::regclass)`;
240
+ }
241
+
224
242
  /**
225
243
  * One column definition (no leading indent). Clause order matches drizzle's:
226
244
  * type, PRIMARY KEY (single-column only), DEFAULT, NOT NULL. `.references()`,
227
245
  * `.unique()`, and composite PKs are table-level, emitted by compileCreateTable.
246
+ * A serial field renders the `serial` shorthand and no DEFAULT — the shorthand
247
+ * creates the backing sequence a bare nextval default could not reference.
228
248
  */
229
249
  export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
230
- let s = `"${sqlName}" ${pgType(spec)}`;
250
+ const serial = SERIAL_DDL[spec.type];
251
+ let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
231
252
  if (inlinePrimaryKey) s += ' PRIMARY KEY';
232
- const def = defaultExpr(spec);
253
+ const def = serial ? null : defaultExpr(spec);
233
254
  if (def) s += ` DEFAULT ${def}`;
234
255
  if (spec.isNotNull) s += ' NOT NULL';
235
256
  return s;
@@ -394,12 +415,23 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
394
415
  const schema = model.schema ?? opts.schema ?? 'public';
395
416
  const entries = Object.entries(model.fields);
396
417
 
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
- }));
418
+ // A serial column's IR is the pg-precise truth introspection reports: integer/bigint
419
+ // (PG_TYPE), NOT NULL, and the implicit sequence's nextval default — so an unchanged
420
+ // serial column diffs clean. The `serial` shorthand exists only in the DDL renderers.
421
+ const columns = entries.map(([name, field]) => {
422
+ const sqlName = toSnakeCase(name);
423
+ const isSerial = field.spec.type in SERIAL_DDL;
424
+ return {
425
+ name: sqlName,
426
+ type: pgType(field.spec),
427
+ notNull: field.spec.isNotNull || isSerial,
428
+ // The deparser qualifies the sequence only when its schema is off the search_path,
429
+ // so a public table's sequence stays bare and a package-schema one is qualified.
430
+ default: isSerial
431
+ ? serialDefault(schema === 'public' ? model.table : `${schema}.${model.table}`, sqlName)
432
+ : defaultExpr(field.spec),
433
+ };
434
+ });
403
435
 
404
436
  const tableConstraints = modelConstraintSpecs(model);
405
437
 
@@ -111,6 +111,12 @@ export function normalizeDefault(expr: string | null): string | null {
111
111
  } while (s !== prev);
112
112
 
113
113
  if (/^current_timestamp$/i.test(s)) return 'now()';
114
+
115
+ // The deparser qualifies a sequence name only when its schema is off the search_path,
116
+ // so the same serial default reads `nextval('x_seq')` on one database and
117
+ // `nextval('public.x_seq')` on another. public is always on the search_path — strip it.
118
+ s = s.replace(/^nextval\('public\.([^']+)'/, "nextval('$1'");
119
+
114
120
  return s;
115
121
  }
116
122
 
@@ -124,6 +130,9 @@ export type TypeChangeRisk = 'safe' | 'lossy' | 'risky';
124
130
  /** Integer/numeric widening rank — a value at a lower rank always fits a higher one. */
125
131
  const NUMERIC_RANK: Record<string, number> = { smallint: 1, integer: 2, bigint: 3, numeric: 4 };
126
132
 
133
+ /** Float widening rank — float4 always fits float8. */
134
+ const FLOAT_RANK: Record<string, number> = { real: 1, 'double precision': 2 };
135
+
127
136
  /** Split a `format_type` string into its base and any precision/length args: `numeric(12,2)` -> {base:'numeric', args:[12,2]}. */
128
137
  function parseType(t: string): { base: string; args: number[] } {
129
138
  const m = t.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
@@ -157,6 +166,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
157
166
  return tr > fr ? 'safe' : 'lossy'; // widen up the integer/numeric ranks is safe; down can overflow
158
167
  }
159
168
 
169
+ const ff = FLOAT_RANK[f.base];
170
+ const tf = FLOAT_RANK[t.base];
171
+ if (ff && tf) return tf >= ff ? 'safe' : 'lossy'; // float widening is exact; narrowing rounds
172
+ // A true integer type into a float is safe only when the mantissa provably holds every
173
+ // value (smallint fits both; integer fits float8's 53 bits but not float4's 24);
174
+ // otherwise it rounds — lossy, never failing. numeric is excluded (its range exceeds
175
+ // float8, so the cast can fail), and a float into anything narrower falls through to
176
+ // risky: an out-of-range cast (1e300::integer) fails per row.
177
+ if ((f.base === 'smallint' || f.base === 'integer' || f.base === 'bigint') && tf) {
178
+ return f.base === 'smallint' || (f.base === 'integer' && t.base === 'double precision') ? 'safe' : 'lossy';
179
+ }
180
+
160
181
  if (f.base === t.base) return argsWiden(f.args, t.args) ? 'safe' : 'lossy'; // same base, only precision/length moved
161
182
 
162
183
  const timestamps = new Set(['timestamp', 'timestamp with time zone', 'timestamp without time zone']);
@@ -408,16 +429,25 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
408
429
  return out;
409
430
  }
410
431
 
432
+ /** A table's uniques and foreign keys as ConstraintSpecs (the shape addConstraintSql renders). */
433
+ function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
434
+ return [
435
+ ...t.uniques.map((u): ConstraintSpec => ({ type: 'unique', name: u.name, columns: u.columns })),
436
+ ...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate })),
437
+ ];
438
+ }
439
+
411
440
  /**
412
- * Name-keyed constraints whose names are content-derived and stable uniques (named by
413
- * their columns) and foreign keys. Checks are handled separately, matched by predicate, because
414
- * a `check(...)` carries no author-given name and the database's name is arbitrary.
441
+ * The identity a unique/FK is matched on — its content, never its name. A pulled database
442
+ * carries Postgres default names (`posts_author_id_fkey`, `posts_slug_key`) while the
443
+ * compiler names drizzle-style; a same-content constraint under either name is the same
444
+ * constraint (the rule checks already follow). Matching by name would read every brownfield
445
+ * FK as drift and emit an ADD that duplicates a constraint the table already has.
415
446
  */
416
- function namedConstraints(t: TableSchema): Map<string, ConstraintSpec> {
417
- const m = new Map<string, ConstraintSpec>();
418
- for (const u of t.uniques) m.set(u.name, { type: 'unique', name: u.name, columns: u.columns });
419
- for (const fk of t.foreignKeys) m.set(fk.name, { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate });
420
- return m;
447
+ function constraintContentKey(c: ConstraintSpec): string {
448
+ if (c.type === 'unique') return `u|${c.columns.join(',')}`;
449
+ if (c.type === 'foreignKey') return `f|${c.columns.join(',')}|${c.refTable}|${c.refColumns.join(',')}|${c.onDelete ?? ''}|${c.onUpdate ?? ''}`;
450
+ return `c|${normalizeCheck(c.expr)}`; // checks take their own predicate-matched path (diffChecks)
421
451
  }
422
452
 
423
453
  /**
@@ -437,32 +467,25 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
437
467
  }
438
468
  }
439
469
 
440
- function constraintsEqual(a: ConstraintSpec, b: ConstraintSpec): boolean {
441
- if (a.type !== b.type) return false;
442
- if (a.type === 'unique' && b.type === 'unique') return a.columns.join(',') === b.columns.join(',');
443
- if (a.type === 'check' && b.type === 'check') return normalizeCheck(a.expr) === normalizeCheck(b.expr);
444
- if (a.type === 'foreignKey' && b.type === 'foreignKey') {
445
- return a.columns.join(',') === b.columns.join(',')
446
- && a.refTable === b.refTable
447
- && a.refColumns.join(',') === b.refColumns.join(',')
448
- && (a.onDelete ?? '') === (b.onDelete ?? '')
449
- && (a.onUpdate ?? '') === (b.onUpdate ?? '');
450
- }
451
- return false;
452
- }
453
-
454
470
  function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], alters: SchemaChange[]): void {
455
- const desired = namedConstraints(d);
456
- const current = namedConstraints(c);
457
-
458
- // Drop removed or changed constraints first (a changed one is dropped here, re-added below).
459
- for (const [name, cur] of current) {
460
- const des = desired.get(name);
461
- if (!des || !constraintsEqual(des, cur)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name });
471
+ // Multiset-match uniques and FKs by content: a desired constraint that finds a
472
+ // same-content current one is unchanged (whatever either is named); an unmatched
473
+ // desired is an add; a leftover current is dropped by its real name. A *changed*
474
+ // constraint therefore falls out as drop + add, never a silent mutation.
475
+ const currentByKey = new Map<string, ConstraintSpec[]>();
476
+ for (const cur of tableConstraintSpecs(c)) {
477
+ const key = constraintContentKey(cur);
478
+ const bucket = currentByKey.get(key);
479
+ if (bucket) bucket.push(cur);
480
+ else currentByKey.set(key, [cur]);
481
+ }
482
+ for (const des of tableConstraintSpecs(d)) {
483
+ const bucket = currentByKey.get(constraintContentKey(des));
484
+ if (bucket?.length) bucket.pop();
485
+ else addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
462
486
  }
463
- for (const [name, des] of desired) {
464
- const cur = current.get(name);
465
- if (!cur || !constraintsEqual(des, cur)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
487
+ for (const bucket of currentByKey.values()) {
488
+ for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
466
489
  }
467
490
 
468
491
  diffChecks(d, c, dropConstraints, addConstraints);
@@ -486,10 +509,24 @@ const colList = (cols: string[]): string => cols.map(quote).join(', ');
486
509
  /** A single-quoted enum value literal, doubling any embedded quote. */
487
510
  const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
488
511
 
512
+ /**
513
+ * The `serial`/`bigserial` DDL shorthand for a column whose IR is integer/bigint with a
514
+ * nextval default (how compileTableSchema and introspection both represent a serial
515
+ * column), or null. CREATE/ADD COLUMN must use the shorthand — it creates the backing
516
+ * sequence, which a bare `DEFAULT nextval(...)` would reference before it exists.
517
+ */
518
+ function serialSpelling(col: ColumnSchema): string | null {
519
+ if (col.default == null || !/^nextval\('[^']+'::regclass\)$/.test(col.default)) return null;
520
+ if (col.type === 'integer') return 'serial';
521
+ if (col.type === 'bigint') return 'bigserial';
522
+ return null;
523
+ }
524
+
489
525
  /** A column definition body (no leading indent): `"name" type [DEFAULT expr] [NOT NULL]`. */
490
526
  function columnSql(col: ColumnSchema): string {
491
- let s = `${quote(col.name)} ${col.type}`;
492
- if (col.default != null) s += ` DEFAULT ${col.default}`;
527
+ const serial = serialSpelling(col);
528
+ let s = `${quote(col.name)} ${serial ?? col.type}`;
529
+ if (!serial && col.default != null) s += ` DEFAULT ${col.default}`;
493
530
  if (col.notNull) s += ' NOT NULL';
494
531
  return s;
495
532
  }
@@ -69,8 +69,14 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
69
69
  return { call: `integer(${n})`, builder: 'integer' };
70
70
  case 'bigint':
71
71
  return { call: `bigint(${n}, { mode: 'number' })`, builder: 'bigint' };
72
+ case 'serial':
73
+ return { call: `serial(${n})`, builder: 'serial' };
72
74
  case 'bigserial':
73
75
  return { call: `bigserial(${n}, { mode: 'number' })`, builder: 'bigserial' };
76
+ case 'real':
77
+ return { call: `real(${n})`, builder: 'real' };
78
+ case 'doublePrecision':
79
+ return { call: `doublePrecision(${n})`, builder: 'doublePrecision' };
74
80
  case 'boolean':
75
81
  return { call: `boolean(${n})`, builder: 'boolean' };
76
82
  case 'timestamptz':