@everystack/cli 0.4.53 → 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.53",
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
  }
@@ -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}`);
@@ -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);
@@ -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
@@ -140,6 +143,23 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
140
143
  // databases already have their schemas (their own migrations made them), so this is
141
144
  // invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
142
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
+
143
163
  const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
144
164
  const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
145
165
  .filter((s) => s !== 'public' && !liveSchemas.has(s))
@@ -269,7 +289,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
269
289
  })
270
290
  : [];
271
291
 
272
- return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
292
+ return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
273
293
  }
274
294
 
275
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 = 9;
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 {