@everystack/cli 0.4.52 → 0.4.55

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.4.52",
3
+ "version": "0.4.55",
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>",
@@ -164,6 +164,7 @@
164
164
  "scripts": {
165
165
  "test": "jest",
166
166
  "build": "tsc --build",
167
- "lint": "tsc --noEmit"
167
+ "lint": "tsc --noEmit",
168
+ "check:artifact": "tsx scripts/check-generated-artifact.ts"
168
169
  }
169
170
  }
@@ -23,6 +23,7 @@
23
23
  import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
25
  import { parenthesizeOnce } from './authz-contract.js';
26
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
26
27
 
27
28
  export interface CompileOptions {
28
29
  /** Schema the table lives in. Default: `public`. */
@@ -400,7 +401,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
400
401
  name: string, command: PolicyCommand, roles: string[],
401
402
  using: string | null, check: string | null,
402
403
  ): void => {
403
- policies.push({ name, command, roles, permissive: true, using, check });
404
+ // Same normalization the live producer applies (authz-contract's `predicate`): a model
405
+ // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
406
+ // and it must compare equal against a stage on another. One rule table, two producers.
407
+ policies.push({
408
+ name, command, roles, permissive: true,
409
+ using: using == null ? null : normalizeDeparsedExpr(using),
410
+ check: check == null ? null : normalizeDeparsedExpr(check),
411
+ });
404
412
  };
405
413
 
406
414
  // admin-bypass — one ALL policy, true/true.
@@ -24,6 +24,7 @@
24
24
  import { parsePgArray } from './security-catalog.js';
25
25
  import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
26
26
  import { matchPolicies, roleSetEqual } from './authz-identity.js';
