@everystack/cli 0.4.45 → 0.4.47

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 (42) 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 +82 -20
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-backfill.ts +1 -1
  14. package/src/cli/commands/db-exec.ts +20 -1
  15. package/src/cli/commands/db-fingerprint.ts +54 -18
  16. package/src/cli/commands/db-generate.ts +11 -17
  17. package/src/cli/commands/db-plan.ts +89 -9
  18. package/src/cli/commands/db-pull.ts +16 -18
  19. package/src/cli/commands/db-reconcile.ts +19 -21
  20. package/src/cli/commands/db-refresh.ts +33 -5
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/commands/db.ts +2 -1
  24. package/src/cli/db-build.ts +2 -2
  25. package/src/cli/db-source.ts +56 -0
  26. package/src/cli/derived-introspect.ts +27 -26
  27. package/src/cli/derived-lint.ts +7 -8
  28. package/src/cli/edge-plan.ts +112 -16
  29. package/src/cli/exec-digest.ts +55 -13
  30. package/src/cli/git-descent.ts +16 -9
  31. package/src/cli/index.ts +3 -3
  32. package/src/cli/model-render.ts +56 -50
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +67 -7
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +145 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -2,7 +2,7 @@
2
2
  * `everystack db:plan` — mint a verified edge against a target database
3
3
  * (brick 4's mint surface; the review artifact of the deploy boundary).
4
4
  *
5
- * db:plan [--stage <name> | --database-url <url>] [--models <barrel>]
5
+ * db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>]
6
6
  * [--allow-drops] [--out db.plan.json | --out -]
7
7
  *
8
8
  * Minting asks the TARGET its fingerprint (no repo-side release pointer to
@@ -16,15 +16,38 @@
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
+ * THREE VENUES, and the difference is what the resulting fingerprint is worth:
21
+ *
22
+ * --stage <name> the ops Lambda. A full read is two invokes (state, then authz),
23
+ * answerable by two containers at two moments, so the minted `from`
24
+ * can describe no single real state. This lane reads TWICE and
25
+ * refuses a disagreement — a DETECTOR, never a verification
26
+ * (stage-read-consistency.ts). Agreement proves nothing.
27
+ * --stage <name> --direct the stage's OPERATOR connection, resolved from its IAM-gated ops
28
+ * Lambda and held in memory only. One real connection, one session,
29
+ * read ONCE — a fingerprint that describes one database at one
30
+ * moment. The credential never reaches argv.
31
+ * --database-url <url> a LOCAL connection. Same read guarantee as --direct, but against a
32
+ * deployed stage it forces a privileged DSN onto the command line.
33
+ *
34
+ * `--direct` EXISTED FOR db:apply AND NOT FOR db:plan, WHICH INVERTED THE COST OF SAFETY. The
35
+ * only lane that can verify a fingerprint was the only lane that made the operator print their
36
+ * credential, so verifying a plan cost them their secret hygiene — and the destructive refusal
37
+ * then recommended exactly that. A consumer's operator put it plainly: "every command that
38
+ * requires ARN is a risk of exposure. A major purpose of everystack is to PREVENT this." The
39
+ * resolution is shared with db:apply, db:reconcile, db:backfill, db:refresh and db:swap
40
+ * (direct-venue.ts) — db:plan was the verb that never adopted it.
19
41
  */
20
42
 
21
43
  import fs from 'node:fs/promises';
22
44
  import { spawnSync } from 'node:child_process';
23
45
  import type { ModelDescriptor } from '@everystack/model';
24
- import { introspectContract, type QueryRunner } from '../authz-contract.js';
46
+ import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
25
47
  import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
26
48
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
27
- import { introspectSchema } from '../schema-introspect.js';
49
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
50
+ import { readStageStateTwice } from '../stage-read-consistency.js';
28
51
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
29
52
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
30
53
  import { verifyDescent } from '../git-descent.js';
@@ -32,13 +55,16 @@ import { planBackfills, readBackfillLog } from '../backfill.js';
32
55
  import { readSqlDirIfPresent } from './db-sync.js';
33
56
  import { currentGitRef } from '../state-apply.js';
34
57
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
58
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
35
59
  import { resolveModelsPath } from '../models-path.js';
36
60
  import { resolveConfig, opsFunction } from '../config.js';
37
- import { lambdaQueryRunner } from '../aws.js';
61
+ import { resolveOperatorUrlViaStage } from '../direct-venue.js';
62
+ import { lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
38
63
  import { loadModels } from './db-generate.js';
39
64
  import { loadDeclaredDerived } from '../declared-derived.js';
40
65
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
41
66
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
67
+ import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents.js';
42
68
  import { compileTableContract } from '../authz-compile.js';
43
69
  import { reportPipelineLastRun } from './pipeline-run.js';
44
70
  import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
@@ -88,21 +114,58 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
88
114
  process.exit(1);
89
115
  }
90
116
 
117
+ // `--stage --direct`: resolve the stage's OPERATOR connection from its IAM-gated ops Lambda
118
+ // and mint over it. The URL lives in this process's memory only — never printed, never
119
+ // written, never on argv. From here it IS a url source, so the single-read path below picks
120
+ // it up and `connectingVia` reports the operator credential honestly.
121
+ if (dbSource.kind === 'stage' && flags.direct === 'true') {
122
+ try {
123
+ step('Resolving the operator connection from the stage (--direct)...');
124
+ const op = await resolveOperatorUrlViaStage(flags.stage);
125
+ info(`operator credential resolved (${op.source}) — minting CLI-side over one connection.`);
126
+ dbSource = { kind: 'url', url: op.url, from: 'operator' };
127
+ } catch (err: any) {
128
+ fail(err.message);
129
+ process.exit(1);
130
+ }
131
+ }
132
+
91
133
  let runner: QueryRunner;
134
+
135
+ let session: SessionRunner;
92
136
  let end: (() => Promise<void>) | undefined;
93
137
  if (dbSource.kind === 'url') {
94
138
  step(connectingVia(dbSource));
95
- ({ runner, end } = await createUrlRunner(dbSource.url));
139
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
96
140
  } else {
97
141
  step('Resolving deployed config...');
98
142
  const config = await resolveConfig(flags.stage);
99
143
  runner = lambdaQueryRunner(config.region, opsFunction(config));
144
+ session = lambdaSessionRunner(config.region, opsFunction(config));
100
145
  }
101
146
 
102
147
  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);
