@everystack/cli 0.4.85 → 0.4.87

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,29 @@ 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.87] - 2026-09-21
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - cache:purge --stage works without CDN_URL on the API Lambda; update --stage now refuses when no HOST_URL is found, export HOST_URL to override
12
+
13
+ ### Fixed
14
+
15
+ - the FK-orphan advisory doubled its own '!' on top of warn()'s
16
+ - db:provision detects PUBLIC's CREATE on schema public, revokes it under --confirm
17
+ - the db:provision pointer swallowed every other grantee's revoke line
18
+
19
+ ## [0.4.86] - 2026-09-16
20
+
21
+ ### Added
22
+
23
+ - validate: false on a foreign key, and a per-key apply preflight
24
+
25
+ ### Fixed
26
+
27
+ - non-blocking validation-pending was blind to real anomalies, and its receipt overstated what ran
28
+ - bump APPLY_CONTRACT_VERSION for the client-side exit-code field, close a sibling overstatement bug, prove the producer side
29
+
7
30
  ## [0.4.85] - 2026-09-16
8
31
 
9
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.85",
3
+ "version": "0.4.87",
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.18"
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.31"
185
+ "@everystack/server": "0.4.32"
186
186
  },
187
187
  "scripts": {
188
188
  "test": "jest",
@@ -57,8 +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 { fkOrphanChecks, fkOrphanSql, fkOrphanRefusal, fkPreflightUnavailableRefusal, checkOrphanRowShape } from './fk-preflight';
61
- import { validationPendingReason } from './apply-refusals';
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';
62
70
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
63
71
  import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
64
72
  import type { DescentLedgerRow } from './git-descent';
@@ -150,14 +158,49 @@ export interface ApplyPlanResult {
150
158
  | 'authority-refused'
151
159
  | 'snapshot-refused'
152
160
  | 'authz-refused'
153
- /** Rows already in the table do not satisfy a foreign key this plan installs — nothing ran. */
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
+ */
154
170
  | 'preflight-refused'
155
- /** The edge COMMITTED; a post-commit `VALIDATE CONSTRAINT` did not pass. Not a rollback. */
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
+ */
156
176
  | 'validation-pending'
157
177
  | 'verify-failed';
158
178
  liveFingerprint: string;
159
179
  reason?: string;
160
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[];
161
204
  }
162
205
 
163
206
  /**
@@ -373,32 +416,45 @@ export async function executeApplyPlan(
373
416
  }
374
417
  }
375
418
 
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.
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.
381
435
  const orphanChecks = fkOrphanChecks(plan.statements, before);
436
+ let orphans: FkOrphanFinding[] = [];
382
437
  if (orphanChecks.length > 0) {
383
- let refusal: string | null;
384
438
  try {
385
439
  const rows = (await runner(fkOrphanSql(orphanChecks))) as unknown;
386
440
  // 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.
441
+ // missing answer is not "clean" — it is the gate not having run.
389
442
  checkOrphanRowShape(rows, orphanChecks.length);
390
- refusal = fkOrphanRefusal(rows);
443
+ orphans = fkOrphanFindings(rows);
391
444
  } 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) {
445
+ const refusal = fkPreflightUnavailableRefusal(String(err?.message ?? err));
396
446
  await recordRefusal(runner, plan, live, 'foreign key preflight', refusal, options);
397
- return { status: 'preflight-refused', liveFingerprint: live, reason: refusal };
447
+ return { status: 'preflight-refused', liveFingerprint: live, reason: refusal, fkOrphans: [] };
398
448
  }
399
449
  }
400
450
 
401
- const result = await applyGeneratedStatements(runner, plan.statements, {
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 } : {}),
402
458
  actor: options.actor,
403
459
  gitRef: options.gitRef ?? plan.gitRef,
404
460
  planRef: planHash(plan),
@@ -434,6 +490,9 @@ export async function executeApplyPlan(
434
490
  // clock is a lane change. See validationPendingReason.
435
491
  reason: validationPendingReason(result.pendingValidations),
436
492
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
493
+ fkOrphans: orphans,
494
+ pendingIsSafe: pendingValidationsAreSafe(result.pendingValidations),
495
+ unappliedStatements: result.pendingValidations.map((p) => p.sql),
437
496
  };
438
497
  }
439
498
 
@@ -444,12 +503,14 @@ export async function executeApplyPlan(
444
503
  liveFingerprint: landed,
445
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).`,
446
505
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
506
+ fkOrphans: orphans,
447
507
  };
448
508
  }
449
509
  return {
450
510
  status: 'applied',
451
511
  liveFingerprint: landed,
452
512
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
513
+ fkOrphans: orphans,
453
514
  };
454
515
  }
455
516
 
@@ -31,10 +31,13 @@ export function snapshotMovedRefusal(ref: string, captured: string, planFrom: st
31
31
  }
32
32
 
33
33
  /**
34
- * VALIDATION PENDING: the edge committed and a post-commit `VALIDATE CONSTRAINT` did not pass.
34
+ * VALIDATION PENDING: the edge committed and a post-commit `VALIDATE CONSTRAINT` is outstanding.
35
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:
36
+ * to name a next move, and WHICH move depends on why the check has not passed:
37
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.
38
41
  * ORPHAN DATA — the rows genuinely do not satisfy the key. The key is the truth and the data
39
42
  * is wrong, so the fix is the DATA (db:exec), then re-plan; the next plan emits
40
43
  * the VALIDATE alone.
@@ -47,10 +50,16 @@ export function snapshotMovedRefusal(ref: string, captured: string, planFrom: st
47
50
  * the rest of this file follows about guessing.
48
51
  */
49
52
  export interface PendingValidationRecord {
50
- /** The exact `VALIDATE CONSTRAINT` statement that did not pass. */
53
+ /** The exact `VALIDATE CONSTRAINT` statement that did not pass, or was never attempted. */
51
54
  sql: string;
52
- /** PostgreSQL's (or the driver's) message. */
55
+ /** PostgreSQL's (or the driver's) message — or why the check was withheld. */
53
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;
54
63
  }
55
64
 
56
65
  /** The rows really do violate the key: SQLSTATE 23503 and the message pg renders for it. */
@@ -60,17 +69,45 @@ const CUT_OFF = /timeout|canceling statement|terminat(ed|ing)|connection (closed
60
69
 
61
70
  type PendingCause = 'data' | 'cut-off' | 'unknown';
62
71
 
63
- function causeOf(error: string): PendingCause {
64
- if (ORPHAN_DATA.test(error)) return 'data';
65
- if (CUT_OFF.test(error)) return 'cut-off';
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';
66
78
  return 'unknown';
67
79
  }
68
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
+
69
93
  export function validationPendingReason(pending: readonly PendingValidationRecord[]): string {
70
- const causes = new Set(pending.map((p) => causeOf(p.error)));
94
+ const causes = new Set(pending.map(causeOf));
71
95
  const unknown = causes.has('unknown');
96
+ const withheld = pending.filter((p) => p.skipped === true).length;
72
97
 
73
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.
74
111
  if (causes.has('data') || unknown) {
75
112
  moves.push(
76
113
  'Rows already in the table do not satisfy the key: fix the data (everystack db:exec runs a '
@@ -87,6 +124,7 @@ export function validationPendingReason(pending: readonly PendingValidationRecor
87
124
  }
88
125
  if (unknown) moves.unshift('The error below is not one this command recognises, so both moves are named:');
89
126
 
127
+
90
128
  const names = pending.map((p) => p.sql.replace(/\s+/g, ' ').trim()).join(' | ');
91
129
  return [
92
130
  `the edge applied, but ${pending.length} constraint(s) could not be validated against the rows `
@@ -140,7 +178,7 @@ export function formatApplyRefusal(status: ApplyRefusalStatus, reason: string):
140
178
  case 'authority-refused': // the destructive-approver set — hand the plan to an approver
141
179
  case 'snapshot-refused': // no confirmed rollback point before a destructive statement
142
180
  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
181
+ case 'preflight-refused': // the foreign-key row check could not RUN — nothing ran (orphaned rows no longer come here)
144
182
  return `REFUSED: ${reason}`;
145
183
  case 'validation-pending':
146
184
  // NOT a refusal and NOT a fault: the edge committed, and a constraint is installed but
@@ -8,7 +8,7 @@
8
8
  * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
9
  */
10
10
 
11
- import { resolveConfig, opsFunction } from '../config';
11
+ import { resolveConfig, opsFunction, probeUrl } from '../config';
12
12
  import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle';
13
13
  import { runUiAudit, reportUiAudit } from './ui-audit';
14
14
  import { checkRunbook } from './runbook';
@@ -28,7 +28,7 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
28
28
  fail(err.message);
29
29
  process.exit(1);
30
30
  }
31
- const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
31
+ const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
32
32
  if (!base) {
33
33
  fail('No router URL to audit. Pass --host or ensure the stage has a deployed URL.');
34
34
  process.exit(1);
@@ -48,7 +48,7 @@ import {
48
48
  DEFAULT_UI_BLOAT_BUDGET,
49
49
  type UiBloatBudget,
50
50
  } from '../ui-bloat';
51
- import { resolveConfig } from '../config';
51
+ import { resolveConfig, probeUrl } from '../config';
52
52
  import { fail, info, step, success, warn } from '../output';
53
53
 
54
54
  // Expo Router web export puts client chunks under client/_expo/...; a plain
@@ -430,7 +430,7 @@ export async function bundleAuditCommand(
430
430
  let target = positional || flags.url;
431
431
  if (!target) {
432
432
  try {
433
- target = (await resolveConfig(flags.stage)).baseUrl;
433
+ target = probeUrl(await resolveConfig(flags.stage));
434
434
  } catch (err: any) {
435
435
  fail(err.message);
436
436
  info('Pass a URL positionally or use --stage <name>.');
@@ -9,7 +9,7 @@
9
9
  * Writes directly to CloudFront KVS — no Lambda invoke.
10
10
  */
11
11
 
12
- import { resolveConfig } from '../config';
12
+ import { resolveConfig, probeUrl } from '../config';
13
13
  import { putKvsKey, createInvalidation, getDistributionStatus, getFromS3 } from '../aws';
14
14
  import { step, success, warn, fail, info } from '../output';
15
15
 
@@ -37,7 +37,7 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
37
37
  // Safety gate: verify the origin is serving the expected release and the distribution is stable.
38
38
  if (!force) {
39
39
  const channel = flags.channel || flags.stage || 'production';
40
- const blocked = await preflight(config.region, config.baseUrl, config.updatesBucket, channel, config.distributionId);
40
+ const blocked = await preflight(config.region, probeUrl(config), config.updatesBucket, channel, config.distributionId);
41
41
  if (blocked) {
42
42
  fail(blocked);
43
43
  info('Pass --force to override this check.');
@@ -111,7 +111,7 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
111
111
  */
112
112
  export async function preflight(
113
113
  region: string,
114
- baseUrl: string,
114
+ siteUrl: string,
115
115
  updatesBucket: string,
116
116
  channel: string,
117
117
  distributionId?: string,
@@ -134,19 +134,26 @@ export async function preflight(
134
134
  }
135
135
 
136
136
  // 2. Probe the origin for the serving release.
137
+ if (!siteUrl) {
138
+ return (
139
+ 'No site URL for this stage, so the origin cannot be probed. ' +
140
+ 'Set CDN_URL on the API function and redeploy, or tag the distribution sst:app/sst:stage; ' +
141
+ 'then re-run with --refresh to discover the stage again. Or pass --force to skip this check.'
142
+ );
143
+ }
137
144
  try {
138
- const probe = `${baseUrl}?_probe=${Date.now()}`;
145
+ const probe = `${siteUrl}?_probe=${Date.now()}`;
139
146
  const res = await fetch(probe, { redirect: 'follow' });
140
147
  const serving = res.headers.get('x-everystack-release');
141
148
  if (serving !== expectedUpdateId) {
142
149
  return (
143
150
  `Origin is serving release ${serving ? serving.slice(0, 12) + '...' : '(none)'} ` +
144
- `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... ` +
151
+ `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... Probed ${siteUrl}. ` +
145
152
  `Purging now would expose stale containers to uncached traffic.`
146
153
  );
147
154
  }
148
155
  } catch (err: any) {
149
- return `Could not reach origin at ${baseUrl}: ${err.message}`;
156
+ return `Could not reach origin at ${siteUrl}: ${err.message}`;
150
157
  }
151
158
 
152
159
  // 3. Check distribution status (if discoverable).
@@ -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 neither success nor a known refusal
155
- // stops this compiling — the fallthrough that let `authz-refused` print nothing and exit 0
156
- // cannot come back. The message is an action; the internal cause code stays in schema_log.
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
  }
@@ -262,9 +315,12 @@ async function catchUpAfterApply(
262
315
  ): Promise<CatchUpReport> {
263
316
  if (!LANDED.has(result.status)) return { ok: true, statements: [] };
264
317
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
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 ?? []);
265
321
  const applied = result.status === 'already-applied'
266
322
  ? 0
267
- : classifyGeneratedStatements(plan.statements ?? []).executable.length;
323
+ : classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
268
324
  return runCatchUp(venue, flags, applied, 'throw');
269
325
  }
270
326
 
@@ -299,6 +355,27 @@ async function declaredGranteesForReceipt(
299
355
  }
300
356
  }
301
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
+
302
379
  /**
303
380
  * Print a failed catch-up and exit: the report line, then the cause as it was written.
304
381
  *
@@ -563,10 +640,12 @@ async function applyPlanViaStage(
563
640
  const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags);
564
641
  exitOnCatchUpFailure(catchUp);
565
642
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
643
+ const advisoryModels = await modelsForAdvisory(landedResult!, flags);
566
644
  reportApplyResult(landedResult!, plan, {
567
645
  verbose: flags.verbose === 'true',
568
646
  catchUp: catchUp.statements,
569
647
  ...(declaredGrantees ? { declaredGrantees } : {}),
648
+ ...(advisoryModels ? { models: advisoryModels } : {}),
570
649
  });
571
650
  }
572
651
 
@@ -1065,9 +1144,11 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1065
1144
 
1066
1145
  exitOnCatchUpFailure(catchUp);
1067
1146
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
1147
+ const advisoryModels = await modelsForAdvisory(landed!, flags);
1068
1148
  reportApplyResult(landed!, plan, {
1069
1149
  verbose: flags.verbose === 'true',
1070
1150
  catchUp: catchUp.statements,
1071
1151
  ...(declaredGrantees ? { declaredGrantees } : {}),
1152
+ ...(advisoryModels ? { models: advisoryModels } : {}),
1072
1153
  });
1073
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.
@@ -26,6 +26,8 @@ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): st
26
26
  ' - If your DB is RDS: add `databaseInstanceId: database.id` to the outputs return block in\n' +
27
27
  ' sst.config.ts and redeploy (run db:snapshot from the app directory so .sst/outputs.json is\n' +
28
28
  ' readable), or pass --instance <id>.\n' +
29
+ ' - .sst/outputs.json is used only when it is THIS stage\'s deploy (its apiFunctionName names the\n' +
30
+ ' stage). If the last `sst deploy` was another stage, redeploy this one, or pass --instance <id>.\n' +
29
31
  ' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
30
32
  );
31
33
  process.exit(1);