27
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -270,6 +271,15 @@ export interface FunctionContract {
270
271
  export interface AuthzContract {
271
272
  tables: TableContract[];
272
273
  functions: FunctionContract[];
274
+ /**
275
+ * Schema-level ACLs (pg_namespace.nspacl), schema → grantee → sorted privileges
276
+ * (USAGE | CREATE). `PUBLIC` is the pseudo-role entry. A schema with a NULL acl is
277
+ * ABSENT — owner-default, no explicit grants — so absence means "the grant does not
278
+ * exist" and an emitter may emit it. Optional: contracts assembled from older recorders
279
+ * or hand-built fixtures simply carry no schema knowledge, and consumers must treat
280
+ * that as "unknown", never as "no grants".
281
+ */
282
+ schemaAcls?: Record<string, Record<string, string[]>>;
273
283
  }
274
284
 
275
285
  export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
@@ -398,6 +408,82 @@ WHERE c.relkind = 'r'
398
408
  ORDER BY n.nspname, c.relname;
399
409
  `.trim();
400
410
 
411
+ /**
412
+ * Schema ACLs, for the emitters that grant schema USAGE. The consumer bug this closes: the
413
+ * usage phase emitted `GRANT USAGE ON SCHEMA` with no live read at all, so every plan with
414
+ * any authz statement re-granted what the database already held — two false statements on
415
+ * every stage plan, forever. `nspacl` casts to its array-literal text; NULL stays NULL
416
+ * (owner default — no explicit grants — which the parser must NOT read as an empty grant
417
+ * list on purpose: for nspacl the two mean the same emittable thing, but the distinction
418
+ * is kept so the contract says what the catalog said).
419
+ */
420
+ export const SCHEMA_ACL_SQL = `
421
+ SELECT n.nspname AS schema, n.nspacl::text AS acl
422
+ FROM pg_namespace n
423
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
424
+ AND n.nspname NOT LIKE 'pg_%'
425
+ ORDER BY n.nspname;
426
+ `.trim();
427
+
428
+ export interface SchemaAclRow {
429
+ schema: string;
430
+ acl: unknown;
431
+ }
432
+
433
+ /** The two privileges a schema ACL can carry, in aclitem letter form. */
434
+ const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
435
+
436
+ /**
437
+ * Parse one `aclitem[]::text` literal — `{postgres=UC/postgres,authenticator=U/postgres}` —
438
+ * into grantee → privileges. An empty grantee (`=U/postgres`) is PUBLIC. A quoted grantee
439
+ * (`"odd,role"=U/postgres`) is unwrapped with its doubled-quote escapes. A `*` (grant
440
+ * option) rides the letter before it and is dropped — holding WITH GRANT OPTION still
441
+ * holds the privilege. Unparseable input returns null: the caller treats it as unknown,
442
+ * never as "no grants".
443
+ */
444
+ export function parseSchemaAcl(text: string | null | undefined): Record<string, string[]> | null {
445
+ if (text == null) return null;
446
+ const s = String(text).trim();
447
+ if (!s.startsWith('{') || !s.endsWith('}')) return null;
448
+ const body = s.slice(1, -1);
449
+ if (body.trim() === '') return {};
450
+ const out: Record<string, string[]> = {};
451
+ // Split items at top-level commas — a quoted region may contain commas.
452
+ const items: string[] = [];
453
+ let start = 0;
454
+ for (let j = 0; j < body.length; j++) {
455
+ if (body[j] === '"') {
456
+ for (j++; j < body.length; j++) {
457
+ if (body[j] !== '"') continue;
458
+ if (body[j + 1] === '"') { j++; continue; }
459
+ break;
460
+ }
461
+ } else if (body[j] === ',') {
462
+ items.push(body.slice(start, j));
463
+ start = j + 1;
464
+ }
465
+ }
466
+ items.push(body.slice(start));
467
+ for (const item of items) {
468
+ // grantee=letters/grantor — grantee may be quoted; the grantor half is irrelevant here.
469
+ const eq = item.indexOf('=', item.startsWith('"') ? item.indexOf('"', 1) + 1 : 0);
470
+ if (eq === -1) return null;
471
+ let grantee = item.slice(0, eq);
472
+ if (grantee.startsWith('"') && grantee.endsWith('"')) grantee = grantee.slice(1, -1).replace(/""/g, '"');
473
+ if (grantee === '') grantee = 'PUBLIC';
474
+ const slash = item.indexOf('/', eq);
475
+ const letters = item.slice(eq + 1, slash === -1 ? undefined : slash);
476
+ const privs = new Set<string>();
477
+ for (const ch of letters) {
478
+ if (ch === '*') continue;
479
+ const p = SCHEMA_PRIV_LETTERS[ch];
480
+ if (p) privs.add(p);
481
+ }
482
+ if (privs.size) out[grantee] = [...privs].sort();
483
+ }
484
+ return out;
485
+ }
486
+
401
487
  // ---------------------------------------------------------------------------
402
488
  // Pure mappers — one catalog row -> one descriptor.
403
489
  // ---------------------------------------------------------------------------
@@ -411,7 +497,10 @@ function truthy(v: unknown): boolean {
411
497
  function predicate(v: unknown): string | null {
412
498
  if (v == null) return null;
413
499
  const s = String(v).trim();
414
- return s.length > 0 ? s : null;
500
+ // Normalized at the PRODUCER, so the matcher, the canonical hash, and the drift detail
501
+ // all see one spelling — a live tree deparsed by an older major must compare equal to
502
+ // the same predicate parsed on a newer one. See deparse-normal.ts for the rule table.
503
+ return s.length > 0 ? normalizeDeparsedExpr(s) : null;
415
504
  }
416
505
 
417
506
  export interface PolicyRow {
@@ -524,6 +613,8 @@ export interface ContractRows {
524
613
  policies: PolicyRow[];
525
614
  grants: GrantRow[];
526
615
  columnGrants?: ColumnGrantRow[];
616
+ /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
617
+ schemaAcls?: SchemaAclRow[];
527
618
  /** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
528
619
  functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
529
620
  }
@@ -585,9 +676,22 @@ export function assembleContract(rows: ContractRows): AuthzContract {
585
676
  }))
586
677
  .sort((a, b) => a.name.localeCompare(b.name));
587
678
 
679
+ // Schema ACLs, keyed only when the read ran (absent = unknown, per the contract's doc).
680
+ // A NULL acl (owner default) contributes an empty entry so "we looked, nothing granted"
681
+ // is distinguishable from "we never looked".
682
+ let schemaAcls: Record<string, Record<string, string[]>> | undefined;
683
+ if (rows.schemaAcls) {
684
+ schemaAcls = {};
685
+ for (const row of rows.schemaAcls) {
686
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
687
+ schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
688
+ }
689
+ }
690
+
588
691
  return {
589
692
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
590
693
  functions,
694
+ ...(schemaAcls ? { schemaAcls } : {}),
591
695
  };
592
696
  }
593
697
 
@@ -597,11 +701,11 @@ export async function introspectContract(
597
701
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
598
702
  functionsSql: string,
599
703
  ): Promise<AuthzContract> {
600
- // ONE session: the five queries describe one moment under one pinned search_path.
704
+ // ONE session: the six queries describe one moment under one pinned search_path.
601
705
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
602
706
  // across connections can report authz drift that does not exist.
603
- const [rls, policies, grants, columnGrants, fnRows] = await session(
604
- [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, functionsSql],
707
+ const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
708
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
605
709
  INTROSPECTION_SESSION,
606
710
  );
607
711
  return assembleContract({
@@ -609,6 +713,7 @@ export async function introspectContract(
609
713
  policies: policies as PolicyRow[],
610
714
  grants: grants as GrantRow[],
611
715
  columnGrants: columnGrants as ColumnGrantRow[],
716
+ schemaAcls: schemaAcls as SchemaAclRow[],
612
717
  functions: (fnRows as any[]).map(mapFunctionRow),
613
718
  });
614
719
  }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `everystack db:build --database-url <url>` — build a database FROM the models, and KEEP it.
3
+ *
4
+ * db:build --database-url postgres://…/mydb [--models db/models]
5
+ *
6
+ * WHY THIS EXISTS. Deleting a migration folder is only safe once the models alone can rebuild
7
+ * an identical database — local dev, the test DB, a new stage. Every other verb failed that:
8
+ * `db:check` proved buildability on an ephemeral database and then DROPPED it, while
9
+ * `db:sync` / `db:generate --apply` run the DIFF builder, which has no bootstrap phase and
10
+ * cannot start from nothing. A consumer tested our own documentation sentence ("a fresh
11
+ * database is db:check's compose") and found no verb behind it. This is that verb.
12
+ *
13
+ * It is deliberately THIN: `buildIntoDatabase` is the same core `db:check`'s compose, the dev
14
+ * template and `createEphemeralDatabase` already run — extensions, schemas, contract roles,
15
+ * declared functions before the policies that call them, then state and the derived layer.
16
+ * A second implementation of that ordering would be a second thing to keep true.
17
+ *
18
+ * The venue is EXPLICIT (`--database-url` only, never the ambient env, same rule as db:swap
19
+ * and db:export): this command writes a whole schema, so the target must be named on the
20
+ * command line and never inherited from a shell that happens to point at production.
21
+ *
22
+ * It REFUSES a database that already holds declared objects. Building into an existing
23
+ * database is `db:sync`'s job (dev) or `db:plan`/`db:apply`'s (a stage); a verb that silently
24
+ * did both is how a populated database gets clobbered by a command whose name says "build".
25
+ */
26
+
27
+ import type { ModelDescriptor } from '@everystack/model';
28
+ import { buildIntoDatabase } from '../db-build.js';
29
+ import { createUrlRunner } from '../db-source.js';
30
+ import { resolveModelsPath } from '../models-path.js';
31
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
32
+ import { loadModels } from './db-generate.js';
33
+ import { currentGitRef } from '../state-apply.js';
34
+ import { step, info, success, fail, warn } from '../output.js';
35
+
36
+ /** Tables already in the target, outside the schemas PostgreSQL owns. */
37
+ const OCCUPANCY_SQL = `
38
+ SELECT n.nspname || '.' || c.relname AS identity
39
+ FROM pg_class c
40
+ JOIN pg_namespace n ON n.oid = c.relnamespace
41
+ WHERE c.relkind IN ('r', 'v', 'm')
42
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
43
+ AND n.nspname NOT LIKE 'pg_%'
44
+ ORDER BY 1
45
+ LIMIT 20;
46
+ `.trim();
47
+
48
+ export async function dbBuildCommand(flags: Record<string, string>): Promise<void> {
49
+ // Flag-only venue. The env is never consulted: `db:build` writes a whole schema, and an
50
+ // ambient DATABASE_URL must not be able to choose which database that happens to.
51
+ const url = flags['database-url'];
52
+ if (!url || url === 'true') {
53
+ fail('db:build needs --database-url <url> — the venue is explicit by design (the ambient environment never picks the target for a command that writes a whole schema).');
54
+ process.exit(1);
55
+ }
56
+ if (flags.stage) {
57
+ fail('db:build has no --stage lane: building a fresh database is a local/dev operation. Evolve a deployed stage with db:plan → db:apply.');
58
+ process.exit(1);
59
+ }
60
+
61
+ const modelsPath = resolveModelsPath(flags.models);
62
+ let models: ModelDescriptor[];
63
+ try {
64
+ step(`Loading models from ${modelsPath}...`);
65
+ models = await loadModels(modelsPath);
66
+ info(`${models.length} model(s).`);
67
+ } catch (err: any) {
68
+ fail(err.message);
69
+ process.exit(1);
70
+ }
71
+
72
+ try {
73
+ if (flags['sql-dir']) {
74
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
75
+ process.exit(1);
76
+ }
77
+ const retired = await retiredSqlDirAnywhere(flags.models);
78
+ if (retired) {
79
+ fail(retired);
80
+ process.exit(1);
81
+ }
82
+ } catch (err: any) {
83
+ fail(err.message);
84
+ process.exit(1);
85
+ }
86
+
87
+ // The modules carry what `models` alone cannot: the derived layer, standalone sequences,
88
+ // and the extensions whose types the columns are declared in.
89
+ let declaredDb: DeclaredDerived | null = null;
90
+ try {
91
+ declaredDb = await loadDeclaredDerived(flags.models);
92
+ } catch (err: any) {
93
+ fail(err.message);
94
+ process.exit(1);
95
+ }
96
+
97
+ // Refuse a target that already holds objects — see the header. Read on its own connection,
98
+ // closed before the build opens its own, so no session outlives the check it performed.
99
+ try {
100
+ const { runner, end } = await createUrlRunner(url);
101
+ let occupied: string[];
102
+ try {
103
+ occupied = (await runner(OCCUPANCY_SQL)).map((r: any) => String(r.identity));
104
+ } finally {
105
+ await end?.();
106
+ }
107
+ if (occupied.length > 0) {
108
+ fail(
109
+ `db:build refuses a database that already holds objects — it found ${occupied.length}: ${occupied.slice(0, 8).join(', ')}${occupied.length > 8 ? ', …' : ''}.\n`
110
+ + ` This verb builds a FRESH database from the models. To evolve an existing one: db:sync (a dev database) or db:plan → db:apply (a stage).`,
111
+ );
112
+ process.exit(1);
113
+ }
114
+ } catch (err: any) {
115
+ fail(`Could not read the target: ${err.message}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ step('Building the declared state (extensions → schemas → roles → functions → state → derived)...');
120
+ let built;
121
+ try {
122
+ built = await buildIntoDatabase(url, models, {
123
+ declared: declaredDb?.objects,
124
+ sequences: declaredDb?.sequences,
125
+ extensions: declaredDb?.extensions,
126
+ actor: process.env.USER ?? null,
127
+ gitRef: currentGitRef(),
128
+ });
129
+ } catch (err: any) {
130
+ fail(`Build failed: ${err.message}`);
131
+ process.exit(1);
132
+ }
133
+
134
+ for (const line of built.report) info(line);
135
+ if (built.createdRoles.length) {
136
+ info(`Created ${built.createdRoles.length} contract role(s) (NOLOGIN, cluster-level): ${built.createdRoles.join(', ')}.`);
137
+ }
138
+
139
+ // The bar is the same one db:check reports, and it is stated as a fact or not at all:
140
+ // a database that did not land on the models' fingerprint is not the declared state,
141
+ // however few statements were left over.
142
+ if (!built.converged || !built.fingerprintMatch) {
143
+ fail(
144
+ `The database was built but does NOT match the declared state (fingerprint ${built.fingerprint.slice(0, 12)}). `
145
+ + `It is left in place for inspection — run db:generate --dry-run against it to see what differs.`,
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ success(`Built and kept — the database IS the declared state at ${built.fingerprint.slice(0, 12)}.`);
151
+ warn('Roles are cluster-level: a role this build created is visible to every database in the cluster.');
152
+ info('Verify independently: everystack db:fingerprint --database-url <url> (expect MATCH), db:reconcile --check.');
153
+ }
@@ -37,7 +37,8 @@ import { compileDrizzleSource } from '../schema-source.js';
37
37
  import { currentGitRef } from '../state-apply.js';
38
38
  import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
39
39
  import { resolveModelsPath } from '../models-path.js';
40
- import { loadModels, loadModules } from './db-generate.js';
40
+ import { loadModels, loadModules, readJournal, DEFAULT_MIGRATIONS } from './db-generate.js';
41
+ import { resolveSchemaOut } from '../migration-generate.js';
41
42
  import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
42
43
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
43
44
  import type { SequenceDescriptor } from '@everystack/model';
@@ -345,7 +346,17 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
345
346
  } catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
346
347
  }
