@everystack/cli 0.4.96 → 0.4.98

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/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.98] - 2026-09-27
8
+
9
+ ### Added
10
+
11
+ - mint state function prerequisites
12
+ - apply state prerequisites atomically
13
+
14
+ ### Fixed
15
+
16
+ - version state prerequisites in plans
17
+ - govern state prerequisites
18
+ - defer state prerequisite body checks
19
+ - verify prerequisite grants before commit
20
+ - preserve prerequisite search paths
21
+
22
+ ## [0.4.97] - 2026-09-26
23
+
24
+ ### Fixed
25
+
26
+ - revoke sessions when users are blocked
27
+ - converge literal and owner policy predicates
28
+ - precreate missing function overloads
29
+
30
+ ### Changed
31
+
32
+ - reuse declared derived during apply
33
+
34
+ ## [0.4.96] - 2026-09-26
35
+
36
+ ### BREAKING CHANGES
37
+
38
+ - defineModel refuses owner or via under a no-session audience
39
+
40
+ ### Fixed
41
+
42
+ - a policy that calls a declared function builds in one run
43
+ - refuse role: 'anon' combined with owner or via
44
+ - db:pull never renders role: 'anon' with owner:
45
+
7
46
  ## [0.4.95] - 2026-09-26
8
47
 
9
48
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.96",
3
+ "version": "0.4.98",
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>",
@@ -182,7 +182,7 @@
182
182
  "react": "19.2.0",
183
183
  "ts-jest": "29.4.9",
184
184
  "zod": "3.25.67",
185
- "@everystack/server": "0.4.33"
185
+ "@everystack/server": "0.4.34"
186
186
  },
