gongxiangp2/scripts/safe-production-migrate.mjs

1419 lines
74 KiB
JavaScript

import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { createReadStream, realpathSync, readFileSync } from 'node:fs'
import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const MIGRATIONS_ROOT = path.join(ROOT, 'prisma', 'migrations')
const BAKED_COMMIT_FILE = path.join(ROOT, 'DEPLOYMENT_COMMIT')
const TARGET_MIGRATIONS = Object.freeze([
'20260811_add_video_transcode_lease',
'20260811_update_brand_logo_defaults',
'20260812_add_product_package_upload_job_lease',
'20260812_align_material_upload_idempotency_index',
'20260813_add_material_custody_department',
'20260813_add_organization_units_and_employment_state',
'20260813_add_user_employee_binding',
'20260817_add_material_is_incomplete_shell',
'20260820_add_ding_workflow_snapshots',
'20260824_expand_ding_workflow_insights',
'20260826_add_ding_workflow_demand_type',
'20260827_add_ding_workflow_detail_fields',
'20260826_add_pack_video_early_access',
'20260828_add_pack_item_can_edit_assets',
'20260828_add_task_detail_change_approvals',
'20260829_add_recoverable_api_key_secret',
'20260831_add_private_task_detail',
'20260902_add_employee_roster_history',
'20260903_add_work_review_brand',
'20260908_add_brand_knowledge',
'20260909_expand_brand_knowledge_upload',
'20260914_add_brand_knowledge_engagement',
'20260914_expand_brand_knowledge_download_count',
'20260914_expand_brand_knowledge_preview_slices',
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
'20260924_add_certificate_tested_on',
'20261008_permission_group_department_identity',
'20261008_team_function_configs',
'20261009_material_purge_outbox',
'20261009_material_purge_reference_indexes',
'20261012_detail_generator',
'20261012_ai_model_registry',
'20261013_ai_model_capabilities',
'20261013_ai_model_verifications',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
const VERIFIED_BACKUP_SUMMARY_TABLES = Object.freeze([
'materials',
'users',
'systemSettings',
'prismaMigrations',
'productPackageUploadJobs',
])
const ISOLATED_DATABASE_PATTERN = /^gongxiangp2_restore_[a-z0-9][a-z0-9_]{7,55}$/
function fail(message) {
throw new Error(message)
}
export function requireExactCommit(value, label) {
const normalized = String(value || '').trim()
if (!/^[0-9a-f]{40}$/.test(normalized)) {
fail(`${label} must be an exact 40-character lowercase Git SHA`)
}
return normalized
}
export function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
export function normalizeColumnDefault(value) {
let normalized = String(value ?? '').trim().replace(/::(?:text|character varying|boolean)$/i, '')
if (normalized.startsWith("'") && normalized.endsWith("'")) {
normalized = normalized.slice(1, -1)
}
return normalized
}
function normalizeConstraintExpression(value) {
return String(value ?? '')
.trim()
.replace(/::(?:text|character varying|boolean)/gi, '')
.replace(/["\s()]/g, '')
.toLowerCase()
}
function assertExactCheckConstraint(constraint, expectedTable, expectedName, expectedColumns, expectedExpression) {
if (
!constraint
|| String(constraint.tablename || '') !== expectedTable
|| String(constraint.constraint_name || '') !== expectedName
|| String(constraint.contype || '') !== 'c'
|| constraint.convalidated !== true
) {
fail(`Database constraint mismatch: ${expectedName}`)
}
const actualColumns = Array.isArray(constraint.columns)
? constraint.columns.map((column) => String(column)).sort()
: []
const normalizedExpectedColumns = [...expectedColumns].sort()
if (
actualColumns.length !== normalizedExpectedColumns.length
|| actualColumns.some((column, index) => column !== normalizedExpectedColumns[index])
) {
fail(`Database constraint columns mismatch: ${expectedName}`)
}
if (
normalizeConstraintExpression(constraint.expression)
!== normalizeConstraintExpression(expectedExpression)
) {
fail(`Database constraint expression mismatch: ${expectedName}`)
}
}
function assertExactForeignKeyConstraint(constraint, expectedTable, expectedName, expectedColumn, expectedReferencedTable, expectedReferencedColumn) {
if (
!constraint
|| String(constraint.tablename || '') !== expectedTable
|| String(constraint.constraint_name || '') !== expectedName
|| String(constraint.contype || '') !== 'f'
|| constraint.convalidated !== true
|| String(constraint.referenced_table || '') !== expectedReferencedTable
|| !Array.isArray(constraint.columns)
|| constraint.columns.length !== 1
|| String(constraint.columns[0]) !== expectedColumn
|| !Array.isArray(constraint.referenced_columns)
|| constraint.referenced_columns.length !== 1
|| String(constraint.referenced_columns[0]) !== expectedReferencedColumn
) {
fail(`Database foreign key mismatch: ${expectedName}`)
}
}
function normalizedPort(value) {
const port = String(value || '').trim()
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
fail('Database port must be an integer between 1 and 65535')
}
return String(Number(port))
}
export function assertExactBtreeIndex(indexDefinition, expectedTable, expectedColumns) {
const definition = String(indexDefinition || '').trim().replace(/\s+/g, ' ')
if (!definition || /\bUNIQUE\b/i.test(definition) || /\bWHERE\b/i.test(definition)) {
fail(`Database index must be a non-unique, non-partial btree index on ${expectedTable}`)
}
const match = definition.match(
/^CREATE INDEX \S+ ON (?:ONLY )?(?:(?:"[^"]+"|[a-zA-Z_][\w$]*)\.)?("[^"]+"|[a-zA-Z_][\w$]*) USING ([a-zA-Z0-9_]+) \(([^()]*)\)$/i,
)
if (!match) fail(`Database index definition is not a simple index on ${expectedTable}`)
const table = match[1].replace(/^"|"$/g, '')
const method = match[2].toLowerCase()
const columns = match[3].split(',').map((part) => {
const column = part.trim()
const identifier = column.match(/^"?([a-zA-Z_][\w$]*)"?$/)
if (!identifier) fail(`Database index contains an expression or sort modifier: ${column}`)
return identifier[1]
})
if (
table !== expectedTable
|| method !== 'btree'
|| columns.length !== expectedColumns.length
|| columns.some((column, index) => column !== expectedColumns[index])
) {
fail(`Database index columns or method mismatch: ${expectedTable}`)
}
}
function assertExactUniqueBtreeIndex(index, expectedTable, expectedName, expectedColumns) {
if (
!index
|| String(index.tablename || '') !== expectedTable
|| String(index.indexname || '') !== expectedName
|| index.indisunique !== true
|| index.indisprimary !== false
|| index.indisvalid !== true
|| index.indisready !== true
|| String(index.access_method || '') !== 'btree'
|| String(index.predicate || '') !== ''
|| !Array.isArray(index.columns)
|| index.columns.length !== expectedColumns.length
|| index.columns.some((column, position) => String(column).replaceAll('"', '') !== expectedColumns[position])
) {
fail(`Database unique index mismatch: ${expectedName}`)
}
}
function normalizedPredicate(value) {
return String(value || '')
.replace(/[()\"]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function assertExactActiveUploadIdIndex(index) {
if (
!index
|| String(index.tablename || '') !== 'materials'
|| String(index.indexname || '') !== 'materials_uploadId_key'
|| index.indisunique !== true
|| index.indisprimary !== false
|| index.indisvalid !== true
|| index.indisready !== true
|| String(index.access_method || '') !== 'btree'
|| !Array.isArray(index.columns)
|| index.columns.length !== 1
|| String(index.columns[0]) !== '"uploadId"'
|| normalizedPredicate(index.predicate) !== 'uploadId IS NOT NULL AND deletedAt IS NULL'
) {
fail('Database index mismatch: materials_uploadId_key must be the exact active-only uploadId unique btree')
}
}
async function sha256File(filePath) {
const hash = createHash('sha256')
await new Promise((resolve, reject) => {
const stream = createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.once('error', reject)
stream.once('end', resolve)
})
return hash.digest('hex')
}
export function databaseFingerprintHash(database) {
return sha256(Buffer.from(JSON.stringify({
configuredHost: String(database.configuredHost || ''),
configuredPort: String(database.configuredPort || ''),
configuredDatabase: String(database.configuredDatabase || ''),
configuredUser: String(database.configuredUser || ''),
serverAddress: String(database.serverAddress || ''),
serverPort: String(database.serverPort || ''),
})))
}
function stableJson(value) {
if (Array.isArray(value)) return value.map(stableJson)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableJson(value[key])]))
}
return value
}
export function verifiedBackupSummaryHash(summary) {
return sha256(Buffer.from(JSON.stringify(stableJson(summary))))
}
function assertBoundedBackupSummary(summary, label) {
if (
!summary
|| typeof summary !== 'object'
|| Array.isArray(summary)
|| summary.schemaVersion !== 1
|| summary.sampleLimit !== 32
) {
fail(`${label} must use the bounded backup summary schemaVersion 1`)
}
for (const table of VERIFIED_BACKUP_SUMMARY_TABLES) {
const evidence = summary[table]
if (
!evidence
|| typeof evidence !== 'object'
|| Array.isArray(evidence)
|| !/^(?:0|[1-9]\d*)$/.test(String(evidence.count || ''))
|| !/^[0-9a-f]{32}$/.test(String(evidence.sampleSha256InputMd5 || ''))
) {
fail(`${label} is missing valid bounded evidence for ${table}`)
}
}
}
function assertIsolatedRestoreFingerprint(production, isolated) {
const keys = [
'configuredHost',
'configuredPort',
'configuredDatabase',
'configuredUser',
'serverAddress',
'serverPort',
]
if (
!isolated
|| typeof isolated !== 'object'
|| Array.isArray(isolated)
|| keys.some((key) => !String(isolated[key] || '').trim())
|| !ISOLATED_DATABASE_PATTERN.test(String(isolated.configuredDatabase))
) {
fail('Backup receipt must contain the exact isolated restore database fingerprint')
}
normalizedPort(isolated.configuredPort)
normalizedPort(isolated.serverPort)
if (
production.configuredDatabase === isolated.configuredDatabase
|| (
production.configuredHost === isolated.configuredHost
&& String(production.configuredPort) === String(isolated.configuredPort)
)
|| (
production.serverAddress === isolated.serverAddress
&& String(production.serverPort) === String(isolated.serverPort)
)
) {
fail('Backup receipt isolated restore target is not distinct from production')
}
}
function redactDatabaseFingerprint(url) {
return {
configuredHost: url.hostname,
configuredPort: url.port || '5432',
configuredDatabase: decodeURIComponent(url.pathname.replace(/^\//, '')),
configuredUser: decodeURIComponent(url.username),
}
}
async function loadLocalMigrations() {
const entries = await readdir(MIGRATIONS_ROOT, { withFileTypes: true })
const result = new Map()
for (const entry of entries) {
if (!entry.isDirectory()) continue
const sqlPath = path.join(MIGRATIONS_ROOT, entry.name, 'migration.sql')
try {
const sql = await readFile(sqlPath)
result.set(entry.name, { sqlPath, checksum: sha256(sql) })
} catch {
// Directories without migration.sql are not Prisma migrations.
}
}
return result
}
async function queryEvidence(prisma, localMigrations) {
const names = Array.from(localMigrations.keys())
const migrationRows = await prisma.$queryRawUnsafe(
`SELECT migration_name, checksum, finished_at::text AS finished_at,
rolled_back_at::text AS rolled_back_at, logs
FROM "_prisma_migrations"
WHERE migration_name = ANY($1::text[])`,
names,
)
const identityRows = await prisma.$queryRawUnsafe(
`SELECT current_database() AS database,
current_user AS "user",
COALESCE(inet_server_addr()::text, '') AS server_address,
COALESCE(inet_server_port()::text, '') AS server_port`,
)
const columns = await prisma.$queryRawUnsafe(
`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications')
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active'))
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
OR (table_name = 'performance_survey_employee_roster_snapshots' AND column_name IN ('id', 'captured_at', 'profile_count', 'employee_profile_id', 'dingtalk_user_id', 'name', 'department_name', 'department_path', 'employment_state', 'is_brand_center', 'created_at'))
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status'))
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields'))
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count'))
OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at'))
OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at'))
OR (table_name = 'brand_knowledge' AND column_name IN ('id', 'title', 'knowledge_type', 'brand', 'product_category_name', 'file_name', 'file_path', 'mime_type', 'file_size', 'download_count', 'preview_image_name', 'preview_image_path', 'preview_image_slices', 'preview_image_mime_type', 'preview_image_size', 'uploaded_by', 'created_at', 'updated_at', 'deleted_at'))
OR (table_name IN ('brand_knowledge_likes', 'brand_knowledge_favorites') AND column_name IN ('id', 'knowledge_id', 'user_id', 'created_at'))
OR (table_name = 'performance_conversion_metric_snapshots' AND column_name IN ('id', 'source_key', 'source_row_id', 'product_id', 'metric_date', 'product_name', 'source_brand', 'category1', 'category2', 'category3', 'payment_amount', 'payment_users', 'cart_users', 'visitors', 'conversion_rate', 'favorites', 'impressions', 'clicks', 'click_rate', 'promotion_spend', 'promotion_revenue', 'roi', 'synced_at', 'created_at', 'updated_at'))
OR (table_name = 'performance_conversion_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_started_at', 'last_completed_at', 'last_error', 'lease_owner', 'lease_expires_at', 'rows_upserted', 'products_synced', 'created_at', 'updated_at')))
ORDER BY table_name, column_name`,
)
const indexes = await prisma.$queryRawUnsafe(
`SELECT tbl.relname AS tablename,
idx.relname AS indexname,
pg_get_indexdef(i.indexrelid) AS indexdef,
i.indisunique,
i.indisprimary,
i.indisvalid,
i.indisready,
am.amname AS access_method,
ARRAY(
SELECT pg_get_indexdef(i.indexrelid, ordinal, true)
FROM generate_series(1, i.indnkeyatts) AS ordinal
ORDER BY ordinal
) AS columns,
COALESCE(pg_get_expr(i.indpred, i.indrelid), '') AS predicate
FROM pg_index i
JOIN pg_class idx ON idx.oid = i.indexrelid
JOIN pg_class tbl ON tbl.oid = i.indrelid
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
JOIN pg_am am ON am.oid = idx.relam
WHERE ns.nspname = current_schema()
AND idx.relname IN (
'materials_transcode_lease_idx',
'materials_uploadId_key',
'materials_is_incomplete_shell_idx',
'brand_knowledge_product_category_name_idx',
'brand_knowledge_deleted_at_idx',
'brand_knowledge_likes_knowledge_id_user_id_key',
'brand_knowledge_likes_knowledge_id_idx',
'brand_knowledge_likes_user_id_idx',
'brand_knowledge_favorites_knowledge_id_user_id_key',
'brand_knowledge_favorites_knowledge_id_idx',
'brand_knowledge_favorites_user_id_idx',
'product_package_upload_jobs_lease_idx',
'ding_workflow_snapshots_ongoing_updated_idx',
'ding_workflow_snapshots_brand_idx',
'ding_workflow_snapshots_demand_type_idx',
'ding_workflow_snapshots_requester_department_idx',
'ding_workflow_sync_states_lease_idx',
'task_detail_change_requests_status_expires_idx',
'task_detail_change_requests_status_updated_idx',
'task_detail_change_notifications_status_retry_idx',
'api_key_reveal_audits_api_key_id_created_at_idx',
'api_key_reveal_audits_viewer_user_id_created_at_idx',
'performance_conversion_metric_snapshots_source_key_key',
'performance_conversion_metric_product_date_idx',
'performance_conversion_metric_date_idx',
'performance_conversion_sync_states_lease_idx'
,'material_permission_pack_dept_unit_key', 'material_permission_pack_departments_pack_id_idx',
'team_function_configs_organization_unit_id_key', 'team_function_config_audits_config_id_created_at_idx',
'material_purge_jobs_material_id_key', 'material_purge_jobs_status_next_attempt_at_idx', 'material_purge_audits_job_id_created_at_idx',
'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx',
'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx',
'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx',
'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx',
'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx'
)
ORDER BY idx.relname`,
)
const constraints = await prisma.$queryRawUnsafe(
`SELECT cls.relname AS tablename,
con.conname AS constraint_name,
con.contype::text AS contype,
con.convalidated,
con.confdeltype::text AS delete_action,
ref.relname AS referenced_table,
ARRAY(
SELECT att.attname
FROM unnest(con.conkey) WITH ORDINALITY AS keys(attnum, ordinal)
JOIN pg_attribute att
ON att.attrelid = con.conrelid
AND att.attnum = keys.attnum
ORDER BY keys.ordinal
) AS columns,
ARRAY(
SELECT att.attname
FROM unnest(con.confkey) WITH ORDINALITY AS keys(attnum, ordinal)
JOIN pg_attribute att
ON att.attrelid = con.confrelid
AND att.attnum = keys.attnum
ORDER BY keys.ordinal
) AS referenced_columns,
pg_get_expr(con.conbin, con.conrelid, true) AS expression
FROM pg_constraint con
JOIN pg_class cls ON cls.oid = con.conrelid
LEFT JOIN pg_class ref ON ref.oid = con.confrelid
JOIN pg_namespace ns ON ns.oid = cls.relnamespace
WHERE ns.nspname = current_schema()
AND (con.conname IN (
'materials_private_optimization_check',
'brand_knowledge_likes_knowledge_id_fkey',
'brand_knowledge_likes_user_id_fkey',
'brand_knowledge_favorites_knowledge_id_fkey',
'brand_knowledge_favorites_user_id_fkey'
,'material_permission_pack_departments_organization_unit_id_fkey',
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check',
'material_purge_audits_job_id_fkey'
) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications')
OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
ORDER BY con.conname`,
)
const brandRows = await prisma.$queryRawUnsafe(
`SELECT id, "headerLogoUrl", "loginLogoUrl"
FROM "system_settings"
ORDER BY id`,
)
const referenceFunctions = await prisma.$queryRawUnsafe(`
SELECT proname, prosrc, provolatile::text, proparallel::text, proisstrict,
pg_get_function_identity_arguments(p.oid) AS arguments
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = current_schema() AND proname IN
('purge_decode_reference', 'purge_json_reference_strings', 'purge_reference_keys')`)
return { migrationRows, identity: identityRows[0], columns, indexes, constraints, brandRows, referenceFunctions }
}
function completedMigrationMap(rows) {
const map = new Map()
for (const row of rows) {
if (!row.finished_at || row.rolled_back_at) continue
map.set(String(row.migration_name), row)
}
return map
}
export function assertTargetMigrationEvidence(localMigrations, evidence) {
const completed = completedMigrationMap(evidence.migrationRows)
for (const name of TARGET_MIGRATIONS) {
const local = localMigrations.get(name)
if (!local) fail(`Required migration file is missing: ${name}`)
const row = completed.get(name)
if (!row) fail(`Required migration is not completed: ${name}`)
if (String(row.checksum) !== local.checksum) {
fail(`Database checksum differs from the exact source tree: ${name}`)
}
}
const columnByKey = new Map(evidence.columns.map((row) => [
`${row.table_name}.${row.column_name}`,
row,
]))
const requireColumn = (table, column, type, nullable) => {
const row = columnByKey.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== nullable) {
fail(`Database column mismatch: ${table}.${column}`)
}
}
requireColumn('materials', 'transcode_lease_token', 'text', 'YES')
requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'uploadId', 'text', 'YES')
requireColumn('materials', 'report_kind', 'text', 'YES')
requireColumn('materials', 'certificate_expires_on', 'date', 'YES')
requireColumn('materials', 'certificate_tested_on', 'date', 'YES')
requireColumn('materials', 'certificate_number', 'text', 'YES')
requireColumn('materials', 'certificate_usage_instructions', 'text', 'YES')
requireColumn('materials', 'report_usage_rules', 'text', 'YES')
requireColumn('api_keys', 'secret_ciphertext', 'text', 'YES')
requireColumn('api_keys', 'secret_nonce', 'text', 'YES')
requireColumn('api_keys', 'secret_auth_tag', 'text', 'YES')
requireColumn('api_key_reveal_audits', 'id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'api_key_id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'viewer_user_id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('task_detail_change_requests', 'id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'manager_dingtalk_user_id', 'text', 'NO')
requireColumn('brand_knowledge', 'id', 'text', 'NO')
requireColumn('brand_knowledge', 'title', 'text', 'NO')
requireColumn('brand_knowledge', 'knowledge_type', 'text', 'NO')
requireColumn('brand_knowledge', 'brand', 'text', 'NO')
requireColumn('brand_knowledge', 'product_category_name', 'text', 'NO')
requireColumn('brand_knowledge', 'file_name', 'text', 'NO')
requireColumn('brand_knowledge', 'file_path', 'text', 'NO')
requireColumn('brand_knowledge', 'mime_type', 'text', 'NO')
requireColumn('brand_knowledge', 'file_size', 'bigint', 'NO')
requireColumn('brand_knowledge', 'download_count', 'integer', 'NO')
requireColumn('brand_knowledge', 'preview_image_name', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_path', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_slices', 'ARRAY', 'NO')
requireColumn('brand_knowledge', 'preview_image_mime_type', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_size', 'bigint', 'YES')
requireColumn('brand_knowledge', 'uploaded_by', 'text', 'NO')
requireColumn('brand_knowledge', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('brand_knowledge', 'updated_at', 'timestamp without time zone', 'NO')
requireColumn('brand_knowledge', 'deleted_at', 'timestamp without time zone', 'YES')
for (const table of ['brand_knowledge_likes', 'brand_knowledge_favorites']) {
requireColumn(table, 'id', 'text', 'NO')
requireColumn(table, 'knowledge_id', 'text', 'NO')
requireColumn(table, 'user_id', 'text', 'NO')
requireColumn(table, 'created_at', 'timestamp without time zone', 'NO')
}
requireColumn('task_detail_change_requests', 'status', 'text', 'NO')
requireColumn('task_detail_change_requests', 'operations', 'jsonb', 'NO')
requireColumn('task_detail_change_requests', 'base_material_updated_at', 'timestamp without time zone', 'NO')
requireColumn('task_detail_change_requests', 'expires_at', 'timestamp without time zone', 'NO')
requireColumn('task_detail_change_notifications', 'id', 'text', 'NO')
requireColumn('task_detail_change_notifications', 'request_id', 'text', 'NO')
requireColumn('task_detail_change_notifications', 'event_type', 'text', 'NO')
requireColumn('task_detail_change_notifications', 'business_key', 'text', 'NO')
requireColumn('task_detail_change_notifications', 'status', 'text', 'NO')
requireColumn('task_detail_change_notifications', 'next_retry_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'custody_department_id', 'text', 'YES')
requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'custody_reason', 'text', 'YES')
requireColumn('materials', 'task_detail_demand_type', 'text', 'YES')
requireColumn('materials', 'task_detail_private', 'boolean', 'NO')
requireColumn('materials', 'is_incomplete_shell', 'boolean', 'NO')
requireColumn('material_permission_pack_items', 'can_access_private', 'boolean', 'NO')
requireColumn('product_package_upload_jobs', 'worker_id', 'text', 'YES')
requireColumn('product_package_upload_jobs', 'lease_token', 'text', 'YES')
requireColumn('product_package_upload_jobs', 'lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('organization_units', 'id', 'text', 'NO')
requireColumn('organization_units', 'source_dept_id', 'integer', 'NO')
requireColumn('organization_units', 'parent_id', 'text', 'YES')
requireColumn('organization_units', 'path', 'text', 'NO')
requireColumn('organization_units', 'level', 'integer', 'NO')
requireColumn('organization_units', 'is_active', 'boolean', 'NO')
requireColumn('performance_survey_employee_profiles', 'employment_state', 'text', 'YES')
requireColumn('performance_survey_employee_profiles', 'resigned_at', 'timestamp without time zone', 'YES')
requireColumn('performance_survey_employee_profiles', 'handover_dingtalk_user_id', 'text', 'YES')
requireColumn('performance_survey_employee_roster_snapshots', 'id', 'text', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'captured_at', 'timestamp without time zone', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'profile_count', 'integer', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'employee_profile_id', 'text', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'dingtalk_user_id', 'text', 'YES')
requireColumn('performance_survey_employee_roster_snapshots', 'name', 'text', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'department_name', 'text', 'YES')
requireColumn('performance_survey_employee_roster_snapshots', 'department_path', 'text', 'YES')
requireColumn('performance_survey_employee_roster_snapshots', 'employment_state', 'text', 'YES')
requireColumn('performance_survey_employee_roster_snapshots', 'is_brand_center', 'boolean', 'NO')
requireColumn('performance_survey_employee_roster_snapshots', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('user_employee_bindings', 'id', 'text', 'NO')
requireColumn('user_employee_bindings', 'user_id', 'text', 'NO')
requireColumn('user_employee_bindings', 'employee_profile_id', 'text', 'NO')
requireColumn('user_employee_bindings', 'binding_source', 'text', 'NO')
requireColumn('user_employee_bindings', 'status', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'process_instance_id', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'status', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'is_ongoing', 'boolean', 'NO')
requireColumn('ding_workflow_snapshots', 'source_updated_at_raw', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'scheme_field_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'task_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'active_task_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'synced_at', 'timestamp without time zone', 'NO')
requireColumn('ding_workflow_snapshots', 'brand', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'demand_type', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'requester_name', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'requester_department', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'project_type', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'active_tasks', 'jsonb', 'YES')
requireColumn('ding_workflow_snapshots', 'form_fields', 'jsonb', 'YES')
requireColumn('ding_workflow_sync_states', 'id', 'text', 'NO')
requireColumn('ding_workflow_sync_states', 'status', 'text', 'NO')
requireColumn('ding_workflow_sync_states', 'source_cursor_raw', 'text', 'YES')
requireColumn('ding_workflow_sync_states', 'last_completed_at', 'timestamp without time zone', 'YES')
requireColumn('ding_workflow_sync_states', 'lease_owner', 'text', 'YES')
requireColumn('ding_workflow_sync_states', 'lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('ding_workflow_sync_states', 'current_ongoing_count', 'integer', 'NO')
requireColumn('system_settings', 'headerLogoUrl', 'text', 'NO')
requireColumn('system_settings', 'loginLogoUrl', 'text', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'id', 'text', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'source_key', 'text', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'source_row_id', 'text', 'YES')
requireColumn('performance_conversion_metric_snapshots', 'product_id', 'text', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'metric_date', 'date', 'NO')
for (const column of [
'product_name', 'source_brand', 'category1', 'category2', 'category3',
'payment_amount', 'payment_users', 'cart_users', 'visitors', 'conversion_rate',
'favorites', 'impressions', 'clicks', 'click_rate', 'promotion_spend',
'promotion_revenue', 'roi',
]) {
requireColumn('performance_conversion_metric_snapshots', column, 'text', 'YES')
}
requireColumn('performance_conversion_metric_snapshots', 'synced_at', 'timestamp without time zone', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('performance_conversion_metric_snapshots', 'updated_at', 'timestamp without time zone', 'NO')
requireColumn('performance_conversion_sync_states', 'id', 'text', 'NO')
requireColumn('performance_conversion_sync_states', 'status', 'text', 'NO')
requireColumn('performance_conversion_sync_states', 'source_cursor_raw', 'text', 'YES')
requireColumn('performance_conversion_sync_states', 'last_started_at', 'timestamp without time zone', 'YES')
requireColumn('performance_conversion_sync_states', 'last_completed_at', 'timestamp without time zone', 'YES')
requireColumn('performance_conversion_sync_states', 'last_error', 'text', 'YES')
requireColumn('performance_conversion_sync_states', 'lease_owner', 'text', 'YES')
requireColumn('performance_conversion_sync_states', 'lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('performance_conversion_sync_states', 'rows_upserted', 'integer', 'NO')
requireColumn('performance_conversion_sync_states', 'products_synced', 'integer', 'NO')
requireColumn('performance_conversion_sync_states', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('performance_conversion_sync_states', 'updated_at', 'timestamp without time zone', 'NO')
const header = columnByKey.get('system_settings.headerLogoUrl')
const login = columnByKey.get('system_settings.loginLogoUrl')
const legacy = columnByKey.get('system_settings.logoUrl')
const taskDetailDemandType = columnByKey.get('materials.task_detail_demand_type')
const taskDetailPrivate = columnByKey.get('materials.task_detail_private')
const canAccessPrivate = columnByKey.get('material_permission_pack_items.can_access_private')
const brandKnowledgeDownloadCount = columnByKey.get('brand_knowledge.download_count')
const brandKnowledgePreviewSlices = columnByKey.get('brand_knowledge.preview_image_slices')
const performanceSyncStatus = columnByKey.get('performance_conversion_sync_states.status')
const performanceSyncRowsUpserted = columnByKey.get('performance_conversion_sync_states.rows_upserted')
const performanceSyncProductsSynced = columnByKey.get('performance_conversion_sync_states.products_synced')
if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') {
fail('Database default mismatch: system_settings.headerLogoUrl')
}
if (!login || normalizeColumnDefault(login.column_default) !== '/brand/login-logo.png') {
fail('Database default mismatch: system_settings.loginLogoUrl')
}
if (!taskDetailDemandType || normalizeColumnDefault(taskDetailDemandType.column_default) !== '') {
fail('Database default mismatch: materials.task_detail_demand_type')
}
if (!taskDetailPrivate || normalizeColumnDefault(taskDetailPrivate.column_default) !== 'false') {
fail('Database default mismatch: materials.task_detail_private')
}
if (!canAccessPrivate || normalizeColumnDefault(canAccessPrivate.column_default) !== 'false') {
fail('Database default mismatch: material_permission_pack_items.can_access_private')
}
if (!brandKnowledgeDownloadCount || normalizeColumnDefault(brandKnowledgeDownloadCount.column_default) !== '0') {
fail('Database default mismatch: brand_knowledge.download_count')
}
if (!brandKnowledgePreviewSlices || String(brandKnowledgePreviewSlices.column_default || '').replace(/\s+/g, '').toLowerCase() !== 'array[]::text[]') {
fail('Database default mismatch: brand_knowledge.preview_image_slices')
}
if (!performanceSyncStatus || normalizeColumnDefault(performanceSyncStatus.column_default) !== 'idle') {
fail('Database default mismatch: performance_conversion_sync_states.status')
}
if (!performanceSyncRowsUpserted || normalizeColumnDefault(performanceSyncRowsUpserted.column_default) !== '0') {
fail('Database default mismatch: performance_conversion_sync_states.rows_upserted')
}
if (!performanceSyncProductsSynced || normalizeColumnDefault(performanceSyncProductsSynced.column_default) !== '0') {
fail('Database default mismatch: performance_conversion_sync_states.products_synced')
}
if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) {
fail('Database column mismatch: system_settings.logoUrl')
}
if (legacy && normalizeColumnDefault(legacy.column_default) !== '/brand/header-logo.png') {
fail('Database default mismatch: system_settings.logoUrl')
}
const indexByName = new Map(evidence.indexes.map((row) => [String(row.indexname), String(row.indexdef)]))
assertExactBtreeIndex(
indexByName.get('materials_transcode_lease_idx'),
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
)
assertExactBtreeIndex(
indexByName.get('product_package_upload_jobs_lease_idx'),
'product_package_upload_jobs',
['status', 'lease_expires_at'],
)
assertExactActiveUploadIdIndex(
evidence.indexes.find((row) => String(row.indexname) === 'materials_uploadId_key'),
)
assertExactBtreeIndex(
indexByName.get('materials_is_incomplete_shell_idx'),
'materials',
['is_incomplete_shell'],
)
assertExactBtreeIndex(
indexByName.get('brand_knowledge_product_category_name_idx'),
'brand_knowledge',
['product_category_name'],
)
assertExactBtreeIndex(
indexByName.get('brand_knowledge_deleted_at_idx'),
'brand_knowledge',
['deleted_at'],
)
for (const table of ['brand_knowledge_likes', 'brand_knowledge_favorites']) {
assertExactUniqueBtreeIndex(
evidence.indexes.find((row) => String(row.indexname) === `${table}_knowledge_id_user_id_key`),
table,
`${table}_knowledge_id_user_id_key`,
['knowledge_id', 'user_id'],
)
assertExactBtreeIndex(indexByName.get(`${table}_knowledge_id_idx`), table, ['knowledge_id'])
assertExactBtreeIndex(indexByName.get(`${table}_user_id_idx`), table, ['user_id'])
}
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_ongoing_updated_idx'),
'ding_workflow_snapshots',
['is_ongoing', 'source_updated_at_raw'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_brand_idx'),
'ding_workflow_snapshots',
['brand'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_demand_type_idx'),
'ding_workflow_snapshots',
['demand_type'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_requester_department_idx'),
'ding_workflow_snapshots',
['requester_department'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_sync_states_lease_idx'),
'ding_workflow_sync_states',
['lease_expires_at'],
)
assertExactBtreeIndex(
indexByName.get('task_detail_change_requests_status_expires_idx'),
'task_detail_change_requests',
['status', 'expires_at'],
)
assertExactBtreeIndex(
indexByName.get('task_detail_change_requests_status_updated_idx'),
'task_detail_change_requests',
['status', 'updated_at'],
)
assertExactBtreeIndex(
indexByName.get('task_detail_change_notifications_status_retry_idx'),
'task_detail_change_notifications',
['status', 'next_retry_at'],
)
assertExactBtreeIndex(
indexByName.get('api_key_reveal_audits_api_key_id_created_at_idx'),
'api_key_reveal_audits',
['api_key_id', 'created_at'],
)
assertExactBtreeIndex(
indexByName.get('api_key_reveal_audits_viewer_user_id_created_at_idx'),
'api_key_reveal_audits',
['viewer_user_id', 'created_at'],
)
assertExactUniqueBtreeIndex(
evidence.indexes.find((row) => String(row.indexname) === 'performance_conversion_metric_snapshots_source_key_key'),
'performance_conversion_metric_snapshots',
'performance_conversion_metric_snapshots_source_key_key',
['source_key'],
)
assertExactBtreeIndex(
indexByName.get('performance_conversion_metric_product_date_idx'),
'performance_conversion_metric_snapshots',
['product_id', 'metric_date'],
)
assertExactBtreeIndex(
indexByName.get('performance_conversion_metric_date_idx'),
'performance_conversion_metric_snapshots',
['metric_date'],
)
assertExactBtreeIndex(
indexByName.get('performance_conversion_sync_states_lease_idx'),
'performance_conversion_sync_states',
['lease_expires_at'],
)
assertExactCheckConstraint(
(evidence.constraints || []).find((row) => String(row.constraint_name) === 'materials_private_optimization_check'),
'materials',
'materials_private_optimization_check',
['materialType', 'task_detail_demand_type', 'task_detail_private'],
`NOT task_detail_private OR ((materialType = '任务-详情' AND task_detail_demand_type = '优化') IS TRUE)`,
)
for (const table of ['brand_knowledge_likes', 'brand_knowledge_favorites']) {
assertExactForeignKeyConstraint(
(evidence.constraints || []).find((row) => String(row.constraint_name) === `${table}_knowledge_id_fkey`),
table,
`${table}_knowledge_id_fkey`,
'knowledge_id',
'brand_knowledge',
'id',
)
assertExactForeignKeyConstraint(
(evidence.constraints || []).find((row) => String(row.constraint_name) === `${table}_user_id_fkey`),
table,
`${table}_user_id_fkey`,
'user_id',
'users',
'id',
)
}
const isTrustedBrandValue = (value, localDefault) => {
const normalized = String(value || '').trim()
if (normalized === localDefault) return true
try {
const parsed = new URL(normalized)
return parsed.protocol === 'https:'
&& parsed.hostname === 's3.jiansun.vip'
&& parsed.port === ''
&& parsed.username === ''
&& parsed.password === ''
&& parsed.pathname.startsWith('/gongxiangpan/')
&& parsed.search === ''
&& parsed.hash === ''
} catch {
return false
}
}
for (const row of evidence.brandRows || []) {
if (!isTrustedBrandValue(row.headerLogoUrl, '/brand/header-logo.png')) {
fail(`Stored brand logo is untrusted: system_settings.${row.id}.headerLogoUrl`)
}
if (!isTrustedBrandValue(row.loginLogoUrl, '/brand/login-logo.png')) {
fail(`Stored brand logo is untrusted: system_settings.${row.id}.loginLogoUrl`)
}
}
assertOrganizationConfigurationEvidence(evidence)
assertDetailGeneratorEvidence(evidence)
assertAiModelRegistryEvidence(evidence)
assertMaterialPurgeEvidence(evidence)
assertMaterialPurgeReferenceEvidence(evidence)
}
export function materialPurgeReferenceExpectations() {
const sql = readFileSync(path.join(MIGRATIONS_ROOT, '20261009_material_purge_reference_indexes/migration.sql'), 'utf8').replace(/\r\n/g, '\n')
const functions = [...sql.matchAll(/CREATE FUNCTION (\w+)\(([^)]*)\) RETURNS [^\n]+\nLANGUAGE plpgsql IMMUTABLE STRICT PARALLEL UNSAFE AS \$\$([\s\S]*?)\$\$;/g)]
.map(match => ({ proname: match[1], arguments: match[2], prosrc: match[3], provolatile: 'i', proparallel: 'u', proisstrict: true }))
const indexes = [...sql.matchAll(/CREATE INDEX (\w+) ON (\w+) USING gin\n \(([^;]+)\);/g)]
.map(match => {
const [expression, predicate = ''] = match[3].split(') WHERE ')
const normalizedPredicate = predicate
? predicate.startsWith('status NOT IN') ? "status <> ALL (ARRAY['applied','rejected','expired','cancelled'])" : "status = ANY (ARRAY['queued','processing'])"
: ''
return { indexname: match[1], tablename: match[2], access_method: 'gin', indisunique: false,
indisprimary: false, indisvalid: true, indisready: true, columns: [expression], predicate: normalizedPredicate }
})
if (functions.length !== 3 || indexes.length !== 9) fail('Purge reference migration definitions are incomplete')
return { referenceFunctions: functions, indexes }
}
export function assertDetailGeneratorEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const required = {
detail_generator_projects: { id: 'text', owner_id: 'text', document: 'jsonb', revision: 'integer' },
detail_generator_assets: { id: 'text', project_id: 'text', owner_id: 'text', storage_key: 'text', byte_size: 'integer', mime_type: 'text' },
detail_generator_jobs: { id: 'text', project_id: 'text', owner_id: 'text', status: 'text', idempotency_key: 'text', checkpoint: 'jsonb', project_revision: 'integer', lease_token: 'text', lease_expires_at: 'timestamp with time zone' },
detail_generator_brand_profiles: { id: 'text', name: 'text', profile: 'jsonb', revision: 'integer' },
}
for (const [table, fields] of Object.entries(required)) {
for (const [column, type] of Object.entries(fields)) {
if (columns.get(`${table}.${column}`)?.data_type !== type) fail(`Database column mismatch: ${table}.${column}`)
}
}
}
export function assertAiModelRegistryEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const required = {
ai_model_providers: { id: 'text', name: 'text', base_url: 'text', enabled: 'boolean', credential_source: 'text', secret_ciphertext: 'text?', secret_nonce: 'text?', secret_auth_tag: 'text?', legacy_binding: 'boolean', revision: 'integer', synced_at: 'timestamp with time zone?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' },
ai_model_catalog: { id: 'text', provider_id: 'text', model_id: 'text', enabled: 'boolean', stages: 'ARRAY', present: 'boolean', compatibility: 'text', capability_metadata: 'jsonb?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' },
ai_model_configuration: { id: 'text', default_analysis: 'text?', default_copy: 'text?', default_image: 'text?', revision: 'integer', updated_at: 'timestamp with time zone' },
ai_model_audit: { id: 'text', actor_id: 'text', action: 'text', provider_id: 'text?', details: 'jsonb', created_at: 'timestamp with time zone' },
ai_model_verifications: { id: 'text', model_key: 'text', stage: 'text', context_hash: 'text', idempotency_key: 'text', actor_id: 'text', status: 'text', request_id: 'text?', task_id: 'text?', message: 'text?', evidence: 'jsonb', started_at: 'timestamp with time zone', deadline_at: 'timestamp with time zone', finished_at: 'timestamp with time zone?' },
}
for (const [table, fields] of Object.entries(required)) for (const [column, definition] of Object.entries(fields)) {
const nullable = definition.endsWith('?'), type = nullable ? definition.slice(0, -1) : definition
const row = columns.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== (nullable ? 'YES' : 'NO') || type === 'ARRAY' && row.udt_name !== '_text') fail(`Database column mismatch: ${table}.${column}`)
}
for (const [table, column, expected] of [
['ai_model_providers', 'enabled', 'false'], ['ai_model_providers', 'legacy_binding', 'false'],
['ai_model_catalog', 'enabled', 'false'], ['ai_model_catalog', 'present', 'true'],
['ai_model_catalog', 'compatibility', 'unconfirmed'],
]) if (normalizeColumnDefault(columns.get(`${table}.${column}`)?.column_default) !== expected) fail(`Database default mismatch: ${table}.${column}`)
const indexes = evidence.indexes || [], constraints = evidence.constraints || []
assertExactUniqueBtreeIndex(indexes.find(row => row.indexname === 'ai_model_catalog_provider_model_key'), 'ai_model_catalog', 'ai_model_catalog_provider_model_key', ['provider_id', 'model_id'])
const legacy = indexes.find(row => row.indexname === 'ai_model_providers_legacy_binding_key')
if (normalizeConstraintExpression(legacy?.predicate) !== 'legacy_binding=true') fail('Database index predicate mismatch: ai_model_providers_legacy_binding_key')
assertExactUniqueBtreeIndex(legacy && { ...legacy, predicate: '' }, 'ai_model_providers', 'ai_model_providers_legacy_binding_key', ['legacy_binding'])
assertExactBtreeIndex(indexes.find(row => row.indexname === 'ai_model_audit_created_idx')?.indexdef, 'ai_model_audit', ['created_at'])
assertExactUniqueBtreeIndex(indexes.find(row => row.indexname === 'ai_model_verifications_idempotency_key'), 'ai_model_verifications', 'ai_model_verifications_idempotency_key', ['model_key', 'stage', 'idempotency_key'])
const unresolved = indexes.find(row => row.indexname === 'ai_model_verifications_unresolved_key')
if (normalizeConstraintExpression(unresolved?.predicate) !== normalizeConstraintExpression("status = ANY (ARRAY['running', 'unknown'])")) fail('Database index predicate mismatch: ai_model_verifications_unresolved_key')
assertExactUniqueBtreeIndex(unresolved && { ...unresolved, predicate: '' }, 'ai_model_verifications', 'ai_model_verifications_unresolved_key', ['model_key', 'stage'])
const verificationOrder = indexes.find(row => row.indexname === 'ai_model_verifications_model_stage_idx')
if (!verificationOrder || verificationOrder.indisvalid !== true || verificationOrder.indisready !== true || verificationOrder.indisunique !== false || String(verificationOrder.predicate || '') !== ''
|| !/^CREATE INDEX \S+ ON (?:public\.)?ai_model_verifications USING btree \(model_key, stage, started_at DESC\)$/.test(String(verificationOrder.indexdef || ''))) fail('Database index mismatch: ai_model_verifications_model_stage_idx')
for (const [table, column, referenced] of [
['ai_model_catalog', 'provider_id', 'ai_model_providers'],
['ai_model_verifications', 'model_key', 'ai_model_catalog'],
...['default_analysis', 'default_copy', 'default_image'].map(column => ['ai_model_configuration', column, 'ai_model_catalog']),
]) {
const name = `${table}_${column}_fkey`, constraint = constraints.find(row => row.constraint_name === name)
assertExactForeignKeyConstraint(constraint, table, name, column, referenced, 'id')
if (constraint.delete_action !== 'r') fail(`Database foreign key deletion mismatch: ${name}`)
}
for (const [table, name, fields, expression] of [
['ai_model_providers', 'ai_model_providers_credential_source_check', ['credential_source'], "credential_source = ANY (ARRAY['environment', 'encrypted'])"],
['ai_model_providers', 'ai_model_providers_revision_check', ['revision'], 'revision > 0'],
['ai_model_providers', 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment' AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted' AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"],
['ai_model_catalog', 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis', 'copy', 'image']"],
['ai_model_catalog', 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed', 'unconfirmed'])"],
['ai_model_catalog', 'ai_model_enabled_stages', ['enabled', 'stages'], 'NOT enabled OR cardinality(stages) > 0'],
['ai_model_verifications', 'ai_model_verifications_stage_check', ['stage'], "stage = ANY (ARRAY['analysis', 'copy', 'image'])"],
['ai_model_verifications', 'ai_model_verifications_context_hash_check', ['context_hash'], "context_hash ~ '^[0-9a-f]{64}$'"],
['ai_model_verifications', 'ai_model_verifications_status_check', ['status'], "status = ANY (ARRAY['running', 'passed', 'failed', 'unknown'])"],
['ai_model_configuration', 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'"],
['ai_model_configuration', 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'],
]) assertExactCheckConstraint(constraints.find(row => row.constraint_name === name), table, name, fields, expression)
}
export function assertMaterialPurgeReferenceEvidence(evidence) {
const expected = materialPurgeReferenceExpectations()
for (const item of expected.indexes) {
const actual = evidence.indexes.find(index => index.indexname === item.indexname)
if (!actual || actual.tablename !== item.tablename || actual.access_method !== 'gin'
|| actual.indisunique !== false || actual.indisprimary !== false || actual.indisvalid !== true || actual.indisready !== true
|| actual.columns?.length !== 1
|| normalizeConstraintExpression(actual.columns[0]) !== normalizeConstraintExpression(item.columns[0])
|| normalizeConstraintExpression(actual.predicate) !== normalizeConstraintExpression(item.predicate)) {
fail(`Purge reference index mismatch: ${item.indexname}`)
}
}
for (const item of expected.referenceFunctions) {
const matches = (evidence.referenceFunctions || []).filter(fn => fn.proname === item.proname)
const actual = matches[0]
if (matches.length !== 1 || actual.arguments !== item.arguments || actual.provolatile !== 'i'
|| actual.proparallel !== 'u' || actual.proisstrict !== true
|| String(actual.prosrc).replace(/\r\n/g, '\n') !== item.prosrc) {
fail(`Purge reference function mismatch: ${item.proname}`)
}
}
}
export function assertMaterialPurgeEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const job = 'material_purge_jobs'
const audit = 'material_purge_audits'
for (const [table, column, type, nullable] of [
...['id', 'material_id', 'operator_user_id', 'status'].map(column => [job, column, 'text', 'NO']),
...['material_snapshot', 'files'].map(column => [job, column, 'jsonb', 'NO']),
[job, 'attempts', 'integer', 'NO'],
...['error_message', 'lease_token'].map(column => [job, column, 'text', 'YES']),
...['lease_expires_at', 'completed_at'].map(column => [job, column, 'timestamp without time zone', 'YES']),
...['next_attempt_at', 'created_at', 'updated_at'].map(column => [job, column, 'timestamp without time zone', 'NO']),
...['id', 'job_id', 'event'].map(column => [audit, column, 'text', 'NO']),
...['operator_user_id', 'message'].map(column => [audit, column, 'text', 'YES']),
[audit, 'created_at', 'timestamp without time zone', 'NO'],
]) {
const row = columns.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== nullable) fail(`Database column mismatch: ${table}.${column}`)
}
const uniqueName = 'material_purge_jobs_material_id_key'
assertExactUniqueBtreeIndex(evidence.indexes.find(row => row.indexname === uniqueName), job, uniqueName, ['material_id'])
for (const [table, name, fields] of [
[job, 'material_purge_jobs_status_next_attempt_at_idx', ['status', 'next_attempt_at']],
[audit, 'material_purge_audits_job_id_created_at_idx', ['job_id', 'created_at']],
]) assertExactBtreeIndex(evidence.indexes.find(row => row.indexname === name)?.indexdef, table, fields)
const fk = 'material_purge_audits_job_id_fkey'
assertExactForeignKeyConstraint(evidence.constraints.find(row => row.constraint_name === fk), audit, fk, 'job_id', job, 'id')
if (evidence.constraints.some(row => ['material_logs', 'material_download_logs'].includes(row.tablename) && row.referenced_table === 'materials')) {
fail('Material audit logs must survive permanent deletion; material foreign key is still present')
}
}
export function assertOrganizationConfigurationEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
for (const [table, column, type, nullable] of [
['material_permission_pack_departments', 'organization_unit_id', 'text', 'YES'],
...['id', 'organization_unit_id', 'description', 'updated_by_user_id'].map(column => ['team_function_configs', column, 'text', 'NO']),
['team_function_configs', 'lead_employee_profile_id', 'text', 'YES'],
['team_function_configs', 'branches', 'jsonb', 'NO'],
['team_function_configs', 'revision', 'integer', 'NO'],
...['created_at', 'updated_at'].map(column => ['team_function_configs', column, 'timestamp without time zone', 'NO']),
...['id', 'config_id', 'actor_user_id'].map(column => ['team_function_config_audits', column, 'text', 'NO']),
['team_function_config_audits', 'before', 'jsonb', 'YES'],
['team_function_config_audits', 'after', 'jsonb', 'NO'],
['team_function_config_audits', 'created_at', 'timestamp without time zone', 'NO'],
]) {
const row = columns.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== nullable) fail(`Database column mismatch: ${table}.${column}`)
}
for (const [table, name, column] of [
['material_permission_pack_departments', 'material_permission_pack_dept_unit_key', 'organization_unit_id'],
['team_function_configs', 'team_function_configs_organization_unit_id_key', 'organization_unit_id'],
]) assertExactUniqueBtreeIndex(evidence.indexes.find(row => row.indexname === name), table, name, [column])
for (const [table, name, columns] of [
['material_permission_pack_departments', 'material_permission_pack_departments_pack_id_idx', ['pack_id']],
['team_function_config_audits', 'team_function_config_audits_config_id_created_at_idx', ['config_id', 'created_at']],
]) assertExactBtreeIndex(evidence.indexes.find(row => row.indexname === name)?.indexdef, table, columns)
for (const [table, name, column, referenced] of [
['material_permission_pack_departments', 'material_permission_pack_departments_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'],
['team_function_configs', 'team_function_configs_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'],
['team_function_configs', 'team_function_configs_lead_employee_profile_id_fkey', 'lead_employee_profile_id', 'performance_survey_employee_profiles'],
['team_function_config_audits', 'team_function_config_audits_config_id_fkey', 'config_id', 'team_function_configs'],
]) assertExactForeignKeyConstraint(evidence.constraints.find(row => row.constraint_name === name), table, name, column, referenced, 'id')
assertExactCheckConstraint(evidence.constraints.find(row => row.constraint_name === 'team_function_configs_revision_check'),
'team_function_configs', 'team_function_configs_revision_check', ['revision'], 'revision > 0')
}
function assertNoUnexpectedPendingMigrations(localMigrations, evidence) {
const completed = completedMigrationMap(evidence.migrationRows)
const targetSet = new Set(TARGET_MIGRATIONS)
const unexpected = Array.from(localMigrations.keys()).filter(
(name) => !completed.has(name) && !targetSet.has(name),
)
if (unexpected.length > 0) {
fail(`Unexpected pending migrations exist; refusing a bundled production write: ${unexpected.join(', ')}`)
}
const failed = evidence.migrationRows.filter((row) => !row.finished_at && !row.rolled_back_at)
if (failed.length > 0) {
fail(`Unresolved failed migrations exist: ${failed.map((row) => row.migration_name).join(', ')}`)
}
}
export function assertExpectedDatabaseIdentity(databaseUrl, identity) {
const expected = {
configuredHost: String(process.env.EXPECTED_DATABASE_HOST || '').trim(),
configuredPort: String(process.env.EXPECTED_DATABASE_PORT || '').trim(),
configuredDatabase: String(process.env.EXPECTED_DATABASE_NAME || '').trim(),
configuredUser: String(process.env.EXPECTED_DATABASE_USER || '').trim(),
serverAddress: String(process.env.EXPECTED_DATABASE_SERVER_ADDRESS || '').trim(),
serverPort: String(process.env.EXPECTED_DATABASE_SERVER_PORT || '').trim(),
}
if (Object.values(expected).some((value) => !value)) {
fail('Apply mode requires the configured and server database host, port, name and user fingerprint')
}
expected.configuredPort = normalizedPort(expected.configuredPort)
expected.serverPort = normalizedPort(expected.serverPort)
const configured = redactDatabaseFingerprint(databaseUrl)
if (
configured.configuredHost !== expected.configuredHost
|| configured.configuredPort !== expected.configuredPort
|| configured.configuredDatabase !== expected.configuredDatabase
|| configured.configuredUser !== expected.configuredUser
|| String(identity.database) !== expected.configuredDatabase
|| String(identity.user) !== expected.configuredUser
|| String(identity.server_address) !== expected.serverAddress
|| normalizedPort(identity.server_port) !== expected.serverPort
) {
fail('Production database identity does not match the explicit expected fingerprint')
}
return { ...configured, serverAddress: expected.serverAddress, serverPort: expected.serverPort }
}
export function assertBackupReceiptDocument(receipt, context, now = new Date()) {
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) || receipt.schemaVersion !== 1) {
fail('Backup receipt must use schemaVersion 1')
}
const maxAgeMinutes = Number(context.maxAgeMinutes)
if (!Number.isInteger(maxAgeMinutes) || maxAgeMinutes < 1 || maxAgeMinutes > 1440) {
fail('BACKUP_MAX_AGE_MINUTES must be an integer from 1 to 1440')
}
if (receipt.sourceCommit !== context.expectedCommit) {
fail('Backup receipt sourceCommit does not match the migrator commit')
}
if (receipt.generatedBy !== VERIFIED_BACKUP_GENERATOR) {
fail('Backup receipt must be machine-generated by the verified backup helper')
}
const database = receipt.database
const expectedDatabase = context.database
if (!database || typeof database !== 'object' || [
'configuredHost',
'configuredPort',
'configuredDatabase',
'configuredUser',
'serverAddress',
'serverPort',
].some((key) => String(database[key] || '') !== String(expectedDatabase[key] || ''))) {
fail('Backup receipt database fingerprint does not match the verified production database')
}
const backup = receipt.backup
const restore = receipt.restoreValidation
if (!backup || typeof backup !== 'object' || !restore || typeof restore !== 'object') {
fail('Backup receipt must contain backup and restoreValidation evidence')
}
if (backup.format !== 'pg_dump-custom') {
fail('Backup receipt format must be pg_dump-custom')
}
if (!path.isAbsolute(String(backup.artifactPath || ''))) {
fail('Backup artifactPath must be absolute')
}
if (!Number.isSafeInteger(backup.artifactSize) || backup.artifactSize <= 0) {
fail('Backup artifactSize must be a positive integer')
}
if (!/^[0-9a-f]{64}$/.test(String(backup.artifactSha256 || ''))) {
fail('Backup artifactSha256 must be a lowercase SHA-256')
}
if (
restore.status !== 'passed'
|| restore.artifactSha256 !== backup.artifactSha256
|| restore.databaseFingerprint !== databaseFingerprintHash(database)
) {
fail('Backup receipt does not prove a restore validation for this artifact and database fingerprint')
}
const isolated = restore.isolatedDatabase
assertIsolatedRestoreFingerprint(database, isolated)
if (restore.isolatedDatabaseFingerprint !== databaseFingerprintHash(isolated)) {
fail('Backup receipt isolated restore database fingerprint hash mismatch')
}
const summary = restore.verificationSummary
if (
!summary
|| typeof summary !== 'object'
|| Array.isArray(summary)
|| summary.schemaVersion !== 1
|| summary.maintenanceWindowConfirmation !== VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION
|| summary.statementTimeoutMs !== 30_000
|| summary.sampleLimit !== 32
) {
fail('Backup receipt must contain machine-generated bounded restore verification evidence')
}
assertBoundedBackupSummary(summary.sourceBefore, 'Production pre-dump summary')
assertBoundedBackupSummary(summary.sourceAfter, 'Production post-dump summary')
assertBoundedBackupSummary(summary.restored, 'Isolated restore summary')
const sourceBeforeHash = verifiedBackupSummaryHash(summary.sourceBefore)
const sourceAfterHash = verifiedBackupSummaryHash(summary.sourceAfter)
const restoredHash = verifiedBackupSummaryHash(summary.restored)
if (
sourceBeforeHash !== sourceAfterHash
|| sourceAfterHash !== restoredHash
|| summary.sourceSummarySha256 !== sourceAfterHash
|| summary.restoredSummarySha256 !== restoredHash
) {
fail('Backup receipt bounded source and restore verification summaries do not match')
}
const startedAt = new Date(backup.startedAt)
const completedAt = new Date(backup.completedAt)
const restoreStartedAt = new Date(restore.startedAt)
const restoreCompletedAt = new Date(restore.completedAt)
if (
!Number.isFinite(startedAt.getTime())
|| !Number.isFinite(completedAt.getTime())
|| !Number.isFinite(restoreStartedAt.getTime())
|| !Number.isFinite(restoreCompletedAt.getTime())
) {
fail('Backup and restore validation timestamps must be valid ISO timestamps')
}
const futureToleranceMs = 5 * 60 * 1000
if (
completedAt.getTime() > now.getTime() + futureToleranceMs
|| restoreCompletedAt.getTime() > now.getTime() + futureToleranceMs
|| completedAt.getTime() < startedAt.getTime()
|| restoreStartedAt.getTime() < completedAt.getTime()
|| restoreCompletedAt.getTime() < restoreStartedAt.getTime()
|| restoreCompletedAt.getTime() < completedAt.getTime()
|| now.getTime() - completedAt.getTime() > maxAgeMinutes * 60 * 1000
) {
fail('Backup receipt is stale or has an invalid validation timeline')
}
return { backup, restore }
}
async function assertBackupReceipt(context) {
const receiptPathInput = String(process.env.BACKUP_RECEIPT_PATH || '').trim()
const artifactRootInput = String(process.env.BACKUP_ARTIFACT_ROOT || '').trim()
const receiptPath = receiptPathInput ? path.resolve(receiptPathInput) : ''
const expectedHash = String(process.env.EXPECTED_BACKUP_RECEIPT_SHA256 || '').trim().toLowerCase()
const artifactRoot = artifactRootInput ? path.resolve(artifactRootInput) : ''
const maxAgeMinutes = String(process.env.BACKUP_MAX_AGE_MINUTES || '').trim()
if (!receiptPath || !artifactRoot || !/^[0-9a-f]{64}$/.test(expectedHash) || !maxAgeMinutes) {
fail('Apply mode requires the backup receipt hash, artifact root and freshness limit')
}
const stat = await lstat(receiptPath)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0) {
fail('Backup receipt must be a non-empty regular file')
}
const receiptBytes = await readFile(receiptPath)
const actualHash = sha256(receiptBytes)
if (actualHash !== expectedHash) fail('Backup receipt checksum mismatch')
let receipt
try {
receipt = JSON.parse(receiptBytes.toString('utf8'))
} catch {
fail('Backup receipt must be valid JSON')
}
const { backup, restore } = assertBackupReceiptDocument(receipt, {
...context,
maxAgeMinutes,
})
const artifactPath = path.resolve(backup.artifactPath)
const relativeArtifact = path.relative(artifactRoot, artifactPath)
if (!relativeArtifact || relativeArtifact.startsWith('..') || path.isAbsolute(relativeArtifact)) {
fail('Backup artifact must be a file below BACKUP_ARTIFACT_ROOT')
}
const artifactStat = await lstat(artifactPath)
if (!artifactStat.isFile() || artifactStat.isSymbolicLink() || artifactStat.size !== backup.artifactSize) {
fail('Backup artifact is missing, unsafe or has an unexpected size')
}
const artifactHash = await sha256File(artifactPath)
if (artifactHash !== backup.artifactSha256) fail('Backup artifact checksum mismatch')
return {
receiptPath,
receiptSha256: actualHash,
receiptSize: stat.size,
artifactPath,
artifactSha256: artifactHash,
artifactSize: artifactStat.size,
backupCompletedAt: backup.completedAt,
restoreValidatedAt: restore.completedAt,
}
}
async function writeAuditRecord(record) {
const outputPath = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim()
if (!outputPath) {
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`)
return
}
const resolved = path.resolve(outputPath)
await mkdir(path.dirname(resolved), { recursive: true, mode: 0o750 })
const temp = `${resolved}.tmp-${process.pid}-${Date.now()}`
await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
await rename(temp, resolved)
}
async function assertApplyAuditOutputWritable() {
const outputInput = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim()
if (!outputInput) fail('Apply mode requires MIGRATION_AUDIT_OUTPUT')
const outputPath = path.resolve(outputInput)
try {
await lstat(outputPath)
fail('MIGRATION_AUDIT_OUTPUT already exists; use a new immutable audit filename')
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
const parent = path.dirname(outputPath)
const parentStat = await lstat(parent)
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
fail('Migration audit output parent must be a real directory')
}
const suffix = `.write-probe-${process.pid}-${Date.now()}`
const source = path.join(parent, suffix)
const destination = `${source}.renamed`
try {
await writeFile(source, 'audit-write-probe\n', { mode: 0o600, flag: 'wx' })
await rename(source, destination)
} finally {
await unlink(source).catch((error) => {
if (error?.code !== 'ENOENT') throw error
})
await unlink(destination).catch((error) => {
if (error?.code !== 'ENOENT') throw error
})
}
}
async function main() {
const userArgs = process.argv.slice(2)
const apply = userArgs.includes('--apply')
if (userArgs.some((arg) => !['--apply', '--audit'].includes(arg))) {
fail('Only --audit (default) or --apply is supported')
}
if (apply && userArgs.includes('--audit')) fail('Choose exactly one mode')
const bakedCommit = requireExactCommit(await readFile(BAKED_COMMIT_FILE, 'utf8'), 'Baked commit')
const expectedCommit = requireExactCommit(process.env.EXPECTED_COMMIT_SHA, 'EXPECTED_COMMIT_SHA')
if (bakedCommit !== expectedCommit) fail('Migrator image commit does not match EXPECTED_COMMIT_SHA')
if (!process.env.DATABASE_URL) fail('DATABASE_URL is required')
const databaseUrl = new URL(process.env.DATABASE_URL)
if (databaseUrl.protocol !== 'postgresql:' && databaseUrl.protocol !== 'postgres:') {
fail('DATABASE_URL must use PostgreSQL')
}
const localMigrations = await loadLocalMigrations()
const { PrismaClient } = await import('@prisma/client')
const prisma = new PrismaClient()
let before
let backup = null
let verifiedDatabaseFingerprint = null
try {
before = await queryEvidence(prisma, localMigrations)
assertNoUnexpectedPendingMigrations(localMigrations, before)
if (apply) {
if (process.env.ALLOW_PRODUCTION_MIGRATIONS !== 'APPLY') {
fail('Apply mode requires ALLOW_PRODUCTION_MIGRATIONS=APPLY')
}
await assertApplyAuditOutputWritable()
verifiedDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, before.identity)
backup = await assertBackupReceipt({
expectedCommit,
database: verifiedDatabaseFingerprint,
})
}
} finally {
await prisma.$disconnect()
}
if (apply) {
const prismaCli = path.join(ROOT, 'node_modules', '.bin', 'prisma')
const result = spawnSync(
prismaCli,
['migrate', 'deploy', '--schema', path.join(ROOT, 'prisma', 'schema.prisma')],
{ cwd: ROOT, env: process.env, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (result.error) throw result.error
if (result.status !== 0) fail(`prisma migrate deploy failed with exit code ${result.status}`)
}
const verifyClient = new PrismaClient()
let after
try {
after = await queryEvidence(verifyClient, localMigrations)
if (apply) {
const postApplyDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, after.identity)
if (databaseFingerprintHash(postApplyDatabaseFingerprint) !== databaseFingerprintHash(verifiedDatabaseFingerprint)) {
fail('Production database identity changed while migrations were being applied')
}
}
if (apply || TARGET_MIGRATIONS.every((name) => completedMigrationMap(after.migrationRows).has(name))) {
assertTargetMigrationEvidence(localMigrations, after)
}
} finally {
await verifyClient.$disconnect()
}
const completed = completedMigrationMap(after.migrationRows)
const record = {
mode: apply ? 'apply' : 'audit',
timestamp: new Date().toISOString(),
sourceCommit: bakedCommit,
database: {
...redactDatabaseFingerprint(databaseUrl),
actualDatabase: String(after.identity.database || ''),
actualUser: String(after.identity.user || ''),
serverAddress: String(after.identity.server_address || ''),
serverPort: String(after.identity.server_port || ''),
},
backup,
targetMigrations: TARGET_MIGRATIONS.map((name) => ({
name,
sourceChecksum: localMigrations.get(name)?.checksum || null,
databaseChecksum: completed.get(name)?.checksum || null,
completed: completed.has(name),
})),
verified: apply || TARGET_MIGRATIONS.every((name) => completed.has(name)),
}
await writeAuditRecord(record)
if (apply) process.stdout.write('Production migrations applied and verified.\n')
else process.stdout.write('Read-only production migration audit completed.\n')
}
if (
process.argv[1]
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
) {
main().catch((error) => {
process.stderr.write(`[SAFE_MIGRATION_ERROR] ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}