347
348
 
348
- const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
349
+ // The SAME resolution db:generate uses — flag > journal-recorded > default. db:check read
350
+ // only the flag, so a project that had RELOCATED its artifact (recorded in the journal by
351
+ // db:generate, per its own help) had the two verbs disagree about which file is the
352
+ // artifact: generate wrote one path, check guarded another. A consumer worked around it by
353
+ // passing the flag explicitly in their CI script.
354
+ const journal = await readJournal(path.resolve(flags.dir || DEFAULT_MIGRATIONS));
355
+ const artifactRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
356
+ const artifactPath = artifactRes.path;
357
+ if (artifactRes.source === 'recorded') {
358
+ info(`schema-out: ${artifactPath} (recorded in the journal; override with --schema-out)`);
359
+ }
349
360
  let artifactSource: string | null = null;
350
361
  try {
351
362
  artifactSource = await fs.readFile(artifactPath, 'utf8');
@@ -438,6 +449,9 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
438
449
  // ring proves the checkout against ITSELF, on an empty database. Whether an existing
439
450
  // database IS this state is a different question with a different verb.
440
451
  info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
452
+ // The ephemeral database is DROPPED. Saying so here is what stops this line being read
453
+ // as "you now have a database" — a consumer read exactly that into it, from our own docs.
454
+ info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
441
455
  } else {
442
456
  success('db:check passed (static ring only — no database provided for the ephemeral compose).');
443
457
  }
