1205 lines
56 KiB
JavaScript
1205 lines
56 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { createReadStream, realpathSync } 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',
|
|
])
|
|
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, 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 = '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'
|
|
)
|
|
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,
|
|
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'
|
|
)
|
|
ORDER BY con.conname`,
|
|
)
|
|
const brandRows = await prisma.$queryRawUnsafe(
|
|
`SELECT id, "headerLogoUrl", "loginLogoUrl"
|
|
FROM "system_settings"
|
|
ORDER BY id`,
|
|
)
|
|
return { migrationRows, identity: identityRows[0], columns, indexes, constraints, brandRows }
|
|
}
|
|
|
|
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`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|