2121 *
2222 * A baseline entry is matched on its key AND its onDelete action AND its ordered fkColumns, so
2323 * changing a relation's FK column or flipping Cascade/SetNull re-triggers the guard rather than
24- * silently inheriting the old acceptance.
24+ * silently inheriting the old acceptance. `--check` also fails on STALE baseline entries whose
25+ * fingerprint no longer appears in the schema (the FK got indexed or removed), so the baseline
26+ * can't rot: a fixed entry must be pruned by regenerating, otherwise a later change that removes
27+ * the index would be silently re-accepted by the leftover entry.
2528 *
2629 * Modes (mirrors guard:runops-legacy):
2730 * tsx ./scripts/fkCascadeIndexGuard.ts # regenerate the baseline
@@ -205,7 +208,9 @@ function serializeBaseline(comment: string, violations: Violation[]): string {
205208 return lines . join ( "\n" ) + "\n" ;
206209}
207210
208- function loadBaselineFingerprints ( ) : Set < string > {
211+ type BaselineEntry = { key : string ; onDelete : string ; fkColumns : string [ ] } ;
212+
213+ function loadBaseline ( ) : BaselineEntry [ ] {
209214 let parsed : unknown ;
210215 try {
211216 parsed = JSON . parse ( fs . readFileSync ( BASELINE_PATH , "utf8" ) ) ;
@@ -218,7 +223,7 @@ function loadBaselineFingerprints(): Set<string> {
218223 console . error ( `baseline is missing a "violations" array: ${ BASELINE_PATH } .` ) ;
219224 process . exit ( 2 ) ;
220225 }
221- const fps = new Set < string > ( ) ;
226+ const entries : BaselineEntry [ ] = [ ] ;
222227 for ( const v of violations ) {
223228 const entry = v as { key ?: unknown ; onDelete ?: unknown ; fkColumns ?: unknown } ;
224229 if (
@@ -231,9 +236,13 @@ function loadBaselineFingerprints(): Set<string> {
231236 console . error ( `baseline has a malformed entry: ${ JSON . stringify ( v ) } ` ) ;
232237 process . exit ( 2 ) ;
233238 }
234- fps . add ( fingerprint ( entry . key , entry . onDelete , entry . fkColumns as string [ ] ) ) ;
239+ entries . push ( {
240+ key : entry . key ,
241+ onDelete : entry . onDelete ,
242+ fkColumns : entry . fkColumns as string [ ] ,
243+ } ) ;
235244 }
236- return fps ;
245+ return entries ;
237246}
238247
239248function main ( ) {
@@ -264,30 +273,55 @@ function main() {
264273 console . error ( `baseline missing: ${ BASELINE_PATH } . Run without --check to generate it.` ) ;
265274 process . exit ( 2 ) ;
266275 }
267- const baselined = loadBaselineFingerprints ( ) ;
268- const fresh = all . filter ( ( v ) => ! baselined . has ( fingerprint ( v . key , v . onDelete , v . fkColumns ) ) ) ;
276+ const baselineEntries = loadBaseline ( ) ;
277+ const baselinedFps = new Set (
278+ baselineEntries . map ( ( e ) => fingerprint ( e . key , e . onDelete , e . fkColumns ) )
279+ ) ;
280+ const currentFps = new Set ( all . map ( ( v ) => fingerprint ( v . key , v . onDelete , v . fkColumns ) ) ) ;
269281
270- if ( fresh . length === 0 ) {
271- console . log ( `fk-cascade-index guard: OK (${ baselined . size } baselined, 0 new).` ) ;
282+ const fresh = all . filter ( ( v ) => ! baselinedFps . has ( fingerprint ( v . key , v . onDelete , v . fkColumns ) ) ) ;
283+ const stale = baselineEntries . filter (
284+ ( e ) => ! currentFps . has ( fingerprint ( e . key , e . onDelete , e . fkColumns ) )
285+ ) ;
286+
287+ if ( fresh . length === 0 && stale . length === 0 ) {
288+ console . log ( `fk-cascade-index guard: OK (${ baselinedFps . size } baselined, 0 new, 0 stale).` ) ;
272289 return ;
273290 }
274291
275- console . error (
276- `\nfk-cascade-index guard: ${ fresh . length } new unindexed cascade FK column(s).\n` +
277- `Each fires a full sequential scan of the child table on every parent delete.\n`
278- ) ;
279- for ( const v of fresh ) {
292+ if ( fresh . length > 0 ) {
280293 console . error (
281- ` ${ v . schema } : ${ v . model } .${ v . relationField } ` +
282- `(onDelete: ${ v . onDelete } , fk: [${ v . fkColumns . join ( ", " ) } ])`
294+ `\nfk-cascade-index guard: ${ fresh . length } new unindexed cascade FK column(s).\n` +
295+ `Each fires a full sequential scan of the child table on every parent delete.\n`
296+ ) ;
297+ for ( const v of fresh ) {
298+ console . error (
299+ ` ${ v . schema } : ${ v . model } .${ v . relationField } ` +
300+ `(onDelete: ${ v . onDelete } , fk: [${ v . fkColumns . join ( ", " ) } ])`
301+ ) ;
302+ }
303+ console . error (
304+ `\nFix: add @@index([${ fresh [ 0 ] . fkColumns [ 0 ] } ]) (or a composite leading with it) to the ` +
305+ `child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` +
306+ `If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` +
307+ `and explain why in the PR.\n`
283308 ) ;
284309 }
285- console . error (
286- `\nFix: add @@index([${ fresh [ 0 ] . fkColumns [ 0 ] } ]) (or a composite leading with it) to the ` +
287- `child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` +
288- `If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` +
289- `and explain why in the PR.\n`
290- ) ;
310+
311+ if ( stale . length > 0 ) {
312+ console . error (
313+ `\nfk-cascade-index guard: ${ stale . length } stale baseline entr${ stale . length === 1 ? "y" : "ies" } ` +
314+ `no longer present in the schema (now indexed or removed):\n`
315+ ) ;
316+ for ( const e of stale ) {
317+ console . error ( ` ${ e . key } (onDelete: ${ e . onDelete } , fk: [${ e . fkColumns . join ( ", " ) } ])` ) ;
318+ }
319+ console . error (
320+ `\nRegenerate the baseline so a later change can't silently re-accept these:\n` +
321+ ` pnpm --filter webapp run guard:fk-cascade-index\n`
322+ ) ;
323+ }
324+
291325 process . exit ( 1 ) ;
292326}
293327
0 commit comments