import { readPostgresMigrationHistory, type PostgresMigrationQueryable, } from '../adapters/postgresMigrationStreamStore'; import { auditMigrationStreamHistory } from '../core/migrationStream'; import { postgresqlMainMigrationStream } from '.'; import { postgresqlControlSchemaContract, type PostgresSchemaContract, } from './schemaContract'; export const POSTGRES_SCHEMA_READINESS_ERROR_CODES = [ 'server_version_unsupported', 'server_not_writable_primary', 'migration_history_invalid', 'capability_invalid', 'schema_contract_invalid', 'runtime_role_invalid', ] as const; export type PostgresSchemaReadinessErrorCode = (typeof POSTGRES_SCHEMA_READINESS_ERROR_CODES)[number]; export class PostgresSchemaReadinessError extends Error { constructor( readonly code: PostgresSchemaReadinessErrorCode, readonly facts: readonly string[] = [], ) { super(`PostgreSQL schema is not ready: ${code}`); this.name = 'PostgresSchemaReadinessError'; } } export interface PostgresSchemaReadinessReport { readonly ready: true; readonly writablePrimary: true; readonly serverVersionNum: number; readonly serverMajor: number; readonly currentUser: string; readonly contractName: string; readonly contractVersion: number; readonly migrationIds: readonly string[]; } interface ServerRow extends Record { serverVersionNum: unknown; currentUser: unknown; inRecovery: unknown; transactionReadOnly: unknown; } interface CapabilityRow extends Record { contractName: unknown; contractVersion: unknown; migrationId: unknown; capabilities: unknown; } interface ColumnRow extends Record { tableName: unknown; columnName: unknown; } interface IndexRow extends Record { indexName: unknown; } interface ConstraintRow extends Record { constraintName: unknown; constraintType: unknown; } interface SchemaPrivilegeRow extends Record { schemaUsage: unknown; schemaCreate: unknown; } interface TablePrivilegeRow extends Record { tableName: unknown; selectAllowed: unknown; insertAllowed: unknown; updateAllowed: unknown; deleteAllowed: unknown; isOwner: unknown; } const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({ schema_migrations: Object.freeze({ select: true, insert: false, update: false, delete: false, }), schema_capabilities: Object.freeze({ select: true, insert: false, update: false, delete: false, }), runs: Object.freeze({ select: true, insert: true, update: true, delete: false, }), run_attempts: Object.freeze({ select: true, insert: true, update: true, delete: false, }), run_events: Object.freeze({ select: true, insert: true, update: false, delete: false, }), run_retry_policies: Object.freeze({ select: true, insert: true, update: true, delete: false, }), }); function safeInteger(value: unknown): number | null { if (typeof value === 'number' && Number.isSafeInteger(value)) return value; if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) { const parsed = Number(value); return Number.isSafeInteger(parsed) ? parsed : null; } return null; } function sorted(values: Iterable): string[] { return [...values].sort((left, right) => left.localeCompare(right)); } function exactJsonObject( actual: unknown, expected: Readonly>, ): boolean { if (!actual || typeof actual !== 'object' || Array.isArray(actual)) { return false; } const actualObject = actual as Record; const actualKeys = sorted(Object.keys(actualObject)); const expectedKeys = sorted(Object.keys(expected)); return ( actualKeys.length === expectedKeys.length && actualKeys.every( (key, index) => key === expectedKeys[index] && actualObject[key] === expected[key], ) ); } async function readServer( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract, ): Promise<{ writablePrimary: true; serverVersionNum: number; serverMajor: number; currentUser: string; }> { const result = await queryable.query( ` SELECT current_setting('server_version_num') AS "serverVersionNum", current_user AS "currentUser", pg_is_in_recovery() AS "inRecovery", current_setting('transaction_read_only') AS "transactionReadOnly" `.trim(), ); const row = result.rows[0]; const serverVersionNum = safeInteger(row?.serverVersionNum); const currentUser = row?.currentUser; const inRecovery = row?.inRecovery; const transactionReadOnly = row?.transactionReadOnly; const serverMajor = serverVersionNum === null ? null : Math.floor(serverVersionNum / 10_000); if ( result.rows.length !== 1 || serverVersionNum === null || serverMajor === null || serverMajor < contract.minimumServerMajor || serverMajor > contract.maximumServerMajor || typeof currentUser !== 'string' || currentUser.length === 0 ) { throw new PostgresSchemaReadinessError('server_version_unsupported', [ String(serverVersionNum ?? 'invalid'), ]); } if (inRecovery !== false || transactionReadOnly !== 'off') { throw new PostgresSchemaReadinessError('server_not_writable_primary', [ `in-recovery:${String(inRecovery)}`, `transaction-read-only:${String(transactionReadOnly)}`, ]); } return { writablePrimary: true, serverVersionNum, serverMajor, currentUser, }; } async function assertHistory( queryable: PostgresMigrationQueryable, ): Promise { const history = await readPostgresMigrationHistory(queryable); try { auditMigrationStreamHistory(history, postgresqlMainMigrationStream); return Object.freeze(history.map(({ migrationId }) => migrationId)); } catch (error) { throw new PostgresSchemaReadinessError('migration_history_invalid', [ error instanceof Error ? error.name : 'UnknownError', ]); } } async function assertCapability( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract, ): Promise { const result = await queryable.query( ` SELECT contract_name AS "contractName", contract_version AS "contractVersion", migration_id AS "migrationId", capabilities FROM "${contract.schema}"."schema_capabilities" WHERE contract_name = $1 `.trim(), [contract.contractName], ); const row = result.rows[0]; if ( result.rows.length !== 1 || !row || row.contractName !== contract.contractName || safeInteger(row.contractVersion) !== contract.contractVersion || row.migrationId !== contract.migrationId || !exactJsonObject(row.capabilities, contract.capabilities) ) { throw new PostgresSchemaReadinessError('capability_invalid'); } } async function assertSchemaContract( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract, ): Promise { const [columnsResult, indexesResult, constraintsResult] = await Promise.all([ queryable.query( ` SELECT table_name AS "tableName", column_name AS "columnName" FROM information_schema.columns WHERE table_schema = $1 ORDER BY table_name, ordinal_position `.trim(), [contract.schema], ), queryable.query( ` SELECT indexname AS "indexName" FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname `.trim(), [contract.schema], ), queryable.query( ` SELECT constraints.conname AS "constraintName", CASE constraints.contype WHEN 'c' THEN 'check' WHEN 'f' THEN 'foreign_key' END AS "constraintType" FROM pg_constraint constraints JOIN pg_class tables ON tables.oid = constraints.conrelid JOIN pg_namespace schemas ON schemas.oid = tables.relnamespace WHERE schemas.nspname = $1 AND constraints.contype IN ('c', 'f') ORDER BY constraints.contype, constraints.conname `.trim(), [contract.schema], ), ]); const actualTables = new Map>(); for (const row of columnsResult.rows) { if ( typeof row.tableName !== 'string' || typeof row.columnName !== 'string' ) { throw new PostgresSchemaReadinessError('schema_contract_invalid'); } const columns = actualTables.get(row.tableName) ?? new Set(); columns.add(row.columnName); actualTables.set(row.tableName, columns); } const expectedTables = new Map( contract.tables.map((table) => [table.name, new Set(table.columns)]), ); const findings: string[] = []; for (const [tableName, expectedColumns] of expectedTables) { const actualColumns = actualTables.get(tableName); if (!actualColumns) { findings.push(`missing-table:${tableName}`); continue; } for (const column of expectedColumns) { if (!actualColumns.has(column)) { findings.push(`missing-column:${tableName}.${column}`); } } for (const column of actualColumns) { if (!expectedColumns.has(column)) { findings.push(`unknown-column:${tableName}.${column}`); } } } for (const tableName of actualTables.keys()) { if (!expectedTables.has(tableName)) findings.push(`unknown-table:${tableName}`); } const actualIndexes = new Set(); for (const row of indexesResult.rows) { if (typeof row.indexName !== 'string') { throw new PostgresSchemaReadinessError('schema_contract_invalid'); } actualIndexes.add(row.indexName); } const expectedIndexes = new Set(contract.indexes); for (const index of expectedIndexes) { if (!actualIndexes.has(index)) findings.push(`missing-index:${index}`); } for (const index of actualIndexes) { if (!expectedIndexes.has(index)) findings.push(`unknown-index:${index}`); } const actualChecks = new Set(); const actualForeignKeys = new Set(); for (const row of constraintsResult.rows) { if ( typeof row.constraintName !== 'string' || (row.constraintType !== 'check' && row.constraintType !== 'foreign_key') ) { throw new PostgresSchemaReadinessError('schema_contract_invalid'); } const target = row.constraintType === 'check' ? actualChecks : actualForeignKeys; target.add(row.constraintName); } for (const check of contract.checks) { if (!actualChecks.has(check)) findings.push(`missing-check:${check}`); } for (const check of actualChecks) { if (!contract.checks.includes(check)) findings.push(`unknown-check:${check}`); } for (const foreignKey of contract.foreignKeys) { if (!actualForeignKeys.has(foreignKey)) { findings.push(`missing-foreign-key:${foreignKey}`); } } for (const foreignKey of actualForeignKeys) { if (!contract.foreignKeys.includes(foreignKey)) { findings.push(`unknown-foreign-key:${foreignKey}`); } } if (findings.length > 0) { throw new PostgresSchemaReadinessError( 'schema_contract_invalid', sorted(findings), ); } } async function assertRuntimeRole( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract, ): Promise { const schemaResult = await queryable.query( ` SELECT has_schema_privilege(current_user, $1, 'USAGE') AS "schemaUsage", has_schema_privilege(current_user, $1, 'CREATE') AS "schemaCreate" `.trim(), [contract.schema], ); const schema = schemaResult.rows[0]; const tableNames = Object.keys(REQUIRED_RUNTIME_PRIVILEGES); const tableResult = await queryable.query( ` SELECT requested.table_name AS "tableName", has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'SELECT') AS "selectAllowed", has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'INSERT') AS "insertAllowed", has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'UPDATE') AS "updateAllowed", has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'DELETE') AS "deleteAllowed", pg_get_userbyid(classes.relowner) = current_user AS "isOwner" FROM unnest($2::text[]) AS requested(table_name) JOIN pg_namespace namespaces ON namespaces.nspname = $1 JOIN pg_class classes ON classes.relnamespace = namespaces.oid AND classes.relname = requested.table_name ORDER BY requested.table_name `.trim(), [contract.schema, tableNames], ); const findings: string[] = []; if ( schemaResult.rows.length !== 1 || schema?.schemaUsage !== true || schema?.schemaCreate !== false ) { findings.push('schema-privileges'); } const privilegesByTable = new Map( tableResult.rows.map((row) => [row.tableName, row]), ); for (const tableName of tableNames) { const expected = REQUIRED_RUNTIME_PRIVILEGES[ tableName as keyof typeof REQUIRED_RUNTIME_PRIVILEGES ]; const actual = privilegesByTable.get(tableName); if ( !actual || actual.selectAllowed !== expected.select || actual.insertAllowed !== expected.insert || actual.updateAllowed !== expected.update || actual.deleteAllowed !== expected.delete || actual.isOwner !== false ) { findings.push(`table-privileges:${tableName}`); } } if (privilegesByTable.size !== tableNames.length) { findings.push('table-privilege-row-count'); } if (findings.length > 0) { throw new PostgresSchemaReadinessError( 'runtime_role_invalid', sorted(findings), ); } } export async function assertPostgresSchemaReady( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract = postgresqlControlSchemaContract, ): Promise { const server = await readServer(queryable, contract); const migrationIds = await assertHistory(queryable); await assertCapability(queryable, contract); await assertSchemaContract(queryable, contract); await assertRuntimeRole(queryable, contract); return Object.freeze({ ready: true, ...server, contractName: contract.contractName, contractVersion: contract.contractVersion, migrationIds, }); }