@everystack/cli 0.4.45 → 0.4.46

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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +60 -14
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-fingerprint.ts +54 -18
  14. package/src/cli/commands/db-generate.ts +11 -17
  15. package/src/cli/commands/db-plan.ts +56 -8
  16. package/src/cli/commands/db-pull.ts +16 -18
  17. package/src/cli/commands/db-reconcile.ts +18 -20
  18. package/src/cli/commands/db-swap.ts +5 -4
  19. package/src/cli/commands/db-sync.ts +8 -5
  20. package/src/cli/db-build.ts +2 -2
  21. package/src/cli/db-source.ts +56 -0
  22. package/src/cli/derived-introspect.ts +27 -26
  23. package/src/cli/derived-lint.ts +7 -8
  24. package/src/cli/edge-plan.ts +112 -16
  25. package/src/cli/git-descent.ts +16 -9
  26. package/src/cli/index.ts +1 -1
  27. package/src/cli/model-render.ts +56 -50
  28. package/src/cli/schema-compile.ts +6 -1
  29. package/src/cli/schema-diff.ts +1 -1
  30. package/src/cli/schema-fingerprint.ts +67 -7
  31. package/src/cli/schema-introspect.ts +44 -17
  32. package/src/cli/schema-source.ts +9 -0
  33. package/src/cli/session.ts +184 -0
  34. package/src/cli/stage-read-consistency.ts +128 -0
  35. package/src/cli/state-apply.ts +4 -2
  36. package/src/cli/swap-execute.ts +4 -3
  37. package/src/cli/search-path.ts +0 -51
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
57
57
  import { resolveConfig, opsFunction } from '../config.js';
58
58
  import { resolveModelsPath } from '../models-path.js';
59
59
  import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
60
- import { invokeAction } from '../aws.js';
60
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
61
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
61
62
  import { step, success, fail, info, warn } from '../output.js';
62
63
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
63
64
  import { loadModels } from './db-generate.js';
64
65
 
65
66
  const DEFAULT_DIR = 'authz';
66
67
 
67
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
68
- function lambdaRunner(region: string, fn: string): QueryRunner {
69
- return async (sql: string) => {
70
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
71
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
72
- return result?.rows ?? [];
73
- };
74
- }
75
-
76
68
  /**
77
69
  * Where an authz command runs. Both venues expose the same two capabilities, so the
78
70
  * commands never branch on venue after this point — that is what keeps the local
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
81
73
  interface AuthzVenue {
82
74
  /** Read-only introspection. */
83
75
  runner: QueryRunner;
76
+ /** The consistent multi-statement read — what every introspection takes. */
77
+ session: SessionRunner;
84
78
  /** Self-reverting red-team probe (writes, always rolled back). */
85
79
  probe: (setup: string, read: string) => Promise<any[]>;
86
80
  /** Human-readable venue, printed with every verdict. */
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
97
91
  const source = resolveDbSource(flags);
98
92
  if (source.kind === 'url') {
99
93
  step(connectingVia(source));
100
- const { runner, probe, end } = await createUrlProbeRunner(source.url);
101
- return { runner, probe, end, label: 'direct connection' };
94
+ const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
95
+ return { runner, session, probe, end, label: 'direct connection' };
102
96
  }
103
97
  step('Resolving deployed config...');
104
98
  const config = await resolveConfig(flags.stage);
105
99
  const fn = opsFunction(config);
106
100
  info(`Region: ${config.region}, Function: ${fn}`);
