diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 357f4c8d1..5877a17f0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,6 +10,22 @@ jobs: pr: name: PR runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: builder_schema_verifier_tests + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + BUILDER_SCHEMA_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/builder_schema_verifier_tests steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/scripts/verify-builder-schema.ts b/scripts/verify-builder-schema.ts index d4005020f..8aa7259a3 100644 --- a/scripts/verify-builder-schema.ts +++ b/scripts/verify-builder-schema.ts @@ -56,15 +56,22 @@ const journalUrl = new URL( const journal = migrationJournalSchema.parse( JSON.parse(await readFile(journalUrl, 'utf8')), ) -const latestMigration = journal.entries.reduce((_, entry) => entry) +const builderMigration = journal.entries.find( + (entry) => entry.tag === '0000_builder_durability', +) +if (!builderMigration) { + throw new Error( + 'Builder migration 0000_builder_durability is missing from the journal', + ) +} const migrationSql = await readFile( - new URL(`../drizzle/migrations/${latestMigration.tag}.sql`, import.meta.url), + new URL(`../drizzle/migrations/${builderMigration.tag}.sql`, import.meta.url), 'utf8', ) const expectedMigrationHash = createHash('sha256') .update(migrationSql) .digest('hex') -const expectedMigrationVersion = String(latestMigration.when) +const expectedMigrationVersion = String(builderMigration.when) const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) { @@ -95,7 +102,8 @@ try { const [appliedMigration] = await sql<{ hash: string; version: string }[]>` select hash, created_at::text as version from drizzle.__drizzle_migrations - order by created_at desc, id desc + where created_at = ${expectedMigrationVersion} + order by id desc limit 1 ` @@ -104,7 +112,7 @@ try { appliedMigration.version !== expectedMigrationVersion ) { throw new Error( - `Builder database migration mismatch: expected ${latestMigration.tag} (${expectedMigrationVersion}, ${expectedMigrationHash})`, + `Builder database migration mismatch: expected ${builderMigration.tag} (${expectedMigrationVersion}, ${expectedMigrationHash})`, ) } @@ -155,7 +163,7 @@ try { } console.info( - `Builder database schema matches ${latestMigration.tag} (${expectedMigrationHash})`, + `Builder database schema matches ${builderMigration.tag} (${expectedMigrationHash})`, ) } finally { await sql.end() diff --git a/tests/builder-schema-verifier.test.ts b/tests/builder-schema-verifier.test.ts new file mode 100644 index 000000000..48a940b22 --- /dev/null +++ b/tests/builder-schema-verifier.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { promisify } from 'node:util' +import postgres from 'postgres' +import { assertBuilderTestDatabaseUrl } from './helpers/builder-test-database' +import { + BUILDER_MESSAGE_ROLES, + BUILDER_PROJECT_EVENT_TYPES, + BUILDER_RUN_STATUSES, +} from '../src/db/types' + +const execute = promisify(execFile) +const databaseUrl = process.env.BUILDER_SCHEMA_TEST_DATABASE_URL +const tables = [ + 'builder_project_legacy_imports', + 'builder_project_snapshots', + 'builder_project_snapshot_reservations', + 'builder_projects', + 'builder_project_usage', + 'builder_project_tombstones', + 'builder_project_mutation_receipts', + 'builder_project_revisions', + 'builder_project_threads', + 'builder_project_runs', + 'builder_project_messages', + 'builder_project_events', +] +const enums = [ + ['builder_message_role', BUILDER_MESSAGE_ROLES], + ['builder_run_status', BUILDER_RUN_STATUSES], + ['builder_project_event_type', BUILDER_PROJECT_EVENT_TYPES], +] as const + +test( + 'Builder schema verifier accepts compatible later migrations and rejects invalid schemas', + { skip: databaseUrl ? false : 'Set BUILDER_SCHEMA_TEST_DATABASE_URL' }, + async (t) => { + assert.ok(databaseUrl) + // This suite resets schemas; never accept a production database name. + assertBuilderTestDatabaseUrl(databaseUrl) + const sql = postgres(databaseUrl, { max: 1, onnotice: () => {} }) + const journal = JSON.parse( + await readFile( + new URL('../drizzle/migrations/meta/_journal.json', import.meta.url), + 'utf8', + ), + ) + const migration = journal.entries.find( + (entry: { tag: string }) => entry.tag === '0000_builder_durability', + ) + assert.ok(migration, 'Builder migration must exist in the journal') + const hash = createHash('sha256') + .update( + await readFile( + new URL( + `../drizzle/migrations/${migration.tag}.sql`, + import.meta.url, + ), + ), + ) + .digest('hex') + + async function reset() { + await sql`drop schema if exists drizzle cascade` + await sql`drop schema public cascade` + await sql`create schema public` + await sql`create schema drizzle` + await sql`create table drizzle.__drizzle_migrations (id serial primary key, hash text not null, created_at bigint not null)` + await sql`insert into drizzle.__drizzle_migrations (hash, created_at) values (${hash}, ${migration.when})` + for (const table of tables) { + await sql`create table ${sql(table)} (id text primary key)` + } + for (const [name, labels] of enums) { + // Enum labels cannot be bind parameters in CREATE TYPE. + await sql.unsafe( + `create type ${name} as enum (${labels.map((label) => `'${label}'`).join(', ')})`, + ) + } + } + + async function verify(error?: RegExp) { + const result = execute( + process.execPath, + ['--import', 'tsx', 'scripts/verify-builder-schema.ts'], + { + cwd: new URL('../', import.meta.url), + env: { ...process.env, DATABASE_URL: databaseUrl }, + timeout: 30_000, + }, + ) + if (error) { + await assert.rejects(result, error) + } else { + assert.match((await result).stdout, /Builder database schema matches/) + } + } + + try { + const [target] = await sql< + { database: string }[] + >`select current_database() as database` + assert.equal(target?.database, 'builder_schema_verifier_tests') + await t.test( + 'accepts the required migration as the latest entry', + async () => { + await reset() + await verify() + }, + ) + await t.test('accepts a newer unrelated migration', async () => { + await reset() + await sql`insert into drizzle.__drizzle_migrations (hash, created_at) values ('later-chat-migration', ${migration.when + 1})` + await verify() + }) + await t.test('rejects missing migration history', async () => { + await reset() + await sql`drop schema drizzle cascade` + await verify(/Drizzle migration history is missing/) + }) + await t.test( + 'rejects a newer entry without the required migration', + async () => { + await reset() + await sql`update drizzle.__drizzle_migrations set created_at = ${migration.when + 1}` + await verify(/Builder database migration mismatch/) + }, + ) + await t.test('rejects a mismatched migration hash', async () => { + await reset() + await sql`update drizzle.__drizzle_migrations set hash = 'wrong-hash'` + await verify(/Builder database migration mismatch/) + }) + await t.test( + 'rejects a missing required table after a later migration', + async () => { + await reset() + await sql`insert into drizzle.__drizzle_migrations (hash, created_at) values ('later-chat-migration', ${migration.when + 1})` + await sql`drop table builder_projects` + await verify(/invalid or missing table builder_projects/) + }, + ) + await t.test('rejects a view in place of a required table', async () => { + await reset() + await sql`drop table builder_projects` + await sql`create view builder_projects as select 'project'::text as id` + await verify(/invalid or missing table builder_projects/) + }) + await t.test('rejects a missing required enum', async () => { + await reset() + await sql`drop type builder_message_role` + await verify(/invalid or missing enum builder_message_role/) + }) + await t.test('rejects incompatible enum labels', async () => { + await reset() + await sql`alter type builder_message_role add value 'unexpected'` + await verify(/invalid or missing enum builder_message_role/) + }) + } finally { + await sql.end() + } + }, +) diff --git a/tests/builder-test-database.test.ts b/tests/builder-test-database.test.ts new file mode 100644 index 000000000..06e09c1b7 --- /dev/null +++ b/tests/builder-test-database.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { assertBuilderTestDatabaseUrl } from './helpers/builder-test-database' + +test('Builder destructive fixture only accepts the dedicated local database without overrides', () => { + assert.doesNotThrow(() => + assertBuilderTestDatabaseUrl( + 'postgres://postgres@127.0.0.1:55443/builder_schema_verifier_tests', + ), + ) + for (const url of [ + 'postgres://postgres@remote.invalid/builder_schema_verifier_tests', + 'postgres://postgres@localhost/production', + 'https://localhost/builder_schema_verifier_tests', + 'postgres://postgres@localhost/builder_schema_verifier_tests?database=production', + 'postgres://postgres@localhost/builder_schema_verifier_tests?options=-c%20search_path%3Dpublic', + ]) + assert.throws(() => assertBuilderTestDatabaseUrl(url)) +}) diff --git a/tests/helpers/builder-test-database.ts b/tests/helpers/builder-test-database.ts new file mode 100644 index 000000000..dbff109af --- /dev/null +++ b/tests/helpers/builder-test-database.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' + +export function assertBuilderTestDatabaseUrl(databaseUrl: string) { + const url = new URL(databaseUrl) + assert.ok(['postgres:', 'postgresql:'].includes(url.protocol)) + assert.ok(['localhost', '127.0.0.1'].includes(url.hostname)) + assert.equal(url.pathname, '/builder_schema_verifier_tests') + assert.equal( + url.search, + '', + 'Test database URL must not override connection options', + ) +}