@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.
@@ -187,6 +187,12 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
187
187
  * disagree. Single-column FKs come from each field's `.references()`; composite FKs come from
188
188
  * the table-level `foreignKey([...], () => Other, [...])` constraint. Both name themselves the
189
189
  * way drizzle does — `<table>_<cols>_<target>_<refcols>_fk` — so a brownfield FK round-trips.
190
+ *
191
+ * `validate: false` on either surface compiles to `notValid: true`. That is the DECLARED half of
192
+ * a field introspection also sets (see {@link ForeignKey.notValid}): the author is saying this
193
+ * key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
194
+ * for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
195
+ * the state a build of that declaration produces would be two different states.
190
196
  */
191
197
  export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
192
198
  const out: ForeignKey[] = [];
@@ -207,6 +213,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
207
213
  refTable: refTableName(target),
208
214
  refColumns: [targetPk],
209
215
  ...refActions(field.spec),
216
+ ...(field.spec.referencesValidate === false ? { notValid: true } : {}),
210
217
  });
211
218
  }
212
219
 
@@ -222,6 +229,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
222
229
  refTable: refTableName(target),
223
230
  refColumns: refCols,
224
231
  ...refActions(con),
232
+ ...(con.validate === false ? { notValid: true } : {}),
225
233
  });
226
234
  }
227
235
 
@@ -614,6 +622,10 @@ export { sequenceCreateSql } from './schema-diff';
614
622
  * are emitted *after* every `CREATE TABLE` (a FK's target table must already exist), so they
615
623
  * are separate from the table DDL — which is why FKs are not rendered inline by
616
624
  * compileCreateTable. Named the way drizzle names them: `<table>_<cols>_<target>_<refcols>_fk`.
625
+ *
626
+ * A key declared `validate: false` is rendered `… NOT VALID` here too. The declared state IS
627
+ * unproven-against-old-rows, so a migration artifact that installed it validated would build a
628
+ * database that does not match the models it was generated from.
617
629
  */
618
630
  export function foreignKeySql(model: ModelDescriptor): string[] {
619
631
  return modelForeignKeys(model).map((fk) => {
@@ -624,7 +636,8 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
624
636
  // stays bare-but-quoted (`"uploads"`), preserving the proven output.
625
637
  return (
626
638
  `ALTER TABLE ${qualifiedTable(model)} ADD CONSTRAINT "${fk.name}" ` +
627
- `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)};`
639
+ `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)}`
640
+ + `${fk.notValid ? ' NOT VALID' : ''};`
628
641
  );
629
642
  });
630
643
  }
@@ -29,6 +29,7 @@ import type {
29
29
  TableSchema,
30
30
  ColumnSchema,
31
31
  EnumType,
32
+ ForeignKey,
32
33
  IndexSchema,
33
34
  SequenceSchema,
34
35
  } from './schema-introspect';
@@ -42,9 +43,16 @@ export type ConstraintSpec =
42
43
  | { type: 'unique'; name: string; columns: string[] }
43
44
  | { type: 'check'; name: string; expr: string }
44
45
  | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean;
45
- /** LIVE-SIDE ONLY: the key is installed but unchecked (see {@link ForeignKey.notValid}).
46
- * It is never part of {@link constraintContentKey} — a NOT VALID key IS the declared key,
47
- * one step short — and the emitter ignores it, because an ADD is always emitted NOT VALID. */
46
+ /**
47
+ * The key is installed but unchecked — on the LIVE side an observation, on the DESIRED
48
+ * side the author's `validate: false` (see {@link ForeignKey.notValid}).
49
+ *
50
+ * It is never part of {@link constraintContentKey}: a NOT VALID key IS the declared key,
51
+ * one step short, so validity can never make a key match a DIFFERENT key. What it decides
52
+ * is the SECOND statement — a desired `notValid` key emits `ADD … NOT VALID` alone, and a
53
+ * live `notValid` key is finished with a `VALIDATE` only when the desired side asked for
54
+ * one.
55
+ */
48
56
  notValid?: boolean };
49
57
 
50
58
  export type SchemaChange =