148
+ let snapshot: SchemaSnapshot;
149
+ let contract: AuthzContract;
150
+ if (dbSource.kind === 'url') {
151
+ // One session, one database — the read is atomic enough to trust.
152
+ step('Asking the target its fingerprint (introspecting state + authz)...');
153
+ snapshot = await introspectSchema(session);
154
+ contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
155
+ } else {
156
+ // Each introspection is one session now, but a full read is TWO of them (state,
157
+ // then authz) and they can land on two containers at two moments. Read twice and
158
+ // refuse a disagreement — see stage-read-consistency.ts for why agreement is a
159
+ // non-detection and not a verification.
160
+ step('Asking the target its fingerprint TWICE (the stage lane reads state and authz in separate ops-Lambda invokes)...');
161
+ const pair = await readStageStateTwice(session);
162
+ if (!pair.ok) {
163
+ fail(pair.reason);
164
+ process.exit(1);
165
+ }
166
+ warn(pair.warning);
167
+ ({ snapshot, contract } = pair.state);
168
+ }
106
169
  // Not part of the plan and not part of either fingerprint — the owner is
107
170
  // environment-specific. It is read here so the REVIEW surface can name it.
108
171
  const owners = await introspectTableOwners(runner);
@@ -145,6 +208,19 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
145
208
  process.exit(1);
146
209
  }
147
210
 
211
+ // The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
212
+ // column a view/matview/rule binds would fail MID-TRANSACTION at apply
213
+ // ("cannot alter type of a column used by a view or rule") — refuse at mint,
214
+ // with the dependents named, while there is still a live catalog to ask.
215
+ const alterTargets = alterTypeStatementTargets(plan.statements);
216
+ if (alterTargets.length > 0) {
217
+ const blockedAlters = await findAlterTypeDependents(runner, alterTargets);
218
+ if (blockedAlters.length > 0) {
219
+ fail(`Mint refused: ${renderAlterTypeRefusal(blockedAlters)}`);
220
+ process.exit(1);
221
+ }
222
+ }
223
+
148
224
  const body = JSON.stringify(plan, null, 2) + '\n';
