@everystack/cli 0.4.84 → 0.4.86
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 +18 -1
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +123 -2
- package/src/cli/apply-refusals.ts +112 -0
- package/src/cli/commands/db-apply.ts +104 -7
- package/src/cli/commands/db-plan.ts +30 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +64 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +414 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/model-render.ts +10 -0
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +137 -13
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +52 -3
- package/src/cli/schema-introspect.ts +36 -0
- package/src/cli/state-apply.ts +227 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,28 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.4.86] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- validate: false on a foreign key, and a per-key apply preflight
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- non-blocking validation-pending was blind to real anomalies, and its receipt overstated what ran
|
|
16
|
+
- bump APPLY_CONTRACT_VERSION for the client-side exit-code field, close a sibling overstatement bug, prove the producer side
|
|
17
|
+
|
|
18
|
+
## [0.4.85] - 2026-09-16
|
|
8
19
|
|
|
9
20
|
### Added
|
|
10
21
|
|
|
11
22
|
- automate the version-drift check — a manual note is not a gate
|
|
23
|
+
- add foreign keys in two steps, and check the rows before the DDL
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- the two-step FK add's gate could OOM the database, and its record could go missing
|
|
28
|
+
- db:plan --verbose actually expands the truncated change list
|
|
12
29
|
|
|
13
30
|
## [0.4.84] - 2026-09-16
|
|
14
31
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.86",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
"structured-headers": "1.0.1",
|
|
136
136
|
"tsx": "4.21.0",
|
|
137
137
|
"typescript": "5.9.3",
|
|
138
|
-
"@everystack/model": "0.4.
|
|
138
|
+
"@everystack/model": "0.4.19"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"@everystack/server": ">=0.4.0",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"react": "19.2.0",
|
|
183
183
|
"ts-jest": "29.4.9",
|
|
184
184
|
"zod": "3.25.67",
|
|
185
|
-
"@everystack/server": "0.4.
|
|
185
|
+
"@everystack/server": "0.4.31"
|
|
186
186
|
},
|
|
187
187
|
"scripts": {
|
|
188
188
|
"test": "jest",
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -57,6 +57,16 @@ import {
|
|
|
57
57
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
58
58
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
59
59
|
import { applyGeneratedStatements } from './state-apply';
|
|
60
|
+
import {
|
|
61
|
+
fkOrphanChecks,
|
|
62
|
+
fkOrphanSql,
|
|
63
|
+
fkOrphanFindings,
|
|
64
|
+
withheldValidations,
|
|
65
|
+
fkPreflightUnavailableRefusal,
|
|
66
|
+
checkOrphanRowShape,
|
|
67
|
+
type FkOrphanFinding,
|
|
68
|
+
} from './fk-preflight';
|
|
69
|
+
import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
|
|
60
70
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
61
71
|
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
62
72
|
import type { DescentLedgerRow } from './git-descent';
|
|
@@ -140,10 +150,57 @@ export interface ApplyPlanOptions {
|
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
export interface ApplyPlanResult {
|
|
143
|
-
status:
|
|
153
|
+
status:
|
|
154
|
+
| 'applied'
|
|
155
|
+
| 'already-applied'
|
|
156
|
+
| 'refused'
|
|
157
|
+
| 'descent-refused'
|
|
158
|
+
| 'authority-refused'
|
|
159
|
+
| 'snapshot-refused'
|
|
160
|
+
| 'authz-refused'
|
|
161
|
+
/**
|
|
162
|
+
* The foreign-key row check itself could not RUN — no privilege, a lock timeout, a dropped
|
|
163
|
+
* connection. Nothing ran: a check that did not run has not passed.
|
|
164
|
+
*
|
|
165
|
+
* It is no longer what "some rows do not satisfy a key" produces. That is a per-key finding
|
|
166
|
+
* now: the offending key's `VALIDATE` is withheld and the rest of the edge commits as
|
|
167
|
+
* `validation-pending`. Refusing a whole edge — unrelated grants, unrelated clean keys —
|
|
168
|
+
* over legacy rows nobody was changing was the defect, not the safety.
|
|
169
|
+
*/
|
|
170
|
+
| 'preflight-refused'
|
|
171
|
+
/**
|
|
172
|
+
* The edge COMMITTED and at least one `VALIDATE CONSTRAINT` is outstanding — because it
|
|
173
|
+
* failed, or because the row check counted the offenders first and it was withheld. Not a
|
|
174
|
+
* rollback, in either case.
|
|
175
|
+
*/
|
|
176
|
+
| 'validation-pending'
|
|
177
|
+
| 'verify-failed';
|
|
144
178
|
liveFingerprint: string;
|
|
145
179
|
reason?: string;
|
|
146
180
|
logId?: number;
|
|
181
|
+
/**
|
|
182
|
+
* The keys the row check found orphans on, if any — data, for the caller's advisory (see
|
|
183
|
+
* fk-orphan-advisory). Present on the paths that got as far as running the check, empty
|
|
184
|
+
* otherwise. Crosses the ops-Lambda wire with the rest of the result.
|
|
185
|
+
*/
|
|
186
|
+
fkOrphans?: FkOrphanFinding[];
|
|
187
|
+
/**
|
|
188
|
+
* Only present when `status` is `'validation-pending'`. True iff every outstanding key's cause
|
|
189
|
+
* is understood to be the data (withheld by the preflight, or a live failure with the ordinary
|
|
190
|
+
* orphan-row error) — never a check that was cut off or failed for an unrecognised reason. The
|
|
191
|
+
* CLI reports the safe case as a non-blocking success (the whole point of this status) and the
|
|
192
|
+
* unsafe case exactly like any other refusal, exit code included — a real operational anomaly
|
|
193
|
+
* must still reach automation gating on this command's exit status.
|
|
194
|
+
*/
|
|
195
|
+
pendingIsSafe?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Only present when `status` is `'validation-pending'`. The exact statement text of every
|
|
198
|
+
* `VALIDATE CONSTRAINT` that did not land — withheld before it ran, or attempted and failed —
|
|
199
|
+
* so a receipt built from `plan.statements` (which is never rewritten; see below) can exclude
|
|
200
|
+
* them. Without this, a receipt naively re-derived from the full plan reports a withheld or
|
|
201
|
+
* failed VALIDATE as an applied change, which it never was.
|
|
202
|
+
*/
|
|
203
|
+
unappliedStatements?: string[];
|
|
147
204
|
}
|
|
148
205
|
|
|
149
206
|
/**
|
|
@@ -359,7 +416,45 @@ export async function executeApplyPlan(
|
|
|
359
416
|
}
|
|
360
417
|
}
|
|
361
418
|
|
|
362
|
-
|
|
419
|
+
// The LAST step before any DDL, and the only one that asks about DATA. A foreign key this plan
|
|
420
|
+
// asks to be VALIDATED can only be validated if the rows already in the table satisfy it;
|
|
421
|
+
// PostgreSQL would answer that during the VALIDATE, one row at a time, with the lock already
|
|
422
|
+
// taken. Asked here, the answer arrives with an exact count and a sample. The snapshot is the
|
|
423
|
+
// one the lock was just computed from, so the question and the lock describe one instant.
|
|
424
|
+
//
|
|
425
|
+
// WHAT THE ANSWER DOES, and this is the fix. It used to REFUSE the whole edge if any key had
|
|
426
|
+
// one orphan — the unrelated grants, the clean keys, everything, held behind a data audit of
|
|
427
|
+
// rows nobody was touching. Now the answer is read PER KEY: the offending key's `VALIDATE` is
|
|
428
|
+
// withheld (the key is still installed by the batch, NOT VALID, enforced on every new write),
|
|
429
|
+
// recorded as a pending validation, and the rest of the edge commits. The apply's outcome is
|
|
430
|
+
// `validation-pending`, which already means exactly this.
|
|
431
|
+
//
|
|
432
|
+
// TWO THINGS STILL REFUSE, and only these two: the check could not RUN, and the read answered
|
|
433
|
+
// in a shape nobody can read. Both are the gate not having run, which is not the same as
|
|
434
|
+
// passing, so both fail closed with nothing applied.
|
|
435
|
+
const orphanChecks = fkOrphanChecks(plan.statements, before);
|
|
436
|
+
let orphans: FkOrphanFinding[] = [];
|
|
437
|
+
if (orphanChecks.length > 0) {
|
|
438
|
+
try {
|
|
439
|
+
const rows = (await runner(fkOrphanSql(orphanChecks))) as unknown;
|
|
440
|
+
// The read answers with exactly one row per key, zero orphans included. A short or
|
|
441
|
+
// missing answer is not "clean" — it is the gate not having run.
|
|
442
|
+
checkOrphanRowShape(rows, orphanChecks.length);
|
|
443
|
+
orphans = fkOrphanFindings(rows);
|
|
444
|
+
} catch (err: any) {
|
|
445
|
+
const refusal = fkPreflightUnavailableRefusal(String(err?.message ?? err));
|
|
446
|
+
await recordRefusal(runner, plan, live, 'foreign key preflight', refusal, options);
|
|
447
|
+
return { status: 'preflight-refused', liveFingerprint: live, reason: refusal, fkOrphans: [] };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The edge minus the checks whose answer is already known. `plan.statements` itself is never
|
|
452
|
+
// rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
|
|
453
|
+
// keep naming the file the operator reviewed.
|
|
454
|
+
const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
|
|
455
|
+
|
|
456
|
+
const result = await applyGeneratedStatements(runner, toRun, {
|
|
457
|
+
...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
|
|
363
458
|
actor: options.actor,
|
|
364
459
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
365
460
|
planRef: planHash(plan),
|
|
@@ -367,6 +462,11 @@ export async function executeApplyPlan(
|
|
|
367
462
|
fpVersion: FINGERPRINT_VERSION,
|
|
368
463
|
dirty: options.dirty ?? false,
|
|
369
464
|
now: options.now,
|
|
465
|
+
// The hash to stamp on the record written the moment the batch commits — the live state as
|
|
466
|
+
// it is THEN, with any new key installed and not yet proven. The plan's own endpoint is
|
|
467
|
+
// stamped below, after the last check has actually passed; predicting it here would put a
|
|
468
|
+
// state the database is not in onto the row every later apply descends from.
|
|
469
|
+
readFingerprint: async () => (await liveBaseFingerprint(session)).hash,
|
|
370
470
|
...(options.descentNote ? { descentNote: options.descentNote } : {}),
|
|
371
471
|
});
|
|
372
472
|
|
|
@@ -377,6 +477,25 @@ export async function executeApplyPlan(
|
|
|
377
477
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
378
478
|
}
|
|
379
479
|
|
|
480
|
+
// A key left NOT VALID is reported as ITSELF, before the endpoint comparison it would otherwise
|
|
481
|
+
// fail. The edge committed and the schema is correct; what is missing is the proof that the
|
|
482
|
+
// existing rows satisfy the key, and the fix is a data fix plus a re-plan (which will emit the
|
|
483
|
+
// VALIDATE alone). Reporting that as `verify-failed` would tell the operator to stop touching a
|
|
484
|
+
// database that is in a perfectly well-defined state.
|
|
485
|
+
if (result.pendingValidations.length > 0) {
|
|
486
|
+
return {
|
|
487
|
+
status: 'validation-pending',
|
|
488
|
+
liveFingerprint: landed,
|
|
489
|
+
// The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
|
|
490
|
+
// clock is a lane change. See validationPendingReason.
|
|
491
|
+
reason: validationPendingReason(result.pendingValidations),
|
|
492
|
+
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
493
|
+
fkOrphans: orphans,
|
|
494
|
+
pendingIsSafe: pendingValidationsAreSafe(result.pendingValidations),
|
|
495
|
+
unappliedStatements: result.pendingValidations.map((p) => p.sql),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
380
499
|
// The full `to` equals the post-apply raw live hash, so verify-after compares them directly.
|
|
381
500
|
if (landed !== plan.toFingerprint) {
|
|
382
501
|
return {
|
|
@@ -384,12 +503,14 @@ export async function executeApplyPlan(
|
|
|
384
503
|
liveFingerprint: landed,
|
|
385
504
|
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
386
505
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
506
|
+
fkOrphans: orphans,
|
|
387
507
|
};
|
|
388
508
|
}
|
|
389
509
|
return {
|
|
390
510
|
status: 'applied',
|
|
391
511
|
liveFingerprint: landed,
|
|
392
512
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
513
|
+
fkOrphans: orphans,
|
|
393
514
|
};
|
|
394
515
|
}
|
|
395
516
|
|
|
@@ -30,6 +30,110 @@ export function snapshotMovedRefusal(ref: string, captured: string, planFrom: st
|
|
|
30
30
|
return `the rollback point ${ref} captures ${captured.slice(0, 12)}, not the plan's start ${planFrom.slice(0, 12)} — the database moved since the plan was minted. Re-plan (db:plan) and apply again.`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* VALIDATION PENDING: the edge committed and a post-commit `VALIDATE CONSTRAINT` is outstanding.
|
|
35
|
+
* Neither class above — nothing was refused and nothing is in an unknown state — but it still has
|
|
36
|
+
* to name a next move, and WHICH move depends on why the check has not passed:
|
|
37
|
+
*
|
|
38
|
+
* WITHHELD — the row preflight counted the offending rows BEFORE any DDL, so the check was
|
|
39
|
+
* never attempted and the rest of the edge committed around it. The rows are the
|
|
40
|
+
* known cause, so this is the ORPHAN DATA move with no guessing involved.
|
|
41
|
+
* ORPHAN DATA — the rows genuinely do not satisfy the key. The key is the truth and the data
|
|
42
|
+
* is wrong, so the fix is the DATA (db:exec), then re-plan; the next plan emits
|
|
43
|
+
* the VALIDATE alone.
|
|
44
|
+
* CUT OFF — a wall clock or a dropped connection ended the check before it answered.
|
|
45
|
+
* NOTHING is known about the rows. Sending that operator to go fix data is a
|
|
46
|
+
* wild goose chase; the move is to run the same check where no clock ends it,
|
|
47
|
+
* which is the direct lane.
|
|
48
|
+
*
|
|
49
|
+
* An error neither pattern recognises names BOTH moves rather than picking one — the same rule
|
|
50
|
+
* the rest of this file follows about guessing.
|
|
51
|
+
*/
|
|
52
|
+
export interface PendingValidationRecord {
|
|
53
|
+
/** The exact `VALIDATE CONSTRAINT` statement that did not pass, or was never attempted. */
|
|
54
|
+
sql: string;
|
|
55
|
+
/** PostgreSQL's (or the driver's) message — or why the check was withheld. */
|
|
56
|
+
error: string;
|
|
57
|
+
/**
|
|
58
|
+
* The check was WITHHELD by the row preflight, never run. A structural flag rather than one
|
|
59
|
+
* more regex, because the text it would be matched against is text this CLI wrote itself: a
|
|
60
|
+
* pattern over our own message is a pattern that breaks the next time somebody rewords it.
|
|
61
|
+
*/
|
|
62
|
+
skipped?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The rows really do violate the key: SQLSTATE 23503 and the message pg renders for it. */
|
|
66
|
+
const ORPHAN_DATA = /violates foreign key constraint|is violated by some row|\b23503\b/i;
|
|
67
|
+
/** The check never answered: a statement timeout, a lock timeout, or a connection that died. */
|
|
68
|
+
const CUT_OFF = /timeout|canceling statement|terminat(ed|ing)|connection (closed|reset|refused|lost)|socket|ECONNRESET|EPIPE|\b(57014|55P03|08006|08003)\b/i;
|
|
69
|
+
|
|
70
|
+
type PendingCause = 'data' | 'cut-off' | 'unknown';
|
|
71
|
+
|
|
72
|
+
function causeOf(pending: PendingValidationRecord): PendingCause {
|
|
73
|
+
// A withheld check is the ONE case where the cause is known rather than inferred: the
|
|
74
|
+
// preflight counted the rows itself. No pattern is consulted.
|
|
75
|
+
if (pending.skipped === true) return 'data';
|
|
76
|
+
if (ORPHAN_DATA.test(pending.error)) return 'data';
|
|
77
|
+
if (CUT_OFF.test(pending.error)) return 'cut-off';
|
|
78
|
+
return 'unknown';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True iff every outstanding key's cause is the DATA — withheld by the preflight, or a live
|
|
83
|
+
* `VALIDATE` that failed with the ordinary orphan-row error. False the moment any cause is CUT
|
|
84
|
+
* OFF or UNKNOWN: a check that did not answer, or failed for a reason this command does not
|
|
85
|
+
* recognise, is a real operational anomaly, not the known, harmless fact this status otherwise
|
|
86
|
+
* describes — the caller must not report the two the same way (a non-blocking success either
|
|
87
|
+
* way would swallow a race condition or an infra hiccup from any automation gating on exit code).
|
|
88
|
+
*/
|
|
89
|
+
export function pendingValidationsAreSafe(pending: readonly PendingValidationRecord[]): boolean {
|
|
90
|
+
return pending.every((p) => causeOf(p) === 'data');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validationPendingReason(pending: readonly PendingValidationRecord[]): string {
|
|
94
|
+
const causes = new Set(pending.map(causeOf));
|
|
95
|
+
const unknown = causes.has('unknown');
|
|
96
|
+
const withheld = pending.filter((p) => p.skipped === true).length;
|
|
97
|
+
|
|
98
|
+
const moves: string[] = [];
|
|
99
|
+
if (withheld > 0) {
|
|
100
|
+
moves.push(
|
|
101
|
+
`${withheld} of them ${withheld === 1 ? 'was' : 'were'} never attempted: the row check counted the `
|
|
102
|
+
+ 'offending rows before any DDL ran, so the apply skipped that scan and committed everything '
|
|
103
|
+
+ 'else in the edge. Declare `validate: false` on the reference to keep the key enforced '
|
|
104
|
+
+ 'forward and stop being asked.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
// Named for BOTH a withheld key and one that ran and failed with the ordinary orphan-row error
|
|
108
|
+
// — deliberately, not a duplicate of the withheld move above: they are two DIFFERENT valid
|
|
109
|
+
// resolutions (accept it forever vs. clean the data and re-plan), not two descriptions of one
|
|
110
|
+
// fact, so an operator choosing between them is owed both options in the same message.
|
|
111
|
+
if (causes.has('data') || unknown) {
|
|
112
|
+
moves.push(
|
|
113
|
+
'Rows already in the table do not satisfy the key: fix the data (everystack db:exec runs a '
|
|
114
|
+
+ 'DML-only file against the target, credential-free), then db:plan and db:apply, which will '
|
|
115
|
+
+ 'emit the VALIDATE alone.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (causes.has('cut-off') || unknown) {
|
|
119
|
+
moves.push(
|
|
120
|
+
'The check was cut off rather than answered (a timeout or a dropped connection), so nothing '
|
|
121
|
+
+ 'is yet known about the rows: re-plan and apply again naming the direct lane '
|
|
122
|
+
+ '(--stage <name> --direct), which runs with no wall clock over it.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (unknown) moves.unshift('The error below is not one this command recognises, so both moves are named:');
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
const names = pending.map((p) => p.sql.replace(/\s+/g, ' ').trim()).join(' | ');
|
|
129
|
+
return [
|
|
130
|
+
`the edge applied, but ${pending.length} constraint(s) could not be validated against the rows `
|
|
131
|
+
+ 'already in the table — they are installed and enforced on new writes, but unproven.',
|
|
132
|
+
...moves,
|
|
133
|
+
`The check(s) still to pass: ${names}`,
|
|
134
|
+
].join(' ');
|
|
135
|
+
}
|
|
136
|
+
|
|
33
137
|
// ---------------------------------------------------------------------------
|
|
34
138
|
// The USER-FACING refusal message (apply-legibility Phase 3, p3-action-refusals).
|
|
35
139
|
//
|
|
@@ -55,6 +159,8 @@ export type ApplyRefusalStatus =
|
|
|
55
159
|
| 'authority-refused'
|
|
56
160
|
| 'snapshot-refused'
|
|
57
161
|
| 'authz-refused'
|
|
162
|
+
| 'preflight-refused'
|
|
163
|
+
| 'validation-pending'
|
|
58
164
|
| 'verify-failed';
|
|
59
165
|
|
|
60
166
|
/**
|
|
@@ -72,7 +178,13 @@ export function formatApplyRefusal(status: ApplyRefusalStatus, reason: string):
|
|
|
72
178
|
case 'authority-refused': // the destructive-approver set — hand the plan to an approver
|
|
73
179
|
case 'snapshot-refused': // no confirmed rollback point before a destructive statement
|
|
74
180
|
case 'authz-refused': // a foreign grantee arrived after the plan was minted
|
|
181
|
+
case 'preflight-refused': // the foreign-key row check could not RUN — nothing ran (orphaned rows no longer come here)
|
|
75
182
|
return `REFUSED: ${reason}`;
|
|
183
|
+
case 'validation-pending':
|
|
184
|
+
// NOT a refusal and NOT a fault: the edge committed, and a constraint is installed but
|
|
185
|
+
// unproven against the rows that were already there. Its own word, because "refused" would
|
|
186
|
+
// say nothing ran and "verify failed" would say the database is in an unknown state.
|
|
187
|
+
return `Applied, validation PENDING: ${reason}`;
|
|
76
188
|
case 'verify-failed':
|
|
77
189
|
return `Verify FAILED: ${reason}`;
|
|
78
190
|
default: {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import fs from 'node:fs/promises';
|
|
33
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
33
34
|
import { type QueryRunner } from '../authz-contract';
|
|
34
35
|
import { currentGitRef, worktreeDirty, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
|
|
35
36
|
import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, declaredGranteesOf, type EdgePlan } from '../edge-plan';
|
|
@@ -73,6 +74,7 @@ import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
|
73
74
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
74
75
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
75
76
|
import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
|
|
77
|
+
import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
|
|
76
78
|
import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
|
|
77
79
|
import { buildReconcileReport } from './db-reconcile';
|
|
78
80
|
import { step, success, fail, info, warn, line as printLine } from '../output';
|
|
@@ -110,12 +112,27 @@ export function reportApplyResult(
|
|
|
110
112
|
* the lesser evil against guessing, and the gate that actually decides already ran.
|
|
111
113
|
*/
|
|
112
114
|
declaredGrantees?: ReadonlySet<string>;
|
|
115
|
+
/**
|
|
116
|
+
* The models, for the advisory's row-security cross-reference. Absent means the barrel could
|
|
117
|
+
* not be read — the notice then prints without the escalation heading rather than not at all.
|
|
118
|
+
*/
|
|
119
|
+
models?: readonly ModelDescriptor[];
|
|
113
120
|
} = {},
|
|
114
121
|
): void {
|
|
115
122
|
// What the function/view catch-up ran on the same target. It joins the receipt's own change
|
|
116
123
|
// list — one command, one list, one count — so a caught-up view is never a second report the
|
|
117
124
|
// reader has to reconcile with the first.
|
|
118
125
|
const catchUp = opts.catchUp ?? [];
|
|
126
|
+
|
|
127
|
+
// The recurring notice about keys that stay unproven — BEFORE the outcome, at every verbosity,
|
|
128
|
+
// on every status that got as far as running the row check. It blocks nothing and it never
|
|
129
|
+
// proposes a row change; it is the debt this apply committed around, stated out loud. Printed
|
|
130
|
+
// here, in the one place both the stage lane and the direct lane converge.
|
|
131
|
+
for (const line of renderFkOrphanAdvisory(
|
|
132
|
+
result.fkOrphans ?? [],
|
|
133
|
+
ownershipColumns(opts.models ?? []),
|
|
134
|
+
{ verbose: opts.verbose === true },
|
|
135
|
+
)) warn(line);
|
|
119
136
|
switch (result.status) {
|
|
120
137
|
case 'already-applied':
|
|
121
138
|
if (opts.verbose) success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
@@ -149,11 +166,47 @@ export function reportApplyResult(
|
|
|
149
166
|
success(lines[lines.length - 1]);
|
|
150
167
|
return;
|
|
151
168
|
}
|
|
169
|
+
case 'validation-pending': {
|
|
170
|
+
// NOT every validation-pending outcome is safe to treat as a success. `pendingIsSafe` is
|
|
171
|
+
// false the moment any outstanding key's cause is a check that was cut off or failed for a
|
|
172
|
+
// reason this command does not recognise — a real operational anomaly, not the withheld-
|
|
173
|
+
// by-design case this status otherwise describes. That still exits non-zero, exactly like
|
|
174
|
+
// any other refusal: a CI pipeline gating a release on this command's exit code must see it.
|
|
175
|
+
if (result.pendingIsSafe !== true) {
|
|
176
|
+
fail(formatApplyRefusal(result.status, result.reason ?? ''));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
// LANDED, not refused: the table and column changes are committed (see the LANDED set
|
|
180
|
+
// below) — only a VALIDATE CONSTRAINT is outstanding on keys the preflight already
|
|
181
|
+
// withheld. Exiting non-zero here would tell a CI pipeline gating on this command's exit
|
|
182
|
+
// code that the apply FAILED, when nothing did: the grant, the clean FKs, everything else
|
|
183
|
+
// landed exactly as planned. That false failure is the whole reason this status exists —
|
|
184
|
+
// undoing it with a non-zero exit here would silently reintroduce the block this feature
|
|
185
|
+
// was built to remove. The receipt below is the same one `applied` prints, minus every
|
|
186
|
+
// statement `unappliedStatements` names — a withheld or failed VALIDATE never ran, so
|
|
187
|
+
// `plan.statements` (still the full, un-rewritten plan) must not be classified as-is or
|
|
188
|
+
// it reads as an applied change that never happened. The pending notice rode in already,
|
|
189
|
+
// via renderFkOrphanAdvisory above.
|
|
190
|
+
if (opts.verbose) {
|
|
191
|
+
success(`Applied, validation PENDING — the target is at ${result.liveFingerprint.slice(0, 12)}. ${formatApplyRefusal(result.status, result.reason ?? '')}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const unapplied = new Set(result.unappliedStatements ?? []);
|
|
195
|
+
const executable = classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable;
|
|
196
|
+
const { drops, narrowings, strips } = classifyDestructive(executable, {
|
|
197
|
+
...(opts.declaredGrantees ? { declaredGrantees: opts.declaredGrantees } : {}),
|
|
198
|
+
});
|
|
199
|
+
const lines = renderApplyReceipt({ executable, destructive: [...drops, ...narrowings, ...strips], catchUp });
|
|
200
|
+
for (const text of lines.slice(0, -1)) printLine(text);
|
|
201
|
+
success(lines[lines.length - 1]);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
152
204
|
default:
|
|
153
205
|
// Every remaining status is a refusal or a verify fault. TS narrows result.status here to
|
|
154
|
-
// exactly ApplyRefusalStatus, so a future status that is
|
|
155
|
-
//
|
|
156
|
-
// cannot come back. The message is an
|
|
206
|
+
// exactly ApplyRefusalStatus minus 'validation-pending', so a future status that is
|
|
207
|
+
// neither success, pending, nor a known refusal stops this compiling — the fallthrough
|
|
208
|
+
// that let `authz-refused` print nothing and exit 0 cannot come back. The message is an
|
|
209
|
+
// action; the internal cause code stays in schema_log.
|
|
157
210
|
fail(formatApplyRefusal(result.status, result.reason ?? ''));
|
|
158
211
|
process.exit(1);
|
|
159
212
|
}
|
|
@@ -227,11 +280,27 @@ async function runCatchUp(
|
|
|
227
280
|
}
|
|
228
281
|
}
|
|
229
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The statuses where the table and column state is COMMITTED on the target. Everything else
|
|
285
|
+
* either refused before any write or ended in a state nothing may be built on.
|
|
286
|
+
*/
|
|
287
|
+
const LANDED: ReadonlySet<ApplyPlanResult['status']> = new Set<ApplyPlanResult['status']>([
|
|
288
|
+
'applied',
|
|
289
|
+
'already-applied',
|
|
290
|
+
'validation-pending',
|
|
291
|
+
]);
|
|
292
|
+
|
|
230
293
|
/**
|
|
231
294
|
* The chain: a plan that LANDED is followed by the catch-up, on the same target, always —
|
|
232
295
|
* unless --state-only asked for the tables alone. A refusal or a verify fault chains nothing
|
|
233
296
|
* (there is no applied state to build on); reportApplyResult renders those and exits.
|
|
234
297
|
*
|
|
298
|
+
* `validation-pending` LANDED. It is not a refusal: the tables and columns committed and only a
|
|
299
|
+
* `VALIDATE CONSTRAINT` is outstanding. Functions, views, matviews and triggers do not depend on
|
|
300
|
+
* whether a foreign key has been PROVEN against the old rows — they compile and run identically
|
|
301
|
+
* either way — so skipping the catch-up there deployed half the checkout and said nothing about
|
|
302
|
+
* it, which is the exact incident this whole chain was built to end.
|
|
303
|
+
*
|
|
235
304
|
* THE POST-WRITE CALLER, and the only reason `'throw'` exists: the table and column changes are
|
|
236
305
|
* committed by the time this runs. Nothing downstream may end the process on its own, because
|
|
237
306
|
* the operator is owed one sentence first — your changes are safe, here is what to rerun. So a
|
|
@@ -244,11 +313,14 @@ async function catchUpAfterApply(
|
|
|
244
313
|
venue: CatchUpVenue,
|
|
245
314
|
flags: Record<string, string>,
|
|
246
315
|
): Promise<CatchUpReport> {
|
|
247
|
-
if (
|
|
316
|
+
if (!LANDED.has(result.status)) return { ok: true, statements: [] };
|
|
248
317
|
if (flags['state-only'] === 'true') return { ok: true, statements: [] };
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
318
|
+
// Same exclusion the receipt applies (reportApplyResult): a withheld or failed VALIDATE never
|
|
319
|
+
// ran, so it must not inflate the count a catch-up FAILURE report names as already safe.
|
|
320
|
+
const unapplied = new Set(result.unappliedStatements ?? []);
|
|
321
|
+
const applied = result.status === 'already-applied'
|
|
322
|
+
? 0
|
|
323
|
+
: classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
|
|
252
324
|
return runCatchUp(venue, flags, applied, 'throw');
|
|
253
325
|
}
|
|
254
326
|
|
|
@@ -283,6 +355,27 @@ async function declaredGranteesForReceipt(
|
|
|
283
355
|
}
|
|
284
356
|
}
|
|
285
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The models, for the orphan advisory's row-security cross-reference — and ONLY when there is
|
|
360
|
+
* something to cross-reference, so a clean apply never pays to load a barrel.
|
|
361
|
+
*
|
|
362
|
+
* Silently best-effort, unlike its sibling above. The advisory's own lines are complete without
|
|
363
|
+
* it (the generic heading, the counts, the samples); what is missing is the escalation to the
|
|
364
|
+
* row-security heading. Warning about a degraded sub-heading, next to a notice that is itself
|
|
365
|
+
* advisory, would be noise about noise.
|
|
366
|
+
*/
|
|
367
|
+
async function modelsForAdvisory(
|
|
368
|
+
result: ApplyPlanResult,
|
|
369
|
+
flags: Record<string, string>,
|
|
370
|
+
): Promise<readonly ModelDescriptor[] | undefined> {
|
|
371
|
+
if (!result.fkOrphans?.length) return undefined;
|
|
372
|
+
try {
|
|
373
|
+
return await loadModels(resolveModelsPath(flags.models));
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
286
379
|
/**
|
|
287
380
|
* Print a failed catch-up and exit: the report line, then the cause as it was written.
|
|
288
381
|
*
|
|
@@ -547,10 +640,12 @@ async function applyPlanViaStage(
|
|
|
547
640
|
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags);
|
|
548
641
|
exitOnCatchUpFailure(catchUp);
|
|
549
642
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
643
|
+
const advisoryModels = await modelsForAdvisory(landedResult!, flags);
|
|
550
644
|
reportApplyResult(landedResult!, plan, {
|
|
551
645
|
verbose: flags.verbose === 'true',
|
|
552
646
|
catchUp: catchUp.statements,
|
|
553
647
|
...(declaredGrantees ? { declaredGrantees } : {}),
|
|
648
|
+
...(advisoryModels ? { models: advisoryModels } : {}),
|
|
554
649
|
});
|
|
555
650
|
}
|
|
556
651
|
|
|
@@ -1049,9 +1144,11 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1049
1144
|
|
|
1050
1145
|
exitOnCatchUpFailure(catchUp);
|
|
1051
1146
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
1147
|
+
const advisoryModels = await modelsForAdvisory(landed!, flags);
|
|
1052
1148
|
reportApplyResult(landed!, plan, {
|
|
1053
1149
|
verbose: flags.verbose === 'true',
|
|
1054
1150
|
catchUp: catchUp.statements,
|
|
1055
1151
|
...(declaredGrantees ? { declaredGrantees } : {}),
|
|
1152
|
+
...(advisoryModels ? { models: advisoryModels } : {}),
|
|
1056
1153
|
});
|
|
1057
1154
|
}
|
|
@@ -71,6 +71,8 @@ import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefu
|
|
|
71
71
|
import { compileTableContract } from '../authz-compile';
|
|
72
72
|
import { reportPipelineLastRun } from './pipeline-run';
|
|
73
73
|
import { renderPlanReport } from '../plain-output';
|
|
74
|
+
import { fkOrphanChecks, fkOrphanSql, fkOrphanFindings, checkOrphanRowShape } from '../fk-preflight';
|
|
75
|
+
import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
|
|
74
76
|
import { step, success, fail, info, warn, line as printLine, reserveStdoutForData } from '../output';
|
|
75
77
|
|
|
76
78
|
/**
|
|
@@ -389,6 +391,33 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
389
391
|
else if (verbose) info(line);
|
|
390
392
|
}
|
|
391
393
|
|
|
394
|
+
// WHICH foreign keys this plan would ask to be PROVEN, and whether the rows can prove them.
|
|
395
|
+
//
|
|
396
|
+
// db:plan asks now so the review surface carries it: an operator reading the plan learns
|
|
397
|
+
// that N keys will stay NOT VALID BEFORE they apply, rather than from the apply's outcome.
|
|
398
|
+
// It is the same read the apply runs (one anti-join per key), against the same snapshot the
|
|
399
|
+
// fingerprint was taken from.
|
|
400
|
+
//
|
|
401
|
+
// IT NEVER BLOCKS, and a read that fails costs the plan nothing: minting is read-only, the
|
|
402
|
+
// plan file is already written, and the apply asks the question again with the answer that
|
|
403
|
+
// actually decides anything. A failure here is one line, not an exit.
|
|
404
|
+
//
|
|
405
|
+
// NONE OF IT IS IN THE PLAN FILE. Row counts go stale between minting and applying, and a
|
|
406
|
+
// plan is a content-addressed artifact that must not assert something it cannot still be
|
|
407
|
+
// true days later.
|
|
408
|
+
const orphanChecks = fkOrphanChecks(plan.statements, snapshot);
|
|
409
|
+
if (orphanChecks.length > 0) {
|
|
410
|
+
try {
|
|
411
|
+
const rows = (await runner(fkOrphanSql(orphanChecks))) as unknown;
|
|
412
|
+
checkOrphanRowShape(rows, orphanChecks.length);
|
|
413
|
+
for (const text of renderFkOrphanAdvisory(fkOrphanFindings(rows), ownershipColumns(models), { verbose })) {
|
|
414
|
+
warn(text);
|
|
415
|
+
}
|
|
416
|
+
} catch (err: any) {
|
|
417
|
+
warn(`the foreign-key row check could not run against this target (${err?.message ?? err}) — the plan is unaffected, but it cannot say here whether ${orphanChecks.length} key(s) will validate. db:apply asks again and fails closed if it still cannot.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
392
421
|
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
393
422
|
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
394
423
|
// work — say so on the review surface, where a human still reads it.
|
|
@@ -445,6 +474,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
445
474
|
target: stageName,
|
|
446
475
|
stage: flags.stage ?? null,
|
|
447
476
|
planPath: out === '-' ? null : out,
|
|
477
|
+
verbose,
|
|
448
478
|
})) printLine(text);
|
|
449
479
|
|
|
450
480
|
if (out !== '-') {
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -586,3 +586,20 @@ RETURNING id`;
|
|
|
586
586
|
export function renderSchemaLogFingerprintUpdate(id: number, toFingerprint: string): string {
|
|
587
587
|
return `UPDATE everystack.schema_log SET to_fingerprint = ${escapeLiteral(toFingerprint)} WHERE id = ${Math.floor(id)}`;
|
|
588
588
|
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Finish a row that was written BEFORE the work it describes had finished.
|
|
592
|
+
*
|
|
593
|
+
* The `state apply` row goes in the moment the batch commits, so a death during the post-commit
|
|
594
|
+
* `VALIDATE CONSTRAINT` pass cannot leave committed DDL with nothing on the record. That means
|
|
595
|
+
* the row exists while its own outcome is still open — and when the pass ends, what changes is
|
|
596
|
+
* the outcome and the statement text, not the identity of the apply. So it is AMENDED. A second
|
|
597
|
+
* row would put two applies in a ledger the descent witness reads as a series of live states.
|
|
598
|
+
*/
|
|
599
|
+
export function renderSchemaLogCompletionUpdate(
|
|
600
|
+
id: number,
|
|
601
|
+
entry: { outcome: string; sql: string; durationMs?: number },
|
|
602
|
+
): string {
|
|
603
|
+
const duration = entry.durationMs === undefined ? '' : `, duration_ms = ${Math.round(entry.durationMs)}`;
|
|
604
|
+
return `UPDATE everystack.schema_log SET outcome = ${escapeLiteral(entry.outcome)}, sql = ${escapeLiteral(entry.sql)}${duration} WHERE id = ${Math.floor(id)}`;
|
|
605
|
+
}
|