@@ -512,7 +520,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
512
520
  addConstraints.push({
513
521
  kind: 'addConstraint',
514
522
  table: d.table,
515
- constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate },
523
+ constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.notValid ? { notValid: true } : {}) },
516
524
  });
517
525
  }
518
526
  for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
@@ -817,10 +825,25 @@ function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
817
825
  */
818
826
  function constraintContentKey(c: ConstraintSpec): string {
819
827
  if (c.type === 'unique') return `u|${c.columns.join(',')}`;
820
- if (c.type === 'foreignKey') return `f|${c.columns.join(',')}|${c.refTable}|${c.refColumns.join(',')}|${c.onDelete ?? ''}|${c.onUpdate ?? ''}`;
828
+ if (c.type === 'foreignKey') return foreignKeyContentKey(c);
821
829
  return `c|${normalizeCheck(c.expr)}`; // checks take their own predicate-matched path (diffChecks)
822
830
  }
823
831
 
832
+ /**
833
+ * A foreign key's content identity — columns, target, target columns, referential actions.
834
+ * VALIDITY IS NOT IN IT (see {@link ConstraintSpec}), and neither is the name.
835
+ *
836
+ * Exported because a second surface asks the same question: the plan's predicted endpoint has to
837
+ * find the live counterpart of a declared key to carry its validity through. Two spellings of
838
+ * "are these the same key" is how the differ and the prediction end up disagreeing about whether
839
+ * an edge changed anything.
840
+ */
841
+ export function foreignKeyContentKey(
842
+ fk: Pick<ForeignKey, 'columns' | 'refTable' | 'refColumns' | 'onDelete' | 'onUpdate'>,
843
+ ): string {
844
+ return `f|${fk.columns.join(',')}|${fk.refTable}|${fk.refColumns.join(',')}|${fk.onDelete ?? ''}|${fk.onUpdate ?? ''}`;
845
+ }
846
+
824
847
  /**
825
848
  * Diff CHECK constraints by their normalized predicate, not their name: a check with the
826
849
  * same predicate is the same check no matter what it's called, so a pulled check (whose DB
@@ -871,13 +894,22 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
871
894
  renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
872
895
  }
873
896
  // The live key is there but was never checked against the rows already in the table —
874
- // the state an interrupted two-step add leaves behind, and the state a hand-run
875
- // `ADD CONSTRAINT … NOT VALID` leaves behind. The declared side is a VALIDATED key, so
876
- // this is a real difference and the edge finishes it: VALIDATE alone, never a second
897
+ // what an interrupted two-step add leaves behind, and what a hand-run
898
+ // `ADD CONSTRAINT … NOT VALID` leaves behind. When the declared side is a VALIDATED key
899
+ // that is a real difference and the edge finishes it: VALIDATE alone, never a second
877
900
  // ADD (which would fail on the name that is already taken). Under the live name, which
878
901
  // may not be the declared one — a rename emitted just above lands FIRST, so the
879
902
  // validate must speak the name the constraint will have by then.
880
- if (matched.type === 'foreignKey' && matched.notValid) {
903
+ //
904
+ // …UNLESS the declared side ASKED for NOT VALID (`validate: false`). Then live and
905
+ // declared agree and there is nothing to emit: validating would be the edge doing the
906
+ // one thing the author declared it must never do. Note the direction — a live key that
907
+ // is fully VALID under a `validate: false` declaration also falls through here with no
908
+ // statement, because `matched.notValid` is absent. That is the accept-either rule: there
909
+ // is no `ALTER TABLE … INVALIDATE CONSTRAINT`, so a key proven MORE than the model asked
910
+ // for is not a difference anyone could act on.
911
+ const desiredNotValid = des.type === 'foreignKey' && des.notValid === true;
912
+ if (matched.type === 'foreignKey' && matched.notValid && !desiredNotValid) {
881
913
  const name = des.type === 'foreignKey' && des.nameDeclared ? des.name : matched.name;
882
914
  addConstraints.push({ kind: 'validateConstraint', table: d.table, name });
883
915
  }
@@ -985,6 +1017,10 @@ export function validateConstraintSql(table: string, name: string): string {
985
1017
  * commit), which is why the apply path runs every VALIDATE after the edge's batch commits — see
986
1018
  * `splitValidations` in state-apply. The text is produced HERE for both `db:diff` (no database)
987
1019
  * and `db:plan` (against a live one), so the two can never render the same edge differently.
1020
+ *
1021
+ * ONE statement again for a key the model declared `validate: false`: step 1 alone IS the whole
1022
+ * declared state there, so step 2 is not deferred, it does not exist. See
1023
+ * `FieldSpec.referencesValidate` for why a schema needs to be able to say that.
988
1024
  */
