@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.
@@ -233,6 +233,62 @@ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]
233
233
  return warnings;
234
234
  }
235
235
 
236
+ // ---------------------------------------------------------------------------
237
+ // Advisory: SECURITY DEFINER with no pin (db:check warn) — A3's linter half
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export interface UnpinnedDefinerFinding extends DerivedAuthzGap {
241
+ /** The schemas the DECLARATION can justify, `pg_catalog` first. A floor to review — never applied. */
242
+ suggested: string[];
243
+ }
244
+
245
+ /**
246
+ * Every SECURITY DEFINER function that declares `searchPath: 'unpinned'`, with a SUGGESTED pin.
247
+ *
248
+ * This is the half of A3 that keeps the honesty from becoming permission. `db:pull` no longer
249
+ * invents `['pg_catalog']` for a function whose live `proconfig` is empty — inventing it was an
250
+ * outage that passed every gate, and fabrication corrodes fingerprint, plan and apply, all of
251
+ * which rest on the model telling the truth. So the parser records what is there and the
252
+ * OPINION lives here: an unpinned definer function is a real latent vulnerability, and it gets
253
+ * named on every run until someone decides about it.
254
+ *
255
+ * SUGGEST, never apply. The suggestion is derived from the DECLARED structure — the function's
256
+ * own schema plus the schemas of its `dependsOn` — and not from reading the body. Body-derived
257
+ * pins are inventing with extra steps: dynamic SQL, operators and casts defeat the parse, and a
258
+ * pin that is wrong in the tool's favour is the same failure mode with better intentions. The
259
+ * suggestion is a floor. The author reviews it and lands it themselves.
260
+ *
261
+ * WARN, not fail. On an adopted database this is a faithful report of what is already true, and
262
+ * failing CI on a faithful pull would make `db:pull` unusable on exactly the schemas it exists
263
+ * for. Same call, and the same reasoning, as findPublicExecutableSecdef above.
264
+ */
265
+ export function findUnpinnedDefiners(derived: readonly DerivedDescriptor[]): UnpinnedDefinerFinding[] {
266
+ const findings: UnpinnedDefinerFinding[] = [];
267
+ for (const d of derived) {
268
+ if (d.kind !== 'function' || d.security !== 'definer' || d.searchPath !== 'unpinned') continue;
269
+ const identity = identityOf(d);
270
+ const schemas = new Set<string>([parseQualified(d.name).schema]);
271
+ for (const ref of d.dependsOn ?? []) {
272
+ schemas.add(isModelRef(ref) ? (ref.schema ?? 'public') : parseQualified(ref.name).schema);
273
+ }
274
+ // pg_catalog FIRST and always: it is what stops a caller shadowing a built-in the body
275
+ // calls unqualified, which is the whole attack this pin defends against.
276
+ const suggested = ['pg_catalog', ...[...schemas].filter((s) => s !== 'pg_catalog').sort()];
277
+ findings.push({
278
+ identity,
279
+ suggested,
280
+ message:
281
+ `${identity} is SECURITY DEFINER with NO search_path — declared 'unpinned', which is the truth about the `
282
+ + `database, not an approval. It runs with its owner's rights, and a caller controls the search_path, so an `
283
+ + `unqualified name in the body can be resolved to an object the caller planted. SUGGESTED pin from what the `
284
+ + `declaration references: searchPath: [${suggested.map((s) => `'${s}'`).join(', ')}]. That is a FLOOR derived `
285
+ + `from dependsOn, not from reading the body — dynamic SQL, operators and casts are invisible to it, so review `
286
+ + `it, extend it, and land it yourself. Nothing here is ever applied for you.`,
287
+ });
288
+ }
289
+ return findings;
290
+ }
291
+
236
292
  // ---------------------------------------------------------------------------
237
293
  // Gate: matview snapshot over row-scoped sources (db:check warn)
238
294
  // ---------------------------------------------------------------------------