@@ -43,7 +43,7 @@ import { resolveConfig, opsFunction } from '../config.js';
43
43
  import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
44
44
  import { step, success, fail, info, warn } from '../output.js';
45
45
 
46
- const DEFAULT_MIGRATIONS = 'drizzle';
46
+ export const DEFAULT_MIGRATIONS = 'drizzle';
47
47
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
48
48
 
49
49
  /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
@@ -187,7 +187,7 @@ function reportOwnership(
187
187
  }
188
188
  }
189
189
 
190
- async function readJournal(migrationsDir: string): Promise<Journal | null> {
190
+ export async function readJournal(migrationsDir: string): Promise<Journal | null> {
191
191
  try {
192
192
  return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
193
193
  } catch {
@@ -290,7 +290,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
290
290
 
291
291
  // One ordered migration carries both layers: data DDL first, then the authz reconcile
292
292
  // (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
293
- const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles });
293
+ const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles, extensions: declaredDb?.extensions });
294
294
  const unmodeled = unmodeledTables(models, current);
295
295
  if (unmodeled.length) {
296
296
  info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
@@ -172,8 +172,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
172
172
  // The modules' widened governed-role set. A barrel that exports only `models` has none,
173
173
  // which is the greenfield default: govern exactly what the models name.
174
174
  let declaredGovernedRoles: string[] = [];
175
+ // The modules' extensions ride the same read. Without them the plan omits every
176
+ // CREATE EXTENSION the target lacks, and the apply fails on a type the plan promised.
177
+ let declaredExtensions: string[] = [];
175
178
  try {
176
- declaredGovernedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
179
+ const declared = await loadDeclaredDerived(flags.models);
180
+ declaredGovernedRoles = declared?.governedRoles ?? [];
181
+ declaredExtensions = declared?.extensions ?? [];
177
182
  } catch {
178
183
  // The barrel's own compose errors surface on the paths that need the derived layer;
179
184
  // a plan must not fail to mint because a module could not be read for this one field.
@@ -202,6 +207,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
202
207
  // Without this the MINTED PLAN carries a REVOKE for every live grantee the models
203
208
  // do not name — which is the artifact an operator actually applies.
204
209
  governedRoles: declaredGovernedRoles,
210
+ extensions: declaredExtensions,
205
211
  });