187
187
  "scripts": {
188
188
  "test": "jest",
@@ -56,7 +56,11 @@ import {
56
56
  } from './schema-introspect';
57
57
  import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
58
58
  import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
59
- import { applyGeneratedStatements } from './state-apply';
59
+ import {
60
+ applyGeneratedStatements,
61
+ STATE_PREREQUISITE_DRIFT_PREFIX,
62
+ type StateApplyResult,
63
+ } from './state-apply';
60
64
  import {
61
65
  fkOrphanChecks,
62
66
  fkOrphanSql,
@@ -88,6 +92,8 @@ import type { DescentLedgerRow, LedgerRead } from './git-descent';
88
92
  */
89
93
  export interface ApplyPlanOptions {
90
94
  actor?: string | null;
95
+ /** Pinned write transaction for prerequisite DDL + state DDL + provenance. */
96
+ transaction?: <T>(work: (runner: QueryRunner) => Promise<T>) => Promise<T>;
91
97
  /** Defaults to the plan's own gitRef (the intent that minted it). */
92
98
  gitRef?: string | null;
93
99
  /**
@@ -149,15 +155,8 @@ export interface ApplyPlanOptions {
149
155
  * (which runs before the write) — the getter reads it at write time.
150
156
  */
151
157
  descentNote?: () => string | undefined;
152
- /**
153
- * Called with the statements about to run, AFTER every gate above has passed and BEFORE the
154
- * batch — so a refused apply writes nothing through it. db:apply's direct lane uses it to
155
- * create the declared functions a policy in the plan calls (state-function-deps.ts), which on
156
- * a fresh target do not exist until the derived catch-up. Functions are not in the base
157
- * fingerprint, so this moves neither the lock nor the verify-after endpoint. Omitted, the
158
- * apply is unchanged (the ops-Lambda lane does not pass it).
159
- */
160
- beforeStateBatch?: (statements: readonly string[]) => Promise<unknown>;
158
+ /** State-only deliberately skips function writes, including plan prerequisites. */
159
+ stateOnly?: boolean;
161
160
  }
162
161
 
163
162
  export interface ApplyPlanResult {
@@ -542,25 +541,38 @@ export async function executeApplyPlan(
542
541
  // rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
543
542
  // keep naming the file the operator reviewed.
544
543
  const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
544
+ if (options.stateOnly && plan.statePrerequisites?.length) {
545
+ const reason = 'this plan needs state prerequisites, but --state-only skips function writes. Apply the reviewed plan without --state-only, or satisfy the prerequisites and re-mint.';
546
+ await recordRefusal(runner, plan, live, 'state prerequisites', reason, options);
547
+ return { status: 'refused', liveFingerprint: live, reason };
548
+ }
545
549
 
546
- if (options.beforeStateBatch) await options.beforeStateBatch(toRun);
547
-
548
- const result = await applyGeneratedStatements(runner, toRun, {
549
- ...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
550
- actor: options.actor,
551
- gitRef: options.gitRef ?? plan.gitRef,
552
- planRef: planHash(plan),
553
- fromFingerprint: live,
554
- fpVersion: FINGERPRINT_VERSION,
555
- dirty: options.dirty ?? false,
556
- now: options.now,
550
+ let result: StateApplyResult;
551
+ try {
552
+ result = await applyGeneratedStatements(runner, toRun, {
553
+ statePrerequisites: plan.statePrerequisites,
554
+ transaction: options.transaction,
555
+ ...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
556
+ actor: options.actor,
557
+ gitRef: options.gitRef ?? plan.gitRef,
558
+ planRef: planHash(plan),
559
+ fromFingerprint: live,
560
+ fpVersion: FINGERPRINT_VERSION,
561
+ dirty: options.dirty ?? false,
562
+ now: options.now,
557
563
  // The hash to stamp on the record written the moment the batch commits — the live state as
558
564
  // it is THEN, with any new key installed and not yet proven. The plan's own endpoint is
559
565
  // stamped below, after the last check has actually passed; predicting it here would put a
560
566
  // state the database is not in onto the row every later apply descends from.
561
567
  readFingerprint: async () => (await liveBaseFingerprint(session)).hash,
562
- ...(options.descentNote ? { descentNote: options.descentNote } : {}),
563
- });
568
+ ...(options.descentNote ? { descentNote: options.descentNote } : {}),
569
+ });
570
+ } catch (err: any) {
571
+ const message = String(err?.message ?? err);
572
+ if (!message.includes(STATE_PREREQUISITE_DRIFT_PREFIX)) throw err;
573
+ await recordRefusal(runner, plan, live, 'state prerequisites', message, options);
574
+ return { status: 'refused', liveFingerprint: live, reason: message };
575
+ }
564
576
 
565
577
  const { hash: landed } = await liveBaseFingerprint(session);
566
578
  if (result.logId !== undefined) {
@@ -77,6 +77,16 @@ function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: st
77
77
  return `(${sqlColumn} = (${ce})::${castForType(type)})`;
78
78
  }
79
79
 
80
+ // A hand-written predicate can spell the same inline JWT owner check as `owner:`. Match only
81
+ // that complete shape, then emit the one PostgreSQL form the structured shorthand uses.
82
+ const HAND_WRITTEN_OWNER_RE =
83
+ /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)::([a-z]+)\)?$/i;
84
+
85
+ function normalizeHandWrittenOwnerPredicate(text: string): string | null {
86
+ const match = HAND_WRITTEN_OWNER_RE.exec(text);
87
+ return match ? ownerPredicate(match[1], match[3], match[2]) : null;
88
+ }
89
+
80
90
  /** The soft-delete guard, in deparser normal form: `(deleted_at IS NULL)`. */
81
91
  function softDeleteGuard(sqlColumn: string): string {
82
92
  return `(${sqlColumn} IS NULL)`;
@@ -315,7 +325,9 @@ function rawPredicate(value: unknown, where: string): string | null {
315
325
  // plan a DROP + CREATE that changed nothing. A bare call is the exception: the deparser
316
326
  // prints `has_permission()` with no outer parens, so wrapping it is that same churn.
317
327
  const t = text.trim();
318
- return isBareCall(t) ? t : parenthesizeOnce(t);
328
+ const owner = normalizeHandWrittenOwnerPredicate(t);
329
+ if (owner) return owner;
330
+ return isBareCall(t) || t === 'true' || t === 'false' ? t : parenthesizeOnce(t);
319
331
  }
320
332
 
321
333
  /**
@@ -62,7 +62,6 @@ import path from 'node:path';
62
62
  import { resolveModelsPath } from '../models-path';
63
63
  import { loadModels } from './db-generate';
64
64
  import { loadDeclaredDerived } from '../declared-derived';
65
- import { createStateReferencedFunctions } from '../state-function-deps';
66
65
  import { compileTableContract } from '../authz-compile';
67
66
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
68
67
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
@@ -620,6 +619,7 @@ async function applyPlanViaStage(
620
619
  actor: process.env.USER ?? null,
621
620
  gitRef: currentGitRef() ?? plan.gitRef,
622
621
  dirty: worktreeDirty(resolveModelsPath(flags.models)),
622
+ stateOnly: flags['state-only'] === 'true',
623
623
  descentVerdict,
624
624
  ...(descentAttested ? { descentAttested } : {}),
625
625
  ...(authorityVerdict ? { authorityVerdict } : {}),
@@ -1052,10 +1052,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1052
1052
  }
1053
1053
 
1054
1054
  step(connectingVia(dbSource));
1055
- const { runner, session, end } = await createUrlRunner(dbSource.url);
1055
+ const { runner, session, transaction, end } = await createUrlRunner(dbSource.url);
1056
1056
 
1057
1057
  let landed: ApplyPlanResult | undefined;
1058
1058
  let catchUp: CatchUpReport = { ok: true, statements: [] };
1059
+ // Both gates below need the declared derived bundle. Keep its existing lazy timing — the
1060
+ // baseline gate is still the first reader — but never evaluate the models barrel twice.
1061
+ let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
1062
+ const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
1063
+ declaredDbPromise ??= loadDeclaredDerived(flags.models);
1059
1064
  try {
1060
1065
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1061
1066
  if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -1086,6 +1091,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1086
1091
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1087
1092
  async () => {
1088
1093
  const result = await executeApplyPlan(runner, session, plan, {
1094
+ transaction,
1089
1095
  actor: process.env.USER ?? null,
1090
1096
  gitRef: currentGitRef() ?? plan.gitRef,
1091
1097
  ...(verifyAuthority ? { verifyAuthority } : {}),
@@ -1110,7 +1116,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1110
1116
  verifyAuthzBaseline: async ({ contract }) => {
1111
1117
  const stageName = flags.stage || 'local';
1112
1118
  const models = await loadModels(resolveModelsPath(flags.models));
1113
- const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
1119
+ const governedRoles = (await declaredDb())?.governedRoles ?? [];
1114
1120
  const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
1115
1121
  const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
1116
1122
  const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
@@ -1120,18 +1126,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1120
1126
  },
1121
1127
  dirty: worktreeDirty(resolveModelsPath(flags.models)),
1122
1128
  descentNote: () => descentAttested,
1123
- // A declared function a policy in this plan calls must exist before the policy — on a
1124
- // fresh target it would otherwise arrive only with the catch-up below. Only the called,
1125
- // missing ones; --state-only asked for no derived writes, so it is skipped there.
1126
- ...(flags['state-only'] === 'true' ? {} : {
1127
- beforeStateBatch: async (statements: readonly string[]) => {
1128
- const declaredDb = await loadDeclaredDerived(flags.models);
1129
- const created = await createStateReferencedFunctions(runner, session, statements, declaredDb?.objects ?? [], {
1130
- actor: process.env.USER ?? null, gitRef: currentGitRef() ?? plan.gitRef,
1131
- });
1132
- if (created.length > 0) info(`created ${created.length} declared function(s) the plan's policies call, before the plan: ${created.join(', ')}`);
1133
- },
1134
- }),
1129
+ stateOnly: flags['state-only'] === 'true',
1135
1130
  checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }) => {
1136
1131
  step('Descent: proving the fast-forward rule against the schema_log ledger...');
1137
1132
  const result = await proveDescentForApply({
@@ -76,6 +76,7 @@ import { reportPipelineLastRun } from './pipeline-run';
76
76
  import { renderPlanReport } from '../plain-output';
77
77
  import { fkOrphanChecks, fkOrphanSql, fkOrphanFindings, checkOrphanRowShape } from '../fk-preflight';
78
78
  import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
79
+ import { mintStatePrerequisites } from '../state-function-deps';
79
80
  import { step, success, fail, info, warn, line as printLine, reserveStdoutForData } from '../output';
80
81
 
81
82
  /**
@@ -308,6 +309,11 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
308
309
  process.exit(1);
309
310
  }
310
311
 
312
+ // The edge is pure, but this decision belongs to mint: only this command holds both the
313
+ // checkout's declared functions and the live catalog. Embedding the exact missing CREATEs
314
+ // lets every apply lane enact the reviewed answer without reading source again.
315
+ plan.statePrerequisites = await mintStatePrerequisites(runner, session, plan.statements, declaredDerived?.objects ?? []);
316
+
311
317
  // The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
312
318
  // column a view/matview/rule binds would fail MID-TRANSACTION at apply
313
319
  // ("cannot alter type of a column used by a view or rule") — refuse at mint,
@@ -73,6 +73,8 @@ export interface UrlRunner {
73
73
  * so a caller typed against `SessionRunner` reads identically on either venue.
74
74
  */
75
75
  session: SessionRunner;
76
+ /** One pinned write transaction, for an edge that must inspect uncommitted catalog state. */
77
+ transaction: <T>(fn: (runner: QueryRunner) => Promise<T>) => Promise<T>;
76
78
  /** Close the client so the process can exit cleanly. */
77
79
  end: () => Promise<void>;
78
80
  }
@@ -154,6 +156,7 @@ export async function createUrlRunner(
154
156
  return {
155
157
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
156
158
  session: sessionRunnerOver(sql),
159
+ transaction: async (fn) => sql.begin((tx: any) => fn(async (query: string) => Array.from(await tx.unsafe(query)))),
157
160
  end: () => sql.end({ timeout: 5 }),
158
161
  };
159
162
  }
@@ -155,7 +155,7 @@ export interface ReconcileOptions {
155
155
 
156
156
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
157
157
 
158
- const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext => ({
158
+ const ownerContext = (src: Pick<SourceObject, 'owner'>, liveObj: LiveObject): GrantOwnerContext => ({
159
159
  ...(src.owner !== undefined ? { declared: src.owner } : {}),
160
160
  ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
161
161
  });
@@ -178,7 +178,7 @@ const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext
178
178
  * failure to ADD access (availability), never to remove it.
179
179
  */
180
180
  export function objectGrantDelta(
181
- src: SourceObject,
181
+ src: Pick<SourceObject, 'kind' | 'identity' | 'attachments' | 'owner'>,
182
182
  liveObj: LiveObject,
183
183
  liveGrants: Record<string, string[]> | undefined = liveObj.grants,
184
184
  ): (ObjectGrantDiff & { columnScoped: boolean }) | null {
@@ -25,6 +25,8 @@
25
25
  */
26
26
 
27
27
  import { createHash } from 'node:crypto';
28
+ import type { Attachment } from './derived-source';
29
+ import type { OwnerApplyMode } from './derived-apply';
28
30
  import type { ModelDescriptor } from '@everystack/model';
29
31
  import type { SchemaSnapshot, TableSchema } from './schema-introspect';
30
32
  import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
@@ -39,7 +41,19 @@ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForM
39
41
  import { compileDeclaredState } from './declared-diff';
40
42
  import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
41
43
 
42
- export const PLAN_VERSION = 2;
44
+ /**
45
+ * v3 adds `statePrerequisites`: SQL definitions db:apply must establish before the state edge.
46
+ * v4 makes each prerequisite a complete, governed derived-object create and carries the source
47
+ * metadata needed to record canonical catalog provenance in the same transaction as the edge.
48
+ * v5 adds the declared grant contract and owner mode for post-create ACL settlement and
49
+ * verification. A v4 reader skips those checks and must not apply this artifact.
50
+ * v6 records each prerequisite's declared search path, restored before state DDL. A v5 reader
51
+ * can bind argument defaults to the wrong function and must not apply this artifact.
52
+ * A v3 reader does not know how to enact or record those prerequisites, so the file formats are
53
+ * not compatible. The existing db:apply version refusal makes the mismatch a re-mint before any
54
+ * write, rather than letting a peer silently omit part of the reviewed artifact.
55
+ */
56
+ export const PLAN_VERSION = 6;
43
57
 
44
58
  /**
45
59
  * The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
@@ -84,8 +98,28 @@ export const PLAN_VERSION = 2;
84
98
  * to remove, reintroduced silently across a version skew instead of refused cleanly. The BUMP
85
99
  * RULE covers exactly this: a field the CLIENT now depends on to decide something is part of the
86
100
  * contract. Peers refuse until the stage is redeployed.
101
+ *
102
+ * v6 (milestone plan-state-prerequisites-format): the plan in the payload gained
103
+ * `statePrerequisites`, definitions the server must establish before it applies the state edge.
104
+ * A v5 ops Lambda would not know to execute them and would silently skip a reviewed prerequisite
105
+ * before applying the rest of the plan. The BUMP RULE says that changed server work is part of
106
+ * the contract, so the peers refuse until the stage is redeployed.
107
+ *
108
+ * v7 (milestone atomic-state-prerequisites): the payload gained `stateOnly` and the ops Lambda
109
+ * refuses a prerequisite-bearing plan when it is true. A v6 Lambda would accept the request and
110
+ * create the prerequisite, contradicting `--state-only`'s promise to skip function writes.
111
+ *
112
+ * v8 (milestone governed-state-prerequisites): prerequisite application now records derived
113
+ * provenance before its transaction commits. A v7 ops Lambda could create a governed function
114
+ * without its provenance row, leaving the next catch-up to require a baseline.
115
+ *
116
+ * v9 settles and verifies prerequisite grants before commit. A v8 stage could leave an
117
+ * undeclared default EXECUTE grantee live while reporting a successful governed create.
118
+ *
119
+ * v10 applies the reviewed prerequisite search path and restores the transaction's original
120
+ * path before state DDL and grant verification. A v9 stage could bind defaults to other schemas.
87
121
  */
88
- export const APPLY_CONTRACT_VERSION = 5;
122
+ export const APPLY_CONTRACT_VERSION = 10;
89
123
 
90
124
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
91
125
  export interface PlanClassification {
@@ -107,6 +141,31 @@ export interface PlanClassification {
107
141
  unclassified?: number;
108
142
  }
109
143
 
144
+ /** A definition that db:apply must establish before it applies the state edge. */
145
+ export type StatePrerequisite = {
146
+ /** The function's full PostgreSQL identity: `schema.name(argtypes)`. */
147
+ identity: string;
148
+ /** The exact schema preparation, CREATE, ownership, grant, and comment SQL db:apply runs. */
149
+ sql: string;
150
+ /** The catalog condition db:apply must prove before it runs `sql`. */
151
+ expected: 'absent';
152
+ /** Full declared derived path, with quoted identifiers; null preserves the ambient all-public path. */
153
+ searchPath: string | null;
154
+ /** Reviewed grant declarations and the owner mechanism selected at mint. No source reload at apply. */
155
+ grantContract: {
156
+ attachments: Attachment[];
157
+ owner?: string;
158
+ ownerMode: OwnerApplyMode;
159
+ };
160
+ /** Source facts needed to record the catalog-derived provenance hash after this transaction creates it. */
161
+ provenance: {
162
+ srcHash: string;
163
+ bodyHash?: string;
164
+ kind: 'function';
165
+ dropSql?: string;
166
+ };
167
+ };
168
+
110
169
  export interface EdgePlan {
111
170
  v: number;
112
171
  /** The target's exact live fingerprint at mint time — the lock. */
@@ -169,6 +228,11 @@ export interface EdgePlan {
169
228
  declaredFingerprint: string;
170
229
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
171
230
  statements: string[];
231
+ /**
232
+ * Definitions that must exist before the state edge can land. Optional because plans minted
233
+ * before this field existed have none; an absent field means no prerequisite was recorded.
234
+ */
235
+ statePrerequisites?: readonly StatePrerequisite[];
172
236
  executable: number;
173
237
  /** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
174
238
  destructive: number;
@@ -20,7 +20,7 @@
20
20
  import { execSync, execFileSync } from 'node:child_process';
21
21
  import type { ModelDescriptor } from '@everystack/model';
22
22
  import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
23
- import type { SessionRunner } from './session';
23
+ import { borrowedSessionRunner, type SessionRunner } from './session';
24
24
  import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
25
25
  import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
26
26
  import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
@@ -28,13 +28,19 @@ import type { SequenceDescriptor } from '@everystack/model';
28
28
  import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
29
29
  import {
30
30
  ENSURE_RECONCILER_SQL,
31
+ renderProvenanceUpsert,
31
32
  renderSchemaLogInsert,
32
33
  renderSchemaLogFingerprintUpdate,
33
34
  renderSchemaLogCompletionUpdate,
34
35
  } from './derived-apply';
36
+ import { introspectDerived } from './derived-introspect';
37
+ import { objectGrantDelta } from './derived-plan';
35
38
  import { isPureComment, statementBody, statementHead } from './sql-statement';
39
+ import { splitFunctionIdentity } from './pg-argtypes';
40
+ import type { StatePrerequisite } from './edge-plan';
36
41
 
37
42
  const NOTICE_PREFIX = '-- NOTICE';
43
+ export const STATE_PREREQUISITE_DRIFT_PREFIX = 'state prerequisite drift:';
38
44
 
39
45
  export interface ClassifiedStatements {
40
46
  /** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
@@ -354,6 +360,18 @@ export function worktreeDirty(modelsPath: string, cwd: string = process.cwd()):
354
360
  }
355
361
 
356
362
  export interface StateApplyOptions {
363
+ /**
364
+ * Reviewed functions that must be absent and then created before this state batch. They join
365
+ * the same SQL request as the policy that needs them: a collision or a later DDL error rolls
366
+ * back both, rather than leaving a derived object ahead of the state it serves.
367
+ */
368
+ statePrerequisites?: readonly StatePrerequisite[];
369
+ /**
370
+ * An explicit write transaction over one pinned connection. Prerequisites need this because
371
+ * their DDL, the state edge, and their catalog-derived provenance must commit or roll back
372
+ * together. A session-only or pooled QueryRunner cannot make that promise.
373
+ */
374
+ transaction?: <T>(work: (runner: QueryRunner) => Promise<T>) => Promise<T>;
357
375
  actor?: string | null;
358
376
  gitRef?: string | null;
359
377
  planRef?: string | null;
@@ -406,6 +424,18 @@ export interface StateApplyOptions {
406
424
  skippedValidations?: readonly PendingValidation[];
407
425
  }
408
426
 
427
+ const sqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
428
+
429
+ /** The catalog guard belongs in the batch, not in a preceding read: a concurrent creator then
430
+ * causes the whole batch to roll back instead of racing past an out-of-date plan. */
431
+ export function renderStatePrerequisiteGuard(prerequisite: StatePrerequisite): string {
432
+ const { qualified, args } = splitFunctionIdentity(prerequisite.identity);
433
+ const dot = qualified.lastIndexOf('.');
434
+ const schema = dot === -1 ? 'public' : qualified.slice(0, dot);
435
+ const name = dot === -1 ? qualified : qualified.slice(dot + 1);
436
+ return `DO $everystack_prerequisite$\nBEGIN\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = ${sqlLiteral(schema)}\n AND p.proname = ${sqlLiteral(name)}\n AND (SELECT COALESCE(string_agg(pg_catalog.format_type(t.oid, NULL), ', ' ORDER BY t.ord), '')\n FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS t(oid, ord)) = ${sqlLiteral(args ?? '')}\n ) THEN\n RAISE EXCEPTION ${sqlLiteral(`${STATE_PREREQUISITE_DRIFT_PREFIX} ${prerequisite.identity} is already present; the target changed since minting. Re-mint with db:plan.`)};\n END IF;\nEND\n$everystack_prerequisite$`;
437
+ }
438
+
409
439
  /** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
410
440
  export interface PendingValidation {
411
441
  /** The exact statement to re-run once the data satisfies the key. */
@@ -475,17 +505,24 @@ async function recordPendingValidations(
475
505
  }
476
506
  }
477
507
 
508
+ /** Escape strings independently of standard_conforming_strings, including captured session values. */
509
+ function renderLocalSearchPath(path: string): string {
510
+ const literal = "E'" + path.replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
511
+ return `SELECT pg_catalog.set_config('search_path', ${literal}, true)`;
512
+ }
513
+
478
514
  export async function applyGeneratedStatements(
479
515
  runner: QueryRunner,
480
516
  statements: string[],
481
517
  options: StateApplyOptions = {},
482
518
  ): Promise<StateApplyResult> {
483
519
  const classified = classifyGeneratedStatements(statements);
520
+ const prerequisites = options.statePrerequisites ?? [];
484
521
  // Checks the caller withheld before calling. They are outstanding from the first line of this
485
522
  // function: the outcome written before the batch already counts them, and they come back in the
486
523
  // result whether or not any check actually ran here.
487
524
  const skipped: PendingValidation[] = [...(options.skippedValidations ?? [])];
488
- if (classified.executable.length === 0) {
525
+ if (classified.executable.length === 0 && prerequisites.length === 0) {
489
526
  // No DDL to run — but a re-plan against keys a PREVIOUS apply already withheld still owes
490
527
  // the audit trail a row: the promise (docs/cli.md) is every apply lands in schema_log, and
491
528
  // an apply whose only content is "these keys are still unproven" is a real apply, not a
@@ -533,12 +570,91 @@ export async function applyGeneratedStatements(
533
570
  // `batchSql` is the batch that RAN IN THIS TRANSACTION — the checks are not in it. The record
534
571
  // written below is a record of what committed, and the checks had not been attempted at that
535
572
  // point; they join the row on the amendment, where their outcome is known.
536
- const batchSql = batch.join('\n;\n');
573
+ const prerequisiteSql = prerequisites
574
+ .flatMap((prerequisite) => [renderStatePrerequisiteGuard(prerequisite), prerequisite.sql]);
575
+ // A SQL-language prerequisite may read a table this same state edge creates. PostgreSQL
576
+ // validates such bodies at CREATE time unless this transaction-local setting defers it.
577
+ let batchSql = [...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []), ...prerequisiteSql, ...batch].join('\n;\n');
537
578
  const validationSql = validations.join('\n;\n');
538
- const fullSql = [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n');
539
579
  const started = now();
540
580
  try {
541
- if (batch.length > 0) await runner(batchSql);
581
+ if (prerequisites.length > 0) {
582
+ if (!options.transaction) {
583
+ throw new Error('state prerequisites require a pinned write transaction; this apply lane cannot prove atomic provenance.');
584
+ }
585
+ await options.transaction(async (transactionRunner) => {
586
+ const [settings] = await transactionRunner("SELECT pg_catalog.current_setting('search_path') AS search_path");
587
+ const originalPath = settings?.search_path;
588
+ if (typeof originalPath !== 'string') throw new Error('could not capture prerequisite search_path; no state was applied.');
589
+ // Defaults and SQL-standard bodies bind names at CREATE time. Use the full declared
590
+ // path recorded at mint, then restore the exact connection setting before state DDL.
591
+ batchSql = ['SET LOCAL check_function_bodies = off', ...prerequisites.flatMap((prerequisite) => [
592
+ renderStatePrerequisiteGuard(prerequisite),
593
+ ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
594
+ prerequisite.sql,
595
+ ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
596
+ ]), ...batch].join('\n;\n');
597
+ await transactionRunner(batchSql);
598
+ // `defHash` is PostgreSQL's canonical deparse, so source text cannot predict it. Read it
599
+ // after the CREATE through the OPEN transaction, then write provenance before COMMIT.
600
+ const reread = async () => new Map((await introspectDerived(borrowedSessionRunner(transactionRunner)))
601
+ .objects.map((object) => [object.identity, object]));
602
+ let liveByIdentity = await reread();
603
+ const grantDelta = (prerequisite: StatePrerequisite) => {
604
+ const live = liveByIdentity.get(prerequisite.identity);
605
+ if (!live || live.grants === undefined) {
606
+ throw new Error(`state prerequisite ${prerequisite.identity} has no readable grant catalog; no provenance was written.`);
607
+ }
608
+ const contract = prerequisite.grantContract;
609
+ if (contract.owner && live.owner !== contract.owner) {
610
+ throw new Error(`state prerequisite ${prerequisite.identity} owner did not converge to ${contract.owner}; no provenance was written.`);
611
+ }
612
+ const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind: 'function' }, live);
613
+ if (!delta || delta.columnScoped || delta.suppressed.length > 0) {
614
+ throw new Error(`state prerequisite ${prerequisite.identity} grants cannot be verified; no provenance was written.`);
615
+ }
616
+ return delta.statements;
617
+ };
618
+ // CREATE inherits default ACLs, and ALTER OWNER can remove a declared builder grant.
619
+ // Settle from the actual catalog using the same grant diff as derived reconcile.
620
+ const settle = prerequisites.flatMap((prerequisite) => {
621
+ const statements = grantDelta(prerequisite);
622
+ const { owner, ownerMode } = prerequisite.grantContract;
623
+ return statements.length > 0 && owner && ownerMode === 'set-role'
624
+ ? [`SET ROLE ${owner}`, ...statements, 'RESET ROLE'] : statements;
625
+ });
626
+ if (settle.length > 0) {
627
+ const settleSql = settle.join('\n;\n');
628
+ batchSql += `\n;\n${settleSql}`;
629
+ await transactionRunner(settleSql);
630
+ liveByIdentity = await reread();
631
+ }
632
+ // A successful REVOKE can be a warning-only no-op. Only a second catalog read proves it.
633
+ for (const prerequisite of prerequisites) {
634
+ const remaining = grantDelta(prerequisite);
635
+ if (remaining.length > 0) {
636
+ throw new Error(`state prerequisite ${prerequisite.identity} grants did not converge: still needs ${remaining.join('; ')}. No provenance was written.`);
637
+ }
638
+ }
639
+ const provenanceSql = prerequisites.map((prerequisite) => {
640
+ const liveObject = liveByIdentity.get(prerequisite.identity);
641
+ if (!liveObject) {
642
+ throw new Error(`state prerequisite ${prerequisite.identity} was created but is not introspectable before commit; provenance was not recorded.`);
643
+ }
644
+ return renderProvenanceUpsert(
645
+ prerequisite.identity,
646
+ prerequisite.provenance.srcHash,
647
+ liveObject.defHash,
648
+ prerequisite.provenance.kind,
649
+ prerequisite.provenance.dropSql,
650
+ prerequisite.provenance.bodyHash,
651
+ );
652
+ });
653
+ await transactionRunner(provenanceSql.join('\n;\n'));
654
+ });
655
+ } else if (batchSql.length > 0) {
656
+ await runner(batchSql);
657
+ }
542
658
  } catch (err: any) {
543
659
  try {
544
660
  await runner(renderSchemaLogInsert({
@@ -606,7 +722,7 @@ export async function applyGeneratedStatements(
606
722
  try {
607
723
  await runner(renderSchemaLogCompletionUpdate(logId, {
608
724
  outcome: outcomeWith(pendingValidations.length),
609
- sql: fullSql,
725
+ sql: [batchSql, validationSql].filter((s) => s.length > 0).join('\n;\n'),
610
726
  durationMs: now() - started,
611
727
  }));
612
728
  } catch { /* the memoir is best-effort; the row already says what committed */ }
@@ -25,6 +25,20 @@ import type { QueryRunner } from './authz-contract';
25
25
  import type { SessionRunner } from './session';
26
26
  import type { SourceObject } from './derived-source';
27
27
  import { executeReconcile } from './commands/db-reconcile';
28
+ import { introspectDerived } from './derived-introspect';
29
+ import { planReconcile } from './derived-plan';
30
+ import {
31
+ builderOwnerMode,
32
+ derivedSearchPath,
33
+ ownerPreflightRefusal,
34
+ ownerPreflightSql,
35
+ ownerRequirements,
36
+ renderReconcileSql,
37
+ renderEnsureObjectSchemas,
38
+ } from './derived-apply';
39
+ import { functionIdentity } from './pg-argtypes';
40
+ import { quoteIdent } from './pg-ident';
41
+ import type { StatePrerequisite } from './edge-plan';
28
42
 
29
43
  const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
44
 
@@ -47,6 +61,84 @@ export function stateReferencedFunctions(statements: readonly string[], declared
47
61
  && callPattern(o.schema, o.name).test(text));
48
62
  }
49
63
 
64
+ /**
65
+ * The plan artifact's complete prerequisite list. Liveness is supplied by the command shell so
66
+ * this stays pure alongside `stateReferencedFunctions`: db:diff and unit tests can make the same
67
+ * decision without opening a database, while db:plan provides the target's exact identities.
68
+ */
69
+ export function planStatePrerequisites(
70
+ statements: readonly string[],
71
+ declared: readonly SourceObject[],
72
+ present: ReadonlySet<string>,
73
+ ): StatePrerequisite[] {
74
+ const schemas = derivedSearchPath([...declared]);
75
+ const searchPath = schemas.length === 1 && schemas[0] === 'public'
76
+ ? null : schemas.map(quoteIdent).join(', ');
77
+ return stateReferencedFunctions(statements, declared)
78
+ .filter((functionObject) => !present.has(functionObject.identity))
79
+ .map((functionObject) => ({
80
+ identity: functionObject.identity,
81
+ sql: functionObject.sql,
82
+ expected: 'absent',
83
+ searchPath,
84
+ grantContract: {
85
+ attachments: functionObject.attachments.filter((a) => a.kind === 'grant'),
86
+ ...(functionObject.owner ? { owner: functionObject.owner } : {}),
87
+ ownerMode: 'set-role',
88
+ },
89
+ provenance: {
90
+ srcHash: functionObject.hash,
91
+ ...(functionObject.bodyHash ? { bodyHash: functionObject.bodyHash } : {}),
92
+ kind: 'function' as const,
93
+ ...(functionObject.drop ? { dropSql: functionObject.drop } : {}),
94
+ },
95
+ }));
96
+ }
97
+
98
+ /** Read the target's function identities for the plan's referenced declarations only. */
99
+ export async function mintStatePrerequisites(
100
+ runner: QueryRunner,
101
+ session: SessionRunner,
102
+ statements: readonly string[],
103
+ declared: readonly SourceObject[],
104
+ ): Promise<StatePrerequisite[]> {
105
+ const referenced = stateReferencedFunctions(statements, declared);
106
+ if (referenced.length === 0) return [];
107
+ const live = await introspectDerived(session);
108
+ const present = new Set(live.objects.filter((o) => o.kind === 'function').map((o) => o.identity));
109
+ const missing = planStatePrerequisites(statements, declared, present);
110
+ if (missing.length === 0) return [];
111
+
112
+ // The artifact must carry the same owner and attachment SQL as a normal derived apply. The
113
+ // preflight chooses the safe mechanism from the target builder while minting, so apply only
114
+ // executes reviewed text and never re-decides a privilege boundary.
115
+ const parsed = { objects: [...declared], warnings: [] as string[] };
116
+ const identities = missing.map((p) => p.identity);
117
+ const combined = planReconcile(parsed, live, { only: identities });
118
+ const requirements = ownerRequirements(combined, parsed.objects);
119
+ let ownerMode = undefined;
120
+ if (requirements.length > 0) {
121
+ const rows = await runner(ownerPreflightSql(requirements));
122
+ const refusal = ownerPreflightRefusal(rows as any[]);
123
+ if (refusal) throw new Error(`could not mint state prerequisites: ${refusal}`);
124
+ ownerMode = builderOwnerMode(rows as any[]);
125
+ }
126
+
127
+ return missing.map((prerequisite) => {
128
+ const one = planReconcile(parsed, live, { only: [prerequisite.identity] });
129
+ const rendered = renderReconcileSql(one, parsed.objects, ownerMode);
130
+ if (rendered.record.length !== 1 || rendered.record[0] !== prerequisite.identity) {
131
+ throw new Error(`could not mint state prerequisite ${prerequisite.identity}: target changed while minting; re-run db:plan.`);
132
+ }
133
+ const source = referenced.find((o) => o.identity === prerequisite.identity)!;
134
+ return {
135
+ ...prerequisite,
136
+ sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
137
+ grantContract: { ...prerequisite.grantContract, ownerMode: ownerMode ?? 'set-role' },
138
+ };
139
+ });
140
+ }
141
+
50
142
  const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
51
143
 
52
144
  /**
@@ -72,12 +164,17 @@ export async function createStateReferencedFunctions(
72
164
  if (referenced.length === 0) return [];
73
165
 
74
166
  const live = await runner(
75
- `SELECT n.nspname AS schema, p.proname AS name FROM pg_proc p
167
+ `SELECT n.nspname AS schema, p.proname AS name,
168
+ (SELECT COALESCE(string_agg(pg_catalog.format_type(t.oid, NULL), ', ' ORDER BY t.ord), '')
169
+ FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS t(oid, ord)) AS args
170
+ FROM pg_proc p
76
171
  JOIN pg_namespace n ON n.oid = p.pronamespace
77
172
  WHERE p.proname IN (${[...new Set(referenced.map((o) => o.name))].map(literal).join(', ')})`,
78
173
  );
79
- const present = new Set(live.map((r: any) => `${r.schema}.${r.name}`));
80
- const missing = referenced.filter((o) => !present.has(`${o.schema}.${o.name}`));
174
+ const present = new Set(live.map((r: any) => functionIdentity(
175
+ String(r.schema), String(r.name), String(r.args).trim() ? String(r.args).split(',').map((arg) => arg.trim()) : [],
176
+ )));
177
+ const missing = referenced.filter((o) => !present.has(o.identity));
81
178
  if (missing.length === 0) return [];
82
179
 
83
180
  const run = await executeReconcile(runner, session, {