107
101
  return {
108
- runner: lambdaRunner(config.region, fn),
102
+ runner: lambdaQueryRunner(config.region, fn),
103
+ session: lambdaSessionRunner(config.region, fn),
109
104
  probe: async (setup: string, read: string) => {
110
105
  const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
111
106
  if (result?.error) throw new Error(result.error);
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
120
115
  const venue = await resolveVenue(flags);
121
116
  try {
122
117
  step('Introspecting authorization (rls + grants + policies + secdef)...');
123
- return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
118
+ return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
124
119
  } finally {
125
120
  await venue.end();
126
121
  }
@@ -21,46 +21,70 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
21
21
  import {
22
22
  fingerprintLive,
23
23
  fingerprintModels,
24
+ governedRolesForModels,
24
25
  mapUnfingerprintedRows,
25
26
  UNFINGERPRINTED_SQL,
26
27
  type UnfingerprintedObject,
27
28
  } from '../schema-fingerprint.js';
29
+ import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan.js';
28
30
  import { resolveModelsPath } from '../models-path.js';
29
31
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
32
+ import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session.js';
30
33
  import { resolveConfig, opsFunction } from '../config.js';
31
- import { invokeAction } from '../aws.js';
34
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
32
35
  import { step, success, fail, info, warn } from '../output.js';
33
36
  import { loadModels } from './db-generate.js';
34
37
  import { loadDeclaredDerived } from '../declared-derived.js';
35
38
  import type { SequenceDescriptor } from '@everystack/model';
36
39
 
37
40
  export interface FingerprintStatus {
41
+ /** The RAW live hash — everything introspected. The flavor plan.from and the backup chain use. */
38
42
  live: string;
43
+ /**
44
+ * The GOVERNED live hash — the live state filtered through the models' governed-role set,
45
+ * i.e. "the state the models describe". Present only when models loaded; equals `live` when
46
+ * no foreign grantee holds anything. THIS is the half MATCH compares — a live grant nothing
47
+ * will ever reconcile must not keep MATCH unreachable (the v4 rule, wired to the verdict).
48
+ */
49
+ governedLive?: string;
39
50
  declared: string | null;
51
+ /**
52
+ * The predicted endpoint: models + unmodeled live tables riding through, governed —
53
+ * `db:generate`'s no-op state. Equals `declared` when nothing is unmodeled. MATCH is
54
+ * `predicted === governedLive` (the declares-test): the identity is with what generate
55
+ * MANAGES, and generate manages only declared tables.
56
+ */
57
+ predicted?: string;
40
58
  match: boolean | null;
41
59
  unfingerprinted: UnfingerprintedObject[];
42
60
  }
43
61
 
44
62
  /** The testable core: live fingerprint, declared fingerprint, verdict, honesty report. */
45
63
  export async function computeFingerprintStatus(
46
- runner: QueryRunner,
64
+ session: SessionRunner,
47
65
  models: ModelDescriptor[] | null,
48
66
  sequences?: SequenceDescriptor[],
67
+ governedExtras?: readonly string[],
49
68
  ): Promise<FingerprintStatus> {
50
- const snapshot = await introspectSchema(runner);
51
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
69
+ const snapshot = await introspectSchema(session);
70
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
71
+ // Rides the SAME session policy as the two introspections above. It used to be issued
72
+ // bare, outside any search_path pin — the one read in this command that was not
73
+ // canonicalized, and on the stage lane its own separate invoke.
74
+ const [unfingerprintedRows] = await session([UNFINGERPRINTED_SQL], INTROSPECTION_SESSION);
52
75
  const live = fingerprintLive(snapshot, contract).hash;
53
76
  const declared = models ? fingerprintModels(models, { sequences }).hash : null;
54
- const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
55
- return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
56
- }
57
-
58
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
59
- function lambdaRunner(region: string, fn: string): QueryRunner {
60
- return async (sql: string) => {
61
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
62
- if (result?.error) throw new Error(`Query failed: ${result.error}`);
63
- return result?.rows ?? [];
77
+ const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
78
+ const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
79
+ const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
80
+ const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
81
+ return {
82
+ live,
83
+ ...(governedLive !== undefined ? { governedLive } : {}),
84
+ declared,
85
+ ...(predicted !== undefined ? { predicted } : {}),
86
+ match: declared === null ? null : predicted === governedLive,
87
+ unfingerprinted,
64
88
  };
65
89
  }
66
90
 
@@ -76,9 +100,12 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
76
100
  const modelsPath = resolveModelsPath(flags.models);
77
101
  let models: ModelDescriptor[] | null = null;
78
102
  let sequences: SequenceDescriptor[] | undefined;
103
+ let governedExtras: string[] | undefined;
79
104
  try {
80
105
  models = await loadModels(modelsPath);
81
- sequences = (await loadDeclaredDerived(flags.models))?.sequences;
106
+ const declared = await loadDeclaredDerived(flags.models);
107
+ sequences = declared?.sequences;
108
+ governedExtras = declared?.governedRoles;
82
109
  } catch (err: any) {
83
110
  // Two very different situations used to land here identically.
84
111
  //
@@ -100,18 +127,21 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
100
127
  }
101
128
 
102
129
  let runner: QueryRunner;
130
+
131
+ let session: SessionRunner;
103
132
  let end: (() => Promise<void>) | undefined;
104
133
  if (dbSource.kind === 'url') {
105
134
  step(connectingVia(dbSource));
106
- ({ runner, end } = await createUrlRunner(dbSource.url));
135
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
107
136
  } else {
108
137
  step('Resolving deployed config...');
109
138
  const config = await resolveConfig(flags.stage);
110
- runner = lambdaRunner(config.region, opsFunction(config));
139
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
140
+ session = lambdaSessionRunner(config.region, opsFunction(config));
111
141
  }
112
142
 
113
143
  try {
114
- const status = await computeFingerprintStatus(runner, models, sequences);
144
+ const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
115
145
 
116
146
  if (flags.json === 'true') {
117
147
  console.log(JSON.stringify(status, null, 2));
@@ -120,7 +150,13 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
120
150
  if (status.declared === null) {
121
151
  warn(`declared: (no models barrel at ${modelsPath} — live-only)`);
122
152
  } else {
153
+ if (status.governedLive !== undefined && status.governedLive !== status.live) {
154
+ info(`governed: ${status.governedLive} (live state minus ungoverned grantees — the half MATCH compares)`);
155
+ }
123
156
  info(`declared: ${status.declared}`);
157
+ if (status.predicted !== undefined && status.predicted !== status.declared) {
158
+ info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
159
+ }
124
160
  if (status.match) success('MATCH — the database is the state the models declare.');
125
161
  else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
126
162
  }
@@ -34,26 +34,18 @@ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.
34
34
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
35
35
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
36
36
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
37
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
37
38
  import { resolveModelsPath } from '../models-path.js';
38
39
  import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
39
40
  import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
40
41
  import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
41
42
  import { resolveConfig, opsFunction } from '../config.js';
42
- import { invokeAction } from '../aws.js';
43
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
43
44
  import { step, success, fail, info, warn } from '../output.js';
44
45
 
45
46
  const DEFAULT_MIGRATIONS = 'drizzle';
46
47
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
47
48
 
48
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
49
- function lambdaRunner(region: string, fn: string): QueryRunner {
50
- return async (sql: string) => {
51
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
52
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
53
- return result?.rows ?? [];
54
- };
55
- }
56
-
57
49
  /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
58
50
  * Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
59
51
  export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
@@ -164,11 +156,11 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
164
156
  function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
165
157
  if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
166
158
  const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
167
- const { counts } = classifyAdoption(declared, liveAuthz);
159
+ const { counts, statements } = classifyAdoption(declared, liveAuthz);
168
160
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
169
161
  if (total === 0) return;
170
162
  info(`${total} authorization statement(s), by why they exist:`);
171
- for (const line of renderAdoptionReport(counts)) info(line);
163
+ for (const line of renderAdoptionReport(counts, statements)) info(line);
172
164
  }
173
165
 
174
166
  /**
@@ -249,6 +241,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
249
241
  let liveAuthz;
250
242
  let liveOwners: TableOwner[] = [];
251
243
  let runner!: QueryRunner;
244
+ let session!: SessionRunner;
252
245
  let end: (() => Promise<void>) | undefined;
253
246
  try {
254
247
  step(`Loading models from ${modelsPath}...`);
@@ -270,17 +263,18 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
270
263
  }
271
264
  if (dbSource.kind === 'url') {
272
265
  step(connectingVia(dbSource));
273
- ({ runner, end } = await createUrlRunner(dbSource.url));
266
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
274
267
  } else {
275
268
  step('Resolving deployed config...');
276
269
  const config = await resolveConfig(flags.stage);
277
270
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
278
- runner = lambdaRunner(config.region, opsFunction(config));
271
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
272
+ session = lambdaSessionRunner(config.region, opsFunction(config));
279
273
  }
280
274
  step('Introspecting live database (columns + constraints)...');
281
- current = await introspectSchema(runner);
275
+ current = await introspectSchema(session);
282
276
  step('Introspecting authorization (rls + grants + policies)...');
283
- liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
277
+ liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
284
278
  // Ownership rides the same connection: it is not part of the contract (it never
285
279
  // enters the fingerprint), but it decides whether the contract is actually in force.
286
280
  liveOwners = await introspectTableOwners(runner);
@@ -352,7 +346,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
352
346
  step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
353
347
  let outcome: StateSyncOutcome;
354
348
  try {
355
- outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
349
+ outcome = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
356
350
  allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
357
351
  });
358
352
  } catch (err: any) {
@@ -16,15 +16,23 @@
16
16
  * Plans are EPHEMERAL — attach them to the release/PR run, never commit
17
17
  * them (committing plans would rebuild the tape). Minting is read-only, so
18
18
  * it works over the ops Lambda (--stage) as well as a direct connection.
19
+ *
20
+ * On the STAGE lane the read is not atomic: every catalog query is its own
21
+ * Lambda invoke, so one introspection can be assembled from several containers
22
+ * holding connections to different databases, and the minted `from` would then
23
+ * describe no real state. That lane reads TWICE and refuses a disagreement.
24
+ * Agreement is a non-detection, not a verification — see
25
+ * stage-read-consistency.ts. The direct lane reads once, over one session.
19
26
  */
20
27
 
21
28
  import fs from 'node:fs/promises';
22
29
  import { spawnSync } from 'node:child_process';
23
30
  import type { ModelDescriptor } from '@everystack/model';
24
- import { introspectContract, type QueryRunner } from '../authz-contract.js';
31
+ import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
25
32
  import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
26
33
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
27
- import { introspectSchema } from '../schema-introspect.js';
34
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
35
+ import { readStageStateTwice } from '../stage-read-consistency.js';
28
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
29
37
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
30
38
  import { verifyDescent } from '../git-descent.js';
@@ -32,13 +40,15 @@ import { planBackfills, readBackfillLog } from '../backfill.js';
32
40
  import { readSqlDirIfPresent } from './db-sync.js';
33
41
  import { currentGitRef } from '../state-apply.js';
34
42
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
43
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
35
44
  import { resolveModelsPath } from '../models-path.js';
36
45
  import { resolveConfig, opsFunction } from '../config.js';
37
- import { lambdaQueryRunner } from '../aws.js';
46
+ import { lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
38
47
  import { loadModels } from './db-generate.js';
39
48
  import { loadDeclaredDerived } from '../declared-derived.js';
40
49
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
41
50
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
51
+ import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents.js';
42
52
  import { compileTableContract } from '../authz-compile.js';
43
53
  import { reportPipelineLastRun } from './pipeline-run.js';
44
54
  import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
@@ -89,20 +99,41 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
89
99
  }
90
100
 
91
101
  let runner: QueryRunner;
102
+
103
+ let session: SessionRunner;
92
104
  let end: (() => Promise<void>) | undefined;
93
105
  if (dbSource.kind === 'url') {
94
106
  step(connectingVia(dbSource));
95
- ({ runner, end } = await createUrlRunner(dbSource.url));
107
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
96
108
  } else {
97
109
  step('Resolving deployed config...');
98
110
  const config = await resolveConfig(flags.stage);
99
111
  runner = lambdaQueryRunner(config.region, opsFunction(config));
112
+ session = lambdaSessionRunner(config.region, opsFunction(config));
100
113
  }
101
114
 
102
115
  try {
103
- step('Asking the target its fingerprint (introspecting state + authz)...');
104
- const snapshot = await introspectSchema(runner);
105
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
116
+ let snapshot: SchemaSnapshot;
117
+ let contract: AuthzContract;
118
+ if (dbSource.kind === 'url') {
119
+ // One session, one database — the read is atomic enough to trust.
120
+ step('Asking the target its fingerprint (introspecting state + authz)...');
121
+ snapshot = await introspectSchema(session);
122
+ contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
123
+ } else {
124
+ // Each introspection is one session now, but a full read is TWO of them (state,
125
+ // then authz) and they can land on two containers at two moments. Read twice and
126
+ // refuse a disagreement — see stage-read-consistency.ts for why agreement is a
127
+ // non-detection and not a verification.
128
+ step('Asking the target its fingerprint TWICE (the stage lane reads state and authz in separate ops-Lambda invokes)...');
129
+ const pair = await readStageStateTwice(session);
130
+ if (!pair.ok) {
131
+ fail(pair.reason);
132
+ process.exit(1);
133
+ }
134
+ warn(pair.warning);
135
+ ({ snapshot, contract } = pair.state);
136
+ }
106
137
  // Not part of the plan and not part of either fingerprint — the owner is
107
138
  // environment-specific. It is read here so the REVIEW surface can name it.
108
139
  const owners = await introspectTableOwners(runner);
@@ -145,6 +176,19 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
145
176
  process.exit(1);
146
177
  }
147
178
 
179
+ // The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
180
+ // column a view/matview/rule binds would fail MID-TRANSACTION at apply
181
+ // ("cannot alter type of a column used by a view or rule") — refuse at mint,
182
+ // with the dependents named, while there is still a live catalog to ask.
183
+ const alterTargets = alterTypeStatementTargets(plan.statements);
184
+ if (alterTargets.length > 0) {
185
+ const blockedAlters = await findAlterTypeDependents(runner, alterTargets);
186
+ if (blockedAlters.length > 0) {
187
+ fail(`Mint refused: ${renderAlterTypeRefusal(blockedAlters)}`);
188
+ process.exit(1);
189
+ }
190
+ }
191
+
148
192
  const body = JSON.stringify(plan, null, 2) + '\n';
149
193
  if (out === '-') {
150
194
  console.log(body);
@@ -166,8 +210,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
166
210
  const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
167
211
  if (adoptionTotal > 0) {
168
212
  info(`${adoptionTotal} authorization statement(s), by why they exist:`);
169
- for (const line of renderAdoptionReport(adoption.counts)) info(line);
213
+ for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
170
214
  }
215
+ // The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
216
+ // db.plan.json had the aggregate and no way to reach the statements behind it, so
217
+ // `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
218
+ plan.adoption = adoption.statements;
171
219
 
172
220
  // WHO owns the tables this plan authorizes, and does that owner obey the policies it
173
221
  // is about to write? Re-derived live every mint, never stored — the owner is a fact
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
- * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
4
+ * db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
5
5
  * [--derived-out <file.ts>] [--abilities public-read]
6
6
  *
7
+ * `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
8
+ * baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
9
+ * `--database-url … --stage local`, which is the stage db:plan defaults to).
10
+ *
7
11
  * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
8
12
  * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
9
13
  * action — or, for a database no Lambda can reach (the brownfield case where the target
@@ -41,8 +45,9 @@ import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from
41
45
  import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
42
46
  import type { QueryRunner } from '../authz-contract.js';
43
47
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
48
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
44
49
  import { resolveConfig, opsFunction } from '../config.js';
45
- import { invokeAction } from '../aws.js';
50
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
46
51
  import { fail } from '../output.js';
47
52
  import { opsAdviceLines } from '../ops-advice.js';
48
53
 
@@ -102,15 +107,6 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
102
107
  return rel.startsWith('.') ? rel : `./${rel}`;
103
108
  }
104
109
 
105
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
106
- function lambdaRunner(region: string, fn: string): QueryRunner {
107
- return async (sql: string) => {
108
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
109
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
110
- return result?.rows ?? [];
111
- };
112
- }
113
-
114
110
  export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
115
111
  const schema = flags.schema || 'public';
116
112
 
@@ -159,27 +155,29 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
159
155
  let candidatesByIdentity: Map<string, string[]> | undefined;
160
156
  try {
161
157
  let runner: QueryRunner;
158
+ let session: SessionRunner;
162
159
  let end: (() => Promise<void>) | undefined;
163
160
  if (dbSource.kind === 'url') {
164
161
  note(connectingVia(dbSource));
165
- ({ runner, end } = await createUrlRunner(dbSource.url));
162
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
166
163
  } else {
167
164
  note('Resolving deployed config...');
168
165
  const config = await resolveConfig(flags.stage);
169
166
  detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
170
- runner = lambdaRunner(config.region, opsFunction(config));
167
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
168
+ session = lambdaSessionRunner(config.region, opsFunction(config));
171
169
  }
172
170
  note(`Introspecting live database (schema: ${schema})...`);
173
- current = await introspectSchema(runner);
171
+ current = await introspectSchema(session);
174
172
  // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
175
173
  // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
176
- derivedCatalog = await introspectDerived(runner);
174
+ derivedCatalog = await introspectDerived(session);
177
175
  // --abilities live: the authz half of the on-ramp. Same connection, one more read —
178
176
  // the grants and policies that already exist become the models' declared abilities,
179
177
  // instead of a human transcribing them by hand.
180
178
  if (abilities === 'live') {
181
179
  note('Introspecting live authorization (grants + policies)...');
182
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
180
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
183
181
  liveContract = contract;
184
182
  // The owner is NOT rendered into the models — it is the dev user here and the
185
183
  // operator role on a stage, so a model that declared it would drift everywhere.
@@ -357,8 +355,8 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
357
355
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
358
356
  }
359
357
 
360
- const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK )/g) ?? []).length;
361
- if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
358
+ const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
359
+ if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
362
360
  if (abilities === 'commented') {
363
361
  note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
364
362
  } else {
@@ -46,12 +46,13 @@ import {
46
46
  ENSURE_RECONCILER_SQL,
47
47
  } from '../derived-apply.js';
48
48
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
49
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
49
50
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
50
51
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
51
52
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
52
53
  import { currentGitRef } from '../state-apply.js';
53
54
  import { resolveConfig, opsFunction } from '../config.js';
54
- import { invokeAction } from '../aws.js';
55
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
55
56
  import { step, success, fail, info, warn } from '../output.js';
56
57
 
57
58
  // ---------------------------------------------------------------------------
@@ -111,9 +112,10 @@ export function isTransactionalBatch(statements: string[]): boolean {
111
112
 
112
113
  export async function executeReconcile(
113
114
  runner: QueryRunner,
115
+ session: SessionRunner,
114
116
  options: ExecuteOptions = {},
115
117
  ): Promise<ReconcileRun> {
116
- const live = await introspectDerived(runner);
118
+ const live = await introspectDerived(session);
117
119
  const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
118
120
  const plan = planReconcile(parsed, live, options);
119
121
  const rendered = renderReconcileSql(plan, parsed.objects);
@@ -189,11 +191,14 @@ export async function executeReconcile(
189
191
  }
190
192
  }
191
193
 
192
- // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
193
- // would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
194
- // introspectDerived pins the canonical search_path itself, so the defHash it records is stable
195
- // regardless of the create-time wide path above (or any ambient override) no manual reset here.
196
- const after = await introspectDerived(runner);
194
+ // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we
195
+ // hoped would exist. Inside the transaction this reads the batch's own not-yet-committed
196
+ // writes, so the read must BORROW that transaction: a session of its own would open a nested
197
+ // BEGIN and COMMIT this one out from under the bookkeeping below. The borrowed session pins
198
+ // the canonical search_path inside a savepoint it always rolls back, so the def hashes are
199
+ // stable regardless of the create-time wide path above — and that wide path survives the read.
200
+ // The unwrapped path has no transaction to borrow, so it takes a session of its own.
201
+ const after = await introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
197
202
  const liveById = new Map(after.objects.map((o) => [o.identity, o]));
198
203
  const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
199
204
 
@@ -347,15 +352,6 @@ export function checkFails(plan: ReconcilePlan): boolean {
347
352
  // The CLI shell.
348
353
  // ---------------------------------------------------------------------------
349
354
 
350
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
351
- function lambdaRunner(region: string, fn: string): QueryRunner {
352
- return async (sql: string) => {
353
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
354
- if (result?.error) throw new Error(`Query failed: ${result.error}`);
355
- return result?.rows ?? [];
356
- };
357
- }
358
-
359
355
  export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
360
356
  const apply = flags.apply === 'true';
361
357
  const check = flags.check === 'true';
@@ -493,25 +489,27 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
493
489
  run = { plan: result.plan, applied: result.applied, statements: result.statements ?? [], refusal: result.refusal ?? undefined };
494
490
  } else {
495
491
  let runner: QueryRunner;
492
+ let session: SessionRunner;
496
493
  // A write over a direct connection (--database-url or --direct) takes the mutation
497
494
  // lease; a read-only stage dry-run over the ops Lambda does not (reads never lease).
498
495
  let leased = false;
499
496
  if (dbSource.kind === 'url') {
500
497
  step(connectingVia(dbSource));
501
- ({ runner, end } = await createUrlRunner(dbSource.url));
498
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
502
499
  leased = apply;
503
500
  } else {
504
501
  step('Resolving deployed config...');
505
502
  const config = await resolveConfig(flags.stage);
506
- runner = lambdaRunner(config.region, opsFunction(config));
503
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
504
+ session = lambdaSessionRunner(config.region, opsFunction(config));
507
505
  }
508
506
  run = leased
509
507
  ? await withMutationLease(
510
508
  runner,
511
509
  { verb: 'db:reconcile', actor: process.env.USER ?? 'unknown' },
512
- () => executeReconcile(runner, reconcileOptions),
510
+ () => executeReconcile(runner, session, reconcileOptions),
513
511
  )
514
- : await executeReconcile(runner, reconcileOptions);
512
+ : await executeReconcile(runner, session, reconcileOptions);
515
513
  }
516
514
 
517
515
  if (flags.json === 'true') {
@@ -31,6 +31,7 @@ import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage,
31
31
  import { introspectDerived } from '../derived-introspect.js';
32
32
  import { legacyFunctionIdentity } from '../pg-argtypes.js';
33
33
  import { createUrlRunner } from '../db-source.js';
34
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
34
35
  import type { QueryRunner } from '../authz-contract.js';
35
36
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
36
37
  import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
@@ -626,14 +627,14 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
626
627
  }
627
628
  const artifactFingerprint = artifact.fingerprint;
628
629
 
629
- const { runner, end } = await createUrlRunner(url);
630
+ const { runner, session, end } = await createUrlRunner(url);
630
631
  try {
631
632
  step(`Swapping ${schema} — gate, land incoming, atomic swap, verify...`);
632
633
  // One operator mutates a database at a time — the swap is a whole-schema replacement.
633
634
  const res = await withMutationLease(
634
635
  runner,
635
636
  { verb: 'db:swap', actor: process.env.USER ?? 'unknown' },
636
- () => executeSwap(runner, {
637
+ () => executeSwap(runner, session, {
637
638
  models, schema,
638
639
  artifactFingerprint,
639
640
  declaredFingerprint,
@@ -659,8 +660,8 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
659
660
  // later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
660
661
  // ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
661
662
  recordProvenance: paired.length
662
- ? async (r) => {
663
- const live = await introspectDerived(r);
663
+ ? async (r, s) => {
664
+ const live = await introspectDerived(s);
664
665
  const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
665
666
  for (const st of prov.statements) await r(st);
666
667
  if (prov.recorded.length === 0) {