@everystack/cli 0.3.28 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -2
  3. package/src/cli/apply-execute.ts +185 -0
  4. package/src/cli/authz-compile.ts +5 -2
  5. package/src/cli/authz-lint.ts +48 -0
  6. package/src/cli/aws.ts +102 -4
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-apply.ts +159 -169
  9. package/src/cli/commands/db-backfill.ts +2 -2
  10. package/src/cli/commands/db-backup.ts +34 -0
  11. package/src/cli/commands/db-branch.ts +28 -4
  12. package/src/cli/commands/db-check.ts +97 -34
  13. package/src/cli/commands/db-fingerprint.ts +9 -4
  14. package/src/cli/commands/db-generate.ts +73 -22
  15. package/src/cli/commands/db-plan.ts +7 -13
  16. package/src/cli/commands/db-pull.ts +39 -7
  17. package/src/cli/commands/db-reconcile.ts +219 -66
  18. package/src/cli/commands/db-sync.ts +58 -21
  19. package/src/cli/commands/deploy.ts +4 -0
  20. package/src/cli/commands/pipeline-run.ts +269 -0
  21. package/src/cli/db-build.ts +9 -3
  22. package/src/cli/db-source.ts +83 -7
  23. package/src/cli/declared-derived.ts +136 -0
  24. package/src/cli/derived-apply.ts +40 -6
  25. package/src/cli/derived-compile.ts +327 -0
  26. package/src/cli/derived-grants.ts +96 -0
  27. package/src/cli/derived-introspect.ts +258 -21
  28. package/src/cli/derived-lint.ts +261 -0
  29. package/src/cli/derived-plan.ts +194 -12
  30. package/src/cli/derived-render.ts +320 -0
  31. package/src/cli/derived-source.ts +53 -125
  32. package/src/cli/discover.ts +16 -0
  33. package/src/cli/git-descent.ts +15 -2
  34. package/src/cli/index.ts +26 -6
  35. package/src/cli/migration-compile.ts +38 -7
  36. package/src/cli/migration-generate.ts +43 -4
  37. package/src/cli/model-render.ts +106 -13
  38. package/src/cli/models-path.ts +1 -1
  39. package/src/cli/ops-diagnostics.ts +71 -0
  40. package/src/cli/pipeline-loader.ts +68 -0
  41. package/src/cli/pipeline-path.ts +25 -0
  42. package/src/cli/schema-compile.ts +41 -10
  43. package/src/cli/schema-diff.ts +123 -17
  44. package/src/cli/schema-fingerprint.ts +28 -18
  45. package/src/cli/schema-introspect.ts +175 -8
  46. package/src/cli/schema-source.ts +75 -6
  47. package/src/cli/state-apply.ts +4 -1
@@ -30,6 +30,7 @@ import type {
30
30
  ColumnSchema,
31
31
  EnumType,
32
32
  IndexSchema,
33
+ SequenceSchema,
33
34
  } from './schema-introspect.js';
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -79,10 +80,14 @@ export type SchemaChange =
79
80
  | { kind: 'dropType'; name: string }
80
81
  /** An enum value the DB has but the Model dropped/renamed — Postgres can't drop one in place (a notice). */
81
82
  | { kind: 'enumValuesChanged'; name: string; from: string[]; to: string[] }
82
- /** A standalone index to create → `CREATE [UNIQUE] INDEX … [WHERE …]`. */
83
- | { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string }
83
+ /** A standalone index to create → `CREATE [UNIQUE] INDEX … [USING …] (…) [INCLUDE …] [WHERE …]`. */
84
+ | { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[] }
84
85
  /** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
85
- | { kind: 'dropIndex'; table: string; name: string };
86
+ | { kind: 'dropIndex'; table: string; name: string }
87
+ /** A declared standalone sequence missing live → `CREATE SEQUENCE`, before any table. */
88
+ | { kind: 'createSequence'; sequence: SequenceSchema }
89
+ /** A declared sequence whose live properties differ — a NOTICE, never a silent ALTER (it holds state). */
90
+ | { kind: 'sequenceDrift'; name: string; declared: SequenceSchema; live: SequenceSchema };
86
91
 