149
225
  if (out === '-') {
150
226
  console.log(body);
@@ -166,8 +242,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
166
242
  const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
167
243
  if (adoptionTotal > 0) {
168
244
  info(`${adoptionTotal} authorization statement(s), by why they exist:`);
169
- for (const line of renderAdoptionReport(adoption.counts)) info(line);
245
+ for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
170
246
  }
247
+ // The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
248
+ // db.plan.json had the aggregate and no way to reach the statements behind it, so
249
+ // `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
250
+ plan.adoption = adoption.statements;
171
251
 
172
252
  // WHO owns the tables this plan authorizes, and does that owner obey the policies it
173
253
  // 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';
@@ -484,7 +480,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
484
480
  if (result?.error) {
485
481
  fail(`db:reconcile failed: ${result.error}`);
486
482
  if (/Unknown action/i.test(String(result.error))) {
487
- info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply direct: db:reconcile --apply --database-url <url>.');
483
+ info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
488
484
  } else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
489
485
  info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
490
486
  }
@@ -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') {
@@ -8,15 +8,26 @@
8
8
  * unchanged — a pure DATA-changed matview refresh had no credential-free path. This is the
9
9
  * data-lane twin: `--stage` runs the whole refresh in the ops Lambda on the operator connection
10
10
  * (the `db:refresh` action), so a least-privileged operator never holds a URL. `--database-url`
11
- * / `--direct` refresh over a direct connection (dev). `--list` previews the topo-ordered
12
- * matviews without connecting. Plain REFRESH (ACCESS EXCLUSIVE) CONCURRENTLY is a follow-on.
13
- * (consumer field report 2026-07-19.)
11
+ * refreshes over a local connection (dev). `--stage <name> --direct` resolves the stage's
12
+ * operator credential from its ops Lambda and refreshes CLI-side on an UNBOUNDED clock — the
13
+ * lane for a matview set that exceeds the ops-Lambda 900-second limit. `--list` previews the
14
+ * topo-ordered matviews without connecting. Plain REFRESH (ACCESS EXCLUSIVE) — CONCURRENTLY is
15
+ * a follow-on. (consumer field report 2026-07-19.)
16
+ *
17
+ * `--direct` WAS ADVERTISED HERE AND IN `everystack --help` WITHOUT BEING WIRED. `resolveDbSource`
18
+ * does not know the flag — each command wires it — so `db:refresh --direct` fell through to the
19
+ * stage branch and `resolveConfig(undefined)` picked the DEFAULT stage. An operator asking for a
20
+ * direct dev refresh could refresh a deployed stage's matviews instead, believing the flag had
21
+ * scoped them locally. A documented flag that silently does nothing is the same class as the
22
+ * refusal that recommended `--database-url`: the tool's own words sending the operator somewhere
23
+ * they did not choose.
14
24
  */
15
25
 
16
26
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
17
27
  import { loadDeclaredDerived } from '../declared-derived.js';
18
28
  import { executeRefresh, matviewIdentities, type RefreshRun } from '../refresh-execute.js';
19
29
  import { resolveConfig, opsFunction } from '../config.js';
30
+ import { resolveOperatorUrlViaStage } from '../direct-venue.js';
20
31
  import { invokeAction } from '../aws.js';
21
32
  import { step, success, fail, info } from '../output.js';
22
33
 
@@ -57,9 +68,26 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
57
68
  process.exit(1);
58
69
  }
59
70
 
