From 35a9b53d7cf3fec35468ea47b5b6b97448cb3503 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 21 Feb 2026 09:05:40 -0800 Subject: [PATCH 1/2] fix: add ON DELETE cascade/set-null rules to FK relationships Most FK relationships defaulted to NO ACTION, blocking legitimate operations like user deletion. Added appropriate cascade rules. Co-authored-by: Cursor --- shared/database/src/schema.ts | 30 ++-- .../unit/database/schema.fk-cascades.test.ts | 134 ++++++++++++++++++ 2 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 tests/unit/database/schema.fk-cascades.test.ts diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 7a9bfc5..f986831 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -175,9 +175,9 @@ export const schedule = wxyc_schema.table('schedule', { day: smallint('day').notNull(), start_time: time('start_time').notNull(), show_duration: smallint('show_duration').notNull(), // In 15-minute blocs - specialty_id: integer('specialty_id').references(() => specialty_shows.id), //null for regular shows - assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id), - assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id), + specialty_id: integer('specialty_id').references(() => specialty_shows.id, { onDelete: 'set null' }), //null for regular shows + assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), + assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), }); //SELECT date_trunc('week', current_timestamp + timestamp '${n} weeks') + interval '${schedule.day} days' + ${schedule.time} @@ -190,7 +190,7 @@ export const shift_covers = wxyc_schema.table('shift_covers', { .references(() => schedule.id) .notNull(), shift_timestamp: timestamp('shift_timestamp').notNull(), //Timestamp to expire cover requests - cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id), + cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), covered: boolean('covered').default(false), }); @@ -279,7 +279,7 @@ export const rotation = wxyc_schema.table( { id: serial('id').primaryKey(), //need to create an entry w/ id 0 for items not currently on rotation and items from outside the station album_id: integer('album_id') - .references(() => library.id) + .references(() => library.id, { onDelete: 'cascade' }) .notNull(), rotation_bin: freqEnum('rotation_bin').notNull(), add_date: date('add_date').defaultNow().notNull(), @@ -296,9 +296,9 @@ export type NewFSEntry = InferInsertModel; export type FSEntry = InferSelectModel; export const flowsheet = wxyc_schema.table('flowsheet', { id: serial('id').primaryKey(), - show_id: integer('show_id').references(() => shows.id), - album_id: integer('album_id').references(() => library.id), - rotation_id: integer('rotation_id').references(() => rotation.id), + show_id: integer('show_id').references(() => shows.id, { onDelete: 'set null' }), + album_id: integer('album_id').references(() => library.id, { onDelete: 'set null' }), + rotation_id: integer('rotation_id').references(() => rotation.id, { onDelete: 'set null' }), entry_type: flowsheetEntryTypeEnum('entry_type').notNull().default('track'), track_title: varchar('track_title', { length: 128 }), album_title: varchar('album_title', { length: 128 }), @@ -326,7 +326,7 @@ export type Review = InferSelectModel; export const reviews = wxyc_schema.table('reviews', { id: serial('id').primaryKey(), album_id: integer('album_id') - .references(() => library.id) + .references(() => library.id, { onDelete: 'cascade' }) .notNull() .unique(), review: text('review'), @@ -355,10 +355,10 @@ export const genre_artist_crossreference = wxyc_schema.table( { artist_id: integer('artist_id') .notNull() - .references(() => artists.id), + .references(() => artists.id, { onDelete: 'cascade' }), genre_id: integer('genre_id') .notNull() - .references(() => genres.id), + .references(() => genres.id, { onDelete: 'cascade' }), artist_genre_code: integer('artist_genre_code').notNull(), }, (table) => [uniqueIndex('artist_genre_key').on(table.artist_id, table.genre_id)] @@ -369,8 +369,8 @@ export type ArtistLibraryCrossreference = InferSelectModel artists.id), - library_id: integer('library_id').references(() => library.id), + artist_id: integer('artist_id').references(() => artists.id, { onDelete: 'cascade' }), + library_id: integer('library_id').references(() => library.id, { onDelete: 'cascade' }), }, (table) => [uniqueIndex('library_id_artist_id').on(table.artist_id, table.library_id)] ); @@ -379,7 +379,7 @@ export type NewShow = InferInsertModel; export type Show = InferSelectModel; export const shows = wxyc_schema.table('shows', { id: serial('id').primaryKey(), - primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id), + primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), specialty_id: integer('specialty_id') //Null for regular shows .references(() => specialty_shows.id), show_name: varchar('show_name', { length: 128 }), //Null if not provided or specialty show @@ -391,7 +391,7 @@ export type NewShowDJ = InferInsertModel; export type ShowDJ = InferSelectModel; export const show_djs = wxyc_schema.table('show_djs', { show_id: integer('show_id') - .references(() => shows.id) + .references(() => shows.id, { onDelete: 'cascade' }) .notNull(), dj_id: varchar('dj_id', { length: 255 }) .references(() => user.id, { onDelete: 'cascade' }) diff --git a/tests/unit/database/schema.fk-cascades.test.ts b/tests/unit/database/schema.fk-cascades.test.ts new file mode 100644 index 0000000..450f312 --- /dev/null +++ b/tests/unit/database/schema.fk-cascades.test.ts @@ -0,0 +1,134 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const schemaSource = fs.readFileSync( + path.resolve(__dirname, '../../../shared/database/src/schema.ts'), + 'utf-8' +); + +/** + * Extract the full column definition block for a given column in a given table. + * Captures from the column's DB name through to the next column or closing brace. + */ +function getColumnBlock(tableVar: string, columnDbName: string): string | null { + const tablePattern = new RegExp(`export\\s+const\\s+${tableVar}\\s*=`); + const tableMatch = tablePattern.exec(schemaSource); + if (!tableMatch) return null; + + const afterTable = schemaSource.slice(tableMatch.index); + + // Find the column by its DB name, then grab everything until the next + // property definition (a line starting with whitespace + identifier + colon) + // or until a closing brace/paren. + const colPattern = new RegExp( + `(${columnDbName}:\\s*(?:integer|varchar|serial)\\([\\s\\S]*?)(?=\\n\\s+\\w+:\\s|\\n\\s*\\}|\\n\\s*\\))` + ); + const colMatch = colPattern.exec(afterTable); + if (!colMatch) return null; + + return colMatch[1]; +} + +function expectOnDelete(tableVar: string, columnDbName: string, expectedAction: 'set null' | 'cascade') { + const block = getColumnBlock(tableVar, columnDbName); + expect(block).not.toBeNull(); + expect(block).toContain('.references('); + expect(block).toContain('onDelete'); + expect(block).toContain(`'${expectedAction}'`); +} + +describe('FK cascade/set-null rules in schema.ts', () => { + describe('should use onDelete: "set null"', () => { + it('schedule.assigned_dj_id → user.id', () => { + expectOnDelete('schedule', 'assigned_dj_id', 'set null'); + }); + + it('schedule.assigned_dj_id2 → user.id', () => { + expectOnDelete('schedule', 'assigned_dj_id2', 'set null'); + }); + + it('schedule.specialty_id → specialty_shows.id', () => { + expectOnDelete('schedule', 'specialty_id', 'set null'); + }); + + it('shows.primary_dj_id → user.id', () => { + expectOnDelete('shows', 'primary_dj_id', 'set null'); + }); + + it('shift_covers.cover_dj_id → user.id', () => { + expectOnDelete('shift_covers', 'cover_dj_id', 'set null'); + }); + + it('flowsheet.show_id → shows.id', () => { + expectOnDelete('flowsheet', 'show_id', 'set null'); + }); + + it('flowsheet.album_id → library.id', () => { + expectOnDelete('flowsheet', 'album_id', 'set null'); + }); + + it('flowsheet.rotation_id → rotation.id', () => { + expectOnDelete('flowsheet', 'rotation_id', 'set null'); + }); + }); + + describe('should use onDelete: "cascade"', () => { + it('rotation.album_id → library.id', () => { + expectOnDelete('rotation', 'album_id', 'cascade'); + }); + + it('reviews.album_id → library.id', () => { + expectOnDelete('reviews', 'album_id', 'cascade'); + }); + + it('genre_artist_crossreference.artist_id → artists.id', () => { + expectOnDelete('genre_artist_crossreference', 'artist_id', 'cascade'); + }); + + it('genre_artist_crossreference.genre_id → genres.id', () => { + expectOnDelete('genre_artist_crossreference', 'genre_id', 'cascade'); + }); + + it('artist_library_crossreference.artist_id → artists.id', () => { + expectOnDelete('artist_library_crossreference', 'artist_id', 'cascade'); + }); + + it('artist_library_crossreference.library_id → library.id', () => { + expectOnDelete('artist_library_crossreference', 'library_id', 'cascade'); + }); + + it('show_djs.show_id → shows.id', () => { + expectOnDelete('show_djs', 'show_id', 'cascade'); + }); + }); + + describe('should NOT have onDelete (intentional NO ACTION)', () => { + it('library.artist_id → artists.id', () => { + const block = getColumnBlock('library', 'artist_id'); + expect(block).not.toBeNull(); + expect(block).toContain('.references('); + expect(block).not.toContain('onDelete'); + }); + + it('library.genre_id → genres.id', () => { + const block = getColumnBlock('library', 'genre_id'); + expect(block).not.toBeNull(); + expect(block).toContain('.references('); + expect(block).not.toContain('onDelete'); + }); + + it('library.format_id → format.id', () => { + const block = getColumnBlock('library', 'format_id'); + expect(block).not.toBeNull(); + expect(block).toContain('.references('); + expect(block).not.toContain('onDelete'); + }); + + it('artists.genre_id → genres.id', () => { + const block = getColumnBlock('artists', 'genre_id'); + expect(block).not.toBeNull(); + expect(block).toContain('.references('); + expect(block).not.toContain('onDelete'); + }); + }); +}); From c8a2e6ff05970d3ce0ff37be5d6708e11e062107 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 09:48:44 -0800 Subject: [PATCH 2/2] style: format files with Prettier --- tests/unit/database/schema.fk-cascades.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/database/schema.fk-cascades.test.ts b/tests/unit/database/schema.fk-cascades.test.ts index 450f312..5d6e7e8 100644 --- a/tests/unit/database/schema.fk-cascades.test.ts +++ b/tests/unit/database/schema.fk-cascades.test.ts @@ -1,10 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -const schemaSource = fs.readFileSync( - path.resolve(__dirname, '../../../shared/database/src/schema.ts'), - 'utf-8' -); +const schemaSource = fs.readFileSync(path.resolve(__dirname, '../../../shared/database/src/schema.ts'), 'utf-8'); /** * Extract the full column definition block for a given column in a given table.