@@ -233,6 +233,30 @@ export function planReconcile(
233
233
  const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
234
234
  const extraWarnings: string[] = [];
235
235
 
236
+ /**
237
+ * B2 — the declared `SET search_path` compared against the LIVE one, structurally.
238
+ *
239
+ * Every other comparison in this table is a hash against a RECORDED hash, which is why the
240
+ * pin could differ in silence: `--baseline` records the declared source hash beside the live
241
+ * def hash without ever comparing the two, so a model claiming `search_path=pg_catalog` about
242
+ * a database with no pin reads as up to date forever. Measured — `db:reconcile --check`
243
+ * returned zero actions and zero drift on exactly that state.
244
+ *
245
+ * This is the one comparison that reads both sides at once. Null when they agree, or when
246
+ * either side was not measured (a parser-era object with no declared pin, a pre-B5 catalog
247
+ * read with no structured fields) — absent evidence is not evidence.
248
+ */
249
+ const pinMismatch = (src: SourceObject, liveObj: LiveObject): string | null => {
250
+ if (src.kind !== 'function' || src.searchPath === undefined || !liveObj.fn) return null;
251
+ const tokens = (v: string | undefined): string[] =>
252
+ (v ?? '').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
253
+ const declared = src.searchPath === 'unpinned' ? [] : src.searchPath.map((s) => s.trim());
254
+ const livePin = tokens(liveObj.fn.searchPath);
255
+ if (declared.length === livePin.length && declared.every((s, i) => s === livePin[i])) return null;
256
+ const say = (p: string[]): string => (p.length ? `search_path = ${p.join(', ')}` : 'no search_path');
257
+ return `declared ${say(declared)}, live has ${say(livePin)} — replaced from source`;
258
+ };
259
+
236
260
  for (const src of source.objects) {
237
261
  // --only restricts the primary decisions to the named identities; the rest are left
238
262
  // untouched (they are not skips — they were never in scope, so they get no plan entry).
@@ -271,9 +295,16 @@ export function planReconcile(
271
295
  // (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
272
296
  // --rebuild guarantees it by rebuilding from source (relations through the dependency graph
273
297
  // below; functions replace in place), and the default flags it for a decision.
298
+ const pin = pinMismatch(src, liveObj);
274
299
  if (options.rebuild) {
275
300
  if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
276
301
  else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
302
+ } else if (pin) {
303
+ // --baseline asserts "live IS the source, trust me". Here we can PROVE it is not, so
304
+ // recording the assertion would bless the difference permanently — nothing downstream
305
+ // ever compares these two again. Converge instead, on every path: an object whose pin
306
+ // is measurably wrong is not unverifiable, it is verifiably different.
307
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
277
308
  } else if (options.baseline) {
278
309
  baseline.push(src.identity);
279
310
  } else {
@@ -341,6 +372,15 @@ export function planReconcile(
341
372
  else fnReplace.set(src.identity, 'source changed');
342
373
  continue;
343
374
  }
375
+ // Self-consistent provenance is not proof. A --baseline taken before B2, or a mistaken
376
+ // --rebaseline, records the declared source hash beside the live def hash without ever
377
+ // comparing them — so a declared pin the database does not have reads as up to date on
378
+ // every run. The structural read is what breaks that loop; it self-heals in one apply.
379
+ const pin = pinMismatch(src, liveObj);
380
+ if (pin) {
381
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
382
+ continue;
383
+ }
344
384
  // Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
345
385
  // once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
346
386
  // Converges: after this run the row has body_hash, so it skips cleanly next time.
@@ -430,8 +470,15 @@ export function planReconcile(
430
470
  );
431
471
  } else {
432
472
  const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
433
- const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
473
+ // Functions carry ownership context; relations do not (relacl's NULL means owner-only
474
+ // and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
475
+ const owners = src.kind === 'function'
476
+ ? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
477
+ : undefined;
478
+ const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
434
479
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
480
+ // Never silent: a withheld revoke is reported every run until the owner is declared.
481
+ for (const note of suppressed) extraWarnings.push(note);
435
482
  }
436
483
  }
437
484
 
@@ -208,6 +208,110 @@ function parseArgs(args: string): ParsedArg[] | null {
208
208
  return out;
209
209
  }
210
210
 
211
+ /**
212
+ * The role-name shape `defineFunction` accepts (packages/model derived.ts OWNER_ROLE_NAME).
213
+ * The owner is interpolated into `SET ROLE` / `ALTER … OWNER TO`, so anything else is refused
214
+ * at declaration — which means rendering it here would emit source that throws on import.
215
+ */
216
+ const RENDERABLE_ROLE = /^[a-z_][a-z0-9_]*$/;
217
+
218
+ /**
219
+ * An object that RUNS AS ITS OWNER, and the sentence that says so.
220
+ *
221
+ * Two shapes qualify, and they are the same hazard one object apart: a `SECURITY DEFINER`
222
+ * function, and a view with `security_invoker = false`. For both, an owner nobody chose is a
223
+ * live privilege surface, so the FIXME is loud and the warning reaches stderr.
224
+ */
225
+ export interface ElevatedRights {
226
+ /** How the object declares it — `'SECURITY DEFINER'`, `'not securityInvoker'`. */
227
+ posture: string;
228
+ /** What runs as the owner — `'Every call'`, `'Every read through it'`. */
229
+ what: string;
230
+ }
231
+
232
+ export const SECURITY_DEFINER_RIGHTS: ElevatedRights = { posture: 'SECURITY DEFINER', what: 'Every call' };
233
+ export const DEFINER_VIEW_RIGHTS: ElevatedRights = { posture: 'not securityInvoker', what: 'Every read through it' };
234
+ export const MATVIEW_RIGHTS: ElevatedRights = { posture: 'a materialized view', what: 'Every REFRESH' };
235
+
236
+ /**
237
+ * What db:pull says about an object's OBSERVED owner: render it, or flag it.
238
+ *
239
+ * Ownership is a privilege boundary, so a pulled model that stays silent about it reproduces
240
+ * the wrong database. But rendering EVERY observed owner is worse than saying nothing: on a
241
+ * brownfield database most objects are owned by whoever ran the migration, and writing
242
+ * `owner: 'postgres'` into the models would BLESS that accident — converting an unreviewed
243
+ * superuser-owned SECURITY DEFINER function into fingerprinted, reproducible design.
244
+ *
245
+ * So the discriminator is the AMBIENT BUILD CREDENTIAL, two clauses and no more: the owner is
246
+ * a superuser, or it is the role this pull connected as. Neither → a deliberately chosen
247
+ * principal → render it. Either → a FIXME naming the observed owner, loud when the object runs
248
+ * as its owner. The defect stays visible without being ratified.
249
+ *
250
+ * This also settles the portability objection: the roles whose names legitimately differ
251
+ * between a dev machine, a deployed master and a normalized operator ARE the operator tier —
252
+ * exactly the tier this declines to render.
253
+ *
254
+ * A15 — ONE discriminator for functions AND relations. The rule is identical and the reason is
255
+ * identical; two copies of it is how the view half ends up subtly different from the function
256
+ * half, which is the defect this whole plan keeps finding.
257
+ */
258
+ export function renderedOwner(
259
+ o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
260
+ elevated: ElevatedRights | null,
261
+ ): { prop?: string; fixme?: string; warning?: string } {
262
+ const owner = o.owner;
263
+ // Not measured. A pre-owner catalog read carries no owner at all, and absent evidence is
264
+ // not evidence of a problem — say nothing, exactly as before.
265
+ if (owner === undefined) return {};
266
+
267
+ const superuser = o.ownerIsSuperuser;
268
+ const connected = o.ownerIsConnectedRole;
269
+ const ambient: string[] = [];
270
+ if (superuser === true) ambient.push('a superuser');
271
+ if (connected === true) ambient.push('the role db:pull connected as');
272
+
273
+ if (!ambient.length && superuser !== undefined && connected !== undefined) {
274
+ // A chosen principal. Render it — unless PostgreSQL's name is one defineFunction refuses,
275
+ // in which case rendering would emit source that throws on import.
276
+ if (!RENDERABLE_ROLE.test(owner)) {
277
+ const why = `${o.identity}: owner '${owner}' is not a bare lowercase role name, which is all the model's owner property accepts (it is interpolated into SET ROLE) — ownership NOT declared. Rename the role, or declare the owner by hand.`;
278
+ return { fixme: `// FIXME: ${why}`, warning: why };
279
+ }
280
+ return { prop: `owner: ${tsString(owner)},` };
281
+ }
282
+
283
+ if (!ambient.length) {
284
+ // Owner known, discriminator not measured. Refuse to classify rather than guess.
285
+ return {
286
+ fixme: `// FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`,
287
+ };
288
+ }
289
+
290
+ const because = ambient.join(' and ');
291
+ // The runs-as-owner case is the one that reaches stderr as well as the file: it is a live
292
+ // privilege-escalation surface, not a style note. The plain case stays an inline FIXME —
293
+ // on a brownfield database it is the common shape, and echoing all of it would bury the rest.
294
+ return elevated
295
+ ? {
296
+ fixme: `// FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`,
297
+ warning: `${o.identity}: ${elevated.posture}, owned by '${owner}' (${because}) — ${elevated.what.toLowerCase()} runs with that role's privileges. Ownership was NOT declared; declare the intended owner with owner: '<role>'.`,
298
+ }
299
+ : {
300
+ fixme: `// FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * The A4 spelling, kept so existing callers and tests read unchanged. `renderedOwner` is the
306
+ * implementation; this is the function-shaped door onto it.
307
+ */
308
+ export function renderedFunctionOwner(
309
+ o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
310
+ secdef: boolean,
311
+ ): { prop?: string; fixme?: string; warning?: string } {
312
+ return renderedOwner(o, secdef ? SECURITY_DEFINER_RIGHTS : null);
313
+ }
314
+
211
315
  /** One matview index attachment → the Brick E builder chain, or null (FIXME). */
212
316
  function renderIndexBuilder(indexdef: string): string | null {
213
317
  const parsed = parseIndexDefinition(indexdef);
@@ -469,6 +573,24 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
469
573
  const props: string[] = [];
470
574
  const hasPrivileges = Object.keys(privileges).length > 0;
471
575
  if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
576
+ // A15 — the owner, through the SAME discriminator functions use. Elevated exactly when
577
+ // the relation runs as its owner: a view that is not securityInvoker, and a matview
578
+ // always (its rows are computed at REFRESH time with the owner's rights, no exceptions).
579
+ const relationOwnership = renderedOwner(
580
+ o,
581
+ o.kind === 'materialized view' ? MATVIEW_RIGHTS
582
+ : o.securityInvoker === false ? DEFINER_VIEW_RIGHTS
583
+ : null,
584
+ );
585
+ if (relationOwnership.prop) props.push(relationOwnership.prop);
586
+ if (relationOwnership.fixme) fixmes.push(relationOwnership.fixme);
587
+ // The FIXME lands in the file, at the declaration, which is where the operator acts on
588
+ // it. The stderr WARNING is deliberately NOT raised for relations: `SECURITY DEFINER` is
589
+ // opt-in and rare, so echoing those is signal, but `security_invoker = false` is
590
+ // PostgreSQL's DEFAULT for views — on any real database nearly every view and every
591
+ // matview qualifies, and promoting all of them would bury the function findings the
592
+ // channel exists for. Same judgement the function branch already makes for its plain
593
+ // case, applied where the base rate is inverted.
472
594
  // `private: true` means "declared dark, no grants" — it contradicts a recorded
473
595
  // grant, and the model refuses the pair. A relation whose only reach is recorded
474
596
  // (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
@@ -516,6 +638,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
516
638
  const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
517
639
  const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
518
640
  const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
641
+ const ownership = renderedFunctionOwner(o, f?.secdef === true);
519
642
 
520
643
  if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
521
644
  warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
@@ -523,6 +646,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
523
646
  // collapse to one provenance row here too — the declared name carries the signature
524
647
  // (the drop is explicit, so the name is a label, and a unique one is what it needs).
525
648
  const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
649
+ // A defineSql object cannot carry an owner. Where the pull observed a CHOSEN owner that
650
+ // is real fidelity loss, so it is said out loud on both surfaces — never dropped. An
651
+ // ambient owner needs no note here: there is nothing to declare and nothing was lost.
652
+ if (ownership.prop) {
653
+ const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
654
+ warnings.push(why);
655
+ lines.push(`// FIXME: ${why}`);
656
+ }
526
657
  lines.push(
527
658
  `// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
528
659
  `export const ${varName} = defineSql(${tsString(sqlName)}, {`,
@@ -548,11 +679,30 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
548
679
  props.push(`returns: ${tsString(f.returns)},`);
549
680
  props.push(`language: ${tsString(f.language)},`);
550
681
  if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
551
- if (f.secdef) {
552
- props.push(`security: 'definer',`);
553
- const path = (f.searchPath ?? 'pg_catalog').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
682
+ if (f.secdef) props.push(`security: 'definer',`);
683
+ // THE PIN IS READ, NEVER INVENTED.
684
+ //
685
+ // This used to render `searchPath: ['pg_catalog']` for any definer function whose live
686
+ // proconfig was empty. A build from those models then applied the invented pin for real, so
687
+ // functions that had always resolved `public` stopped resolving it — sign-up and password
688
+ // change died on a database built from the consumer's own models, with every gate green.
689
+ // 34 of their 34 pinned-in-the-model functions were unpinned in the database.
690
+ //
691
+ // A live pin renders as the array. No live pin on a definer function renders the explicit
692
+ // `'unpinned'` sentinel — an ANSWER, not silence, so `defineFunction` still throws for the
693
+ // author who simply forgot. No live pin on an invoker function renders nothing, which
694
+ // already means exactly that.
695
+ if (f.searchPath) {
696
+ const path = f.searchPath.split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
554
697
  props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
698
+ } else if (f.secdef) {
699
+ props.push(`searchPath: 'unpinned',`);
555
700
  }
701
+ // Ownership sits beside security because it IS the other half of it: `security: 'definer'`
702
+ // says the function runs as its owner, and this says who that is.
703
+ if (ownership.prop) props.push(ownership.prop);
704
+ if (ownership.fixme) fixmes.push(ownership.fixme);
705
+ if (ownership.warning) warnings.push(ownership.warning);
556
706
  if (roleGrants.length) {
557
707
  props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
558
708
  need('can');
@@ -60,6 +60,30 @@ export interface SourceObject {
60
60
  file: string;
61
61
  /** Position in the concatenated source — a valid dependency order by convention. */
62
62
  seq: number;
63
+ /**
64
+ * kind 'function' only: the declared owning role.
65
+ *
66
+ * A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
67
+ * boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
68
+ * than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
69
+ * builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
70
+ * alter-after form works exactly once and breaks every re-run.
71
+ */
72
+ owner?: string;
73
+ /**
74
+ * kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
75
+ *
76
+ * It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
77
+ * structurally so the differ can compare the declared pin against the LIVE catalog directly
78
+ * rather than only against a recorded hash. Without that, `--baseline` records a declared pin
79
+ * the database does not have and every later check reports zero actions, forever: measured,
80
+ * and it is how a model came to claim `search_path=pg_catalog` about a database with none
81
+ * while every gate stayed green.
82
+ *
83
+ * `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
84
+ * pin. The comparison is skipped there; absent evidence is not evidence.
85
+ */
86
+ searchPath?: string[] | 'unpinned';
63
87
  /** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
64
88
  objectKind?: string;
65
89
  /** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
@@ -82,9 +106,15 @@ export const DECLARED_SOURCE_FILE = 'db/models (declared)';
82
106
  /** The one content-hash formula. Unchanged since the db/sql era on purpose: provenance
83
107
  * recorded from parser-era objects still matches the descriptor-compiled hash of the
84
108
  * same SQL, so the db/sql → descriptor migration reconciles as a no-op. */
85
- export function hashSourceContent(createSql: string, attachments: Attachment[]): string {
109
+ export function hashSourceContent(createSql: string, attachments: Attachment[], owner?: string): string {
86
110
  const content = [normalizeSql(createSql), ...attachments.map((a) => normalizeSql(a.sql))].join('\n');
87
- return createHash('sha256').update(content).digest('hex');
111
+ // Ownership is part of the declared state — a SECURITY DEFINER function that changes owner
112
+ // changes who it executes as, which is a rebuild, not an authz delta. It is folded in as a
113
+ // PREFIX only when declared, so every object without an owner hashes byte-identically to
114
+ // before this existed. That is deliberate: a consumer mid-adoption must not be forced into a
115
+ // second fingerprint re-mint in a week by a field they do not use.
116
+ const body = owner ? `-- owner: ${owner}\n${content}` : content;
117
+ return createHash('sha256').update(body).digest('hex');
88
118
  }
89
119
 
90
120
  /** A raw file the generic dir readers return (db/backfills, seed lanes). */
@@ -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
@@ -73,6 +76,9 @@ export const HELD_DROP_PREFIX = '-- DROP held back';
73
76
  */
74
77
  function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
75
78
  if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
79
+ // DROP IDENTITY destroys the backing sequence and its counter — a removal, not a change.
80
+ // ADD/SET GENERATED are not: they create or retune the auto-value and lose nothing.
81
+ if (change.kind === 'setIdentity' && change.identity == null) return true;
76
82
  if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
77
83
  return false;
78
84
  }
@@ -140,6 +146,23 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
140
146
  // databases already have their schemas (their own migrations made them), so this is
141
147
  // invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
142
148
  // (no tables for the live side to reveal it by) from failing the create.
149
+ // 0-pre. EXTENSIONS, before anything that could use their types.
150
+ //
151
+ // The from-scratch compiler emitted these from day one; this DIFF path never did, so the
152
+ // brownfield loop (db:sync, db:generate --apply) and the whole db:plan/db:apply stage lane
153
+ // were silently missing them. Two distinct failures, both measured on a consumer's
154
+ // checkout: a fresh build dies on `type "hstore" does not exist`, and a DEPLOYED stage
155
+ // evolving onto a model that newly uses an extension type fails the same way at apply.
156
+ //
157
+ // `current.extensions` is ABSENT when the caller never ran the extensions query — absent
158
+ // means unknown, not empty, so everything declared is emitted. `IF NOT EXISTS` makes that
159
+ // safe: the cost of not knowing is a no-op statement, never a failure.
160
+ const liveExtensions = current.extensions === undefined ? null : new Set(current.extensions);
161
+ const extensionPhase = [...new Set(opts.extensions ?? [])]
162
+ .filter((e) => liveExtensions === null || !liveExtensions.has(e))
163
+ .sort()
164
+ .map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}"`);
165
+
143
166
  const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
144
167
  const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
145
168
  .filter((s) => s !== 'public' && !liveSchemas.has(s))
@@ -269,7 +292,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
269
292
  })
270
293
  : [];
271
294
 
272
- return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
295
+ return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
273
296
  }
274
297
 
275
298
  /** The marker drizzle migration files put between statements. */
@@ -20,6 +20,7 @@ import type { DerivedRenderResult } from './derived-render.js';
20
20
  import type { TableContract } from './authz-contract.js';
21
21
  import { deriveAbilities, renderDerivedAbilities } from './authz-derive.js';
22
22
  import { normalizeDefault, normalizeCheck } from './schema-diff.js';
23
+ import { generatedIndexName, generatedForeignKeyName } from './schema-compile.js';
23
24
 
24
25
  /**
25
26
  * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
@@ -459,10 +460,17 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
459
460
  let expr = call ?? verbatimFieldCall(col.type);
460
461
  let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
461
462
 
463
+ // The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
464
+ // pulled surrogate key reads the way one is authored, and rendered at all because without it
465
+ // the rebuilt column has no auto-value and every INSERT that omits it fails NOT NULL.
466
+ if (col.identity) {
467
+ expr += col.identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
468
+ }
469
+
462
470
  const isPk = table.primaryKey.includes(col.name);
463
471
  if (isPk) expr += '.primaryKey()';
464
- // A serial field is NOT NULL by construction, so the modifier would be redundant.
465
- else if (col.notNull && !serial) expr += '.notNull()';
472
+ // A serial or identity field is NOT NULL by construction, so the modifier would be redundant.
473
+ else if (col.notNull && !serial && !col.identity) expr += '.notNull()';
466
474
 
467
475
  if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
468
476
 
@@ -473,7 +481,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
473
481
  fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
474
482
  fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
475
483
  ].filter(Boolean);
476
- const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
484
+ // A8 the FK's own name, said only when it is not the one the compiler would generate.
485
+ if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
486
+ actions.push(`name: ${tsLiteral(fk.name)}`);
487
+ }
488
+ const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
477
489
  expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
478
490
  } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
479
491
  }
@@ -528,6 +540,15 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
528
540
  if (ix.unique) s += '.unique()';
529
541
  if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
530
542
  if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
543
+ // A7 — keep the name the database actually has, but only say it when it is not the one the
544
+ // compiler would generate anyway. A brownfield schema arrives with its previous tooling's
545
+ // names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
546
+ // consumer's indexes: counts and definitions matched, so every gate stayed green while a
547
+ // planner regression test, runbooks and pg_stat_user_indexes history all broke. Declaring
548
+ // only the divergent ones keeps a greenfield pull clean and an adopted one faithful.
549
+ if (ix.name && ix.name !== generatedIndexName(bareName(table.table), ix.columns)) {
550
+ s += `.name(${tsLiteral(ix.name)})`;
551
+ }
531
552
  items.push(s);
532
553
  }
533
554
  for (const fk of table.foreignKeys) {
@@ -542,6 +563,10 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
542
563
  fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
543
564
  fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
544
565
  ].filter(Boolean);
566
+ // A8 — the FK's own name, said only when it is not the one the compiler would generate.
567
+ if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
568
+ actions.push(`name: ${tsLiteral(fk.name)}`);
569
+ }
545
570
  const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
546
571
  items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
547
572
  }