@everystack/cli 0.4.81 → 0.4.83
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/CHANGELOG.md +41 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +126 -6
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-derive.ts +31 -6
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
|
@@ -21,102 +21,25 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
|
+
import type { DerivedAttachment, DerivedDescriptor, DerivedKind as WireDerivedKind } from '@everystack/model/wire';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* `'trigger'` and `'sql'` carry the extra fields the reconciler edges need (a trigger's
|
|
27
28
|
* table for `DROP TRIGGER … ON`, a defineSql object's explicit drop).
|
|
29
|
+
*
|
|
30
|
+
* DEFINED IN `@everystack/model/wire` and re-exported here. `db:reconcile` ships these objects
|
|
31
|
+
* over the wire to a deployed stage's ops Lambda, so the shape is a versioned wire contract
|
|
32
|
+
* (MODEL_WIRE_CONTRACT), not a CLI-local convenience — one definition, imported by both sides,
|
|
33
|
+
* rather than a CLI copy and a server copy that happen to agree.
|
|
28
34
|
*/
|
|
29
|
-
export type DerivedKind =
|
|
35
|
+
export type DerivedKind = WireDerivedKind;
|
|
30
36
|
|
|
31
37
|
/** A statement that belongs to an object: its index, comment, or grant. */
|
|
32
|
-
export
|
|
33
|
-
kind: 'index' | 'comment' | 'grant';
|
|
34
|
-
sql: string;
|
|
35
|
-
}
|
|
38
|
+
export type Attachment = DerivedAttachment;
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
schema: string;
|
|
41
|
-
name: string;
|
|
42
|
-
/** `schema.name` — the reconciler's join key against the live catalog.
|
|
43
|
-
* (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
|
|
44
|
-
identity: string;
|
|
45
|
-
/** The CREATE statement as authored (no trailing semicolon). */
|
|
46
|
-
sql: string;
|
|
47
|
-
/** Indexes / comments / grants targeting this object, in source order. */
|
|
48
|
-
attachments: Attachment[];
|
|
49
|
-
/** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
|
|
50
|
-
hash: string;
|
|
51
|
-
/**
|
|
52
|
-
* sha256 of the normalized CREATE + attachments EXCEPT plain (non-column-scoped) grants — the
|
|
53
|
-
* "would a rebuild be required" hash. Equal across an authz-only change (plain grants moved, body
|
|
54
|
-
* unchanged), so the reconciler can route that to a bare GRANT delta instead of a matview rebuild.
|
|
55
|
-
* Column-scoped grants stay IN (attacl not introspected → they must rebuild). `hash` stays the
|
|
56
|
-
* full/stable formula for provenance compat; this is the additional discriminator.
|
|
57
|
-
*/
|
|
58
|
-
bodyHash: string;
|
|
59
|
-
/** Source file this object came from (descriptors: the declared-models marker). */
|
|
60
|
-
file: string;
|
|
61
|
-
/** Position in the concatenated source — a valid dependency order by convention. */
|
|
62
|
-
seq: number;
|
|
63
|
-
/**
|
|
64
|
-
* kind 'function' only: the declared owning role.
|
|
65
|
-
*
|
|
66
|
-
* A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
|
|
67
|
-
* boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
|
|
68
|
-
* than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
|
|
69
|
-
* builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
|
|
70
|
-
* alter-after form works exactly once and breaks every re-run.
|
|
71
|
-
*/
|
|
72
|
-
owner?: string;
|
|
73
|
-
/**
|
|
74
|
-
* kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
|
|
75
|
-
*
|
|
76
|
-
* It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
|
|
77
|
-
* structurally so the differ can compare the declared pin against the LIVE catalog directly
|
|
78
|
-
* rather than only against a recorded hash. Without that, `--baseline` records a declared pin
|
|
79
|
-
* the database does not have and every later check reports zero actions, forever: measured,
|
|
80
|
-
* and it is how a model came to claim `search_path=pg_catalog` about a database with none
|
|
81
|
-
* while every gate stayed green.
|
|
82
|
-
*
|
|
83
|
-
* `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
|
|
84
|
-
* pin. The comparison is skipped there; absent evidence is not evidence.
|
|
85
|
-
*/
|
|
86
|
-
searchPath?: string[] | 'unpinned';
|
|
87
|
-
/** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
|
|
88
|
-
objectKind?: string;
|
|
89
|
-
/** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
|
|
90
|
-
drop?: string;
|
|
91
|
-
/** kind 'trigger' only: the (possibly schema-qualified) table/view the trigger rides. */
|
|
92
|
-
table?: string;
|
|
93
|
-
/**
|
|
94
|
-
* Views/matviews: every declared `dependsOn` ref as an identity (models AND derived),
|
|
95
|
-
* sorted. B4's dependency drift verifies it against the live catalog's actual edges —
|
|
96
|
-
* "declared, then verified". Absent on kinds whose live edges pg_depend can't see.
|
|
97
|
-
*/
|
|
98
|
-
declaredDeps?: string[];
|
|
99
|
-
/**
|
|
100
|
-
* kind 'function' only: the relations this function's SIGNATURE names — a `setof(ref)`
|
|
101
|
-
* return, or an argument/return type spelled as a declared table, view or matview.
|
|
102
|
-
*
|
|
103
|
-
* Distinct from `dependsOn`, and the distinction is load-bearing for the from-scratch build.
|
|
104
|
-
* A body reference is a FORWARD reference the build can defer (`check_function_bodies = off`
|
|
105
|
-
* is exactly that escape), so a function whose body reads a not-yet-created table still
|
|
106
|
-
* creates. A SIGNATURE reference is not deferrable: PostgreSQL resolves the return and
|
|
107
|
-
* argument types when the function is created, so `RETURNS SETOF stats_view.x_row` needs
|
|
108
|
-
* that view to exist already. `db:build` runs functions ahead of state (an RLS policy's
|
|
109
|
-
* predicate must resolve the function it calls) and uses this to hold back the ones that
|
|
110
|
-
* cannot go first; the compiler also orders the function after them.
|
|
111
|
-
*
|
|
112
|
-
* RELATIONS only — tables, views, matviews. A signature naming a declared ENUM, domain or
|
|
113
|
-
* composite type has the same problem and is NOT covered here, so it still fails in the
|
|
114
|
-
* ahead-of-state pass. Absent/empty means "names no declared relation", never "is safe".
|
|
115
|
-
*
|
|
116
|
-
* Not in the hash — it is derived from `sql`, so carrying it moves no fingerprint.
|
|
117
|
-
*/
|
|
118
|
-
signatureDeps?: string[];
|
|
119
|
-
}
|
|
40
|
+
/** The compiled derived-object descriptor — declared, field by field, in
|
|
41
|
+
* `@everystack/model/wire` as `DerivedDescriptor`. */
|
|
42
|
+
export type SourceObject = DerivedDescriptor;
|
|
120
43
|
|
|
121
44
|
/** The provenance marker descriptor-compiled objects carry in `file` — the compiler
|
|
122
45
|
* ALWAYS declares authz (a private relation is declared-empty, not undeclared), so
|
|
@@ -207,9 +130,21 @@ export function lex(
|
|
|
207
130
|
continue;
|
|
208
131
|
}
|
|
209
132
|
if (ch === "'") {
|
|
210
|
-
// E'...'
|
|
133
|
+
// `E'...'` is PostgreSQL's backslash-escaping string literal — but the `E` only counts
|
|
134
|
+
// when it STANDS ALONE before the quote. Testing the single preceding character glued
|
|
135
|
+
// that rule onto the tail of any identifier or keyword ending in `e`, which in English
|
|
136
|
+
// is most of them: `date'2024-01-01'`, `store'x'`, `xe'y'`. A backslash inside such a
|
|
137
|
+
// literal then swallowed the closing quote, the lexer stayed "inside a string" past the
|
|
138
|
+
// real end, and `splitSqlStatements` stopped seeing the `;` boundaries after it — a
|
|
139
|
+
// multi-statement file counted as ONE, and db:exec/db:backfill sent the whole tail to
|
|
140
|
+
// the database as a single string. So require the character BEFORE the `E`/`e` to be a
|
|
141
|
+
// non-identifier character, or absent (the `E` starts the text).
|
|
142
|
+
// Mirrored in `packages/server/src/sql-lex.ts` — the deliberate fork, kept in step by hand.
|
|
211
143
|
const prev = text[i - 1];
|
|
212
|
-
|
|
144
|
+
const beforePrev = text[i - 2];
|
|
145
|
+
const standaloneE = (prev === 'E' || prev === 'e')
|
|
146
|
+
&& (beforePrev === undefined || !WORDISH.test(beforePrev));
|
|
147
|
+
state = { in: 'single', escaped: standaloneE };
|
|
213
148
|
emit(ch, 'single', i); i++;
|
|
214
149
|
continue;
|
|
215
150
|
}
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { compileTableContract } from './authz-compile';
|
|
|
34
34
|
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
35
35
|
import { BASELINE_FILE } from './authz-baseline';
|
|
36
36
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
|
|
37
|
+
import { statementHead, statementHeadOrSelf } from './sql-statement';
|
|
37
38
|
import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
|
|
38
39
|
import { compileDeclaredState } from './declared-diff';
|
|
39
40
|
import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
|
|
@@ -127,6 +128,23 @@ export interface EdgePlan {
|
|
|
127
128
|
* and verify raw, as they were minted).
|
|
128
129
|
*/
|
|
129
130
|
governedRoles?: string[];
|
|
131
|
+
/**
|
|
132
|
+
* Every grantee the models declared AT MINT, sorted — the discriminator that separates a revoke
|
|
133
|
+
* that is ordinary churn from one that is a STRIP (a revoke against a role no model grants to,
|
|
134
|
+
* so nothing can put the access back).
|
|
135
|
+
*
|
|
136
|
+
* Pinned onto the plan for the same reason `governedRoles` is: the answer must be the one the
|
|
137
|
+
* mint used, not the one the checkout happens to hold later. db:apply can run long after
|
|
138
|
+
* db:plan — across a rebase, or a hotfix to the models — and a receipt that re-reads the models
|
|
139
|
+
* at apply time marks its ⚠ lines against a declared side the plan was never verified under.
|
|
140
|
+
* The plan's own destructive breakdown reads it here too, so what db:plan prints and what
|
|
141
|
+
* db:apply prints come from one snapshot rather than two readings of a moving tree.
|
|
142
|
+
*
|
|
143
|
+
* Absent means a plan minted before this field existed: both readers degrade to their prior
|
|
144
|
+
* behaviour (the summary shows no strips; the receipt re-reads the checkout's models) rather
|
|
145
|
+
* than failing — nothing is DECIDED from this field, every gate that acts on a strip ran at mint.
|
|
146
|
+
*/
|
|
147
|
+
declaredGrantees?: string[];
|
|
130
148
|
/** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
|
|
131
149
|
declaredFingerprint: string;
|
|
132
150
|
/** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
|
|
@@ -390,6 +408,23 @@ export function governedLiveFingerprint(
|
|
|
390
408
|
* state. Pure: introspection in, plan out. Throws when the edge would hold
|
|
391
409
|
* drops back — a plan must be complete to have a reachable endpoint.
|
|
392
410
|
*/
|
|
411
|
+
/**
|
|
412
|
+
* Every grantee the models declare — the discriminator for a STRIP (a revoke against a role no
|
|
413
|
+
* model grants to has nothing to restore it from, so it carries a dropped column's ceremony).
|
|
414
|
+
*
|
|
415
|
+
* Exported because the PLAN REPORT has to mark exactly the statements the --confirm gate counts.
|
|
416
|
+
* Two copies of this loop would eventually disagree, and the direction it would disagree in is
|
|
417
|
+
* the bad one: a report calling a strip ordinary churn while the gate calls it destructive.
|
|
418
|
+
*/
|
|
419
|
+
export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: string } = {}): Set<string> {
|
|
420
|
+
const grantees = new Set<string>();
|
|
421
|
+
for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
|
|
422
|
+
for (const g of Object.keys(t.grants)) grantees.add(g);
|
|
423
|
+
for (const g of Object.keys(t.columnGrants ?? {})) grantees.add(g);
|
|
424
|
+
}
|
|
425
|
+
return grantees;
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
export function mintEdgePlan(
|
|
394
429
|
models: ModelDescriptor[],
|
|
395
430
|
snapshot: SchemaSnapshot,
|
|
@@ -418,11 +453,7 @@ export function mintEdgePlan(
|
|
|
418
453
|
// The grantees the models actually declare — the discriminator for a STRIP (a revoke
|
|
419
454
|
// against a role no model grants to has nothing to restore it from, so it carries the
|
|
420
455
|
// same ceremony as a dropped column).
|
|
421
|
-
const declaredGrantees =
|
|
422
|
-
for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
|
|
423
|
-
for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
|
|
424
|
-
for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
|
|
425
|
-
}
|
|
456
|
+
const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
|
|
426
457
|
const breakdown = partitionStatements(classified.executable, { declaredGrantees });
|
|
427
458
|
const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
|
|
428
459
|
|
|
@@ -444,6 +475,10 @@ export function mintEdgePlan(
|
|
|
444
475
|
defaultAclHash: defaultAclHash(contract),
|
|
445
476
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
446
477
|
governedRoles: [...governedRoles].sort(),
|
|
478
|
+
// Pinned, not re-derived later: the receipt after the apply and the breakdown in this plan's
|
|
479
|
+
// own summary both read the declared side from HERE, so neither can drift onto a checkout
|
|
480
|
+
// whose models moved between mint and apply.
|
|
481
|
+
declaredGrantees: [...declaredGrantees].sort(),
|
|
447
482
|
statements,
|
|
448
483
|
executable: classified.executable.length,
|
|
449
484
|
destructive,
|
|
@@ -487,7 +522,7 @@ export function tablesLeftWithoutARead(statements: readonly string[], live?: Aut
|
|
|
487
522
|
const touched = new Set<string>();
|
|
488
523
|
|
|
489
524
|
for (const statement of statements) {
|
|
490
|
-
const head = (statement
|
|
525
|
+
const head = statementHead(statement);
|
|
491
526
|
let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?(\S+)\s+ON\s+(\S+?);?$/i.exec(head);
|
|
492
527
|
if (m) {
|
|
493
528
|
touched.add(m[2]);
|
|
@@ -627,20 +662,48 @@ export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] }
|
|
|
627
662
|
// The shape first — a four-digit edge is reviewable as a histogram, not a wall.
|
|
628
663
|
const histogram = renderStatementHistogram(classifyGeneratedStatements(plan.statements).executable);
|
|
629
664
|
if (histogram) lines.push(`shape: ${histogram}`);
|
|
665
|
+
// The declared side comes off the PLAN, never off the checkout — this is the same set the mint
|
|
666
|
+
// counted `destructive` with, so every bucket below agrees with what the plan already says about
|
|
667
|
+
// itself. Both classifiers in this function read it: they partition the SAME statement stream,
|
|
668
|
+
// and handing the set to one but not the other makes the two halves of one summary disagree.
|
|
669
|
+
// Absent on a pre-field plan: every reading falls back to the old one exactly, because
|
|
670
|
+
// best-effort marking never blocks the operator.
|
|
671
|
+
const declaredOpt = plan.declaredGrantees
|
|
672
|
+
? { declaredGrantees: new Set(plan.declaredGrantees) }
|
|
673
|
+
: {};
|
|
674
|
+
const declared = plan.declaredGrantees !== undefined;
|
|
630
675
|
if (plan.destructive > 0) {
|
|
631
|
-
|
|
676
|
+
// Without the declared side the strip bucket is empty by construction (classifyDestructive
|
|
677
|
+
// will not guess), and a strips-only plan printed `N DESTRUCTIVE — 0 drop(s), 0 narrowing type
|
|
678
|
+
// change(s)` with the statement itself missing from the list under it.
|
|
679
|
+
const { drops, narrowings, strips } = classifyDestructive(
|
|
680
|
+
classifyGeneratedStatements(plan.statements).executable,
|
|
681
|
+
declaredOpt,
|
|
682
|
+
);
|
|
683
|
+
const stripClause = declared ? `, ${strips.length} authorization strip(s)` : '';
|
|
632
684
|
lines.push(
|
|
633
|
-
`! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
|
|
685
|
+
`! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s)${stripClause}. Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
|
|
634
686
|
);
|
|
635
|
-
for (const statement of [...drops, ...narrowings]) {
|
|
636
|
-
|
|
637
|
-
lines.push(`! ${ddl.trim()}`);
|
|
687
|
+
for (const statement of [...drops, ...narrowings, ...strips]) {
|
|
688
|
+
lines.push(`! ${statementHeadOrSelf(statement)}`);
|
|
638
689
|
}
|
|
639
690
|
}
|
|
640
691
|
// Authorization removed loses no row, so it is not gated — but it decides who can SEE the
|
|
641
692
|
// rows, and a plan that drops a table's only read policy leaves that table returning nothing.
|
|
642
693
|
// 11 tables went dark in a real adoption plan that printed "0 notice(s)".
|
|
643
|
-
|
|
694
|
+
//
|
|
695
|
+
// Same declared side as the destructive breakdown above, and for the same reason. partitionStatements
|
|
696
|
+
// gives each statement exactly one bucket, consulting the data-loss buckets FIRST — so with the set
|
|
697
|
+
// in hand a strip lands in `strips` and is kept out of `authzRemovals`. Without it the strip is
|
|
698
|
+
// unrecognisable as such and falls through to `authzRemovals`, which is where it belongs on a
|
|
699
|
+
// pre-field plan: that is the only bucket that can hold it, and a revoke nobody can grade still
|
|
700
|
+
// has to be seen. Passing it to the classifier above but not here printed the strip TWICE — once
|
|
701
|
+
// under DESTRUCTIVE and again in the REMOVE-authorization count — and that count then disagreed
|
|
702
|
+
// with the plan's own `classification`, which the mint computed with the declared side.
|
|
703
|
+
const { authzRemovals, unclassified } = partitionStatements(
|
|
704
|
+
classifyGeneratedStatements(plan.statements).executable,
|
|
705
|
+
declaredOpt,
|
|
706
|
+
);
|
|
644
707
|
// Prefer what the mint resolved against live. Recomputing here would have only the statement
|
|
645
708
|
// text and would re-introduce the false positives the mint-time answer exists to avoid.
|
|
646
709
|
const dark = plan.dark ?? tablesLeftWithoutARead(plan.statements);
|
|
@@ -653,8 +716,7 @@ export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] }
|
|
|
653
716
|
if (unclassified.length > 0) {
|
|
654
717
|
lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
|
|
655
718
|
for (const statement of unclassified) {
|
|
656
|
-
|
|
657
|
-
lines.push(`! ${ddl.trim()}`);
|
|
719
|
+
lines.push(`! ${statementHeadOrSelf(statement)}`);
|
|
658
720
|
}
|
|
659
721
|
}
|
|
660
722
|
if (plan.unmodeled.length > 0) {
|
package/src/cli/index.ts
CHANGED
|
@@ -351,9 +351,9 @@ Usage:
|
|
|
351
351
|
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
352
352
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
353
353
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
354
|
-
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (
|
|
354
|
+
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (read-only). Runs as the SELECT-only 'reader' role when the stage has one (db:provision --with-reader + QUERY_DATABASE_URL wired to dbPlugin's queryDatabaseUrl); WITHOUT one it runs on the OPERATOR connection, fenced only by a read-only transaction — see docs/cli.md
|
|
355
355
|
everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
356
|
-
everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation
|
|
356
|
+
everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] [--with-reader] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation. --with-reader adds the SELECT-only 'reader' login db:query runs as, grants it read on the declared schemas, and writes QUERY_DATABASE_URL (opt-in once, then kept converged on every re-provision)
|
|
357
357
|
everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
|
|
358
358
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
359
359
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
@@ -364,17 +364,17 @@ Usage:
|
|
|
364
364
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
365
365
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
366
366
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
367
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
367
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts | dir>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. --derived-out <dir> (anything not ending in .ts) splits that module by kind and schema instead — views.ts, functions-<schema>.ts, triggers.ts, index.ts barrel — for a real schema whose derived layer is otherwise one unreviewable file; refuses loudly, before writing anything, if the split files would import each other. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
368
368
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
369
369
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
370
|
-
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
370
|
+
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--rebaseline] [--overwrite-drift] [--only a,b] [--json] ADVANCED — you do not run this for a deploy: db:apply runs it for you (after a plan, and alone when given no --plan), and db:check runs its checkout-side gates. It is fully supported, not deprecated — scripts and CI jobs calling it directly keep working unchanged. Reach for it yourself for a surgical fix (--only <schema.name> rebuilds one object without minting a plan), an adoption move (--baseline / --rebaseline), or a hand-edit overwrite (--overwrite-drift). Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
371
371
|
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|
|
372
372
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
373
373
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
374
|
-
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
|
|
375
|
-
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
|
|
374
|
+
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref)
|
|
375
|
+
everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
|
|
376
376
|
everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
|
|
377
|
-
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
377
|
+
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] [--verbose] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target DEFAULT OUTPUT is ONE line: 'Up to date.' or 'N changes pending. Run db:plan to review.'. --verbose restores every finding and the from-scratch build report
|
|
378
378
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
379
379
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
|
380
380
|
everystack db:exec <file.sql> [--stage <name> | --database-url <url>] [--schemas a,b] [--confirm] Credential-free write SQL — the psql-piped-file replacement (or pipe SQL on stdin). Runs the whole file as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest before vs after rejects ANY schema change (however caused — a function, a DO block, dynamic SQL) by rolling the txn back. Schema changes go through db:apply / db:reconcile. Every run is recorded crash-truthfully in everystack.exec_log (intent before, per-statement rows-affected after). --stage runs in the ops Lambda (operator holds no URL); prod-tier writes require --confirm; --database-url is local-direct (--schemas scopes the digest, default public)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Model-derived wire-contract PREFLIGHT — the CLI half of the handshake.
|
|
3
|
+
*
|
|
4
|
+
* `db:provision`, `db:converge-ownership` and `db:reconcile --apply --stage` all compute a payload
|
|
5
|
+
* from the Models barrel and hand it to a deployed stage's ops Lambda, which acts on it against a
|
|
6
|
+
* real database: it reassigns object ownership, or creates and replaces derived objects. The CLI
|
|
7
|
+
* doing the computing and the `@everystack/cli` bundled into that Lambda are separate installs on
|
|
8
|
+
* independent release trains, so their idea of those shapes can drift.
|
|
9
|
+
*
|
|
10
|
+
* The SERVER gate is the authority (never trust a client's claim about itself). This preflight
|
|
11
|
+
* covers the half the server structurally cannot: a stage deployed BEFORE the guard existed has no
|
|
12
|
+
* code to refuse with. Its `_health` still answers — it simply omits `modelContract` — so ABSENCE
|
|
13
|
+
* is the older-stage signal and refuses exactly as hard as a mismatch.
|
|
14
|
+
*
|
|
15
|
+
* TWO functions, and the split matters when you go to test them:
|
|
16
|
+
*
|
|
17
|
+
* `checkStageModelContract` is PURE — a `_health` answer in, a verdict and its phrasing out. No
|
|
18
|
+
* connection, no I/O, no exit. Every rule about what counts as skew is decided here, so it can
|
|
19
|
+
* be exercised by passing it plain objects.
|
|
20
|
+
*
|
|
21
|
+
* `modelContractPreflight` is the ORCHESTRATION around it, and it is not pure: it invokes
|
|
22
|
+
* `_health` over the wire, prints, and calls `process.exit(1)` on a refusal. It exists so the
|
|
23
|
+
* commands do not each hand-roll that sequence and drift into three wordings for one skew.
|
|
24
|
+
*
|
|
25
|
+
* Sibling of `checkStageApplyContract` in apply-lane.ts, which decides the same question for
|
|
26
|
+
* db:apply's separate `APPLY_CONTRACT_VERSION`. (That command still orchestrates its own preflight
|
|
27
|
+
* inline; consolidating the two is deliberately NOT done here — the apply contract's machinery is
|
|
28
|
+
* left untouched on this branch.) The two numbers are never compared to each other.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { MODEL_WIRE_CONTRACT } from '../wire';
|
|
32
|
+
import { invokeAction } from './aws';
|
|
33
|
+
import { step, fail, info } from './output';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Does the deployed stage speak THIS CLI's Model-derived wire contract?
|
|
37
|
+
*
|
|
38
|
+
* `health` is the raw `_health` answer. `verb` is the command about to dispatch, so the refusal
|
|
39
|
+
* names the thing the operator actually typed.
|
|
40
|
+
*
|
|
41
|
+
* Exact equality, never `>=`: a bump means the payload keys or a field's meaning changed, so an
|
|
42
|
+
* off-by-any peer must refuse rather than guess. A false refusal costs one redeploy; a false
|
|
43
|
+
* accept risks wrong ownership or a wrong derived object on a real database.
|
|
44
|
+
*/
|
|
45
|
+
export function checkStageModelContract(
|
|
46
|
+
health: unknown,
|
|
47
|
+
verb: string,
|
|
48
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
49
|
+
const advertised = (health as { modelContract?: unknown } | null | undefined)?.modelContract;
|
|
50
|
+
if (typeof advertised === 'number' && advertised === MODEL_WIRE_CONTRACT) return { ok: true };
|
|
51
|
+
|
|
52
|
+
if (advertised === undefined) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason:
|
|
56
|
+
`the deployed stage does not advertise a Model-derived wire contract — its ops Lambda `
|
|
57
|
+
+ `predates this handshake, so it would accept the ${verb} payload this CLI builds `
|
|
58
|
+
+ `(model-wire v${MODEL_WIRE_CONTRACT}) without checking that it understands its shape. `
|
|
59
|
+
+ `Deploy the stage first so its server matches this CLI; nothing was sent.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason:
|
|
66
|
+
`the deployed stage speaks model-wire contract v${String(advertised)} but this CLI speaks `
|
|
67
|
+
+ `v${MODEL_WIRE_CONTRACT} — the CLI and the stage disagree about the shape of the `
|
|
68
|
+
+ `Model-derived payload ${verb} sends. Deploy the stage (or align the CLI) so they match; `
|
|
69
|
+
+ `nothing was sent.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A wire-contract refusal, raised instead of printed when the caller asked for `onRefusal: 'throw'`. */
|
|
74
|
+
export class ModelContractError extends Error {
|
|
75
|
+
constructor(message: string) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'ModelContractError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The orchestration around that decision, shared by every command that dispatches a Model-derived
|
|
83
|
+
* payload to a stage: read `_health`, judge it, and exit on a refusal. One implementation and one
|
|
84
|
+
* wording, so `db:provision` and `db:reconcile` cannot drift into saying different things about
|
|
85
|
+
* the same skew.
|
|
86
|
+
*
|
|
87
|
+
* Returns a MEMOIZED runner. `db:provision` dispatches twice to the same stage (credentials, then
|
|
88
|
+
* `db:converge-ownership`), and one handshake covers both — but each dispatch still asks, so the
|
|
89
|
+
* guarantee is per-dispatch rather than a fact an earlier line has to remember to establish.
|
|
90
|
+
*
|
|
91
|
+
* An UNREACHABLE `_health` is deliberately NOT a refusal. A stage with no ops Lambda at all throws
|
|
92
|
+
* here, and that is a different failure with a much better message waiting further down (for
|
|
93
|
+
* `db:provision` it is also the path the ADMIN_DATABASE_URL auto-fallback exists for). Absence of
|
|
94
|
+
* evidence is not evidence of skew. A stage that ANSWERS and names no contract IS evidence, and
|
|
95
|
+
* refuses.
|
|
96
|
+
*
|
|
97
|
+
* `directCommand` is the venue that crosses no wire — the CLI resolves the stage's operator
|
|
98
|
+
* connection itself and runs the work locally — so a refused operator is left with somewhere to go.
|
|
99
|
+
* `directHint` carries whatever is true of that venue for THIS command only; sharing one runner
|
|
100
|
+
* must not cost a caller the specific thing its own operator needed to read.
|
|
101
|
+
*
|
|
102
|
+
* `onRefusal` decides WHO renders the refusal, and the answer belongs to the CALLER, because
|
|
103
|
+
* only the caller knows whether anything has been written yet. A call site that runs BEFORE any
|
|
104
|
+
* write — which is every original one — wants printing and exiting: the refusal already says the
|
|
105
|
+
* stage and the CLI disagree, that nothing was sent, and where the `--direct` venue is. That is
|
|
106
|
+
* `'exit'`. A call site that runs AFTER a write — `db:apply`'s
|
|
107
|
+
* function/view catch-up, once the table and column changes have committed — needs one specific
|
|
108
|
+
* sentence first: your changes are safe, here is what to rerun. A `process.exit` from in here
|
|
109
|
+
* would end the process before that could be said, so that caller asks for `'throw'`.
|
|
110
|
+
*
|
|
111
|
+
* Note the shape: the CALLER passes it. A callee that hardcodes one of the two answers on behalf
|
|
112
|
+
* of callers with opposite post-conditions is the bug this parameter exists to prevent.
|
|
113
|
+
*/
|
|
114
|
+
export function modelContractPreflight(opts: {
|
|
115
|
+
region: string;
|
|
116
|
+
fn: string;
|
|
117
|
+
directCommand: string;
|
|
118
|
+
/** Command-specific detail about `--direct`, appended to the hint. Omit for the generic line. */
|
|
119
|
+
directHint?: string;
|
|
120
|
+
/** REQUIRED. `'exit'` prints and exits. `'throw'` hands the caller a `ModelContractError`. */
|
|
121
|
+
onRefusal: 'exit' | 'throw';
|
|
122
|
+
}): (verb: string) => Promise<void> {
|
|
123
|
+
let verified = false;
|
|
124
|
+
return async (verb: string): Promise<void> => {
|
|
125
|
+
if (verified) return;
|
|
126
|
+
step("Verifying the deployed stage speaks this CLI's Model-derived wire contract...");
|
|
127
|
+
const health = await invokeAction(opts.region, opts.fn, '_health', {}).catch(() => null);
|
|
128
|
+
if (health === null) return;
|
|
129
|
+
const verdict = checkStageModelContract(health, verb);
|
|
130
|
+
if (!verdict.ok) {
|
|
131
|
+
// One wording for both exits — a thrown refusal and a printed one must never drift.
|
|
132
|
+
const directLine =
|
|
133
|
+
`The --direct venue crosses no wire (the CLI resolves the stage's operator connection `
|
|
134
|
+
+ `internally and runs the work itself): ${opts.directCommand}`
|
|
135
|
+
+ (opts.directHint ? ` — ${opts.directHint}` : '');
|
|
136
|
+
if (opts.onRefusal === 'throw') throw new ModelContractError(`${verdict.reason} ${directLine}`);
|
|
137
|
+
fail(`REFUSED: ${verdict.reason}`);
|
|
138
|
+
info(directLine);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
verified = true;
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/cli/output.ts
CHANGED
|
@@ -47,3 +47,15 @@ function say(line: string): void {
|
|
|
47
47
|
if (stdoutReserved) console.error(line);
|
|
48
48
|
else console.log(line);
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A line printed EXACTLY as given \u2014 no marker, no indent of ours.
|
|
53
|
+
*
|
|
54
|
+
* The default reports (plain-output.ts) own their own shape: a header, a blank line, indented
|
|
55
|
+
* change lines, a verdict. `info` would prepend two spaces to every one of them and turn a
|
|
56
|
+
* designed block into a ragged one. Everything else about `say` still applies, so `--out -`
|
|
57
|
+
* keeps moving the report off stdout.
|
|
58
|
+
*/
|
|
59
|
+
export function line(text: string): void {
|
|
60
|
+
say(text);
|
|
61
|
+
}
|