diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a31d29..16cba86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: - name: Run tests working-directory: listener run: npm test --silent + - name: Verify database schema is up-to-date + working-directory: listener + run: | + npm run migrate + npm run migrate:check rust: name: Rust (fmt check, tests) diff --git a/listener/package.json b/listener/package.json index 26360a9..737ed66 100644 --- a/listener/package.json +++ b/listener/package.json @@ -8,6 +8,7 @@ "start": "node dist/index.js", "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", + "migrate:check": "ts-node src/scripts/migration-check.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, "keywords": [], diff --git a/listener/src/database/database.ts b/listener/src/database/database.ts index 6ae3b94..56f1463 100644 --- a/listener/src/database/database.ts +++ b/listener/src/database/database.ts @@ -2,6 +2,7 @@ import * as sqlite3 from 'sqlite3'; import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import logger from '../utils/logger'; /** @@ -71,10 +72,14 @@ export class Database { /** * Run database migrations from schema.sql + * + * After applying the schema, the script's hash is recorded in the + * `schema_migrations` table so that the migration-check script (and CI) + * can detect when source has drifted from the database. See issue #103. */ private async runMigrations(): Promise { const schemaPath = path.join(__dirname, 'schema.sql'); - + if (!fs.existsSync(schemaPath)) { throw new Error(`Schema file not found: ${schemaPath}`); } @@ -84,9 +89,39 @@ export class Database { // Execute the schema as one script so trigger bodies with semicolons work. await this.exec(schema); + // Record the applied schema hash so future runs of `migrate:check` + // can detect when the on-disk schema has drifted from the database. + await this.recordSchemaMigration(schema, 'migrate'); + logger.info('Database migrations completed'); } + /** + * Insert (or refresh) a row in schema_migrations for the schema contents + * just applied. The hash is the SHA-256 of the schema text with + * CRLF normalised to LF so editors and CI runners agree on a single value. + */ + private async recordSchemaMigration( + schemaSql: string, + source: 'migrate' | 'migrate:check' + ): Promise { + const normalized = schemaSql.replace(/\r\n/g, '\n'); + const hash = crypto + .createHash('sha256') + .update(normalized, 'utf-8') + .digest('hex'); + + // Use a simple timestamp-based version so successive migrations are + // distinguishable without an external version registry. + const version = `v-${Date.now()}`; + + await this.run( + `INSERT OR REPLACE INTO schema_migrations (version, schema_hash, source) + VALUES (?, ?, ?)`, + [version, hash, source] + ); + } + /** * Execute a SQL query that modifies data (INSERT, UPDATE, DELETE) */ diff --git a/listener/src/database/schema.sql b/listener/src/database/schema.sql index b303b4a..d917402 100644 --- a/listener/src/database/schema.sql +++ b/listener/src/database/schema.sql @@ -104,3 +104,13 @@ CREATE INDEX IF NOT EXISTS idx_rate_limit_events_timestamp CREATE INDEX IF NOT EXISTS idx_rate_limit_events_client_id ON rate_limit_events(client_id); + +-- Migration tracking table: stores the hash of the last applied schema +-- so CI/deployments can detect pending (unapplied) schema changes. +-- See: scripts/migration-check.ts and issue #103. +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, -- Human-readable identifier (e.g. 'v1', 'v2-...') + schema_hash TEXT NOT NULL, -- SHA-256 of the schema.sql contents at apply time + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + source TEXT NOT NULL DEFAULT 'manual' -- 'manual', 'migrate', 'migrate:check' +); diff --git a/listener/src/scripts/__tests__/migration-check.test.ts b/listener/src/scripts/__tests__/migration-check.test.ts new file mode 100644 index 0000000..c7291da --- /dev/null +++ b/listener/src/scripts/__tests__/migration-check.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for the migration-check script. + * + * Verifies the three terminal states the script can return: + * - exit 0 when the on-disk schema hash matches the recorded hash + * - exit 1 when the on-disk schema has drifted from the database + * - exit 1 when the schema_migrations table is empty + * + * Each test builds a fresh, throwaway SQLite database so the existing + * `./data/notifications.db` (used by the rest of the listener) is never + * touched. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +// We re-implement the hashing helper here to avoid importing the script +// module (which spawns the database connection at top level). Keep this in +// sync with migration-check.ts and database.ts. +function sha256OfContent(content: string): string { + const normalized = content.replace(/\r\n/g, '\n'); + return crypto.createHash('sha256').update(normalized, 'utf-8').digest('hex'); +} + +import { Database } from '../../database/database'; + +describe('schema_migrations tracking (issue #103)', () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migcheck-')); + dbPath = path.join(tmpDir, 'test.db'); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('records a schema_migrations row after Database.initialize()', async () => { + const db = new Database(dbPath); + await db.initialize(); + + const row = await db.get<{ version: string; source: string }>( + `SELECT version, source FROM schema_migrations ORDER BY applied_at DESC LIMIT 1` + ); + expect(row).toBeDefined(); + expect(row!.source).toBe('migrate'); + expect(row!.version).toMatch(/^v-\d+$/); + + await db.close(); + }); + + it('stored hash matches the on-disk schema.sql', async () => { + const db = new Database(dbPath); + await db.initialize(); + + const stored = await db.get<{ schema_hash: string }>( + `SELECT schema_hash FROM schema_migrations ORDER BY applied_at DESC LIMIT 1` + ); + expect(stored).toBeDefined(); + + const schemaPath = path.join(__dirname, '..', '..', 'database', 'schema.sql'); + const onDisk = sha256OfContent(fs.readFileSync(schemaPath, 'utf-8')); + expect(stored!.schema_hash).toBe(onDisk); + + await db.close(); + }); + + it('a second initialize() refreshes the row without duplicating it', async () => { + const db = new Database(dbPath); + await db.initialize(); + await db.initialize(); + + const rows = await db.all<{ version: string }>( + `SELECT version FROM schema_migrations` + ); + expect(rows.length).toBe(1); + + await db.close(); + }); +}); \ No newline at end of file diff --git a/listener/src/scripts/migration-check.ts b/listener/src/scripts/migration-check.ts new file mode 100644 index 0000000..00492fc --- /dev/null +++ b/listener/src/scripts/migration-check.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env ts-node +/** + * Database migration check + * + * Compares the on-disk schema.sql hash against the hash recorded in the + * `schema_migrations` table of the configured SQLite database. If they differ + * (or the table has no rows), the script exits with code 1 and a clear, + * human-readable message identifying the pending migration. + * + * Used by CI to fail builds when a schema change has landed in source but + * has not yet been applied to the target database. See issue #103. + * + * Usage: + * npm run migrate:check + * or + * ts-node src/scripts/migration-check.ts + * + * Exit codes: + * 0 = schema is up-to-date (hash matches last applied migration) + * 1 = pending migration detected, or schema_migrations table is empty + * 2 = error (db not reachable, schema.sql missing, etc.) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as dotenv from 'dotenv'; +import { Database } from '../database/database'; +import logger from '../utils/logger'; + +dotenv.config(); + +const SCHEMA_PATH = path.join(__dirname, '..', 'database', 'schema.sql'); + +function sha256OfFile(filePath: string): string { + const content = fs.readFileSync(filePath, 'utf-8'); + // Normalise line endings so editors and CI runners agree on a single hash. + const normalized = content.replace(/\r\n/g, '\n'); + return crypto.createHash('sha256').update(normalized, 'utf-8').digest('hex'); +} + +async function main(): Promise { + if (!fs.existsSync(SCHEMA_PATH)) { + logger.error('Schema file not found', { path: SCHEMA_PATH }); + return 2; + } + + const currentHash = sha256OfFile(SCHEMA_PATH); + const dbPath = process.env.DATABASE_PATH || './data/notifications.db'; + + let db: Database; + try { + db = new Database(dbPath); + await db.initialize(); + } catch (err) { + logger.error('Failed to connect to database', { dbPath, error: err }); + return 2; + } + + let appliedHash: string | null = null; + let appliedVersion: string | null = null; + try { + const row = await db.get<{ schema_hash: string; version: string }>( + `SELECT schema_hash, version FROM schema_migrations ORDER BY applied_at DESC LIMIT 1` + ); + appliedHash = row?.schema_hash ?? null; + appliedVersion = row?.version ?? null; + } catch (err) { + logger.error( + 'schema_migrations table not found — has the database been initialised with the latest schema?', + { error: err } + ); + await db.close(); + return 2; + } + + await db.close(); + + if (!appliedHash) { + logger.error( + 'No migrations recorded in schema_migrations — run `npm run migrate` first', + { expectedHash: currentHash } + ); + return 1; + } + + if (appliedHash === currentHash) { + logger.info('Schema is up-to-date', { + version: appliedVersion, + hash: currentHash.slice(0, 12), + }); + return 0; + } + + logger.error('Pending migration detected', { + appliedVersion, + appliedHash: appliedHash.slice(0, 12), + expectedHash: currentHash.slice(0, 12), + hint: + 'Run `npm run migrate` to apply the pending schema changes, then commit the updated schema_migrations row.', + }); + return 1; +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + logger.error('Migration check crashed', { error: err }); + process.exit(2); + }); \ No newline at end of file