206
212
  } catch (err: any) {
207
213
  fail(`Mint refused: ${err.message}`);
@@ -440,6 +440,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
440
440
  const stageName = flags.stage;
441
441
  if (!stageName) {
442
442
  note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
443
+ note(`--stage composes with --database-url: the URL stays the connection, the stage only LABELS the baseline entry — a local clone can adopt for a deployed stage without touching it.`);
443
444
  } else {
444
445
  const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
445
446
  const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
@@ -76,6 +76,8 @@ export interface SyncOptions {
76
76
  declared?: SourceObject[];
77
77
  /** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
78
78
  sequences?: SequenceDescriptor[];
79
+ /** Extensions the modules declare — emitted before anything that uses their types. */
80
+ extensions?: string[];
79
81
  /** Roles the modules govern beyond the ones the models name — a grantee outside the set
80
82
  * is exempted from revocation and enumerated instead (authz-reconcile's governedRoleSet). */
81
83
  governedRoles?: string[];
@@ -123,12 +125,12 @@ export async function executeSync(
123
125
  const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
124
126
  const statements = generateMigrationSql(models, current, {
125
127
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
126
- governedRoles: options.governedRoles,
128
+ governedRoles: options.governedRoles, extensions: options.extensions,
127
129
  });
128
130
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
129
131
 
130
132
  const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
131
- allowDrops: options.allowDrops, sequences: options.sequences,
133
+ allowDrops: options.allowDrops, sequences: options.sequences, extensions: options.extensions,
132
134
  actor: options.actor, gitRef: options.gitRef, now: options.now,
133
135
  });
134
136
  hooks.onStateDone?.(state);
@@ -315,6 +317,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
315
317
  {
316
318
  declared: declaredDb?.objects,
317
319
  sequences: declaredDb?.sequences,
320
+ extensions: declaredDb?.extensions,
318
321
  renamedTables: declaredDb?.renamedTables,
319
322
  allowDrops: flags['allow-drops'] === 'true',
320
323
  overwriteDrift: flags['overwrite-drift'] === 'true',
@@ -34,6 +34,16 @@ export interface DeclaredDerived {
34
34
  /** Roles the modules declare as governed beyond the ones the models name. A live grantee
35
35
  * outside the governed set is exempted from reconciliation and ENUMERATED instead. */
36
36
  governedRoles: string[];
37
+ /**
38
+ * Postgres extensions the modules declare — the bootstrap the state layer needs before
39
+ * anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
40
+ * path never saw them, so `db:generate --apply`, `db:sync` and the whole `db:plan`/
41
+ * `db:apply` stage lane emitted zero `CREATE EXTENSION`. A consumer measured the gap as
42
+ * exactly their nine extensions (471 statements vs db:check's 480 on one checkout), and
43
+ * it is not only a fresh-build problem: a deployed stage evolving onto a model that newly
44
+ * uses `hstore` or `postgis` fails on apply.
45
+ */
46
+ extensions: string[];
37
47
  }
38
48
 
39
49
  /**
@@ -178,6 +188,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
178
188
  renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
179
189
  models,
180
190
  governedRoles: moduleGovernedRoles(modules),
191
+ extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
181
192
  };
182
193
  } catch (err) {
183
194
  throw asModelComposeError(modelsPath, err);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * deparse-normal — cross-version normalization of `pg_get_expr` output.
3
+ *
4
+ * A policy predicate's stored tree is built by whichever server PARSED the DDL, and
5
+ * different PostgreSQL majors const-fold the same source differently. The measured case
6
+ * (a consumer's stage, 2026-08-08): an older major stores an array cast OUTSIDE the array,
7
+ *
8
+ * (ARRAY['a'::character varying, 'b'::character varying])::text[]
9
+ *
10
+ * while PG17 distributes it over the elements at parse time,
11
+ *
12
+ * ARRAY[('a'::character varying)::text, ('b'::character varying)::text]
13
+ *
14
+ * Text-identity across venues is then unreachable: models pulled on one major can never
15
+ * equal a stage on another, the differ plans DROP+CREATE forever (the stage re-deparses in
16
+ * its own spelling), and the fingerprint gate cannot MATCH. Identity must not depend on the
17
+ * two venues sharing a parser.
18
+ *
19
+ * THE RULE TABLE IS DELIBERATELY SMALL. Each rewrite must be provably semantics-preserving,
20
+ * because this text feeds the policy matcher, whose safety property is "misses are safe,
21
+ * false matches are the disaster". One rule today:
22
+ *
23
+ * ARRAY-CAST DISTRIBUTION: `(ARRAY[e1, …, en])::T[] == ARRAY[(e1)::T, …, (en)::T]`
24
+ * for n ≥ 1. PostgreSQL defines an array-to-array cast element-wise (parse_coerce), so
25
+ * the two expressions denote the same value for every input; wrapping an element in
26
+ * parentheses is parse-neutral. The empty array is EXCLUDED: `ARRAY[]` without a cast has
27
+ * no type, so its cast is load-bearing and stays.
28
+ *
29
+ * Anything the scanner does not positively recognize is left byte-for-byte unchanged — an
30
+ * unrecognized spelling is a MISS (drop + create, the pre-existing behavior), never a guess.
31
+ * Escalation is named, not implied: the SECOND cross-version variant found in the field is
32
+ * the trigger to adopt libpg_query and move identity onto normalized ASTs (Ty, 2026-08-09) —
33
+ * a growing rule table over raw text is where scanning stops being defensible.
34
+ *
35
+ * Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
36
+ * assembly), so every downstream comparison — the identity matcher, the canonical hash, the
37
+ * drift detail — sees normalized text without holding its own copy of this rule.
38
+ */
39
+
40
+ /** `::text[]` / `::character varying[]` — the cast suffix after `(ARRAY[…])`. */
41
+ const ARRAY_CAST_SUFFIX = /^::([A-Za-z_][A-Za-z0-9_]*(?:\s[A-Za-z_][A-Za-z0-9_]*)*)\[\]/;
42
+
43
+ /**
44
+ * From an opening single or double quote, the index of its closing quote.
45
+ * SQL escapes a quote by doubling it; a doubled quote is content, not a close.
46
+ * Returns -1 on an unterminated literal (the caller then leaves the text alone).
47
+ */
48
+ function skipQuoted(s: string, at: number): number {
49
+ const q = s[at];
50
+ for (let j = at + 1; j < s.length; j++) {
51
+ if (s[j] !== q) continue;
52
+ if (s[j + 1] === q) { j++; continue; }
53
+ return j;
54
+ }
55
+ return -1;
56
+ }
57
+
58
+ /** The index of the `]` closing the `[` at `open`, honoring nesting and quoted regions. */
59
+ function matchBracket(s: string, open: number): number {
60
+ let sq = 0;
61
+ let par = 0;
62
+ for (let j = open; j < s.length; j++) {
63
+ const c = s[j];
64
+ if (c === "'" || c === '"') {
65
+ j = skipQuoted(s, j);
66
+ if (j < 0) return -1;
67
+ } else if (c === '[') sq++;
68
+ else if (c === ']') { sq--; if (sq === 0 && par === 0) return j; }
69
+ else if (c === '(') par++;
70
+ else if (c === ')') par--;
71
+ }
72
+ return -1;
73
+ }
74
+
75
+ /** Split on top-level commas (both nesting depths zero), or null on an unterminated literal. */
76
+ function splitTopLevel(s: string): string[] | null {
77
+ const out: string[] = [];
78
+ let start = 0;
79
+ let sq = 0;
80
+ let par = 0;
81
+ for (let j = 0; j < s.length; j++) {
82
+ const c = s[j];
83
+ if (c === "'" || c === '"') {
84
+ j = skipQuoted(s, j);
85
+ if (j < 0) return null;
86
+ } else if (c === '[') sq++;
87
+ else if (c === ']') sq--;
88
+ else if (c === '(') par++;
89
+ else if (c === ')') par--;
90
+ else if (c === ',' && sq === 0 && par === 0) {
91
+ out.push(s.slice(start, j));
92
+ start = j + 1;
93
+ }
94
+ }
95
+ out.push(s.slice(start));
96
+ return out;
97
+ }
98
+
99
+ /** One left-to-right pass: rewrite the first recognized `(ARRAY[…])::T[]`, or null if none. */
100
+ function distributeOnce(s: string): string | null {
101
+ let i = 0;
102
+ while ((i = s.indexOf('(ARRAY[', i)) !== -1) {
103
+ const open = i + 6; // the '['
104
+ const close = matchBracket(s, open);
105
+ if (close === -1) return null; // unparseable text — leave everything alone
106
+ if (s[close + 1] === ')') {
107
+ const m = ARRAY_CAST_SUFFIX.exec(s.slice(close + 2));
108
+ if (m) {
109
+ const inner = s.slice(open + 1, close);
110
+ const elements = splitTopLevel(inner);
111
+ // Empty array excluded: its cast carries the type. A failed split leaves the text alone.
112
+ if (elements !== null && inner.trim().length > 0) {
113
+ const t = m[1];
114
+ const rewritten = `ARRAY[${elements.map((e) => `(${e.trim()})::${t}`).join(', ')}]`;
115
+ return s.slice(0, i) + rewritten + s.slice(close + 2 + m[0].length);
116
+ }
117
+ }
118
+ }
119
+ i = open;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Normalize one deparsed expression. Idempotent; unrecognized text returns unchanged.
126
+ * Fixpoint-bounded: nesting deeper than the cap returns its progress, which is still
127
+ * deterministic — both sides of every comparison run this same function.
128
+ */
129
+ export function normalizeDeparsedExpr(text: string): string {
130
+ let s = text;
131
+ for (let n = 0; n < 64; n++) {
132
+ const next = distributeOnce(s);
133
+ if (next === null) return s;
134
+ s = next;
135
+ }
136
+ return s;
137
+ }
@@ -123,6 +123,12 @@ export interface MintOptions {
123
123
  * MINTED PLAN carries those revokes, which is where they would actually be applied.
124
124
  */
125
125
  governedRoles?: string[];
126
+ /**
127
+ * Extensions the modules declare. A plan minted without them silently omits every
128
+ * `CREATE EXTENSION` the target lacks — so a stage evolving onto a model that newly uses
129
+ * an extension type fails at APPLY, with the plan having promised it would not.
130
+ */
131
+ extensions?: string[];
126
132
  }
127
133
 
128
134
  /**
@@ -217,6 +223,7 @@ export function mintEdgePlan(
217
223
  allowDrops: opts.allowDrops,
218
224
  liveAuthz: contract,
219
225
  governedRoles: opts.governedRoles,
226
+ extensions: opts.extensions,
220
227
  });
221
228
  const classified = classifyGeneratedStatements(statements);
222
229
  if (classified.heldDrops.length > 0) {
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ import { dbDiffCommand } from './commands/db-diff.js';
19
19
  import { dbPlanCommand } from './commands/db-plan.js';
20
20
  import { dbApplyCommand } from './commands/db-apply.js';
21
21
  import { dbCheckCommand } from './commands/db-check.js';
22
+ import { dbBuildCommand } from './commands/db-build.js';
22
23
  import { dbApproversCommand } from './commands/db-approvers.js';
23
24
  import { dbBackfillCommand } from './commands/db-backfill.js';
24
25
  import { dbExecCommand } from './commands/db-exec.js';
@@ -208,6 +209,9 @@ async function main() {
208
209
  case 'db:check':
209
210
  await dbCheckCommand(flags);
210
211
  break;
212
+ case 'db:build':
213
+ await dbBuildCommand(flags);
214
+ break;
211
215
  case 'db:approvers':
212
216
  await dbApproversCommand(flags);
213
217
  break;
@@ -368,6 +372,7 @@ Usage:
368
372
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
369
373
  everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
370
374
  everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
375
+ everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
371
376
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
372
377
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
373
378
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
@@ -28,6 +28,9 @@ export interface GenerateOptions {
28
28
  schema?: string;
29
29
  /** Standalone sequences the modules declare (state layer — created before tables). */
30
30
  sequences?: SequenceDescriptor[];
31
+ /** Postgres extensions the modules declare — emitted first, before anything using their
32
+ * types. Omitted = none declared; the live side's absent `extensions` means unknown. */
33
+ extensions?: string[];
31
34
  /**
32
35
  * Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
33
36
  * database but absent from the Models. Default `false` — those are held back as
@@ -90,7 +93,11 @@ function holdDrop(sql: string): string {
90
93
  */
91
94
  export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
92
95
  const schema = opts.schema ?? 'public';
93
- const declared = new Set(models.map((m) => `${schema}.${m.table}`));
96
+ // The model's OWN schema first — the same rule the compiler applies. Qualifying every
97
+ // model with the call default read a multi-schema checkout's non-public models as
98
+ // unmodeled: a consumer's plan listed three modeled tables as riding through untouched,
99
+ // which is a false statement about what the plan governs.
100
+ const declared = new Set(models.map((m) => `${m.schema ?? schema}.${m.table}`));
94
101
  // A pending table rename's OR move's source is ours — declared under its new (qualified)
95
102
  // name; without this it reads as an undeclared orphan (F1) and the move/rename silently
96
103
  // degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
@@ -136,6 +143,23 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
136
143
  // databases already have their schemas (their own migrations made them), so this is
137
144
  // invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
138
145
  // (no tables for the live side to reveal it by) from failing the create.
146
+ // 0-pre. EXTENSIONS, before anything that could use their types.
147
+ //
148
+ // The from-scratch compiler emitted these from day one; this DIFF path never did, so the
149
+ // brownfield loop (db:sync, db:generate --apply) and the whole db:plan/db:apply stage lane
150
+ // were silently missing them. Two distinct failures, both measured on a consumer's
151
+ // checkout: a fresh build dies on `type "hstore" does not exist`, and a DEPLOYED stage
152
+ // evolving onto a model that newly uses an extension type fails the same way at apply.
153
+ //
154
+ // `current.extensions` is ABSENT when the caller never ran the extensions query — absent
155
+ // means unknown, not empty, so everything declared is emitted. `IF NOT EXISTS` makes that
156
+ // safe: the cost of not knowing is a no-op statement, never a failure.
157
+ const liveExtensions = current.extensions === undefined ? null : new Set(current.extensions);
158
+ const extensionPhase = [...new Set(opts.extensions ?? [])]
159
+ .filter((e) => liveExtensions === null || !liveExtensions.has(e))
160
+ .sort()
161
+ .map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}"`);
162
+
139
163
  const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
140
164
  const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
141
165
  .filter((s) => s !== 'public' && !liveSchemas.has(s))
@@ -233,6 +257,20 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
233
257
  // The from-scratch path had this from the start; the diff path did not, so a non-public
234
258
  // model reached through db:sync/db:generate was unreachable by every role that did not
235
259
  // pick up USAGE some other way. The example app's analytics schema is what surfaced it.
260
+ // Diffed against the LIVE schema ACLs when the contract carries them. This phase used to
261
+ // emit unconditionally, so any plan with an authz statement re-granted USAGE the database
262
+ // already held — two false statements on every one of a consumer's stage plans (measured,
263
+ // catalog-verified, 2026-08-08). A role holds USAGE when its own nspacl entry says so or
264
+ // when PUBLIC's does (PUBLIC reaches every role). Membership-derived usage is invisible
265
+ // here and stays emitted — idempotent, and rarer than the direct grants that were the bug.
266
+ // No schemaAcls (fresh compose, older recorder) = unknown = emit, exactly as before.
267
+ const liveSchemaAcls = liveAuthzRenamed?.schemaAcls;
268
+ const hasLiveUsage = (s: string, role: string): boolean => {
269
+ const acl = liveSchemaAcls?.[s];
270
+ if (!acl) return false;
271
+ const key = role.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : role;
272
+ return (acl[key] ?? []).includes('USAGE') || (acl.PUBLIC ?? []).includes('USAGE');
273
+ };
236
274
  const usagePhase = authzPhase.length
237
275
  ? [...new Set(desiredContracts.map((c) => c.table.split('.')[0]))]
238
276
  .filter((s) => s !== 'public')
@@ -244,13 +282,14 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
244
282
  for (const r of Object.keys(c.grants)) roles.add(r);
245
283
  for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
246
284
  }
247
- if (!roles.size) return [];
248
- const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
285
+ const missing = [...roles].filter((r) => !hasLiveUsage(s, r));
286
+ if (!missing.length) return [];
287
+ const targets = missing.sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
249
288
  return [`GRANT USAGE ON SCHEMA "${s}" TO ${targets}`];
250
289
  })
251
290
  : [];
252
291
 
253
- return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
292
+ return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
254
293
  }