87
92
  /**
88
93
  * Rename intent, keyed like the snapshot's tables (`public.posts`) → { newColumn: oldColumn }.
@@ -131,9 +136,48 @@ export function normalizeDefault(expr: string | null): string | null {
131
136
  // `nextval('public.x_seq')` on another. public is always on the search_path — strip it.
132
137
  s = s.replace(/^nextval\('public\.([^']+)'/, "nextval('$1'");
133
138
 
139
+ // The deparser also casts the sequence literal (`nextval('x_seq'::regclass)`); an
140
+ // authored `defaultSql` may spell it bare. One canonical (castless) form for both —
141
+ // a normalization change, so FINGERPRINT_VERSION was bumped with it.
142
+ s = s.replace(/^nextval\('([^']+)'::regclass\)$/, "nextval('$1')");
143
+
134
144
  return s;
135
145
  }
136
146
 
147
+ /**
148
+ * The sequence a `nextval` default draws from (schema qualification and the `::regclass`
149
+ * cast stripped), or null when the expression isn't a plain nextval. Drives two decisions:
150
+ * the `serial` shorthand fires ONLY for a column's own conventional `<table>_<col>_seq`
151
+ * (anything else would mint a NEW sequence), and a foreign-sequence default is held out
152
+ * of CREATE/ADD and re-attached as a trailing SET DEFAULT (its sequence may live on a
153
+ * table created later).
154
+ */
155
+ export function nextvalSequence(expr: string | null): string | null {
156
+ if (expr == null) return null;
157
+ const m = expr.trim().match(/^nextval\('(?:[^'.]+\.)?([^'.]+)'(?:::regclass)?\)$/);
158
+ return m ? m[1] : null;
159
+ }
160
+
161
+ /** `public.contracts` / `contracts` → `contracts` (the bare table name for sequence conventions). */
162
+ function bareTable(table: string): string {
163
+ return table.replace(/^[^.]+\./, '');
164
+ }
165
+
166
+ /** `CREATE SEQUENCE` DDL for a standalone sequence — emitted BEFORE any table, so a
167
+ * column's `defaultSql(nextval(…))` can draw on it from the first CREATE. */
168
+ export function sequenceCreateSql(seq: SequenceSchema): string {
169
+ let s = `CREATE SEQUENCE ${seq.name} AS ${seq.as}`;
170
+ if (seq.start != null) s += ` START WITH ${seq.start}`;
171
+ if (seq.increment != null) s += ` INCREMENT BY ${seq.increment}`;
172
+ return `${s};`;
173
+ }
174
+
175
+ /** True when a column's default draws a sequence that is NOT its own conventional one. */
176
+ function isForeignSequenceDefault(table: string, col: ColumnSchema): boolean {
177
+ const seq = nextvalSequence(col.default);
178
+ return seq != null && seq !== `${bareTable(table)}_${col.name}_seq`;
179
+ }
180
+
137
181
  // ---------------------------------------------------------------------------
138
182
  // Type-change classification — does the conversion need a USING cast and a warning?
139
183
  // ---------------------------------------------------------------------------
@@ -318,29 +362,76 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
318
362
 
319
363
  const e = diffEnums(desired.enums ?? [], current.enums ?? []);
320
364
 
365
+ // Standalone sequences — existence-diffed, DECLARED-SCOPED (a live sequence the models
366
+ // never declared is invisible: it holds state, and undeclared ≠ ours to drop). A declared
367
+ // one missing live is a create, before any table (a defaultSql may draw on it from the
368
+ // first CREATE); live property drift is a NOTICE — never a silent ALTER on a counter.
369
+ const sequenceCreates: SchemaChange[] = [];
370
+ const sequenceNotices: SchemaChange[] = [];
371
+ const currentSeqs = new Map((current.sequences ?? []).map((s) => [s.name, s]));
372
+ for (const declared of desired.sequences ?? []) {
373
+ const liveSeq = currentSeqs.get(declared.name);
374
+ if (!liveSeq) {
375
+ sequenceCreates.push({ kind: 'createSequence', sequence: declared });
376
+ } else if (liveSeq.as !== declared.as || (liveSeq.increment ?? 1) !== (declared.increment ?? 1)) {
377
+ sequenceNotices.push({ kind: 'sequenceDrift', name: declared.name, declared, live: liveSeq });
378
+ }
379
+ }
380
+
381
+ // Foreign-sequence defaults are held out of CREATE/ADD by the emitters (the sequence
382
+ // may live on a table created later in the same batch) and re-attached here as
383
+ // SET DEFAULT changes ordered after every create and add.
384
+ const trailingDefaults: SchemaChange[] = [];
385
+ for (const c of creates) {
386
+ if (c.kind !== 'createTable') continue;
387
+ for (const col of c.schema.columns) {
388
+ if (isForeignSequenceDefault(c.table, col)) {
389
+ trailingDefaults.push({ kind: 'setDefault', table: c.table, column: col.name, expr: col.default! });
390
+ }
391
+ }
392
+ }
393
+ for (const c of addColumns) {
394
+ if (c.kind !== 'addColumn') continue;
395
+ if (isForeignSequenceDefault(c.table, c.column)) {
396
+ trailingDefaults.push({ kind: 'setDefault', table: c.table, column: c.column.name, expr: c.column.default! });
397
+ }
398
+ }
399
+
321
400
  // Enum types come first (a table or column may reference one); value adds before the
