Files
DevRunbook-Public/scripts/release/migration-preflight.mts
T
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

170 lines
5.9 KiB
TypeScript

import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { closeDatabase, getSqlClient } from '../../packages/db/src/index'
import {
evaluateMigrationPreflight,
EXPECTED_MIGRATION_COUNT,
type MigrationPreflightSnapshot,
} from '../../packages/db/src/release/migration-preflight'
const sql = getSqlClient()
async function relationExists(name: string): Promise<boolean> {
const [row] = await sql<{ exists: boolean }[]>`
select to_regclass(${name}) is not null as exists
`
return row?.exists === true
}
async function columnExists(table: string, column: string): Promise<boolean> {
const [row] = await sql<{ exists: boolean }[]>`
select exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = ${table}
and column_name = ${column}
) as exists
`
return row?.exists === true
}
async function count(query: string): Promise<number> {
const [row] = await sql.unsafe<{ count: number }[]>(query)
return Number(row?.count ?? 0)
}
async function expectedMigrationHashes(): Promise<readonly string[]> {
const migrationsRoot = new URL(
'../../packages/db/migrations/',
import.meta.url,
)
const journal = JSON.parse(
await readFile(new URL('meta/_journal.json', migrationsRoot), 'utf8'),
) as {
entries: readonly { tag: string }[]
}
if (journal.entries.length !== EXPECTED_MIGRATION_COUNT) {
throw new Error(
`Migration journal has ${journal.entries.length} entries; expected ${EXPECTED_MIGRATION_COUNT}.`,
)
}
return Promise.all(
journal.entries.map(async ({ tag }) =>
createHash('sha256')
.update(await readFile(new URL(`${tag}.sql`, migrationsRoot)))
.digest('hex'),
),
)
}
async function snapshot(): Promise<MigrationPreflightSnapshot> {
const migrationTableExists = await relationExists(
'drizzle.__drizzle_migrations',
)
const applied = migrationTableExists
? await sql<{ hash: string }[]>`
select hash from drizzle.__drizzle_migrations order by created_at, id
`
: []
const expectedHashes = await expectedMigrationHashes()
const migrationHashesMatch = applied.every(
({ hash }, index) => expectedHashes[index] === hash,
)
const [version] = await sql<{ major: number }[]>`
select current_setting('server_version_num')::int / 10000 as major
`
const generatedRunsExist = await relationExists('public.generated_runs')
const integrationSecretsExist = await relationExists(
'public.integration_secrets',
)
const versionsExist = await relationExists('public.playbook_versions')
const draftDigestExists = await columnExists(
'playbook_versions',
'draft_digest',
)
const evaluationCasesExist = await relationExists('public.evaluation_cases')
const evaluationTargetDigestExists = await columnExists(
'evaluation_cases',
'target_digest',
)
const evaluationResultsExist = await relationExists(
'public.evaluation_results',
)
const resultTargetDigestExists = await columnExists(
'evaluation_results',
'target_digest',
)
return {
appliedMigrationCount: applied.length,
migrationTableExists,
migrationHashesMatch,
databaseMajorVersion: Number(version?.major ?? 0),
legacyNullRunIdempotencyKeys: generatedRunsExist
? await count(
'select count(*)::int as count from generated_runs where idempotency_key is null',
)
: 0,
invalidIntegrationSecretEnvelopes: integrationSecretsExist
? await count(`select count(*)::int as count from integration_secrets
where envelope_version <> 1
or length(btrim(key_version)) not between 1 and 64
or secret_kind <> 'access_token'
or octet_length(nonce) <> 12
or octet_length(auth_tag) <> 16
or (last_four is not null and length(last_four) <> 4)`)
: 0,
publishedDraftDigestMismatches:
versionsExist && draftDigestExists
? await count(`select count(*)::int as count from playbook_versions
where published_at is not null and draft_digest <> content_digest`)
: 0,
invalidEvaluationDigestBindings:
(evaluationCasesExist && evaluationTargetDigestExists
? await count(`select count(*)::int as count from evaluation_cases
where (target_digest is not null and target_digest !~ '^[0-9a-f]{64}$')
or (fixture_digest is not null and fixture_digest !~ '^[0-9a-f]{64}$')
or (environment_digest is not null and environment_digest !~ '^[0-9a-f]{64}$')`)
: 0) +
(evaluationResultsExist && resultTargetDigestExists
? await count(`select count(*)::int as count from evaluation_results
where (target_digest is not null and target_digest !~ '^[0-9a-f]{64}$')
or (fixture_digest is not null and fixture_digest !~ '^[0-9a-f]{64}$')
or (environment_digest is not null and environment_digest !~ '^[0-9a-f]{64}$')`)
: 0),
publishedImmutabilityTriggerPresent: versionsExist
? (await count(`select count(*)::int as count from pg_trigger
where tgrelid = 'public.playbook_versions'::regclass
and tgname = 'playbook_versions_published_immutable_trg'
and not tgisinternal`)) === 1
: false,
}
}
try {
const state = await snapshot()
const findings = evaluateMigrationPreflight(state)
const blockers = findings.filter(({ severity }) => severity === 'blocker')
process.stdout.write(
`${JSON.stringify(
{
schemaVersion: 1,
outcome: blockers.length === 0 ? 'ready' : 'blocked',
expectedMigrationCount: EXPECTED_MIGRATION_COUNT,
pendingMigrationCount: Math.max(
0,
EXPECTED_MIGRATION_COUNT - state.appliedMigrationCount,
),
snapshot: state,
findings,
},
null,
2,
)}\n`,
)
process.exitCode = blockers.length === 0 ? 0 : 2
} finally {
await closeDatabase()
}