989
1025
  function addConstraintSql(table: string, c: ConstraintSpec): string[] {
990
1026
  const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
@@ -998,7 +1034,8 @@ function addConstraintSql(table: string, c: ConstraintSpec): string[] {
998
1034
  let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
999
1035
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
1000
1036
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
1001
- return [`${s} NOT VALID;`, validateConstraintSql(table, c.name)];
1037
+ const add = `${s} NOT VALID;`;
1038
+ return c.notValid === true ? [add] : [add, validateConstraintSql(table, c.name)];
1002
1039
  }
1003
1040
  }
1004
1041
  }
@@ -155,6 +155,27 @@ import { fingerprintV12 } from './schema-fingerprint-v12';
155
155
  // re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The
156
156
  // v12 canon is frozen in schema-fingerprint-v12 and registered in RETAINED_CANONS so a schema_log
157
157
  // row recorded under it still descends.
158
+ //
159
+ // NO v14 FOR `validate: false` — worked out here so the next reader does not ask again.
160
+ // The Model can now DECLARE a foreign key permanently NOT VALID (`references(…, { validate:
161
+ // false })` → `ForeignKey.notValid` on the COMPILED side, which v13 said only introspection ever
162
+ // set). A bump is required when the CANONICAL FORM changes — when the same database would hash
163
+ // differently before and after. Neither half of that happens here:
164
+ //
165
+ // 1. The form is byte-identical. `canonicalTable` below already reads `f.notValid` and already
166
+ // writes it only when true. No key is added, removed, or normalized differently; the shape
167
+ // that hashes a live snapshot is untouched.
168
+ // 2. Every hash anyone RECORDED is a live hash. `RETAINED_CANONS`/`fingerprintLiveAtVersion`
169
+ // recompute from a SchemaSnapshot read out of the database, and this feature does not change
170
+ // what introspection reports about any existing key. So every schema_log row still descends.
171
+ //
172
+ // What moves is the DECLARED hash of a model that newly writes `validate: false` — which is a
173
+ // state change the author made on purpose, exactly like adding a column. That is the format
174
+ // working, not the format changing. (Deliberately kept in step: the from-scratch build emits
175
+ // `ADD … NOT VALID` for such a key too — schema-diff's `addConstraintSql` and schema-compile's
176
+ // `foreignKeySql` — so building a database FROM that declaration lands on the declared hash
177
+ // rather than one step past it. Without that half the declared side would hash a state no build
178
+ // of it could produce, and `db:check` could never reach MATCH.)
158
179
  export const FINGERPRINT_VERSION = 13;
159
180
 
160
181
  // ---------------------------------------------------------------------------
@@ -198,6 +219,11 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
198
219
  // hashed identically to a validated one, so `db:fingerprint` reported MATCH on a database
199
220
  // whose data no constraint had ever been proved against. Written only when true, so every
200
221
  // fully-valid database keeps the hash it had.