322
401
  // columns that use them. Table renames run before creates (a rename frees a name a
323
402
  // create may reuse) and before every per-table change (which all target the new name).
324
403
  // Column renames run before adds and alters for the same reason. Enum drops sit with
325
404
  // the other removals; notices are comments — emitted last, after the DDL.
326
405
  return [
327
- ...e.creates, ...e.adds,
406
+ ...e.creates, ...e.adds, ...sequenceCreates,
328
407
  ...renameTables,
329
- ...creates, ...renameColumns, ...addColumns, ...alters,
408
+ ...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
330
409
  ...dropConstraints, ...addConstraints, ...createIndexes,
331
410
  ...dropColumns, ...dropIndexes, ...dropTables,
332
- ...e.drops, ...notices, ...e.notices,
411
+ ...e.drops, ...notices, ...e.notices, ...sequenceNotices,
333
412
  ];
334
413
  }
335
414
 
336
415
  /** A `createIndex` change from an index spec (the new-table path and the per-table diff share it). */
337
416
  function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
338
- return { kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) };
417
+ return {
418
+ kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique,
419
+ ...(ix.where ? { where: ix.where } : {}),
420
+ ...(ix.using ? { using: ix.using } : {}),
421
+ ...(ix.include?.length ? { include: ix.include } : {}),
422
+ };
339
423
  }
340
424
 
341
- /** An index's content identity — columns, uniqueness, and partial predicate, name-independent (like checks). */
425
+ /**
426
+ * An index's content identity — key entries (expressions/direction/opclass are canonical
427
+ * text, normalized like checks), uniqueness, predicate, access method, and INCLUDE set:
428
+ * name-independent, and no longer blind to anything `CREATE INDEX` can say (Brick E —
429
+ * the consumer's 31 phantom drop/creates). Plain btree indexes key exactly as before
430
+ * plus a constant tail — both sides compute the same key, so nothing churns.
431
+ */
342
432
  function indexKey(ix: IndexSchema): string {
343
- return `${ix.unique ? 'u' : ''}|${ix.columns.join(',')}|${normalizeCheck(ix.where ?? '')}`;
433
+ const entries = ix.columns.map((c) => normalizeCheck(c)).join(',');
434
+ return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
344
435
  }
345
436
 
346
437
  /**
@@ -555,25 +646,31 @@ const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
555
646
  * column), or null. CREATE/ADD COLUMN must use the shorthand — it creates the backing
556
647
  * sequence, which a bare `DEFAULT nextval(...)` would reference before it exists.
557
648
  */