255
294
 
256
295
  /** The marker drizzle migration files put between statements. */
@@ -22,6 +22,7 @@
22
22
  import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
23
23
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
24
24
  import { nextvalSequence, type RenameMap } from './schema-diff.js';
25
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
25
26
 
26
27
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
27
28
  export function toSnakeCase(name: string): string {
@@ -46,7 +47,10 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
46
47
  const cols = con.columns.map(toSnakeCase);
47
48
  uniques.push({ name: `${model.table}_${cols.join('_')}_unique`, columns: cols });
48
49
  } else if (con.kind === 'check') {
49
- checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
50
+ // Normalized like the live producer (schema-introspect): a model that transcribes a
51
+ // pulled predicate must compare equal to the database it came from, whichever of the
52
+ // two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
53
+ checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: normalizeDeparsedExpr(con.predicate) });
50
54
  } else if (con.kind === 'index') {
51
55
  // Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
52
56
  // DESC, opclass) pass verbatim — they are already SQL (Brick E).
@@ -64,7 +68,7 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
64
68
  name,
65
69
  columns: cols,
66
70
  unique: con.isUnique,
67
- ...(con.predicate ? { where: con.predicate } : {}),
71
+ ...(con.predicate ? { where: normalizeDeparsedExpr(con.predicate) } : {}),
68
72
  ...(con.method ? { using: con.method } : {}),
69
73
  ...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
70
74
  });