222
+ //
223
+ // The flag now arrives from EITHER side — introspection's observation, or a Model's
224
+ // `validate: false` (see the FINGERPRINT_VERSION note above for why that needed no bump).
225
+ // The canonical form does not care which: one bit, one meaning — this key has never been
226
+ // checked against the rows that were already there.
201
227
  foreignKeys: byKey(
202
228
  table.foreignKeys.map((f) => ({
203
229
  columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
@@ -53,14 +53,27 @@ export interface ForeignKey {
53
53
  onDelete?: string;
54
54
  onUpdate?: string;
55
55
  /**
56
- * The constraint exists but has NOT been checked against the rows already in the table
57
- * (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
56
+ * The key is enforced on every new write but has NOT been checked against the rows already
57
+ * in the table (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
58
58
  *
59
- * LIVE-SIDE ONLY. A Model declares a foreign key, never a half-installed one, so the
60
- * compiled side never sets this — which is exactly what makes it visible as drift: a live
61
- * NOT VALID key is a DIFFERENT state from the declared (validated) one. It rides the
62
- * canonical fingerprint form for the same reason, so a half-applied key can never report
63
- * MATCH. Omitted (not `false`) when the constraint is validated, so every already-recorded
59
+ * BOTH SIDES SET THIS, and they mean two different things by it:
60
+ *
61
+ * LIVE-OBSERVED — introspection read `convalidated = false`. It says nothing about
62
+ * WHY: an interrupted two-step add, a hand-run `ADD … NOT VALID`, or
63
+ * a key someone deliberately left unproven years ago.
64
+ * DECLARED-INTENTIONAL — the Model said `validate: false` on the reference, which is the
65
+ * author declaring "enforce forward, grandfather legacy" as the
66
+ * PERMANENT state of this key (see `FieldSpec.referencesValidate`).
67
+ * The differ then emits `ADD CONSTRAINT … NOT VALID` and NEVER the
68
+ * paired `VALIDATE`.
69
+ *
70
+ * HOW THE TWO MEET. A declared-intentional key is satisfied by EITHER a live NOT VALID key
71
+ * or a live FULLY-VALID one: PostgreSQL has no "un-validate", so "more proven than you asked
72
+ * for" is not a difference anybody can act on and must not read as drift. The asymmetry only
73
+ * runs the other way — a live NOT VALID key under a declaration that did NOT say
74
+ * `validate: false` IS a real difference, and the edge closes it with a `VALIDATE` alone.
75
+ *
76
+ * Omitted (not `false`) when the key is validated, on both sides, so every already-recorded
64
77
  * hash for a fully-valid database is untouched.
65
78
  */
66
79
  notValid?: boolean;
@@ -248,18 +248,30 @@ export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}):
248
248
 
249
249
  if (broad.length > 0) {
250
250
  const who = broad.join(', ');
251
+ // The fix is PER GRANTEE, and the grantees are PARTITIONED — never collapsed into one line.
252
+ //
253
+ // `db:provision --confirm` revokes exactly ONE thing: PUBLIC's CREATE on schema `public`
254
+ // (reviewably — it reports the gap without the flag). Every other (grantee, schema) pair is hand
255
+ // SQL. So the pointer stands in for PUBLIC's own revoke and for nothing else: a schema where BOTH
256
+ // PUBLIC and `authenticated` hold CREATE must still name the second revoke, or an operator runs
257
+ // the one command offered, re-audits, and is still writable with nothing saying why.
258
+ const isPublicPseudoRole = (g: string) => key === 'public' && g.toLowerCase() === 'public';
259
+ const fixes = broad.map((g) => (isPublicPseudoRole(g)
260
+ ? 'REVOKE CREATE ON SCHEMA public FROM PUBLIC — run `everystack db:provision --stage <stage> --confirm`, which does exactly this revoke and verifies it'
261
+ : `REVOKE CREATE ON SCHEMA ${key} FROM ${g}`));
262
+ const fix = `${fixes.join('. ')}.`;
251
263
  if (schema.unpinnedDefinerFunctions > 0) {
252
264
  reasons.push(
253
265
  `${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
254
266
  + `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
255
- + `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
267
+ + `${fix.replace(/\.$/, '')}, and pin those functions.`,
256
268
  );
257
269
  severity = 'red';
258
270
  } else {
259
271
  reasons.push(
260
272
  `${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
261
273
  + `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
262
- + `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
274
+ + `running DDL. ${fix}`,
263
275
  );
264
276
  severity = raise(severity, 'warn');
265
277
  }
@@ -393,14 +393,31 @@ export interface StateApplyOptions {
393
393
  * run — with none there is no window between the commit and the record to protect.
394
394
  */
395
395
  readFingerprint?: () => Promise<string>;
396
+ /**
397
+ * `VALIDATE CONSTRAINT`s the caller REMOVED from `statements` before calling, because the row
398
+ * check it ran first already counted the offending rows (see fk-preflight's `withheldValidations`).
399
+ *
400
+ * They are pending validations that were never attempted, and they are recorded and reported
401
+ * exactly like ones that ran and failed — the key is installed, enforced forward and unproven
402
+ * either way, so giving the operator two vocabularies for one situation would only make them
403
+ * work out that it is the same situation. Running them instead would take a lock and a full
404
+ * scan to be told what is already known.
405
+ */
406
+ skippedValidations?: readonly PendingValidation[];
396
407
  }
397
408
 
398
409
  /** A `VALIDATE CONSTRAINT` that did not pass — the key is installed but still unproven. */
399
410
  export interface PendingValidation {
400
411
  /** The exact statement to re-run once the data satisfies the key. */
401
412
  sql: string;
402
- /** PostgreSQL's message, trimmed for the record. */
413
+ /** PostgreSQL's message, trimmed for the record — or why the check was never attempted. */
403
414
  error: string;
415
+ /**
416
+ * The check was WITHHELD, not run: the row preflight had already counted the offending rows.
417
+ * Set only on that path, so `validationPendingReason` can name the data fix without having to
418
+ * pattern-match an error string it wrote itself.
419
+ */
420
+ skipped?: boolean;
404
421
  }
405
422
 
406
423
  export interface StateApplyResult {
@@ -431,14 +448,76 @@ export interface StateApplyResult {
431
448
  * So the row is written as soon as the batch has committed, describing exactly what is true then
432
449
  * (the key installed, none of the checks run yet), and amended when the checks are over.
433
450
  */
451
+ /** The subset of StateApplyOptions the schema_log memoir writes need — nothing execution-only. */
452
+ type SchemaLogFields = Pick<StateApplyOptions, 'actor' | 'gitRef' | 'planRef' | 'fromFingerprint' | 'fpVersion' | 'dirty'>;
453
+
454
+ /**
455
+ * One row per unproven key, carrying the exact statement to re-run. Best-effort, like every
456
+ * other memoir write: failing to record why a key is pending must not mask that it is. Shared by
457
+ * both the normal path (checks run here) and the all-withheld early return (no checks run here,
458
+ * but the withheld ones are just as real a pending state and owe the same rows).
459
+ */
460
+ async function recordPendingValidations(
461
+ runner: QueryRunner,
462
+ pendingValidations: readonly PendingValidation[],
463
+ options: SchemaLogFields,
464
+ ): Promise<void> {
465
+ for (const pending of pendingValidations) {
466
+ try {
467
+ await runner(renderSchemaLogInsert({
468
+ kind: 'state validate', sql: pending.sql,
469
+ outcome: `pending: ${pending.error}`,
470
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
471
+ fromFingerprint: options.fromFingerprint,
472
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
473
+ }));
474
+ } catch { /* the memoir is best-effort; the row already says what committed */ }
475
+ }
476
+ }
477
+
434
478
  export async function applyGeneratedStatements(
435
479
  runner: QueryRunner,
436
480
  statements: string[],
437
481
  options: StateApplyOptions = {},
438
482
  ): Promise<StateApplyResult> {
439
483
  const classified = classifyGeneratedStatements(statements);
484
+ // Checks the caller withheld before calling. They are outstanding from the first line of this
485
+ // function: the outcome written before the batch already counts them, and they come back in the
486
+ // result whether or not any check actually ran here.
487
+ const skipped: PendingValidation[] = [...(options.skippedValidations ?? [])];
440
488
  if (classified.executable.length === 0) {
441
- return { applied: false, classified, batchSql: '', pendingValidations: [] };
489
+ // No DDL to run — but a re-plan against keys a PREVIOUS apply already withheld still owes
490
+ // the audit trail a row: the promise (docs/cli.md) is every apply lands in schema_log, and
491
+ // an apply whose only content is "these keys are still unproven" is a real apply, not a
492
+ // no-op. Without this, re-running db:apply/db:plan against nothing but a withheld VALIDATE
493
+ // produces a `validation-pending` result the operator sees and a database that remembers
494
+ // nothing about it.
495
+ if (skipped.length > 0) {
496
+ const now = options.now ?? Date.now;
497
+ const started = now();
498
+ let logId: number | undefined;
499
+ // Same annotation the normal path folds in (a honoured lineage waiver, a proven drift
500
+ // origin) — an all-withheld apply is still the row a LATER apply's fast-forward proof
501
+ // reads, so it must carry the same note or that proof silently loses the annotation.
502
+ const note = options.descentNote?.();
503
+ const outcome = ['applied', note, `no schema change; ${skipped.length} constraint(s) still PENDING validation`]
504
+ .filter(Boolean).join(' — ');
505
+ try {
506
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
507
+ const logRows = await runner(renderSchemaLogInsert({
508
+ kind: 'state apply', sql: '',
509
+ outcome,
510
+ actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
511
+ fromFingerprint: options.fromFingerprint,
512
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
513
+ durationMs: now() - started,
514
+ }));
515
+ logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
516
+ } catch { /* the memoir is best-effort — the caller's own result still names the debt */ }
517
+ await recordPendingValidations(runner, skipped, options);
518
+ return { applied: false, classified, batchSql: '', pendingValidations: skipped, ...(logId !== undefined ? { logId } : {}) };
519
+ }
520
+ return { applied: false, classified, batchSql: '', pendingValidations: skipped };
442
521
  }
443
522
 
444
523
  const now = options.now ?? Date.now;
@@ -499,7 +578,7 @@ export async function applyGeneratedStatements(
499
578
  // outcome says exactly that — this row is true at the instant it is written, not a prediction
500
579
  // of how the checks will go.
501
580
  const logRows = await runner(renderSchemaLogInsert({
502
- kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length),
581
+ kind: 'state apply', sql: batchSql, outcome: outcomeWith(validations.length + skipped.length),
503
582
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
504
583
  fromFingerprint: options.fromFingerprint,
505
584
  ...(landedAfterBatch ? { toFingerprint: landedAfterBatch } : {}),
@@ -511,7 +590,7 @@ export async function applyGeneratedStatements(
511
590
  // Every check is attempted, even after one fails: the operator is owed the COMPLETE list of
512
591
  // keys still unproven, not the first one. Each runs on its own, so one failure cannot take a
513
592
  // sibling's successful validation down with it.
514
- const pendingValidations: PendingValidation[] = [];
593
+ const pendingValidations: PendingValidation[] = [...skipped];
515
594
  for (const statement of validations) {
516
595
  try {
517
596
  await runner(statement);
@@ -533,19 +612,7 @@ export async function applyGeneratedStatements(
533
612
  } catch { /* the memoir is best-effort; the row already says what committed */ }
534
613
  }
535
614
 
536
- // One row per unproven key, carrying the exact statement to re-run. Best-effort, like every
537
- // other memoir write: failing to record why a key is pending must not mask that it is.
538
- for (const pending of pendingValidations) {
539
- try {
540
- await runner(renderSchemaLogInsert({
541
- kind: 'state validate', sql: pending.sql,
542
- outcome: `pending: ${pending.error}`,
543
- actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
544
- fromFingerprint: options.fromFingerprint,
545
- fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
546
- }));
547
- } catch { /* see above */ }
548
- }
615
+ await recordPendingValidations(runner, pendingValidations, options);
549
616
 
550
617
  return { applied: true, classified, batchSql, pendingValidations, ...(logId !== undefined ? { logId } : {}) };
551
618
  }
@@ -113,7 +113,8 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
113
113
  mode: 'refuse',
114
114
  reason: 'A physical snapshot needs the RDS instance id, and this stage does not expose one. '
115
115
  + 'Add `databaseInstanceId: database.id` to the outputs return block in sst.config.ts and redeploy '
116
- + '(run db:swap from the app directory so .sst/outputs.json is readable), or pass --snapshot logical '
116
+ + '(run db:swap from the app directory so .sst/outputs.json is readable; it counts only when the last '
117
+ + '`sst deploy` was THIS stage, otherwise redeploy this stage), or pass --snapshot logical '
117
118
  + 'for a pg_dump rollback point instead.',
118
119
  };
119
120
  }