@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 +23 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +80 -19
- package/src/cli/apply-refusals.ts +47 -9
- package/src/cli/commands/audit.ts +2 -2
- package/src/cli/commands/bundle.ts +2 -2
- package/src/cli/commands/cache.ts +13 -6
- package/src/cli/commands/db-apply.ts +85 -4
- package/src/cli/commands/db-plan.ts +29 -0
- package/src/cli/commands/db-snapshot.ts +2 -0
- package/src/cli/commands/db.ts +22 -1
- package/src/cli/commands/diag.ts +4 -4
- package/src/cli/commands/lighthouse.ts +2 -2
- package/src/cli/commands/security-probe.ts +2 -2
- package/src/cli/commands/update.ts +17 -3
- package/src/cli/config.ts +30 -2
- package/src/cli/discover-distribution.ts +58 -22
- package/src/cli/discover.ts +48 -6
- package/src/cli/edge-plan.ts +55 -13
- package/src/cli/fk-orphan-advisory.ts +172 -0
- package/src/cli/fk-preflight.ts +126 -43
- package/src/cli/host-url.ts +127 -0
- package/src/cli/index.ts +1 -41
- package/src/cli/model-render.ts +10 -0
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +47 -10
- package/src/cli/schema-fingerprint.ts +26 -0
- package/src/cli/schema-introspect.ts +20 -7
- package/src/cli/security-audit.ts +14 -2
- package/src/cli/state-apply.ts +84 -17
- package/src/cli/swap-snapshot.ts +2 -1
|
@@ -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
|
}
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
-
//
|
|
875
|
-
// `ADD CONSTRAINT … NOT VALID` leaves behind.
|
|
876
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
+
|
|
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.
|
|
274
|
+
+ `running DDL. ${fix}`,
|
|
263
275
|
);
|
|
264
276
|
severity = raise(severity, 'warn');
|
|
265
277
|
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/swap-snapshot.ts
CHANGED
|
@@ -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
|
|
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
|
}
|