@@ -486,7 +490,9 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
486
490
  const fieldChecks = entries.flatMap(([name, field]) => {
487
491
  const col = toSnakeCase(name);
488
492
  const pred = fieldCheckPredicate(col, field.spec);
489
- return pred ? [{ name: `${model.table}_${col}_check`, expr: pred }] : [];
493
+ // Through the same funnel as every other predicate: a `z.enum([...])` compiles to an
494
+ // IN-list, which is exactly the shape whose deparse is unstable across a dump/restore.
495
+ return pred ? [{ name: `${model.table}_${col}_check`, expr: normalizeDeparsedExpr(pred) }] : [];
490
496
  });
491
497
  const checks = [...fieldChecks, ...tableConstraints.checks];
492
498
 
@@ -110,7 +110,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
110
110
  // it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
111
111
  // policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
112
112
  // exactly including the effective WITH CHECK.
113
- export const FINGERPRINT_VERSION = 8;
113
+ export const FINGERPRINT_VERSION = 10;
114
114
 
115
115
  // ---------------------------------------------------------------------------
116
116
  // Canonical form.
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
17
17
  import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
18
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // The data-layer snapshot — the structured shape both producers target.
@@ -409,9 +410,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
409
410
  onUpdate: action('UPDATE'),
410
411
  };
411
412
  }
412
- // check — keep the predicate text inside the outer CHECK ( ... )
413
+ // check — keep the predicate text inside the outer CHECK ( ... ), normalized.
414
+ // `pg_get_expr` output is NOT a fixed point under re-parse: dump a CHECK and restore it
415
+ // and the same server deparses it differently (measured 2026-08-10, PG13-18 alike). A
416
+ // restored clone would then fingerprint differently from the database it was cloned from.
413
417
  const m = def.match(/^CHECK\s*\((.*)\)$/s);
414
- return { table, name: row.name, kind: 'check', expr: m ? m[1].trim() : def };
418
+ return { table, name: row.name, kind: 'check', expr: normalizeDeparsedExpr(m ? m[1].trim() : def) };
415
419
  }
416
420
 
417
421
  // ---------------------------------------------------------------------------
@@ -491,6 +495,9 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
491
495
  let where = whereMatch ? whereMatch[1].trim() : '';
492
496
  // pg_get_indexdef wraps the predicate in one pair of parens — strip it.
493
497
  if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
