@everystack/cli 0.4.45 → 0.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +60 -14
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-fingerprint.ts +54 -18
  14. package/src/cli/commands/db-generate.ts +11 -17
  15. package/src/cli/commands/db-plan.ts +56 -8
  16. package/src/cli/commands/db-pull.ts +16 -18
  17. package/src/cli/commands/db-reconcile.ts +18 -20
  18. package/src/cli/commands/db-swap.ts +5 -4
  19. package/src/cli/commands/db-sync.ts +8 -5
  20. package/src/cli/db-build.ts +2 -2
  21. package/src/cli/db-source.ts +56 -0
  22. package/src/cli/derived-introspect.ts +27 -26
  23. package/src/cli/derived-lint.ts +7 -8
  24. package/src/cli/edge-plan.ts +112 -16
  25. package/src/cli/git-descent.ts +16 -9
  26. package/src/cli/index.ts +1 -1
  27. package/src/cli/model-render.ts +56 -50
  28. package/src/cli/schema-compile.ts +6 -1
  29. package/src/cli/schema-diff.ts +1 -1
  30. package/src/cli/schema-fingerprint.ts +67 -7
  31. package/src/cli/schema-introspect.ts +44 -17
  32. package/src/cli/schema-source.ts +9 -0
  33. package/src/cli/session.ts +184 -0
  34. package/src/cli/stage-read-consistency.ts +128 -0
  35. package/src/cli/state-apply.ts +4 -2
  36. package/src/cli/swap-execute.ts +4 -3
  37. package/src/cli/search-path.ts +0 -51
@@ -0,0 +1,128 @@
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
+ export function stageDestructiveRefusal(
94
+ shape: string,
95
+ planPath: string,
96
+ stage: string | undefined,
97
+ ): string {
98
+ const takeIt = `everystack db:backup${stage ? ` --stage ${stage}` : ''}`;
99
+ return [
100
+ `REFUSED (stage lane, DESTRUCTIVE plan — ${shape}): the stage lane cannot guarantee read consistency.`,
101
+ '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.',
102
+ `Run destructive applies over a direct connection with the full ceremony: ${takeIt}, then everystack db:apply --plan ${planPath} --database-url <url> --confirm --snapshot-ref <id>.`,
103
+ ].join(' ');
104
+ }
105
+
106
+ /**
107
+ * Read the target twice, sequentially, and compare the two fingerprints.
108
+ *
109
+ * Sequential on purpose — two reads issued concurrently would interleave their
110
+ * invokes across the same container pool and could land the SAME collage twice,
111
+ * which is the one outcome the detector must not manufacture for itself.
112
+ *
113
+ * Callers: the STAGE lane only. The direct lane reads once, over one session,
114
+ * and must not pay this.
115
+ */
116
+ export async function readStageStateTwice(session: SessionRunner): Promise<StageReadResult> {
117
+ const first = await readOnce(session);
118
+ const second = await readOnce(session);
119
+ if (first.fingerprint !== second.fingerprint) {
120
+ return {
121
+ ok: false,
122
+ reason: inconsistentContainersRefusal(first.fingerprint, second.fingerprint),
123
+ first: first.fingerprint,
124
+ second: second.fingerprint,
125
+ };
126
+ }
127
+ return { ok: true, state: second, warning: inconsistentContainersNotDetected() };
128
+ }
@@ -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(runner);
400
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
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));
@@ -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. `
@@ -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
- }