@everystack/cli 0.4.53 → 0.4.56

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.
@@ -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';
@@ -46,6 +47,7 @@ import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
46
47
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
47
48
  import {
48
49
  findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
50
+ findUnpinnedDefiners,
49
51
  } from '../derived-lint.js';
50
52
  import { step, success, fail, info, warn } from '../output.js';
51
53
 
@@ -234,7 +236,9 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
234
236
  for (const gap of derivedGaps) {
235
237
  findings.push({ level: 'fail', area: 'authz', message: gap.message });
236
238
  }
237
- for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived)]) {
239
+ // findUnpinnedDefiners is the advisory half of A3: db:pull stopped inventing a pin, so the
240
+ // opinion moved here. It SUGGESTS one and never applies it.
241
+ for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived), ...findUnpinnedDefiners(input.derived)]) {
238
242
  findings.push({ level: 'warn', area: 'authz', message: warning.message });
239
243
  }
240
244
  if (derivedGaps.length === 0) {
@@ -345,7 +349,17 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
345
349
  } catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
346
350
  }
347
351
 
348
- const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
352
+ // The SAME resolution db:generate uses — flag > journal-recorded > default. db:check read
353
+ // only the flag, so a project that had RELOCATED its artifact (recorded in the journal by
354
+ // db:generate, per its own help) had the two verbs disagree about which file is the
355
+ // artifact: generate wrote one path, check guarded another. A consumer worked around it by
356
+ // passing the flag explicitly in their CI script.
357
+ const journal = await readJournal(path.resolve(flags.dir || DEFAULT_MIGRATIONS));
358
+ const artifactRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
359
+ const artifactPath = artifactRes.path;
360
+ if (artifactRes.source === 'recorded') {
361
+ info(`schema-out: ${artifactPath} (recorded in the journal; override with --schema-out)`);
362
+ }
349
363
  let artifactSource: string | null = null;
350
364
  try {
351
365
  artifactSource = await fs.readFile(artifactPath, 'utf8');
@@ -438,6 +452,9 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
438
452
  // ring proves the checkout against ITSELF, on an empty database. Whether an existing
439
453
  // database IS this state is a different question with a different verb.
440
454
  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.');
455
+ // The ephemeral database is DROPPED. Saying so here is what stops this line being read
456
+ // as "you now have a database" — a consumer read exactly that into it, from our own docs.
457
+ info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
441
458
  } else {
442
459
  success('db:check passed (static ring only — no database provided for the ephemeral compose).');
443
460
  }
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
65
65
  models: ModelDescriptor[] | null,
66
66
  sequences?: SequenceDescriptor[],
67
67
  governedExtras?: readonly string[],
68
+ /** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
69
+ declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
68
70
  ): Promise<FingerprintStatus> {
69
71
  const snapshot = await introspectSchema(session);
70
72
  const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
77
79
  const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
78
80
  const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
79
81
  const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
80
- const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
82
+ // B5 a declared owner IS hashed (derived-source prefixes it), so reporting it as
83
+ // unfingerprinted would be a false claim in the one report whose whole job is honesty about
84
+ // coverage. SQL cannot know what the models declare; the filter belongs here.
85
+ const ownerDeclared = new Set(
86
+ (declaredDerived ?? [])
87
+ .filter((o) => o.kind === 'function' && o.owner !== undefined)
88
+ .map((o) => o.identity.replace(/\(.*\)$/, '')),
89
+ );
90
+ const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
91
+ .filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
81
92
  return {
82
93
  live,
83
94
  ...(governedLive !== undefined ? { governedLive } : {}),
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
101
112
  let models: ModelDescriptor[] | null = null;
102
113
  let sequences: SequenceDescriptor[] | undefined;
103
114
  let governedExtras: string[] | undefined;
115
+ let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
104
116
  try {
105
117
  models = await loadModels(modelsPath);
106
118
  const declared = await loadDeclaredDerived(flags.models);
107
119
  sequences = declared?.sequences;
108
120
  governedExtras = declared?.governedRoles;
121
+ // B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
122
+ declaredDerived = declared?.objects;
109
123
  } catch (err: any) {
110
124
  // Two very different situations used to land here identically.
111
125
  //
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
141
155
  }
142
156
 
143
157
  try {
144
- const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
158
+ const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
145
159
 
146
160
  if (flags.json === 'true') {
147
161
  console.log(JSON.stringify(status, null, 2));
@@ -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}`);
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
35
35
  import path from 'node:path';
36
36
  import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
37
37
  import { fingerprintLive } from '../schema-fingerprint.js';
38
- import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
38
+ import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
39
39
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
40
40
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
41
41
  import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
235
235
  // governed role's grants are DECLARED (transcribed as privileges), so recording them
236
236
  // as exemptions too would double-book them — declared and exempted at once.
237
237
  pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
238
+ // A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
239
+ // common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
240
+ // what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
241
+ // it would be the tool deciding a security property from an inference.
242
+ for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
238
243
  pulledFingerprint = fingerprintLive(current, contract).hash;
239
244
  }
240
245
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
@@ -43,6 +43,11 @@ import {
43
43
  derivedSearchPath,
44
44
  renderSetSearchPath,
45
45
  renderEnsureObjectSchemas,
46
+ ownerRequirements,
47
+ ownerPreflightSql,
48
+ ownerPreflightRefusal,
49
+ builderOwnerMode,
50
+ type OwnerApplyMode,
46
51
  ENSURE_RECONCILER_SQL,
47
52
  } from '../derived-apply.js';
48
53
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
@@ -78,6 +83,13 @@ export interface ReconcileRun {
78
83
  statements: string[];
79
84
  /** Why apply was refused, when it was. */