498
+ // Same fixed-point failure as CHECK predicates: a partial index's WHERE survives a
499
+ // dump/restore as different text for the same predicate. Normalize at the producer.
500
+ where = normalizeDeparsedExpr(where);
494
501
  return {
495
502
  columns,
496
503
  unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
@@ -504,7 +511,10 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
504
511
  * a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
505
512
  * `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
506
513
  export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
507
- const pred = row.predicate == null ? '' : String(row.predicate);
514
+ // The row's own `predicate` column takes precedence over the parsed definition text, so
515
+ // it is the producer that must normalize — normalizing only the parse path left the
516
+ // predicate unstable across a dump/restore and the fingerprint with it.
517
+ const pred = row.predicate == null ? '' : normalizeDeparsedExpr(String(row.predicate));
508
518
  const table = `${row.schema}.${row.table}`;
509
519
  const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
510
520
 
@@ -55,12 +55,23 @@ function strLiteral(value: string): string {
55
55
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
56
56
  }
57
57
 
58
- /** A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts). */
59
- function defaultValueLiteral(value: unknown): string {
58
+ /**
59
+ * A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts).
60
+ *
61
+ * `builder` is the drizzle column builder the value is a default FOR, because the accepted
62
+ * TYPE of the literal depends on it: drizzle's `numeric`/`decimal` take a STRING, since an
63
+ * arbitrary-precision default routed through a JS number is lossy by construction. A pulled
64
+ * model renders `field.numeric(4, 2).default(0.15)` — the model vocabulary accepting a number
65
+ * there is fine — and passing that straight through produced `.default(0.15)`, which fails
66
+ * `tsc --strict` with TS2345 and blocked a consumer from adopting the generated artifact.
67
+ */
68
+ function defaultValueLiteral(value: unknown, builder?: string): string {
60
69
  if (typeof value === 'string') return strLiteral(value);
61
70
  if (typeof value === 'boolean') return value ? 'true' : 'false';
62
71
  if (value === null) return 'null';
63
- if (typeof value === 'number') return String(value);
72
+ if (typeof value === 'number') {
73
+ return builder === 'numeric' || builder === 'decimal' ? strLiteral(String(value)) : String(value);
74
+ }
64
75
  // Arrays / objects: a JSON literal is valid TS for jsonb defaults.
65
76
  return JSON.stringify(value);
66
77
  }
@@ -159,6 +170,7 @@ function columnModifiers(
159
170
  spec: FieldSpec,
160
171
  isComposite: boolean,
161
172
  modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
173
+ ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string> } = {},
162
174
  ): string {
163
175
  let s = '';
164
176
  if (spec.isArray) s += '.array()';
@@ -167,7 +179,7 @@ function columnModifiers(
167
179
 
168
180
  if (spec.defaultKind === 'now') s += '.defaultNow()';
169
181
  else if (spec.defaultKind === 'random') s += '.defaultRandom()';
170
- else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
182
+ else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default, ctx.builder)})`;
171
183
  else if (spec.defaultKind === 'sql') s += `.default(sql.raw(${strLiteral(String(spec.default))}))`;
172
184
 
173
185
  if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
@@ -181,7 +193,17 @@ function columnModifiers(
181
193
  if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
182
194
  if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
183
195
  const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
184
- s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
196
+ // A SELF-referential FK needs drizzle's documented return-type annotation. Without it
197
+ // the table's own initializer references itself, TypeScript cannot infer it, and under
198
+ // `strict` it is TS7022 + TS7024 — which does not merely warn: the whole table
199
+ // degrades to `any`, so every query against it silently loses type safety in a project
200
+ // that otherwise builds. A reference to ANOTHER table infers fine and is left alone.
201
+ if (ctx.self && target === ctx.self) {
202
+ ctx.usedTypes?.add('AnyPgColumn');
203
+ s += `.references((): AnyPgColumn => ${entry.camel}.${parentPk}${optsText})`;
204
+ } else {
205
+ s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
206
+ }
185
207
  }
186
208
  // A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
187
209
  // the runtime + relations never needed it; the DB constraint stays unmanaged.
@@ -333,6 +355,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
333
355
 
334
356
  // --- Track which pg-core builders + drizzle-orm symbols are used ----------
335
357
  const pgCoreBuilders = new Set<string>();
358
+ // TYPE-only imports from pg-core (today: AnyPgColumn, for self-referential FKs). Kept apart
359
+ // from the value imports so the emitted `import type` line carries no runtime cost.
360
+ const pgCoreTypes = new Set<string>();
336
361
  // Only when something actually lands in `public` — an all-non-public model set would
337
362
  // otherwise import a builder it never calls.
338
363
  if (models.some((m) => m.schema === 'public')) pgCoreBuilders.add('pgTable');
@@ -353,7 +378,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
353
378
  const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
354
379
  pgCoreBuilders.add(pgBuilder);
355
380
  if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
356
- const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
381
+ const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
357
382
  if (spec.isDeprecated) {
358
383
  // The contract phase (decision 13): the column stays in the database and
359
384
  // stays readable; the strikethrough warns new code away at author time.
@@ -457,7 +482,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
457
482
  pgCoreBuilders.add(pgBuilder);
458
483
  // Only .array()/.notNull() can appear — defineView/defineMaterializedView
459
484
  // reject every table-structural modifier at define time.
460
- const mods = columnModifiers(spec, true, modelsByDescriptor);
485
+ const mods = columnModifiers(spec, true, modelsByDescriptor, { builder: pgBuilder });
461
486
  colLines.push(` ${key}: ${call}${mods},`);
462
487
  }
463
488
  const cols = `{\n${colLines.join('\n')}\n}`;
@@ -501,6 +526,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
501
526
 
502
527
  const importLines: string[] = [];
503
528
  importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
529
+ if (pgCoreTypes.size) {
530
+ importLines.push(`import type { ${[...pgCoreTypes].sort().join(', ')} } from 'drizzle-orm/pg-core';`);
531
+ }
504
532
 
505
533
  const drizzleOrmNames: string[] = [];
506
534
  if (anyRelations) drizzleOrmNames.push('relations');
@@ -347,6 +347,9 @@ export interface StateSyncOptions extends StateApplyOptions {
347
347
  allowDrops?: boolean;
348
348
  /** Standalone sequences — the verify re-diff must see the same declared state as the plan. */
349
349
  sequences?: SequenceDescriptor[];
350
+ /** Extensions the modules declare — the re-generated stream must carry them too, or a
351
+ * verify-after re-plan disagrees with the plan that was applied. */
352
+ extensions?: string[];
350
353
  }
351
354
 
352
355
  export interface StateSyncOutcome {
@@ -406,7 +409,7 @@ export async function applyStateAndVerify(
406
409
  }
407
410
 
408
411
  const remaining = classifyGeneratedStatements(
409
- generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences }),
412
+ generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences, extensions: options.extensions }),
410
413
  ).executable;
411
414
 
412
415
  return {