diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 8026461..36439f0 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -34,7 +34,8 @@ import { migrateModerationCaseActions, migrateModerationCaseAssessments, migrateModerationCaseRelationships, - migrateModerationCaseFollowups + migrateModerationCaseFollowups, + migrateModerationCaseReferenceIndexes } from '../moderation/schema.js'; import type { Migration } from './migration-runner.js'; @@ -197,6 +198,11 @@ export const migrations: readonly Migration[] = [ version: 34, name: 'moderation follow-up dates and personal updates', run: migrateModerationCaseFollowups + }, + { + version: 35, + name: 'moderation case foreign key reference indexes', + run: migrateModerationCaseReferenceIndexes } ]; diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index dc45e94..856439e 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -223,3 +223,15 @@ export function migrateModerationCaseFollowups(handle: Database.Database): void CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC); `); } + +/** Foreign-key lookups must cover removed relationships and every notification recipient. */ +export function migrateModerationCaseReferenceIndexes(handle: Database.Database): void { + handle.exec(` + CREATE INDEX IF NOT EXISTS moderation_case_review_sanction + ON moderation_cases(review_sanction_id); + CREATE INDEX IF NOT EXISTS moderation_case_relationship_source + ON moderation_case_relationships(case_id,removed_at); + CREATE INDEX IF NOT EXISTS moderation_case_notifications_parent + ON moderation_case_notifications(case_id); + `); +} diff --git a/tests/unit/database-indexes.test.ts b/tests/unit/database-indexes.test.ts index 64ae0d8..6598da6 100644 --- a/tests/unit/database-indexes.test.ts +++ b/tests/unit/database-indexes.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; @@ -10,7 +11,30 @@ afterEach(async () => { await fixture.cleanup(); }); -it('indexes campaign cascades and beta evidence on fresh and reopened databases', async () => { +function expectModerationReferenceIndexes(handle: Database.Database) { + for (const [query, index] of [ + [ + "SELECT id FROM moderation_cases WHERE review_sanction_id='restriction'", + 'moderation_case_review_sanction' + ], + [ + "SELECT id FROM moderation_case_relationships WHERE case_id='source'", + 'moderation_case_relationship_source' + ], + [ + "SELECT id FROM moderation_case_notifications WHERE case_id='source'", + 'moderation_case_notifications_parent' + ] + ]) { + const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[]; + expect( + plan.some((row) => row.detail.includes(index)), + query + ).toBe(true); + } +} + +it('indexes campaign, beta and moderation references on fresh and reopened databases', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); for (let pass = 0; pass < 2; pass++) { const handle = db(); @@ -29,11 +53,48 @@ it('indexes campaign cascades and beta evidence on fresh and reopened databases' }[]; expect(plan.some((row) => row.detail.includes(index))).toBe(true); } + expectModerationReferenceIndexes(handle); expect(handle.pragma('foreign_key_check')).toEqual([]); closeDb(); } }); +it('adds complete moderation reference indexes when upgrading version 34 without changing records', async () => { + const { migrations } = await import('../../src/lib/server/db/migrations.js'); + const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js'); + const legacy = new Database(':memory:'); + legacy.pragma('foreign_keys = ON'); + try { + runMigrations(legacy, migrations.slice(0, 34)); + legacy.exec(` + INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) + VALUES('moderator','moderator','Moderator','unused','moderator',0,0); + INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) + VALUES('source','t5','123','Source case','open','moderator','Moderator','2026-10-17','2026-10-17'), + ('target','t5','123','Target case','open','moderator','Moderator','2026-10-17','2026-10-17'); + INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at) + VALUES('restriction','t5','123','mute','game','Recorded restriction','','moderator','Moderator','2026-10-17'); + UPDATE moderation_cases SET review_sanction_id='restriction' WHERE id='source'; + INSERT INTO moderation_case_relationships VALUES('relationship','source','target','same_incident','moderator','Moderator','2026-10-17','2026-10-18'); + INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) + VALUES('moderator','source','evidence','2026-10-17'); + `); + const records = () => ({ + cases: legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all(), + relationships: legacy.prepare('SELECT * FROM moderation_case_relationships').all(), + notifications: legacy.prepare('SELECT * FROM moderation_case_notifications').all() + }); + const before = records(); + runMigrations(legacy, migrations); + runMigrations(legacy, migrations); + expectModerationReferenceIndexes(legacy); + expect(records()).toEqual(before); + expect(legacy.pragma('foreign_key_check')).toEqual([]); + } finally { + legacy.close(); + } +}); + it('covers every foreign key child prefix, including host and library schemas', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const handle = db();