80
85
  refusal?: string;
86
+ /**
87
+ * Which mechanism enacted the declared owners, when any were enacted. Reported because the two
88
+ * make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
89
+ * the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
90
+ * operator debugging a permission error should not have to guess which one ran.
91
+ */
92
+ ownerMode?: OwnerApplyMode;
81
93
  }
82
94
 
83
95
  /**
@@ -118,7 +130,10 @@ export async function executeReconcile(
118
130
  const live = await introspectDerived(session);
119
131
  const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
120
132
  const plan = planReconcile(parsed, live, options);
121
- const rendered = renderReconcileSql(plan, parsed.objects);
133
+ // Rendered with the mechanism that works for EVERY builder. The preflight below reads the
134
+ // catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
135
+ // (or is refused) shows the conservative form, which is the honest default.
136
+ let rendered = renderReconcileSql(plan, parsed.objects);
122
137
 
123
138
  if (!options.apply) return { plan, applied: false, statements: rendered.statements };
124
139
 
@@ -138,6 +153,23 @@ export async function executeReconcile(
138
153
  return { plan, applied: false, statements: [] };
139
154
  }
140
155
 
156
+ // DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
157
+ //
158
+ // Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
159
+ // rather than one per re-run, so the operator's first news is not a raw Postgres error from the
160
+ // middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
161
+ // with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
162
+ // owner, which needs membership and CREATE on the schema.
163
+ const requirements = ownerRequirements(plan, parsed.objects);
164
+ let ownerMode: OwnerApplyMode | undefined;
165
+ if (requirements.length > 0) {
166
+ const rows = (await runner(ownerPreflightSql(requirements))) as any[];
167
+ const refusal = ownerPreflightRefusal(rows);
168
+ if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
169
+ ownerMode = builderOwnerMode(rows);
170
+ if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
171
+ }
172
+
141
173
  const now = options.now ?? Date.now;
142
174
  await runner(ENSURE_RECONCILER_SQL.join(';\n'));
143
175
 
@@ -263,7 +295,7 @@ export async function executeReconcile(
263
295
  throw explainReconcileError(err);
264
296
  }
265
297
 
266
- return { plan, applied: true, statements: rendered.statements };
298
+ return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
267
299
  }
268
300
 
269
301
  /**
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
520
552
  fail(`not applied: ${run.refusal}`);
521
553
  } else if (run.applied) {
522
554
  success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
555
+ // Which ownership mechanism ran. The two make different demands, so an operator
556
+ // debugging a permission error should not have to guess which one they hit.
557
+ if (run.ownerMode === 'alter-owner') {
558
+ info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
559
+ } else if (run.ownerMode === 'set-role') {
560
+ info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
561
+ }
523
562
  if (run.plan.actions.some((a) => a.action === 'baseline')) {
524
563
  warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
525
564
  }
@@ -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',
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
  import {
17
17
  audit,
18
+ schemaDescriptorsFromContract,
18
19
  parseFunctionsFromSql,
19
20
  parseViewsAndGrantsFromSql,
20
21
  type Waivers,
@@ -28,6 +29,7 @@ import {
28
29
  catalogFunctionToDescriptor,
29
30
  catalogRelationToDescriptor,
30
31
  } from '../security-catalog.js';
32
+ import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract.js';
31
33
  import { resolveConfig, opsFunction } from '../config.js';
32
34
  import { invokeAction } from '../aws.js';
33
35
  import { step, success, fail, info, warn } from '../output.js';
@@ -116,11 +118,28 @@ export async function auditDeployedSql(
116
118
  step('Introspecting database catalog (functions + relations)...');
117
119
  const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
118
120
  const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
121
+ // A10(ii) — the PRECONDITION leg. Unpinned SECDEF and owner-bypasses-RLS both describe the
122
+ // privileged CODE; neither says whether an attacker can plant something for it to resolve to.
123
+ // Only the catalog path can answer that, so the static path leaves `schemas` empty rather
124
+ // than report every schema as clean.
125
+ const aclRows = await catalogQuery(region, opsFn, SCHEMA_ACL_SQL);
119
126
 
120
127
  const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
121
128
  const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
122
- info(`Catalog: ${functions.length} function(s), ${views.length} relation(s).`);
123
- return audit(functions, views, waivers);
129
+ const schemaAcls: Record<string, Record<string, string[]>> = {};
130
+ for (const row of aclRows) {
131
+ schemaAcls[String(row.schema)] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
132
+ }
133
+ const schemas = schemaDescriptorsFromContract(
134
+ schemaAcls,
135
+ fnRows.map((r: any) => ({
136
+ name: `${r.schema}.${r.name}`,
137
+ securityDefiner: r.security_definer === true || r.security_definer === 't',
138
+ hasSearchPath: r.has_search_path === true || r.has_search_path === 't',
139
+ })),
140
+ );
141
+ info(`Catalog: ${functions.length} function(s), ${views.length} relation(s), ${schemas.length} schema(s).`);
142
+ return audit(functions, views, waivers, schemas);
124
143
  }
125
144
 
126
145
  async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
@@ -153,12 +172,14 @@ export function printReport(report: AuditReport, instrument: 'static' | 'catalog
153
172
  const reds = [
154
173
  ...report.functions.filter((f) => f.severity === 'red'),
155
174
  ...report.views.filter((v) => v.severity === 'red'),
175
+ ...report.schemas.filter((s) => s.severity === 'red'),
156
176
  ];
157
177
  const warns = [
158
178
  ...report.functions.filter((f) => f.severity === 'warn'),
159
179
  ...report.views.filter((v) => v.severity === 'warn'),
180
+ ...report.schemas.filter((s) => s.severity === 'warn'),
160
181
  ];
161
- const waived = report.functions.filter((f) => f.waived);
182
+ const waived = [...report.functions.filter((f) => f.waived), ...report.schemas.filter((s) => s.waived)];
162
183
 
163
184
  if (reds.length > 0) {
164
185
  console.log('');
@@ -129,6 +129,19 @@ export async function buildIntoDatabase(
129
129
  for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
130
130
  await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
131
131
  }
132
+ // A10(i) — ASSERT the schema posture, do not inherit it.
133
+ //
134
+ // PG15 removed PUBLIC's CREATE on schema `public`; before that it was the default, and it
135
+ // still rides in with any pre-PG15 dump. So a database everystack BUILDS was hardened only
136
+ // by accident of the server version, while one it ADOPTS was not — and db:fingerprint
137
+ // reported MATCH across both. A security posture that depends on which major created the
138
+ // database is exactly the environmental dependence this class of defect is about.
139
+ //
140
+ // Safe here and ONLY here: db:build refuses a database that already holds objects, so there
141
+ // is nothing in `public` to strand. Bringing an ADOPTED database to this posture is a
142
+ // reviewable REVOKE the operator sees before it runs, never a silent side effect — some
143
+ // legacy apps do create objects in `public` at runtime.
144
+ await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
132
145
  const createdRoles = await ensureContractRoles(runner, models);
133
146
 
134
147
  // FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
@@ -170,12 +183,34 @@ export async function buildIntoDatabase(
170
183
  await runner('RESET check_function_bodies');
171
184
  }
172
185
  }
173
- const run = await executeSync(runner, session, models, {
186
+ let run = await executeSync(runner, session, models, {
174
187
  declared: options.declared,
175
188
  sequences: options.sequences,
176
189
  actor: options.actor ?? 'db-build',
177
190
  gitRef: options.gitRef ?? null,
178
191
  });
192
+ // A12 — THE SECOND PASS, and it is inherent to building from nothing rather than a patch
193
+ // over one bug. `executeSync` is a single diff-and-verify: it reads the live authz contract,
194
+ // computes the delta, applies it. On a FROM-SCRATCH build some objects the authz layer must
195
+ // grant on do not exist at read time — most sharply the sequence behind a serial column,
196
+ // which our own CREATE TABLE makes moments later. So the first pass cannot see it, the
197
+ // sequence inheritance rule (A6) has nothing to grant on, and the build lands with a role
198
+ // that may INSERT into a table but cannot draw its sequence value.
199
+ //
200
+ // Caught by the round-trip oracle (B7), not by A6's own tests: the rule converges perfectly
201
+ // against an EXISTING database, which is what those tests exercise.
202
+ //
203
+ // Bounded at exactly one extra pass. The second read sees everything the first one created,
204
+ // so a third could only differ if the apply were non-convergent — and that is a real failure
205
+ // the `converged` bar must report, never something to loop away.
206
+ if (!run.converged) {
207
+ run = await executeSync(runner, session, models, {
208
+ declared: options.declared,
209
+ sequences: options.sequences,
210
+ actor: options.actor ?? 'db-build',
211
+ gitRef: options.gitRef ?? null,
212
+ });
213
+ }
179
214
  return {
180
215
  converged: run.converged,
181
216
  fingerprintMatch: run.fingerprintMatch,
@@ -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);