@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage lane's read-consistency DETECTOR.
|
|
3
|
+
*
|
|
4
|
+
* WHAT CHANGED. Each introspection is now ONE `db:session` invoke — one container, one
|
|
5
|
+
* connection, one REPEATABLE READ transaction, one pinned `search_path`. The original
|
|
6
|
+
* defect (five catalog queries fanned across five ops-Lambda containers, assembling a
|
|
7
|
+
* snapshot out of two renderings of one schema) is gone at the source.
|
|
8
|
+
*
|
|
9
|
+
* WHAT REMAINS. A full read is TWO sessions: the state introspection, then the authz
|
|
10
|
+
* introspection. Each is internally consistent; the PAIR is not. Two sessions can be
|
|
11
|
+
* answered by two containers at two moments, so a schema that moves between them yields a
|
|
12
|
+
* fingerprint whose two halves describe different instants. Smaller than the defect it
|
|
13
|
+
* replaced — and still not a guarantee.
|
|
14
|
+
*
|
|
15
|
+
* So the stage lane reads TWICE and compares. What that buys, precisely:
|
|
16
|
+
*
|
|
17
|
+
* - Two DIFFERENT fingerprints prove the target moved under at least one read. That is a
|
|
18
|
+
* refusal: the read is known bad, so nothing is minted and nothing is applied.
|
|
19
|
+
* - Two IDENTICAL fingerprints prove NOTHING. Both reads can be wrong the same way
|
|
20
|
+
* whenever the thing that differs differs deterministically. The stage lane cannot
|
|
21
|
+
* verify read consistency. It can only fail to detect its absence.
|
|
22
|
+
*
|
|
23
|
+
* The wording below is written to that distinction and must stay written to it.
|
|
24
|
+
* "fingerprint verified" is a claim this lane is not able to make.
|
|
25
|
+
*
|
|
26
|
+
* Refusals here are CLI-side and pre-dispatch — no ops-Lambda action runs, so
|
|
27
|
+
* there is no live connection to record them on. They match the other
|
|
28
|
+
* pre-dispatch refusals in db:apply (`--confirm` missing, `estimateOpsRuntimeFit`),
|
|
29
|
+
* which likewise print and exit; `everystack.schema_log` is written by the ops
|
|
30
|
+
* Lambda's db:apply action, for refusals that reach it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
34
|
+
import type { SessionRunner } from './session.js';
|
|
35
|
+
import { introspectContract } from './authz-contract.js';
|
|
36
|
+
import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
|
|
37
|
+
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
|
|
38
|
+
import { fingerprintLive } from './schema-fingerprint.js';
|
|
39
|
+
|
|
40
|
+
/** One complete read of a target's base state, with its content address. */
|
|
41
|
+
export interface StageReadPair {
|
|
42
|
+
snapshot: SchemaSnapshot;
|
|
43
|
+
contract: AuthzContract;
|
|
44
|
+
/** sha256 of the canonical state — the same address db:plan / db:apply gate on. */
|
|
45
|
+
fingerprint: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type StageReadResult =
|
|
49
|
+
/** The two reads agreed. `warning` is the honest caveat; print it. */
|
|
50
|
+
| { ok: true; state: StageReadPair; warning: string }
|
|
51
|
+
/** The two reads disagreed. `reason` is the refusal; print it and exit non-zero. */
|
|
52
|
+
| { ok: false; reason: string; first: string; second: string };
|
|
53
|
+
|
|
54
|
+
/** One full read: state + authz, fingerprinted the way every gate fingerprints it. */
|
|
55
|
+
async function readOnce(session: SessionRunner): Promise<StageReadPair> {
|
|
56
|
+
const snapshot = await introspectSchema(session);
|
|
57
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
58
|
+
return { snapshot, contract, fingerprint: fingerprintLive(snapshot, contract).hash };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The refusal: the two reads disagree, so at least one of them was assembled
|
|
63
|
+
* from more than one database. Names the cause, because "fingerprint mismatch"
|
|
64
|
+
* would send the operator hunting for schema drift that is not there.
|
|
65
|
+
*/
|
|
66
|
+
export function inconsistentContainersRefusal(first: string, second: string): string {
|
|
67
|
+
return [
|
|
68
|
+
`REFUSED (stage lane): the stage was read twice and the two reads disagree — ${first.slice(0, 12)} then ${second.slice(0, 12)}.`,
|
|
69
|
+
'Cause: on the stage lane the state read and the authz read are separate ops-Lambda invokes, so they can be answered by different containers at different moments. Either the target changed between the two reads, or inconsistent containers answered them, and the fingerprint computed from the pair describes no single real state.',
|
|
70
|
+
'Nothing was minted and nothing was applied. Re-run. If it repeats, the stage\'s ops Lambda is not serving one database — fix that before you plan or apply against it.',
|
|
71
|
+
].join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The agreement case. NOT a pass — a non-detection. Every clause here is load
|
|
76
|
+
* bearing: this lane must never report that a fingerprint was verified, or that
|
|
77
|
+
* the read was consistent.
|
|
78
|
+
*/
|
|
79
|
+
export function inconsistentContainersNotDetected(): string {
|
|
80
|
+
return [
|
|
81
|
+
'stage lane: read twice, inconsistent containers not detected.',
|
|
82
|
+
'This is a DETECTOR, not a verification — the stage lane cannot verify read consistency.',
|
|
83
|
+
'Two agreeing reads can both be wrong: anything that differs deterministically between containers produces the same wrong answer both times.',
|
|
84
|
+
'Treat this fingerprint as UNVERIFIED; the direct lane (--database-url, one session) is the only lane that reads a single database.',
|
|
85
|
+
].join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The refusal a DESTRUCTIVE plan gets on the stage lane, before anything runs.
|
|
90
|
+
* The double read cannot lift this: a detector that can miss is not a basis for
|
|
91
|
+
* dropping data. The destructive ceremony lives on the direct lane.
|
|
92
|
+
*
|
|
93
|
+
* THE WAY OUT IS `--stage <name> --direct`, AND SAYING SO IS THE POINT OF THIS
|
|
94
|
+
* MESSAGE. It used to end by recommending `--database-url <url>` while calling it
|
|
95
|
+
* "a direct connection" — which is true of the connection and catastrophic as
|
|
96
|
+
* advice: `--database-url` is the ONE lane that puts a privileged DSN on argv, and
|
|
97
|
+
* from there into shell history, `ps`/`/proc/<pid>/cmdline`, scrollback, screen
|
|
98
|
+
* shares and CI logs. `--stage --direct` has identical trust properties (one real
|
|
99
|
+
* connection, one session) and resolves the credential through the stage's IAM-gated
|
|
100
|
+
* ops Lambda into process memory, where it is never printed or written.
|
|
101
|
+
*
|
|
102
|
+
* A consumer read this message, did what it said, and their operator objected:
|
|
103
|
+
* "every command that requires ARN is a risk of exposure. A major purpose of
|
|
104
|
+
* everystack is to PREVENT this." The capability they needed already existed. The
|
|
105
|
+
* refusal pointed away from it. Keep `--stage --direct` first, and keep
|
|
106
|
+
* `--database-url` marked as the LOCAL venue it is.
|
|
107
|
+
*/
|
|
108
|
+
export function stageDestructiveRefusal(
|
|
109
|
+
shape: string,
|
|
110
|
+
planPath: string,
|
|
111
|
+
stage: string | undefined,
|
|
112
|
+
): string {
|
|
113
|
+
const stageArg = stage ? ` --stage ${stage}` : ' --stage <name>';
|
|
114
|
+
const takeIt = `everystack db:backup${stageArg}`;
|
|
115
|
+
return [
|
|
116
|
+
`REFUSED (stage lane, DESTRUCTIVE plan — ${shape}): the stage lane cannot guarantee read consistency.`,
|
|
117
|
+
'A full read is two ops-Lambda invokes (state, then authz), so the fingerprint the concurrency lock gates on can still be assembled from two containers at two moments. A plan that loses data may not ride a read that cannot be verified.',
|
|
118
|
+
`Use the direct lane, which reads over ONE connection and never puts a credential on argv — it resolves the operator connection from the stage itself: ${takeIt}, then everystack db:apply --plan ${planPath}${stageArg} --direct --confirm --snapshot-ref <id>.`,
|
|
119
|
+
'(--database-url is the LOCAL-development venue. Against a deployed stage it forces a privileged connection string onto the command line — shell history, ps, scrollback, CI logs — and buys nothing --direct does not already give you.)',
|
|
120
|
+
].join(' ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the target twice, sequentially, and compare the two fingerprints.
|
|
125
|
+
*
|
|
126
|
+
* Sequential on purpose — two reads issued concurrently would interleave their
|
|
127
|
+
* invokes across the same container pool and could land the SAME collage twice,
|
|
128
|
+
* which is the one outcome the detector must not manufacture for itself.
|
|
129
|
+
*
|
|
130
|
+
* Callers: the STAGE lane only. The direct lane reads once, over one session,
|
|
131
|
+
* and must not pay this.
|
|
132
|
+
*/
|
|
133
|
+
export async function readStageStateTwice(session: SessionRunner): Promise<StageReadResult> {
|
|
134
|
+
const first = await readOnce(session);
|
|
135
|
+
const second = await readOnce(session);
|
|
136
|
+
if (first.fingerprint !== second.fingerprint) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: inconsistentContainersRefusal(first.fingerprint, second.fingerprint),
|
|
140
|
+
first: first.fingerprint,
|
|
141
|
+
second: second.fingerprint,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, state: second, warning: inconsistentContainersNotDetected() };
|
|
145
|
+
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { execSync } from 'node:child_process';
|
|
21
21
|
import type { ModelDescriptor } from '@everystack/model';
|
|
22
22
|
import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract.js';
|
|
23
|
+
import type { SessionRunner } from './session.js';
|
|
23
24
|
import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
|
|
24
25
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
|
|
25
26
|
import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate.js';
|
|
@@ -377,6 +378,7 @@ export interface StateSyncOutcome {
|
|
|
377
378
|
*/
|
|
378
379
|
export async function applyStateAndVerify(
|
|
379
380
|
runner: QueryRunner,
|
|
381
|
+
session: SessionRunner,
|
|
380
382
|
models: ModelDescriptor[],
|
|
381
383
|
statements: string[],
|
|
382
384
|
current: SchemaSnapshot,
|
|
@@ -396,8 +398,8 @@ export async function applyStateAndVerify(
|
|
|
396
398
|
|
|
397
399
|
const result = await applyGeneratedStatements(runner, statements, { ...options, fromFingerprint });
|
|
398
400
|
|
|
399
|
-
const snapshot = await introspectSchema(
|
|
400
|
-
const contract = await introspectContract(
|
|
401
|
+
const snapshot = await introspectSchema(session);
|
|
402
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
401
403
|
const toFingerprint = fingerprintLive(snapshot, contract).hash;
|
|
402
404
|
if (result.logId !== undefined) {
|
|
403
405
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, toFingerprint));
|
package/src/cli/swap-execute.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { ModelDescriptor } from '@everystack/model';
|
|
23
23
|
import type { QueryRunner } from './authz-contract.js';
|
|
24
|
+
import type { SessionRunner } from './session.js';
|
|
24
25
|
import { renderSchemaSwap, dropRetiringSql } from './schema-swap.js';
|
|
25
26
|
|
|
26
27
|
/** One validator's result. A `fatal` (default) failure rolls back; a `warn` failure is surfaced only. */
|
|
@@ -105,7 +106,7 @@ export interface ExecuteSwapOptions {
|
|
|
105
106
|
* identities. Without it the next db:reconcile sees the whole layer as drift and rebuilds it —
|
|
106
107
|
* an expensive, ACCESS EXCLUSIVE no-op that surfaces days later on an unrelated run.
|
|
107
108
|
*/
|
|
108
|
-
recordProvenance?: (runner: QueryRunner) => Promise<void>;
|
|
109
|
+
recordProvenance?: (runner: QueryRunner, session: SessionRunner) => Promise<void>;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
|
|
@@ -417,7 +418,7 @@ async function countRows(runner: QueryRunner, schema: string, table: string): Pr
|
|
|
417
418
|
return Number(rows[0]?.n ?? 0);
|
|
418
419
|
}
|
|
419
420
|
|
|
420
|
-
export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
|
|
421
|
+
export async function executeSwap(runner: QueryRunner, session: SessionRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
|
|
421
422
|
const log = opts.log ?? (() => {});
|
|
422
423
|
// 1. Fingerprint gate — declared-vs-declared: does the artifact and the target agree on the shape.
|
|
423
424
|
if (opts.artifactFingerprint !== opts.declaredFingerprint) {
|
|
@@ -639,7 +640,7 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
|
|
|
639
640
|
// prevents rather than something it can break.
|
|
640
641
|
if (opts.recordProvenance) {
|
|
641
642
|
try {
|
|
642
|
-
await opts.recordProvenance(runner);
|
|
643
|
+
await opts.recordProvenance(runner, session);
|
|
643
644
|
} catch (err: any) {
|
|
644
645
|
log(`WARNING: the swap succeeded but recording provenance failed: ${String(err?.message ?? err)}. `
|
|
645
646
|
+ `The derived layer is live and correct; the next db:reconcile will rebuild it needlessly. `
|
package/src/cli/search-path.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* search-path — the canonical introspection baseline.
|
|
3
|
-
*
|
|
4
|
-
* Every expression PostgreSQL deparses on introspection — column defaults
|
|
5
|
-
* (`pg_get_expr`), CHECK constraints, index expressions, generated columns,
|
|
6
|
-
* view/matview bodies (`pg_get_viewdef`), function bodies — renders its schema
|
|
7
|
-
* qualification RELATIVE to the session `search_path`. So the SAME schema reads
|
|
8
|
-
* differently depending on the ambient path: with a non-public schema on the
|
|
9
|
-
* path a reference to it renders BARE (`nextval('x_seq')`), off the path it
|
|
10
|
-
* renders QUALIFIED (`nextval('stats.x_seq')`). The declared state is always
|
|
11
|
-
* canonical (public bare, non-public qualified), so a non-default session or
|
|
12
|
-
* DB-level `search_path` makes every deparsed expression read as false drift.
|
|
13
|
-
*
|
|
14
|
-
* The fix is to CAPTURE under a fixed canonical path, once, at the introspection
|
|
15
|
-
* boundary — one guard for the whole class (defaults, checks, indexes, bodies),
|
|
16
|
-
* present and future. The baseline is `public`: it renders public-bare and
|
|
17
|
-
* non-public-qualified, matching the declared compile exactly (verified against
|
|
18
|
-
* PostgreSQL 16). It must be an EXPLICIT value — `RESET` / `SET … TO DEFAULT`
|
|
19
|
-
* inherit an `ALTER DATABASE/ROLE … SET search_path` override (the very thing
|
|
20
|
-
* that triggers the bug), so they are NOT canonical baselines.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { QueryRunner } from './authz-contract.js';
|
|
24
|
-
|
|
25
|
-
/** The canonical introspection search_path — explicit, override-proof, declared-matching. */
|
|
26
|
-
export const CANONICAL_SEARCH_PATH_SQL = 'SET search_path = public';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Run `fn` with the connection's search_path pinned to the canonical baseline, so every
|
|
30
|
-
* expression it deparses is captured in the declared-matching form regardless of the
|
|
31
|
-
* ambient path, then RESET to the connection's default afterward. RESET restores the
|
|
32
|
-
* database/role default — which for the case this fixes (an `ALTER DATABASE … SET
|
|
33
|
-
* search_path` override) is exactly the override, so a fresh connection sees no change.
|
|
34
|
-
* It deliberately does NOT preserve a caller's transient session/transaction-local SET:
|
|
35
|
-
* restoring a captured `SET LOCAL` value session-wide would leak it (e.g. the reconcile
|
|
36
|
-
* apply's create-time wide path), and no everystack caller relies on a hand-set path
|
|
37
|
-
* surviving introspection.
|
|
38
|
-
*
|
|
39
|
-
* Effective on a persistent single connection (the direct `createUrlRunner`, `max: 1`);
|
|
40
|
-
* over a per-call pooled runner the SET does not persist, but that path already renders
|
|
41
|
-
* canonically unless the database itself carries a search_path override.
|
|
42
|
-
*/
|
|
43
|
-
export async function withCanonicalSearchPath<T>(run: QueryRunner, fn: () => Promise<T>): Promise<T> {
|
|
44
|
-
await run(CANONICAL_SEARCH_PATH_SQL);
|
|
45
|
-
try {
|
|
46
|
-
return await fn();
|
|
47
|
-
} finally {
|
|
48
|
-
// Best-effort: a failure here must not mask fn's result.
|
|
49
|
-
try { await run('RESET search_path'); } catch { /* connection may be gone */ }
|
|
50
|
-
}
|
|
51
|
-
}
|