60
- // Two venues, mirroring the reconcile lane:
71
+ // `--stage --direct` (the shared direct venue): resolve the stage's OPERATOR connection from
72
+ // its ops Lambda and refresh CLI-side on an unbounded clock. The URL lives in this process's
73
+ // memory only — never printed, never written, never on argv. Same resolution db:apply,
74
+ // db:reconcile, db:backfill and db:swap use.
75
+ if (dbSource.kind === 'stage' && flags.direct === 'true') {
76
+ try {
77
+ step('Resolving the operator connection from the stage (--direct)...');
78
+ const op = await resolveOperatorUrlViaStage(flags.stage);
79
+ info(`operator credential resolved (${op.source}) — refreshing CLI-side, unbounded clock.`);
80
+ dbSource = { kind: 'url', url: op.url, from: 'operator' };
81
+ } catch (err: any) {
82
+ fail(err.message);
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ // Three venues, mirroring the reconcile lane:
61
88
  // - a deployed stage runs the whole refresh in the ops Lambda on the operator connection
62
89
  // (the db:refresh action) — no raw URL on the operator's machine, the whole point.
90
+ // - --stage --direct resolves that same operator credential and runs CLI-side, unbounded.
63
91
  // - a direct URL refreshes locally (dev).
64
92
  let end: (() => Promise<void>) | undefined;
65
93
  try {
@@ -79,7 +107,7 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
79
107
  if (result?.error) {
80
108
  fail(`db:refresh failed: ${result.error}`);
81
109
  if (/Unknown action/i.test(String(result.error))) {
82
- info('The deployed handler predates the db:refresh ops action (needs @everystack/server >= 0.4.9). Upgrade the server, or refresh direct: db:refresh --database-url <url>.');
110
+ info('The deployed handler predates the db:refresh ops action (needs @everystack/server >= 0.4.9). Upgrade the server, or refresh credential-free over the direct lane: db:refresh --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
83
111
  }
84
112
  process.exit(1);
85
113
  }
@@ -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) {
@@ -48,6 +48,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
48
48
  import { compileDrizzleSource } from '../schema-source.js';
49
49
  import type { SourceFile } from '../derived-source.js';
50
50
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
51
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
51
52
  import { planBackfills, readBackfillLog } from '../backfill.js';
52
53
  import { resolveModelsPath } from '../models-path.js';
53
54
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
@@ -113,26 +114,27 @@ export interface SyncRun {
113
114
  */
114
115
  export async function executeSync(
115
116
  runner: QueryRunner,
117
+ session: SessionRunner,
116
118
  models: ModelDescriptor[],
117
119
  options: SyncOptions = {},
118
120
  hooks: SyncHooks = {},
119
121
  ): Promise<SyncRun> {
120
- const current = await introspectSchema(runner);
121
- const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
122
+ const current = await introspectSchema(session);
123
+ const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
122
124
  const statements = generateMigrationSql(models, current, {
123
125
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
124
126
  governedRoles: options.governedRoles,
125
127
  });
126
128
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
127
129
 
128
- const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
130
+ const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
129
131
  allowDrops: options.allowDrops, sequences: options.sequences,
130
132
  actor: options.actor, gitRef: options.gitRef, now: options.now,
131
133
  });
132
134
  hooks.onStateDone?.(state);
133
135
 
134
136
  // The declared derived layer is the one compute stream; nothing declared = skipped.
135
- const compute = !options.declared?.length ? null : await executeReconcile(runner, {
137
+ const compute = !options.declared?.length ? null : await executeReconcile(runner, session, {
136
138
  apply: true,
137
139
  baseline: options.baseline,
138
140
  rebaseline: options.rebaseline,
@@ -303,11 +305,12 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
303
305
  }
304
306
 
305
307
  step(connectingVia(dbSource));
306
- const { runner, end } = await createUrlRunner(dbSource.url);
308
+ const { runner, session, end } = await createUrlRunner(dbSource.url);
307
309
 
308
310
  try {
309
311
  const run = await executeSync(
310
312
  runner,
313
+ session,
311
314
  models,
312
315
  {
313
316
  declared: declaredDb?.objects,
@@ -610,7 +610,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
610
610
  fail(`db:provision failed: ${err.message}`);
611
611
  if (/Unknown action/i.test(String(err.message))) {
612
612
  info('The deployed handler has no dbPlugin (no Ops Lambda), and no ADMIN_DATABASE_URL secret is set.');
613
- info('Provision directly: everystack db:provision --stage <stage> --database-url <master-url> (once), or set the ADMIN_DATABASE_URL secret and use --direct.');
613
+ info('Preferred: set the secret once — everystack secrets set ADMIN_DATABASE_URL <url> --stage <stage> — then everystack db:provision --stage <stage> --direct reads it internally and the credential never reaches argv.');
614
+ info('Bootstrap fallback, when no secret exists yet: everystack db:provision --stage <stage> --database-url <master-url>. This puts the master credential on the command line (shell history, ps, CI logs) — do it once, then use --direct.');
614
615
  }
615
616
  for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
616
617
  process.exit(1);
@@ -107,10 +107,10 @@ export async function buildIntoDatabase(
107
107
  models: ModelDescriptor[],
108
108
  options: BuildOptions = {},
109
109
  ): Promise<BuildResult> {
110
- const { runner, end } = await createUrlRunner(url);
110
+ const { runner, session, end } = await createUrlRunner(url);
111
111
  try {
112
112
  const createdRoles = await ensureContractRoles(runner, models);
113
- const run = await executeSync(runner, models, {
113
+ const run = await executeSync(runner, session, models, {
114
114
  declared: options.declared,
115
115
  sequences: options.sequences,
116
116
  actor: options.actor ?? 'db-build',
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import type { QueryRunner } from './authz-contract.js';
28
+ import { buildSearchPathSql, type SessionRunner, type SessionResult } from './session.js';
28
29
 
29
30
  export type DbSource =
30
31
  | { kind: 'url'; url: string; from: 'flag' | 'env' | 'admin-env' | 'operator' }
@@ -65,10 +66,61 @@ export function connectingVia(source: Extract<DbSource, { kind: 'url' }>): strin
65
66
 
66
67
  export interface UrlRunner {
67
68
  runner: QueryRunner;
69
+ /**
70
+ * The direct lane's `SessionRunner` — N statements in ONE postgres.js transaction on
71
+ * the single connection. The stage lane's twin is `lambdaSessionRunner` (one invoke,
72
+ * one container); both honor the same ordering, `SET LOCAL` and `allowFailure` rules,
73
+ * so a caller typed against `SessionRunner` reads identically on either venue.
74
+ */
75
+ session: SessionRunner;
68
76
  /** Close the client so the process can exit cleanly. */
69
77
  end: () => Promise<void>;
70
78
  }
71
79
 
80
+ /**
81
+ * Build a `SessionRunner` over a postgres.js client whose pool is a single connection.
82
+ *
83
+ * `sql.begin` holds that one connection for the whole callback, so every statement here
84
+ * shares a session by construction. The `searchPath` pin is `SET LOCAL` (it reverts with
85
+ * the transaction, never outliving its command) and consumes no result slot.
86
+ *
87
+ * An `allowFailure` statement rides `tx.savepoint`, the DRIVER's savepoint — not a
88
+ * hand-issued `SAVEPOINT` / `ROLLBACK TO SAVEPOINT` pair. postgres.js records any query
89
+ * error raised inside a transaction scope and re-throws it after the callback returns, so
90
+ * catching the error ourselves recovers the database and still loses the session. Its
91
+ * savepoint opens a nested scope with its own error bookkeeping, which is the only shape
92
+ * that survives. (Proven on a live PostgreSQL: the hand-rolled version passed against a
93
+ * fake and failed against the driver.)
94
+ */
95
+ export function sessionRunnerOver(sql: any): SessionRunner {
96
+ return async (statements, opts) => {
97
+ const stmts = statements.map((s) => (typeof s === 'string' ? { sql: s } : s));
98
+ const results: SessionResult[] = [];
99
+ await sql.begin(async (tx: any) => {
100
+ if (opts?.isolation) await tx.unsafe(`SET TRANSACTION ISOLATION LEVEL ${opts.isolation}`);
101
+ if (opts?.readOnly) await tx.unsafe('SET TRANSACTION READ ONLY');
102
+ if (opts?.searchPath !== undefined) {
103
+ await tx.unsafe(buildSearchPathSql(String(opts.searchPath)));
104
+ }
105
+ for (const stmt of stmts) {
106
+ if (!stmt.allowFailure) {
107
+ results.push(Array.from(await tx.unsafe(stmt.sql)));
108
+ continue;
109
+ }
110
+ try {
111
+ results.push(
112
+ await tx.savepoint(async (sp: any) => Array.from(await sp.unsafe(stmt.sql))),
113
+ );
114
+ } catch (err: any) {
115
+ results.push({ everystackSessionError: true, message: err?.message || String(err) });
116
+ }
117
+ }
118
+ });
119
+ return results;
120
+ };
121
+ }
122
+
123
+
72
124
  /**
73
125
  * Load the postgres.js driver, or explain how to get it. Shared by every direct-connection
74
126
  * runner below so the missing-driver instructions can never drift between them.
@@ -101,6 +153,7 @@ export async function createUrlRunner(
101
153
  const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
102
154
  return {
103
155
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
156
+ session: sessionRunnerOver(sql),
104
157
  end: () => sql.end({ timeout: 5 }),
105
158
  };
106
159
  }
@@ -135,6 +188,8 @@ export async function createUrlPipelineRunner(
135
188
  export interface UrlProbeRunner {
136
189
  /** Read-only introspection, for the contract pull/diff. */
137
190
  runner: QueryRunner;
191
+ /** N statements on this one connection in one transaction — see sessionRunnerOver. */
192
+ session: SessionRunner;
138
193
  /** The self-reverting red-team probe. See `probe` below. */
139
194
  probe: (setup: string, read: string) => Promise<any[]>;
140
195
  /** Close the client so the process can exit cleanly. */
@@ -166,6 +221,7 @@ export async function createUrlProbeRunner(
166
221
  const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
167
222
  return {
168
223
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
224
+ session: sessionRunnerOver(sql),
169
225
  probe: async (setup: string, read: string) => {
170
226
  let rows: any[] = [];
171
227
  try {