558
- function serialSpelling(col: ColumnSchema): string | null {
559
- if (col.default == null || !/^nextval\('[^']+'::regclass\)$/.test(col.default)) return null;
649
+ function serialSpelling(col: ColumnSchema, table: string): string | null {
650
+ // ONLY the column's own conventional sequence is a serial declaration — the shorthand
651
+ // creates a sequence, so spelling it for a foreign `nextval` would mint a duplicate
652
+ // and silently detach the column from the sequence it declared.
653
+ const seq = nextvalSequence(col.default);
654
+ if (seq == null || seq !== `${bareTable(table)}_${col.name}_seq`) return null;
560
655
  if (col.type === 'integer') return 'serial';
561
656
  if (col.type === 'bigint') return 'bigserial';
562
657
  return null;
563
658
  }
564
659
 
565
660
  /** A column definition body (no leading indent): `"name" type [DEFAULT expr] [NOT NULL]`. */
566
- function columnSql(col: ColumnSchema): string {
567
- const serial = serialSpelling(col);
661
+ function columnSql(col: ColumnSchema, table: string): string {
662
+ const serial = serialSpelling(col, table);
568
663
  let s = `${quote(col.name)} ${serial ?? col.type}`;
569
- if (!serial && col.default != null) s += ` DEFAULT ${col.default}`;
664
+ // A foreign-sequence default is held out diffSchema appends the trailing SET DEFAULT
665
+ // (after every create), so the CREATE never references a sequence that doesn't exist yet.
666
+ if (!serial && col.default != null && !isForeignSequenceDefault(table, col)) s += ` DEFAULT ${col.default}`;
570
667
  if (col.notNull) s += ' NOT NULL';
571
668
  return s;
572
669
  }
573
670
 
574
671
  /** `CREATE TABLE` from the IR: columns + PK/unique/check inline; FKs are emitted separately. */
575
672
  function createTableSql(t: TableSchema): string {
576
- const lines = t.columns.map((c) => `\t${columnSql(c)}`);
673
+ const lines = t.columns.map((c) => `\t${columnSql(c, t.table)}`);
577
674
  if (t.primaryKey.length) lines.push(`\tPRIMARY KEY (${colList(t.primaryKey)})`);
578
675
  for (const u of t.uniques) lines.push(`\tCONSTRAINT ${quote(u.name)} UNIQUE (${colList(u.columns)})`);
579
676
  for (const ck of t.checks) lines.push(`\tCONSTRAINT ${quote(ck.name)} CHECK ${wrapOnce(ck.expr)}`);
@@ -613,7 +710,7 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
613
710
  case 'dropTable':
614
711
  return `DROP TABLE ${change.table};`;
615
712
  case 'addColumn': {
616
- const ddl = `ALTER TABLE ${change.table} ADD COLUMN ${columnSql(change.column)};`;
713
+ const ddl = `ALTER TABLE ${change.table} ADD COLUMN ${columnSql(change.column, change.table)};`;
617
714
  // Adding a NOT NULL column with no default fails on a table that already has rows.
618
715
  if (change.column.notNull && change.column.default == null) {
619
716
  return `-- WARNING: ADD COLUMN ${quote(change.column.name)} NOT NULL on ${change.table} has no default and FAILS if the table has rows; add a default or backfill first.\n${ddl}`;
@@ -674,8 +771,13 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
674
771
  return `DROP TYPE ${quote(change.name)};`;
675
772
  case 'createIndex': {
676
773
  const unique = change.unique ? 'UNIQUE ' : '';
774
+ const using = change.using ? ` USING ${change.using}` : '';
775
+ // A plain identifier entry quotes as before; a canonical-text entry (expression,
776
+ // DESC, opclass) passes verbatim — quoting the whole thing would mangle it.
777
+ const entry = (c: string): string => (/^[a-z_][a-z0-9_$]*$/i.test(c) ? quote(c) : c);
778
+ const include = change.include?.length ? ` INCLUDE (${colList(change.include)})` : '';
677
779
  const where = change.where ? ` WHERE ${change.where}` : '';
678
- return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table} (${colList(change.columns)})${where};`;
780
+ return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
679
781
  }
680
782
  case 'dropIndex': {
681
783
  const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
@@ -683,6 +785,10 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
683
785
  }
684
786
  case 'enumValuesChanged':
685
787
  return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
788
+ case 'createSequence':
789
+ return sequenceCreateSql(change.sequence);
790
+ case 'sequenceDrift':
791
+ return `-- NOTICE: sequence ${change.name} differs from its declaration (declared AS ${change.declared.as}${change.declared.increment != null ? ` INCREMENT ${change.declared.increment}` : ''}; live AS ${change.live.as}${change.live.increment != null ? ` INCREMENT ${change.live.increment}` : ''}). A sequence holds a counter — reshape it by hand (ALTER SEQUENCE) after deciding what the counter should be.`;
686
792
  }
687
793
  }
688
794
 
@@ -39,23 +39,30 @@
39
39
  * fingerprints. The version prefix plus the re-diff path keep that honest.
40
40
  *
41
41
  * Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
42
- * base fingerprint does not see (standalone sequences, triggers, domains,
43
- * composite types, foreign/partitioned tables, extensions) so partial
44
- * coverage reads as a report, never as "covered everything".
42
+ * base fingerprint does not see (triggers drift-checked separately by the
43
+ * reconciler's provenance — domains, composite types, foreign/partitioned
44
+ * tables, extensions) so partial coverage reads as a report, never as
45
+ * "covered everything". Standalone sequences left this list in v3: covered.
45
46
  */
46
47
 
47
48
  import { createHash } from 'node:crypto';
48
- import type { ModelDescriptor } from '@everystack/model';
49
+ import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
49
50
  import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
50
51
  import type { AuthzContract, TableContract } from './authz-contract.js';
51
- import { compileTableSchema, compileEnums } from './schema-compile.js';
52
+ import { compileTableSchema, compileEnums, compileSequences } from './schema-compile.js';
52
53
  import { compileTableContract } from './authz-compile.js';
53
54
  import { normalizeDefault, normalizeCheck } from './schema-diff.js';
54
55
 
55
56
  // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
56
57
  // the diff's pg-deparse normalizers before hashing, so the model form and the live
57
58
  // deparsed form agree — the identity `fingerprintModels === fingerprintLive` holds.
58
- export const FINGERPRINT_VERSION = 2;
59
+ // v3: nextval defaults canonicalize to the castless form (`nextval('x_seq')`), so an
60
+ // authored `defaultSql` and the deparser's `nextval('x_seq'::regclass)` hash equal
61
+ // (canonical forms changed for every serial column's default → the bump). ALSO in v3:
62
+ // STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
63
+ // introspected from pg_sequence minus serial-owned) — a coverage expansion; the
64
+ // `sequences` key appears only when any exist, so sequence-free states hash unchanged.
65
+ export const FINGERPRINT_VERSION = 3;
59
66
 
60
67
  // ---------------------------------------------------------------------------
61
68
  // Canonical form.
@@ -149,12 +156,23 @@ export interface CanonicalState {
149
156
  tables: Array<Record<string, unknown>>;
150
157
  enums: Array<{ name: string; values: string[] }>;
151
158
  authz: Array<Record<string, unknown>>;
159
+ /** Standalone sequences — present ONLY when any exist, so a sequence-free state's
160
+ * canonical form (and hash) is byte-identical to the pre-coverage form. */
161
+ sequences?: Array<Record<string, unknown>>;
152
162
  }
153
163
 
154
164
  export function canonicalizeState(
155
165
  snapshot: SchemaSnapshot,
156
166
  authzTables: TableContract[],
157
167
  ): CanonicalState {
168
+ const sequences = byKey(
169
+ (snapshot.sequences ?? []).map((s) => ({
170
+ name: s.name, as: s.as,
171
+ ...(s.start != null ? { start: s.start } : {}),
172
+ ...(s.increment != null ? { increment: s.increment } : {}),
173
+ })),
174
+ (s) => String(s.name),
175
+ );
158
176
  return {
159
177
  v: FINGERPRINT_VERSION,
160
178
  tables: byKey(snapshot.tables.map(canonicalTable), (t) => String(t.table)),
@@ -163,6 +181,7 @@ export function canonicalizeState(
163
181
  (e) => e.name,
164
182
  ),
165
183
  authz: byKey(authzTables.map(canonicalAuthzTable), (t) => String(t.table)),
184
+ ...(sequences.length ? { sequences } : {}),
166
185
  };
167
186
  }
168
187
 
@@ -184,11 +203,12 @@ export function fingerprintState(
184
203
  /** The DECLARED state: compile the models to the same canonical shape and hash it. */
185
204
  export function fingerprintModels(
186
205
  models: ModelDescriptor[],
187
- opts: { schema?: string } = {},
206
+ opts: { schema?: string; sequences?: SequenceDescriptor[] } = {},
188
207
  ): Fingerprint {
189
208
  const snapshot: SchemaSnapshot = {
190
209
  tables: models.map((m) => compileTableSchema(m, opts)),
191
210
  enums: compileEnums(models),
211
+ ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
192
212
  };
193
213
  const authzTables = models.map((m) => compileTableContract(m, opts));
194
214
  return fingerprintState(snapshot, authzTables);
@@ -210,17 +230,7 @@ export function fingerprintLive(snapshot: SchemaSnapshot, contract: AuthzContrac
210
230
  * types, foreign tables, partitioned tables, and installed extensions.
211
231
  */
212
232
  export const UNFINGERPRINTED_SQL = `
213
- SELECT 'sequence' AS kind, n.nspname || '.' || c.relname AS identity
214
- FROM pg_class c
215
- JOIN pg_namespace n ON n.oid = c.relnamespace
216
- WHERE c.relkind = 'S'
217
- AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
218
- AND NOT EXISTS (
219
- SELECT 1 FROM pg_depend d
220
- WHERE d.objid = c.oid AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
221
- )
222
- UNION ALL
223
- SELECT 'trigger', n.nspname || '.' || c.relname || '.' || t.tgname
233
+ SELECT 'trigger' AS kind, n.nspname || '.' || c.relname || '.' || t.tgname AS identity
224
234
  FROM pg_trigger t
225
235
  JOIN pg_class c ON c.oid = t.tgrelid
226
236
  JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -53,10 +53,17 @@ export interface CheckConstraint {
53
53
  /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
54
54
  export interface IndexSchema {
55
55
  name: string;
56
+ /** Canonical key text per position: a plain column name, or anything richer verbatim —
57
+ * `lower(email)`, `created_at DESC`, `title text_pattern_ops` (Brick E: expression
58
+ * indexes, direction, and opclass are IDENTITY, not noise). Default ASC is stripped. */
56
59
  columns: string[];
57
60
  unique: boolean;
58
61
  /** Partial-index predicate (`WHERE …`), omitted when the index is total. */
59
62
  where?: string;
63
+ /** Access method when not btree (`'gin'`, `'gist'`, …) — btree is never spelled. */
64
+ using?: string;
65
+ /** `INCLUDE (…)` covering columns. */
66
+ include?: string[];
60
67
  }
61
68
 
62
69
  export interface TableSchema {
@@ -78,10 +85,25 @@ export interface EnumType {
78
85
  values: string[];
79
86
  }
80
87
 
88
+ /** A STANDALONE sequence — one no serial column owns (those stay implicit in their
89
+ * column). It holds state (the counter position), so it lives in the base schema:
90
+ * migrated before tables, diffed by existence, fingerprinted. `start`/`increment`
91
+ * are omitted when they are PostgreSQL's defaults (1), so a declaration that omits
92
+ * them matches a live default exactly. */
93
+ export interface SequenceSchema {
94
+ /** Bare for public (`order_number_seq`), qualified otherwise (`reporting.order_seq`). */
95
+ name: string;
96
+ as: 'integer' | 'bigint';
97
+ start?: number;
98
+ increment?: number;
99
+ }
100
+
81
101
  export interface SchemaSnapshot {
82
102
  tables: TableSchema[];
83
103
  /** Enum types in the schema (`CREATE TYPE … AS ENUM`). Absent/empty when none. */
84
104
  enums?: EnumType[];
105
+ /** Standalone sequences (serial-owned ones excluded). Absent/empty when none. */
106
+ sequences?: SequenceSchema[];
85
107
  }
86
108
 
87
109
  // ---------------------------------------------------------------------------
@@ -163,6 +185,30 @@ GROUP BY n.nspname, t.typname
163
185
  ORDER BY n.nspname, t.typname;
164
186
  `.trim();
165
187
 
188
+ /**
189
+ * STANDALONE sequences — the ones no serial column owns (a serial's implicit sequence
190
+ * has an 'a'/'i' pg_depend link to its column and stays represented BY that column).
191
+ * These hold state, so they are base-schema: migrated, diffed, fingerprinted.
192
+ */
193
+ export const SEQUENCES_SQL = `
194
+ SELECT
195
+ n.nspname AS schema,
196
+ c.relname AS name,
197
+ format_type(s.seqtypid, NULL) AS data_type,
198
+ s.seqstart AS start,
199
+ s.seqincrement AS increment
200
+ FROM pg_sequence s
201
+ JOIN pg_class c ON c.oid = s.seqrelid
202
+ JOIN pg_namespace n ON n.oid = c.relnamespace
203
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
204
+ AND n.nspname NOT LIKE 'pg_%'
205
+ AND NOT EXISTS (
206
+ SELECT 1 FROM pg_depend d
207
+ WHERE d.objid = c.oid AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
208
+ )
209
+ ORDER BY n.nspname, c.relname;
210
+ `.trim();
211
+
166
212
  /**
167
213
  * Standalone indexes — `CREATE INDEX` the app declares, NOT the implicit indexes that back a
168
214
  * primary key or unique constraint (those are excluded by the `conindid` check, since they're
@@ -179,9 +225,12 @@ SELECT
179
225
  (SELECT array_agg(a.attname ORDER BY k.ord)
180
226
  FROM unnest(idx.indkey) WITH ORDINALITY k(attnum, ord)
181
227
  JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = k.attnum) AS columns,
182
- pg_get_expr(idx.indpred, idx.indrelid) AS predicate
228
+ pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
229
+ pg_get_indexdef(idx.indexrelid) AS definition,
230
+ am.amname AS method
183
231
  FROM pg_index idx
184
232
  JOIN pg_class i ON i.oid = idx.indexrelid
233
+ JOIN pg_am am ON am.oid = i.relam
185
234
  JOIN pg_class t ON t.oid = idx.indrelid
186
235
  JOIN pg_namespace n ON n.oid = t.relnamespace
187
236
  WHERE t.relkind = 'r'
@@ -300,17 +349,107 @@ export interface IndexRow {
300
349
  is_unique: unknown;
301
350
  columns: unknown;
302
351
  predicate: unknown;
352
+ /** `pg_get_indexdef` — the authoritative key list (expressions, DESC, opclass, INCLUDE).
353
+ * Optional: a legacy row without it falls back to the plain columns array. */
354
+ definition?: unknown;
355
+ /** `pg_am.amname`. Optional; btree folds to absent. */
356
+ method?: unknown;
303
357
  }
304
358
 
305
- export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
306
- const columns = Array.isArray(row.columns) ? row.columns.map(String) : [];
307
- const pred = row.predicate == null ? '' : String(row.predicate);
359
+ /** Split a parenthesized list on top-level commas (expression entries contain their own). */
360
+ function splitTopLevel(list: string): string[] {
361
+ const out: string[] = [];
362
+ let depth = 0;
363
+ let start = 0;
364
+ for (let i = 0; i < list.length; i++) {
365
+ const ch = list[i];
366
+ if (ch === '(') depth++;
367
+ else if (ch === ')') depth--;
368
+ else if (ch === ',' && depth === 0) {
369
+ out.push(list.slice(start, i));
370
+ start = i + 1;
371
+ }
372
+ }
373
+ out.push(list.slice(start));
374
+ return out.map((s) => s.trim()).filter(Boolean);
375
+ }
376
+
377
+ /** The parenthesized group starting at `open` (which must point at '('), content only. */
378
+ function parenGroup(text: string, open: number): string | null {
379
+ let depth = 0;
380
+ for (let i = open; i < text.length; i++) {
381
+ if (text[i] === '(') depth++;
382
+ else if (text[i] === ')' && --depth === 0) return text.slice(open + 1, i);
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /** Canonicalize one key entry: unquote a plain `"col"`, strip the default ` ASC`. */
388
+ function canonicalKeyEntry(entry: string): string {
389
+ let s = entry.trim().replace(/\s+ASC$/i, '');
390
+ const quoted = s.match(/^"((?:[^"]|"")*)"$/);
391
+ if (quoted) s = quoted[1].replace(/""/g, '"');
392
+ return s;
393
+ }
394
+
395
+ /**
396
+ * The extended identity from a `pg_get_indexdef` text (Brick E): canonical key entries
397
+ * (expressions, direction, opclass — default ASC stripped, plain names unquoted), the
398
+ * access method from the `USING` clause (btree folds to absent), INCLUDE columns,
399
+ * uniqueness, and the partial predicate. Null when the text isn't index DDL.
400
+ */
401
+ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'name'> | null {
402
+ const usingMatch = definition.match(/\sUSING\s+(\w+)\s*\(/i);
403
+ if (!usingMatch || usingMatch.index === undefined) return null;
404
+ const open = definition.indexOf('(', usingMatch.index);
405
+ const keyList = parenGroup(definition, open);
406
+ if (keyList === null) return null;
407
+ const columns = splitTopLevel(keyList).map(canonicalKeyEntry);
408
+ const method = usingMatch[1].toLowerCase();
409
+ const after = definition.slice(open + keyList.length + 2);
410
+ const includeAt = after.search(/^\s*INCLUDE\s*\(/i);
411
+ const include = includeAt >= 0
412
+ ? splitTopLevel(parenGroup(after, after.indexOf('(')) ?? '').map(canonicalKeyEntry)
413
+ : undefined;
414
+ const whereMatch = after.match(/\sWHERE\s+(.+)$/i);
415
+ let where = whereMatch ? whereMatch[1].trim() : '';
416
+ // pg_get_indexdef wraps the predicate in one pair of parens — strip it.
417
+ if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
308
418
  return {
309
- table: `${row.schema}.${row.table}`,
310
- index: { name: row.name, columns, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) },
419
+ columns,
420
+ unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
421
+ ...(where ? { where } : {}),
422
+ ...(method !== 'btree' ? { using: method } : {}),
423
+ ...(include?.length ? { include } : {}),
311
424
  };
312
425
  }
313
426
 
427
+ /** A row's index descriptor: the definition text when present (the extended identity);
428
+ * a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
429
+ * `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
430
+ export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
431
+ const pred = row.predicate == null ? '' : String(row.predicate);
432
+ const table = `${row.schema}.${row.table}`;
433
+ const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
434
+
435
+ const parsed = row.definition == null ? null : parseIndexDefinition(String(row.definition));
436
+ if (parsed) {
437
+ const method = row.method == null ? parsed.using : String(row.method).toLowerCase();
438
+ return {
439
+ table,
440
+ index: {
441
+ ...base,
442
+ columns: parsed.columns,
443
+ ...(method && method !== 'btree' ? { using: method } : {}),
444
+ ...(parsed.include?.length ? { include: parsed.include } : {}),
445
+ },
446
+ };
447
+ }
448
+
449
+ const columns = Array.isArray(row.columns) ? row.columns.map(String) : [];
450
+ return { table, index: { ...base, columns } };
451
+ }
452
+
314
453
  export interface EnumRow {
315
454
  schema: string;
316
455
  name: string;
@@ -322,11 +461,32 @@ export function enumRowToDescriptor(row: EnumRow): EnumType {
322
461
  return { name: row.name, values };
323
462
  }
324
463
 
464
+ export interface SequenceRow {
465
+ schema: string;
466
+ name: string;
467
+ data_type: unknown;
468
+ start: unknown;
469
+ increment: unknown;
470
+ }
471
+
472
+ export function sequenceRowToDescriptor(row: SequenceRow): SequenceSchema {
473
+ const start = Number(row.start ?? 1);
474
+ const increment = Number(row.increment ?? 1);
475
+ return {
476
+ name: row.schema === 'public' ? row.name : `${row.schema}.${row.name}`,
477
+ as: String(row.data_type) === 'integer' ? 'integer' : 'bigint',
478
+ // PostgreSQL's defaults are omitted, so declared-without matches live-default.
479
+ ...(start !== 1 ? { start } : {}),
480
+ ...(increment !== 1 ? { increment } : {}),
481
+ };
482
+ }
483
+
325
484
  export interface SchemaRows {
326
485
  columns: ColumnRow[];
327
486
  constraints: ConstraintRow[];
328
487
  enums?: EnumRow[];
329
488
  indexes?: IndexRow[];
489
+ sequences?: SequenceRow[];
330
490
  }
331
491
 
332
492
  /**
@@ -400,9 +560,15 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
400
560
  .map(enumRowToDescriptor)
401
561
  .sort((a, b) => a.name.localeCompare(b.name));
402
562
 
563
+ const sequences = (rows.sequences ?? [])
564
+ .filter((r) => !IGNORED_SCHEMAS.has(r.schema))
565
+ .map(sequenceRowToDescriptor)
566
+ .sort((a, b) => a.name.localeCompare(b.name));
567
+
403
568
  return {
404
569
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
405
570
  enums,
571
+ ...(sequences.length ? { sequences } : {}),
406
572
  };
407
573
  }
408
574
 
@@ -413,13 +579,14 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
413
579
  * `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
414
580
  */
415
581
  export async function introspectSchema(run: QueryRunner): Promise<SchemaSnapshot> {
416
- const [columns, constraints, enums, indexes] = await Promise.all([
417
- run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL),
582
+ const [columns, constraints, enums, indexes, sequences] = await Promise.all([
583
+ run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL), run(SEQUENCES_SQL),
418
584
  ]);
419
585
  return assembleSchema({
420
586
  columns: columns as ColumnRow[],
421
587
  constraints: constraints as ConstraintRow[],
422
588
  enums: enums as EnumRow[],
423
589
  indexes: indexes as IndexRow[],
590
+ sequences: sequences as SequenceRow[],
424
591
  });
425
592
  }