@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.
- package/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +82 -20
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-backfill.ts +1 -1
- package/src/cli/commands/db-exec.ts +20 -1
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +11 -17
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +16 -18
- package/src/cli/commands/db-reconcile.ts +19 -21
- package/src/cli/commands/db-refresh.ts +33 -5
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +112 -16
- package/src/cli/exec-digest.ts +55 -13
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +3 -3
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +145 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- 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 {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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>
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
361
|
-
if (flagged) caution(`${flagged} inline comment(s) flag things to review (
|
|
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(
|
|
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
|
|
193
|
-
// would exist. Inside the transaction this reads the batch's own not-yet-committed
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
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 =
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
121
|
-
const liveAuthz = await introspectContract(
|
|
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,
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -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('
|
|
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);
|
package/src/cli/db-build.ts
CHANGED
|
@@ -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',
|
package/src/cli/db-source.ts
CHANGED
|
@@ -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 {
|