diff --git a/src/lib/server/backends/schema.ts b/src/lib/server/backends/schema.ts index 506debf..0736c54 100644 --- a/src/lib/server/backends/schema.ts +++ b/src/lib/server/backends/schema.ts @@ -41,6 +41,7 @@ export function migrateNativeInstances(handle: Database.Database): void { creation_id TEXT PRIMARY KEY REFERENCES server_deployments(id), cleanup_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id) ); + CREATE INDEX IF NOT EXISTS idx_ec_host_migrations_instance ON ec_host_migrations(instance_id); `); } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 8796ff8..ed039cc 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -10,6 +10,7 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js'; import { initializeFleetSettings } from './fleet-settings.js'; import { migrateServerHistory } from './server-history.js'; import { migrateNativeInstances } from '../backends/schema.js'; +import { installPerformanceIndexes } from './performance-indexes.js'; import { timeStatement } from './slow-statements.js'; let _db: Database.Database | null = null; @@ -404,6 +405,7 @@ export function db(): Database.Database { ON server_deployments(finished_at) WHERE server_deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`); + installPerformanceIndexes(handle); installReportRevisions(handle); } catch (err) { handle.close(); diff --git a/src/lib/server/db/performance-indexes.ts b/src/lib/server/db/performance-indexes.ts new file mode 100644 index 0000000..6fad551 --- /dev/null +++ b/src/lib/server/db/performance-indexes.ts @@ -0,0 +1,27 @@ +import type Database from 'better-sqlite3'; + +/** Install after additive column and history migrations, on new and existing databases. */ +export function installPerformanceIndexes(handle: Database.Database): void { + handle.exec(` + CREATE INDEX IF NOT EXISTS idx_staged_files_deployment_file ON staged_file_changes(deployment_file_id); + CREATE INDEX IF NOT EXISTS idx_beta_test_runs_beta_revision ON beta_test_runs(beta_id, revision); + CREATE INDEX IF NOT EXISTS idx_deployments_beta ON deployments(beta_id); + CREATE INDEX IF NOT EXISTS idx_deployments_script ON deployments(script_id); + CREATE INDEX IF NOT EXISTS idx_deployments_version ON deployments(script_version_id); + CREATE INDEX IF NOT EXISTS idx_deployments_prior ON deployments(prior_version_id); + CREATE INDEX IF NOT EXISTS idx_deployment_scripts_script ON deployment_scripts(script_id); + CREATE INDEX IF NOT EXISTS idx_deployment_scripts_version ON deployment_scripts(script_version_id); + CREATE INDEX IF NOT EXISTS idx_deployment_scripts_prior ON deployment_scripts(prior_version_id); + CREATE INDEX IF NOT EXISTS idx_server_scripts_version ON server_scripts(script_version_id); + CREATE INDEX IF NOT EXISTS idx_betas_version ON betas(script_version_id); + CREATE INDEX IF NOT EXISTS idx_beta_scripts_version ON beta_scripts(script_version_id); + CREATE INDEX IF NOT EXISTS idx_beta_servers_prior ON beta_servers(prior_version_id); + CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_script ON beta_server_scripts(script_id); + CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_prior ON beta_server_scripts(prior_version_id); + CREATE INDEX IF NOT EXISTS idx_beta_file_snapshots_server ON beta_file_snapshots(server); + CREATE INDEX IF NOT EXISTS idx_server_launch_previews_deployment ON server_launch_previews(deployment_id); + CREATE INDEX IF NOT EXISTS idx_server_collection_previews_collection ON server_collection_previews(collection_id); + CREATE INDEX IF NOT EXISTS idx_server_collection_removals_deployment ON server_collection_removals(deployment_id); + CREATE INDEX IF NOT EXISTS idx_server_creation_dependencies_removal ON server_creation_dependencies(removal_id); + `); +} diff --git a/src/lib/server/game-files/schema.ts b/src/lib/server/game-files/schema.ts index 12146d7..20086c0 100644 --- a/src/lib/server/game-files/schema.ts +++ b/src/lib/server/game-files/schema.ts @@ -48,6 +48,7 @@ export function ensureGameFileSchema(handle: Database.Database): void { FROM game_file_versions v,json_each(v.manifest) f; CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END; + CREATE INDEX IF NOT EXISTS idx_host_library_caches_version ON host_library_caches(version_id); `); const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[]; if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT'); diff --git a/src/lib/server/hosts/schema.ts b/src/lib/server/hosts/schema.ts index 37ec541..d46f89e 100644 --- a/src/lib/server/hosts/schema.ts +++ b/src/lib/server/hosts/schema.ts @@ -89,6 +89,8 @@ export function ensureHostSchema(handle: Database.Database): void { CHECK(stage IN ('queued','running','cleaned','failed','completed')), requested_at TEXT NOT NULL, updated_at TEXT NOT NULL, cleaned_at TEXT, error TEXT ); + CREATE INDEX IF NOT EXISTS idx_ec_host_credentials_host ON ec_host_credentials(host_id); + CREATE INDEX IF NOT EXISTS idx_ec_host_enrollments_host ON ec_host_enrollments(host_id); `); const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[]; if (!columns.some(column => column.name === 'history_expired_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN history_expired_at TEXT'); diff --git a/tests/unit/database-indexes.test.ts b/tests/unit/database-indexes.test.ts new file mode 100644 index 0000000..0996606 --- /dev/null +++ b/tests/unit/database-indexes.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); +afterEach(async () => { await fixture.cleanup(); }); + +it('indexes campaign cascades and beta evidence 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(); + for (const [query, index] of [ + ['SELECT id FROM staged_file_changes WHERE deployment_file_id = 1', 'idx_staged_files_deployment_file'], + ['SELECT id FROM beta_test_runs WHERE beta_id = 1 AND revision = 2', 'idx_beta_test_runs_beta_revision'] + ]) { + const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[]; + expect(plan.some(row => row.detail.includes(index))).toBe(true); + } + expect(handle.pragma('foreign_key_check')).toEqual([]); + closeDb(); + } +}); + +it('covers every foreign key child prefix, including lazy host and library schemas', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js'); + const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js'); + const handle = db(); + ensureHostSchema(handle); ensureGameFileSchema(handle); + const tables = handle.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]; + const missing: string[] = []; + for (const { name } of tables) { + const foreignKeys = handle.pragma(`foreign_key_list(${name})`) as { from: string; seq: number }[]; + const indexes = handle.pragma(`index_list(${name})`) as { name: string; partial: number }[]; + const prefixes = indexes.filter(index => !index.partial).map(index => + (handle.pragma(`index_info(${index.name})`) as { name: string }[])[0]?.name); + const columns = handle.pragma(`table_info(${name})`) as { name: string; type: string; pk: number }[]; + if (columns.filter(column => column.pk).length === 1) prefixes.push(columns.find(column => column.pk)?.name); + for (const key of foreignKeys.filter(key => key.seq === 0)) { + if (!prefixes.includes(key.from)) missing.push(`${name}.${key.from}`); + } + } + expect(missing).toEqual([]); +});