@everystack/cli 0.4.44 → 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.
- 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 +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- 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 +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- 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 +128 -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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
|
|
17
|
-
import {
|
|
17
|
+
import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// The data-layer snapshot — the structured shape both producers target.
|
|
@@ -281,6 +281,33 @@ export interface ColumnRow {
|
|
|
281
281
|
position: unknown;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Drop a precision qualifier that only restates the default.
|
|
286
|
+
*
|
|
287
|
+
* `timestamp(6) without time zone` and `timestamp without time zone` are THE SAME TYPE:
|
|
288
|
+
* 6 is Postgres's default precision for the timestamp family, so a column declared either
|
|
289
|
+
* way stores identical values with identical semantics. `format_type` still spells the
|
|
290
|
+
* explicit one with its typmod, because the catalog records that somebody typed it.
|
|
291
|
+
*
|
|
292
|
+
* Everything downstream compares these strings — the renderer to pick a `field.*()`, the
|
|
293
|
+
* differ to decide an ALTER, the fingerprint to content-address the schema. With the two
|
|
294
|
+
* spellings distinct, a brownfield column declared `timestamp(6)` had NO field mapping, so
|
|
295
|
+
* db:pull rendered `field.text()` with a FIXME and the next plan proposed rewriting the
|
|
296
|
+
* column to text — destroying a column to reconcile a difference that does not exist. On a
|
|
297
|
+
* real adopter's schema that was 18 of 48 destructive statements.
|
|
298
|
+
*
|
|
299
|
+
* Normalized HERE, at the single funnel every consumer reads, rather than at each comparison:
|
|
300
|
+
* three surfaces answering one question separately is how they drift.
|
|
301
|
+
*
|
|
302
|
+
* ONLY precision 6, and only for the timestamp family. `timestamp(3)` genuinely truncates to
|
|
303
|
+
* milliseconds — a real difference that must keep saying so.
|
|
304
|
+
*/
|
|
305
|
+
// The one funnel — a type's canonical spelling is defined ONCE, in the model
|
|
306
|
+
// package, so the field factory (`field.pgType` refusals), this introspection,
|
|
307
|
+
// the renderer, the differ, and the fingerprint can never disagree about it.
|
|
308
|
+
import { canonicalColumnType } from '@everystack/model';
|
|
309
|
+
export { canonicalColumnType };
|
|
310
|
+
|
|
284
311
|
export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
|
|
285
312
|
const def = row.default == null ? null : String(row.default);
|
|
286
313
|
return {
|
|
@@ -288,7 +315,7 @@ export function columnRowToDescriptor(row: ColumnRow): { table: string; column:
|
|
|
288
315
|
position: Number(row.position),
|
|
289
316
|
column: {
|
|
290
317
|
name: row.column,
|
|
291
|
-
type: String(row.type),
|
|
318
|
+
type: canonicalColumnType(String(row.type)),
|
|
292
319
|
notNull: coerceBool(row.not_null),
|
|
293
320
|
default: def && def.length > 0 ? def : null,
|
|
294
321
|
},
|
|
@@ -604,20 +631,20 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
604
631
|
* (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
|
|
605
632
|
* `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
|
|
606
633
|
*/
|
|
607
|
-
export async function introspectSchema(
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
634
|
+
export async function introspectSchema(session: SessionRunner): Promise<SchemaSnapshot> {
|
|
635
|
+
// ONE session, so all five queries see one database at one moment, under one pinned
|
|
636
|
+
// search_path. Column defaults, CHECK constraints and index expressions all deparse
|
|
637
|
+
// relative to that path — spread across separate connections these five rows can render
|
|
638
|
+
// the same schema two ways, and a fingerprint minted from the mix describes nothing.
|
|
639
|
+
const [columns, constraints, enums, indexes, sequences] = await session(
|
|
640
|
+
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL],
|
|
641
|
+
INTROSPECTION_SESSION,
|
|
642
|
+
);
|
|
643
|
+
return assembleSchema({
|
|
644
|
+
columns: columns as ColumnRow[],
|
|
645
|
+
constraints: constraints as ConstraintRow[],
|
|
646
|
+
enums: enums as EnumRow[],
|
|
647
|
+
indexes: indexes as IndexRow[],
|
|
648
|
+
sequences: sequences as SequenceRow[],
|
|
622
649
|
});
|
|
623
650
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -114,6 +114,15 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
|
|
|
114
114
|
if (!spec.enumName) throw new Error('enum field is missing enumName');
|
|
115
115
|
return { call: `${enumConstName(spec.enumName)}(${n})`, builder: enumConstName(spec.enumName) };
|
|
116
116
|
}
|
|
117
|
+
case 'pgType': {
|
|
118
|
+
// A verbatim type: the generated schema carries the exact string via drizzle's
|
|
119
|
+
// customType — same as the runtime builder in @everystack/model.
|
|
120
|
+
if (!spec.pgTypeName) throw new Error('pgType field is missing pgTypeName');
|
|
121
|
+
return {
|
|
122
|
+
call: `customType<{ data: unknown }>({ dataType: () => ${strLiteral(spec.pgTypeName)} })(${n})`,
|
|
123
|
+
builder: 'customType',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
117
126
|
default: {
|
|
118
127
|
const exhaustive: never = spec.type;
|
|
119
128
|
throw new Error(`Unsupported field type: ${String(exhaustive)}`);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionRunner — the type that can express "these statements describe ONE moment".
|
|
3
|
+
*
|
|
4
|
+
* `QueryRunner` (authz-contract.ts) runs one SQL string and returns rows. It cannot say
|
|
5
|
+
* that two statements shared a connection, because on the stage lane they do not: each
|
|
6
|
+
* one is its own ops-Lambda invoke, answered by whichever container is warm. An
|
|
7
|
+
* introspection built from five of those is a collage, and a fingerprint minted from a
|
|
8
|
+
* collage describes no database that ever existed.
|
|
9
|
+
*
|
|
10
|
+
* So this is a SECOND type, not a widening of the first. A function that takes a
|
|
11
|
+
* `SessionRunner` cannot be handed a session-less venue — the compiler refuses it. That
|
|
12
|
+
* is the whole point: the guarantee is in the type, not in a convention or a capability
|
|
13
|
+
* flag that degrades quietly when the backend cannot honor it.
|
|
14
|
+
*
|
|
15
|
+
* Both venues implement it. The stage lane sends the statements to the ops Lambda's
|
|
16
|
+
* `db:session` action (one invoke, one connection, one transaction); the direct lane runs
|
|
17
|
+
* them in one postgres.js transaction on its single connection. Same contract, same
|
|
18
|
+
* ordering, same `SET LOCAL` semantics.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One statement. A bare string is shorthand for `{ sql, allowFailure: false }`. */
|
|
22
|
+
export interface SessionStatement {
|
|
23
|
+
sql: string;
|
|
24
|
+
/**
|
|
25
|
+
* Survive a failure of THIS statement, leaving the other result slots intact — the
|
|
26
|
+
* venue wraps it in a savepoint. For "this catalog table may not exist yet"
|
|
27
|
+
* (`derived_provenance`, `backfill_log`, `schema_log`), which is otherwise a
|
|
28
|
+
* try/catch that can only be written by giving up the session.
|
|
29
|
+
*/
|
|
30
|
+
allowFailure?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionOptions {
|
|
34
|
+
/** Run the transaction READ ONLY. The caller's policy — no venue assumes it. */
|
|
35
|
+
readOnly?: boolean;
|
|
36
|
+
/** Isolation level; 'repeatable read' gives every statement one snapshot. */
|
|
37
|
+
isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable';
|
|
38
|
+
/**
|
|
39
|
+
* Pin `search_path` for the transaction. Emitted as `SET LOCAL` inside the session and
|
|
40
|
+
* CONSUMING NO RESULT SLOT — results stay indexed by statement. Never prepend the pin
|
|
41
|
+
* as a statement of your own; that shifts every index after it.
|
|
42
|
+
*/
|
|
43
|
+
searchPath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A statement that failed under `allowFailure`. Occupies its slot; carries the cause. */
|
|
47
|
+
export interface SessionStatementError {
|
|
48
|
+
everystackSessionError: true;
|
|
49
|
+
message: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One slot: the statement's rows, or the marker for an allowed failure. */
|
|
53
|
+
export type SessionResult = any[] | SessionStatementError;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run N statements on ONE connection in ONE transaction; N result sets, in input order.
|
|
57
|
+
*
|
|
58
|
+
* Nothing about a `SessionRunner` is optional or best-effort. A venue that cannot make
|
|
59
|
+
* the guarantee does not implement the type.
|
|
60
|
+
*/
|
|
61
|
+
export type SessionRunner = (
|
|
62
|
+
statements: ReadonlyArray<SessionStatement | string>,
|
|
63
|
+
opts?: SessionOptions,
|
|
64
|
+
) => Promise<SessionResult[]>;
|
|
65
|
+
|
|
66
|
+
/** The canonical introspection search_path — explicit, override-proof, declared-matching.
|
|
67
|
+
*
|
|
68
|
+
* Every expression PostgreSQL deparses on introspection (column defaults, CHECK
|
|
69
|
+
* constraints, index expressions, generated columns, view bodies, function bodies)
|
|
70
|
+
* renders its schema qualification RELATIVE to the session `search_path`. The declared
|
|
71
|
+
* state is always canonical — public bare, non-public qualified — so capturing under
|
|
72
|
+
* `public` makes the live read comparable to it. It must be an EXPLICIT value: `RESET`
|
|
73
|
+
* and `SET … TO DEFAULT` inherit an `ALTER DATABASE … SET search_path` override, which
|
|
74
|
+
* is the very condition this exists to neutralize.
|
|
75
|
+
*/
|
|
76
|
+
export const CANONICAL_SEARCH_PATH = 'public';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The options every catalog introspection reads under: one snapshot, no writes, and the
|
|
80
|
+
* canonical deparse baseline. One definition, so the three introspections cannot drift
|
|
81
|
+
* apart in what they mean by "the live state".
|
|
82
|
+
*/
|
|
83
|
+
export const INTROSPECTION_SESSION: SessionOptions = {
|
|
84
|
+
readOnly: true,
|
|
85
|
+
isolation: 'repeatable read',
|
|
86
|
+
searchPath: CANONICAL_SEARCH_PATH,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A `SessionRunner` over a transaction the CALLER already opened.
|
|
91
|
+
*
|
|
92
|
+
* Some venues are inside a transaction before they introspect, and must be: the reconcile
|
|
93
|
+
* apply re-reads the catalog inside its own DDL transaction so provenance records the def
|
|
94
|
+
* hashes of what the batch actually created, not what it hoped to. Handing those a runner
|
|
95
|
+
* that opens its own transaction would issue a nested `BEGIN` and — worse — COMMIT the
|
|
96
|
+
* caller's transaction when it finished.
|
|
97
|
+
*
|
|
98
|
+
* So this borrows the open transaction instead of opening one. The session guarantee still
|
|
99
|
+
* holds, and holds MORE strongly: the statements share the caller's connection and its
|
|
100
|
+
* transaction by construction, and they can see its uncommitted writes.
|
|
101
|
+
*
|
|
102
|
+
* Two consequences, stated rather than hidden:
|
|
103
|
+
*
|
|
104
|
+
* - `isolation` and `readOnly` are NOT applied, because `SET TRANSACTION` only takes effect
|
|
105
|
+
* before a transaction's first statement. They are already fixed — by the caller, who
|
|
106
|
+
* opened the transaction and chose them. This is not a guarantee that degraded; it is the
|
|
107
|
+
* same guarantee supplied by the enclosing transaction.
|
|
108
|
+
* - The `searchPath` pin runs inside a SAVEPOINT that is ALWAYS rolled back. `SET LOCAL`
|
|
109
|
+
* lasts to the end of its transaction, so pinning without unwinding would silently
|
|
110
|
+
* re-point the rest of the caller's transaction — the reconcile apply deliberately runs
|
|
111
|
+
* its creates under a WIDE path, and stealing it back would be a new bug of exactly the
|
|
112
|
+
* shape this whole change removes. Rolling back to the savepoint undoes the SET LOCAL and
|
|
113
|
+
* costs nothing: every statement here is a read, and its rows are already in hand.
|
|
114
|
+
*/
|
|
115
|
+
export function borrowedSessionRunner(run: (sql: string) => Promise<any[]>): SessionRunner {
|
|
116
|
+
return async (statements, opts) => {
|
|
117
|
+
const stmts = statements.map((s) => (typeof s === 'string' ? { sql: s } : s));
|
|
118
|
+
const results: SessionResult[] = [];
|
|
119
|
+
const pinned = opts?.searchPath !== undefined;
|
|
120
|
+
const outer = 'everystack_borrowed_session';
|
|
121
|
+
|
|
122
|
+
if (pinned) {
|
|
123
|
+
await run(`SAVEPOINT ${outer}`);
|
|
124
|
+
await run(buildSearchPathSql(String(opts!.searchPath)));
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
128
|
+
const stmt = stmts[i];
|
|
129
|
+
if (!stmt.allowFailure) {
|
|
130
|
+
results.push(await run(stmt.sql));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const inner = `everystack_borrowed_${i}`;
|
|
134
|
+
await run(`SAVEPOINT ${inner}`);
|
|
135
|
+
try {
|
|
136
|
+
results.push(await run(stmt.sql));
|
|
137
|
+
await run(`RELEASE SAVEPOINT ${inner}`);
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
await run(`ROLLBACK TO SAVEPOINT ${inner}`);
|
|
140
|
+
results.push({ everystackSessionError: true, message: err?.message || String(err) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
// Unconditional: the pin must not outlive this read on ANY path, success or throw.
|
|
145
|
+
if (pinned) await run(`ROLLBACK TO SAVEPOINT ${outer}`);
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A bare SQL identifier — the only shape a search_path element may take. */
|
|
152
|
+
const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `SET LOCAL search_path` for a caller-supplied path, validated as plain identifiers. One
|
|
156
|
+
* definition for every venue — a path one venue pins and another rejects would be a
|
|
157
|
+
* difference in what "the live state" means. `$user` is deliberately not accepted: a
|
|
158
|
+
* role-dependent element would make a canonical capture depend on who read it.
|
|
159
|
+
*/
|
|
160
|
+
export function buildSearchPathSql(searchPath: string): string {
|
|
161
|
+
const parts = searchPath.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
162
|
+
if (parts.length === 0) throw new Error('searchPath must name at least one schema');
|
|
163
|
+
for (const part of parts) {
|
|
164
|
+
if (!PLAIN_IDENT.test(part)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`searchPath element ${JSON.stringify(part)} is not a plain identifier — a session pins only bare schema names`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return `SET LOCAL search_path = ${parts.map((p) => `"${p}"`).join(', ')}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True when a slot carries an allowed statement's failure rather than its rows. */
|
|
174
|
+
export function isSessionError(slot: SessionResult | undefined): slot is SessionStatementError {
|
|
175
|
+
return (
|
|
176
|
+
!!slot && !Array.isArray(slot) && (slot as SessionStatementError).everystackSessionError === true
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A slot's rows — an allowed failure reads as no rows. Use where absence is the answer. */
|
|
181
|
+
export function rowsOrEmpty(slot: SessionResult | undefined): any[] {
|
|
182
|
+
if (slot === undefined || isSessionError(slot)) return [];
|
|
183
|
+
return slot;
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|
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
|
-
}
|