Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions scripts/verify-builder-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
`

Expand All @@ -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})`,
)
}

Expand Down Expand Up @@ -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()
Expand Down
165 changes: 165 additions & 0 deletions tests/builder-schema-verifier.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
},
)
19 changes: 19 additions & 0 deletions tests/builder-test-database.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
13 changes: 13 additions & 0 deletions tests/helpers/builder-test-database.ts
Original file line number Diff line number Diff line change
@@ -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',
)
}
Loading