@everystack/cli 0.4.83 → 0.4.85

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 CHANGED
@@ -4,6 +4,22 @@ 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
+ ## [0.4.85] - 2026-09-16
8
+
9
+ ### Added
10
+
11
+ - automate the version-drift check — a manual note is not a gate
12
+ - add foreign keys in two steps, and check the rows before the DDL
13
+
14
+ ### Fixed
15
+
16
+ - the two-step FK add's gate could OOM the database, and its record could go missing
17
+ - db:plan --verbose actually expands the truncated change list
18
+
19
+ ## [0.4.84] - 2026-09-16
20
+
21
+ _Internal changes only._
22
+
7
23
  ## [0.4.83] - 2026-09-16
8
24
 
9
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.83",
3
+ "version": "0.4.85",
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.17"
138
+ "@everystack/model": "0.4.18"
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.30"
185
+ "@everystack/server": "0.4.31"
186
186
  },
187
187
  "scripts": {
188
188
  "test": "jest",
@@ -57,6 +57,8 @@ 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 { fkOrphanChecks, fkOrphanSql, fkOrphanRefusal, fkPreflightUnavailableRefusal, checkOrphanRowShape } from './fk-preflight';
61
+ import { validationPendingReason } from './apply-refusals';
60
62
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
61
63
  import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
62
64
  import type { DescentLedgerRow } from './git-descent';
@@ -140,7 +142,19 @@ export interface ApplyPlanOptions {
140
142
  }
141
143
 
142
144
  export interface ApplyPlanResult {
143
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'authz-refused' | 'verify-failed';
145
+ status:
146
+ | 'applied'
147
+ | 'already-applied'
148
+ | 'refused'
149
+ | 'descent-refused'
150
+ | 'authority-refused'
151
+ | 'snapshot-refused'
152
+ | 'authz-refused'
153
+ /** Rows already in the table do not satisfy a foreign key this plan installs — nothing ran. */
154
+ | 'preflight-refused'
155
+ /** The edge COMMITTED; a post-commit `VALIDATE CONSTRAINT` did not pass. Not a rollback. */
156
+ | 'validation-pending'
157
+ | 'verify-failed';
144
158
  liveFingerprint: string;
145
159
  reason?: string;
146
160
  logId?: number;
@@ -359,6 +373,31 @@ export async function executeApplyPlan(
359
373
  }
360
374
  }
361
375
 
376
+ // The LAST gate before any DDL, and the only one that asks about DATA. A foreign key this plan
377
+ // installs is only installable if the rows already in the table satisfy it; PostgreSQL would
378
+ // answer that during the VALIDATE, one row at a time, with the lock already taken. Asked here,
379
+ // the answer arrives with a count and a sample and nothing has run. The snapshot is the one the
380
+ // lock was just computed from, so the question and the lock describe one instant.
381
+ const orphanChecks = fkOrphanChecks(plan.statements, before);
382
+ if (orphanChecks.length > 0) {
383
+ let refusal: string | null;
384
+ try {
385
+ const rows = (await runner(fkOrphanSql(orphanChecks))) as unknown;
386
+ // The read answers with exactly one row per key, zero orphans included. A short or
387
+ // missing answer is not "clean" — it is the gate not having run, and it goes down the
388
+ // same fail-closed path as a read that could not run at all.
389
+ checkOrphanRowShape(rows, orphanChecks.length);
390
+ refusal = fkOrphanRefusal(rows);
391
+ } catch (err: any) {
392
+ // Fail CLOSED: a check that could not run has not passed.
393
+ refusal = fkPreflightUnavailableRefusal(String(err?.message ?? err));
394
+ }
395
+ if (refusal) {
396
+ await recordRefusal(runner, plan, live, 'foreign key preflight', refusal, options);
397
+ return { status: 'preflight-refused', liveFingerprint: live, reason: refusal };
398
+ }
399
+ }
400
+
362
401
  const result = await applyGeneratedStatements(runner, plan.statements, {
363
402
  actor: options.actor,
364
403
  gitRef: options.gitRef ?? plan.gitRef,
@@ -367,6 +406,11 @@ export async function executeApplyPlan(
367
406
  fpVersion: FINGERPRINT_VERSION,
368
407
  dirty: options.dirty ?? false,
369
408
  now: options.now,
409
+ // The hash to stamp on the record written the moment the batch commits — the live state as
410
+ // it is THEN, with any new key installed and not yet proven. The plan's own endpoint is
411
+ // stamped below, after the last check has actually passed; predicting it here would put a
412
+ // state the database is not in onto the row every later apply descends from.
413
+ readFingerprint: async () => (await liveBaseFingerprint(session)).hash,
370
414
  ...(options.descentNote ? { descentNote: options.descentNote } : {}),
371
415
  });
372
416
 
@@ -377,6 +421,22 @@ export async function executeApplyPlan(
377
421
  await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
378
422
  }
379
423
 
424
+ // A key left NOT VALID is reported as ITSELF, before the endpoint comparison it would otherwise
425
+ // fail. The edge committed and the schema is correct; what is missing is the proof that the
426
+ // existing rows satisfy the key, and the fix is a data fix plus a re-plan (which will emit the
427
+ // VALIDATE alone). Reporting that as `verify-failed` would tell the operator to stop touching a
428
+ // database that is in a perfectly well-defined state.
429
+ if (result.pendingValidations.length > 0) {
430
+ return {
431
+ status: 'validation-pending',
432
+ liveFingerprint: landed,
433
+ // The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
434
+ // clock is a lane change. See validationPendingReason.
435
+ reason: validationPendingReason(result.pendingValidations),
436
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
437
+ };
438
+ }
439
+
380
440
  // The full `to` equals the post-apply raw live hash, so verify-after compares them directly.
381
441
  if (landed !== plan.toFingerprint) {
382
442
  return {
@@ -30,6 +30,72 @@ 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` did not pass.
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 did not pass:
37
+ *
38
+ * ORPHAN DATA — the rows genuinely do not satisfy the key. The key is the truth and the data
39
+ * is wrong, so the fix is the DATA (db:exec), then re-plan; the next plan emits
40
+ * the VALIDATE alone.
41
+ * CUT OFF — a wall clock or a dropped connection ended the check before it answered.
42
+ * NOTHING is known about the rows. Sending that operator to go fix data is a
43
+ * wild goose chase; the move is to run the same check where no clock ends it,
44
+ * which is the direct lane.
45
+ *
46
+ * An error neither pattern recognises names BOTH moves rather than picking one — the same rule
47
+ * the rest of this file follows about guessing.
48
+ */
49
+ export interface PendingValidationRecord {
50
+ /** The exact `VALIDATE CONSTRAINT` statement that did not pass. */
51
+ sql: string;
52
+ /** PostgreSQL's (or the driver's) message. */
53
+ error: string;
54
+ }
55
+
56
+ /** The rows really do violate the key: SQLSTATE 23503 and the message pg renders for it. */
57
+ const ORPHAN_DATA = /violates foreign key constraint|is violated by some row|\b23503\b/i;
58
+ /** The check never answered: a statement timeout, a lock timeout, or a connection that died. */
59
+ const CUT_OFF = /timeout|canceling statement|terminat(ed|ing)|connection (closed|reset|refused|lost)|socket|ECONNRESET|EPIPE|\b(57014|55P03|08006|08003)\b/i;
60
+
61
+ type PendingCause = 'data' | 'cut-off' | 'unknown';
62
+
63
+ function causeOf(error: string): PendingCause {
64
+ if (ORPHAN_DATA.test(error)) return 'data';
65
+ if (CUT_OFF.test(error)) return 'cut-off';
66
+ return 'unknown';
67
+ }
68
+
69
+ export function validationPendingReason(pending: readonly PendingValidationRecord[]): string {
70
+ const causes = new Set(pending.map((p) => causeOf(p.error)));
71
+ const unknown = causes.has('unknown');
72
+
73
+ const moves: string[] = [];
74
+ if (causes.has('data') || unknown) {
75
+ moves.push(
76
+ 'Rows already in the table do not satisfy the key: fix the data (everystack db:exec runs a '
77
+ + 'DML-only file against the target, credential-free), then db:plan and db:apply, which will '
78
+ + 'emit the VALIDATE alone.',
79
+ );
80
+ }
81
+ if (causes.has('cut-off') || unknown) {
82
+ moves.push(
83
+ 'The check was cut off rather than answered (a timeout or a dropped connection), so nothing '
84
+ + 'is yet known about the rows: re-plan and apply again naming the direct lane '
85
+ + '(--stage <name> --direct), which runs with no wall clock over it.',
86
+ );
87
+ }
88
+ if (unknown) moves.unshift('The error below is not one this command recognises, so both moves are named:');
89
+
90
+ const names = pending.map((p) => p.sql.replace(/\s+/g, ' ').trim()).join(' | ');
91
+ return [
92
+ `the edge applied, but ${pending.length} constraint(s) could not be validated against the rows `
93
+ + 'already in the table — they are installed and enforced on new writes, but unproven.',
94
+ ...moves,
95
+ `The check(s) still to pass: ${names}`,
96
+ ].join(' ');
97
+ }
98
+
33
99
  // ---------------------------------------------------------------------------
34
100
  // The USER-FACING refusal message (apply-legibility Phase 3, p3-action-refusals).
35
101
  //
@@ -55,6 +121,8 @@ export type ApplyRefusalStatus =
55
121
  | 'authority-refused'
56
122
  | 'snapshot-refused'
57
123
  | 'authz-refused'
124
+ | 'preflight-refused'
125
+ | 'validation-pending'
58
126
  | 'verify-failed';
59
127
 
60
128
  /**
@@ -72,7 +140,13 @@ export function formatApplyRefusal(status: ApplyRefusalStatus, reason: string):
72
140
  case 'authority-refused': // the destructive-approver set — hand the plan to an approver
73
141
  case 'snapshot-refused': // no confirmed rollback point before a destructive statement
74
142
  case 'authz-refused': // a foreign grantee arrived after the plan was minted
143
+ case 'preflight-refused': // rows already in the table do not satisfy a foreign key — nothing ran
75
144
  return `REFUSED: ${reason}`;
145
+ case 'validation-pending':
146
+ // NOT a refusal and NOT a fault: the edge committed, and a constraint is installed but
147
+ // unproven against the rows that were already there. Its own word, because "refused" would
148
+ // say nothing ran and "verify failed" would say the database is in an unknown state.
149
+ return `Applied, validation PENDING: ${reason}`;
76
150
  case 'verify-failed':
77
151
  return `Verify FAILED: ${reason}`;
78
152
  default: {
@@ -227,11 +227,27 @@ async function runCatchUp(
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * The statuses where the table and column state is COMMITTED on the target. Everything else
232
+ * either refused before any write or ended in a state nothing may be built on.
233
+ */
234
+ const LANDED: ReadonlySet<ApplyPlanResult['status']> = new Set<ApplyPlanResult['status']>([
235
+ 'applied',
236
+ 'already-applied',
237
+ 'validation-pending',
238
+ ]);
239
+
230
240
  /**
231
241
  * The chain: a plan that LANDED is followed by the catch-up, on the same target, always —
232
242
  * unless --state-only asked for the tables alone. A refusal or a verify fault chains nothing
233
243
  * (there is no applied state to build on); reportApplyResult renders those and exits.
234
244
  *
245
+ * `validation-pending` LANDED. It is not a refusal: the tables and columns committed and only a
246
+ * `VALIDATE CONSTRAINT` is outstanding. Functions, views, matviews and triggers do not depend on
247
+ * whether a foreign key has been PROVEN against the old rows — they compile and run identically
248
+ * either way — so skipping the catch-up there deployed half the checkout and said nothing about
249
+ * it, which is the exact incident this whole chain was built to end.
250
+ *
235
251
  * THE POST-WRITE CALLER, and the only reason `'throw'` exists: the table and column changes are
236
252
  * committed by the time this runs. Nothing downstream may end the process on its own, because
237
253
  * the operator is owed one sentence first — your changes are safe, here is what to rerun. So a
@@ -244,11 +260,11 @@ async function catchUpAfterApply(
244
260
  venue: CatchUpVenue,
245
261
  flags: Record<string, string>,
246
262
  ): Promise<CatchUpReport> {
247
- if (result.status !== 'applied' && result.status !== 'already-applied') return { ok: true, statements: [] };
263
+ if (!LANDED.has(result.status)) return { ok: true, statements: [] };
248
264
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
249
- const applied = result.status === 'applied'
250
- ? classifyGeneratedStatements(plan.statements ?? []).executable.length
251
- : 0;
265
+ const applied = result.status === 'already-applied'
266
+ ? 0
267
+ : classifyGeneratedStatements(plan.statements ?? []).executable.length;
252
268
  return runCatchUp(venue, flags, applied, 'throw');
253
269
  }
254
270
 
@@ -445,6 +445,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
445
445
  target: stageName,
446
446
  stage: flags.stage ?? null,
447
447
  planPath: out === '-' ? null : out,
448
+ verbose,
448
449
  })) printLine(text);
449
450
 
450
451
  if (out !== '-') {
@@ -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
+ }
@@ -64,8 +64,17 @@ export const PLAN_VERSION = 2;
64
64
  * the gate — not a false pass of the apply (it degrades to the post-DDL verify failure), but a
65
65
  * silently dropped gate the operator was told is in force. The BUMP RULE says a gate the server
66
66
  * runs is part of the contract, so the peers refuse until the stage is redeployed.
67
+ *
68
+ * v4 (the two-step foreign key): the server runs a NEW gate — the foreign-key orphan preflight,
69
+ * which counts the rows that would fail each key the plan installs or validates and refuses
70
+ * BEFORE any DDL — and the write path changed shape: `VALIDATE CONSTRAINT` is held out of the
71
+ * edge's transaction and run after it commits, so a failed check is a PENDING constraint rather
72
+ * than a rolled-back edge (`validation-pending`, a status a v3 ops Lambda cannot return). A v3
73
+ * stage would skip the preflight and run the VALIDATE inside the edge — a silently dropped gate
74
+ * and a different failure mode, both of which the BUMP RULE covers. Peers refuse until the stage
75
+ * is redeployed.
67
76
  */
68
- export const APPLY_CONTRACT_VERSION = 3;
77
+ export const APPLY_CONTRACT_VERSION = 4;
69
78
 
70
79
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
71
80
  export interface PlanClassification {