#!/usr/bin/env node import { createRequire } from 'node:module'; const DEFAULTS = { databaseUrl: process.env.DATABASE_URL, productId: 'perf-product-001', projectId: 'perf-project-001-001', versionId: 'perf-version-001-001-001', userId: 'perf-user-dev-01', search: 'REQ', limit: 50, dryRun: false, json: false, strictPlan: false, }; const REQUIRED_INDEXES = [ 'requirements_product_project_status_created_at_idx', 'requirements_version_status_created_at_idx', 'version_plans_version_type_status_idx', 'version_plans_owner_status_end_idx', 'dev_tasks_version_status_updated_at_idx', 'dev_tasks_assignee_unfinished_idx', 'test_cases_version_round_status_updated_at_idx', 'test_cases_version_round_status_updated_at_desc_idx', 'test_cases_assignee_unfinished_idx', 'bugs_version_status_severity_updated_at_idx', 'bugs_assignee_open_idx', 'xiaobao_risk_summaries_warning_score_idx', 'xiaobao_risk_summaries_dirty_updated_at_idx', 'projects_product_created_at_idx', 'versions_product_created_at_idx', 'versions_product_project_created_at_idx', 'work_activities_version_occurred_at_idx', 'task_worklogs_version_date_idx', 'ai_logs_operation_created_at_idx', 'ai_logs_status_created_at_idx', ]; function parseArgs(argv) { const args = { ...DEFAULTS }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--dry-run') { args.dryRun = true; continue; } if (arg === '--json') { args.json = true; continue; } if (arg === '--strict-plan') { args.strictPlan = true; continue; } if (arg === '--database-url') { args.databaseUrl = argv[++index] ?? args.databaseUrl; continue; } if (arg.startsWith('--database-url=')) { args.databaseUrl = arg.slice('--database-url='.length); continue; } if (arg === '--product-id') { args.productId = argv[++index] ?? args.productId; continue; } if (arg === '--project-id') { args.projectId = argv[++index] ?? args.projectId; continue; } if (arg === '--version-id') { args.versionId = argv[++index] ?? args.versionId; continue; } if (arg === '--user-id') { args.userId = argv[++index] ?? args.userId; continue; } if (arg === '--search') { args.search = argv[++index] ?? args.search; continue; } if (arg === '--limit') { args.limit = parsePositiveInt(argv[++index], args.limit); continue; } if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); } throw new Error(`Unknown argument: ${arg}`); } return args; } function printHelp() { console.log(`Usage: node scripts/explain-hot-queries.mjs [--dry-run] [--database-url URL] Audits V2.6 hot query budgets and indexes. Options: --dry-run Print hot query contracts without opening a database connection. --json Print machine-readable JSON. --strict-plan Fail when a SQL plan contains a sequential scan on a hot table. --database-url PostgreSQL URL. Defaults to DATABASE_URL. --product-id Fixture product id. Defaults to ${DEFAULTS.productId} --project-id Fixture project id. Defaults to ${DEFAULTS.projectId} --version-id Fixture version id. Defaults to ${DEFAULTS.versionId} --user-id Fixture assignee/owner id. Defaults to ${DEFAULTS.userId} --search Requirement search term. Defaults to ${DEFAULTS.search} --limit Requirement page limit. Defaults to ${DEFAULTS.limit} `); } function parsePositiveInt(raw, fallback) { const parsed = Number(raw); if (!Number.isFinite(parsed)) return fallback; return Math.max(1, Math.floor(parsed)); } function buildExplainTargets(args) { const search = `%${args.search}%`; const limit = args.limit + 1; return [ { key: 'healthVersion', label: 'health/version', budget: 'HTTP p95 <= 500ms; no database query', partitionKey: 'none', expectedIndexes: [], sql: null, }, { key: 'requirementPool', label: 'V2.2 requirement pool search', budget: 'HTTP p95 <= 1000ms; SQL should prune by product_id', partitionKey: 'requirements.product_id', expectedIndexes: [ 'requirements_product_project_status_created_at_idx', 'requirements_product_id_code_key', ], sql: ` SELECT r.* FROM requirements r LEFT JOIN users u ON u.id = r.creator_id WHERE r.product_id = $1 AND (r.code ILIKE $2 OR r.title ILIKE $2) ORDER BY r.created_at DESC LIMIT $3`, params: [args.productId, search, limit], }, { key: 'versionDetail.version', label: 'V2.2 version detail root', budget: 'point lookup by versions.id', partitionKey: 'versions.id', expectedIndexes: ['versions_pkey'], sql: 'SELECT * FROM versions WHERE id = $1', params: [args.versionId], }, { key: 'versionDetail.requirements', label: 'V2.2 version detail requirements', budget: 'child rows constrained by version_id', partitionKey: 'requirements.version_id', expectedIndexes: ['requirements_version_status_created_at_idx'], sql: ` SELECT r.* FROM requirements r LEFT JOIN users u ON u.id = r.creator_id WHERE r.version_id = $1 ORDER BY r.created_at DESC`, params: [args.versionId], }, { key: 'versionDetail.plans', label: 'V2.2 version detail plans', budget: 'child rows constrained by version_id', partitionKey: 'version_plans.version_id', expectedIndexes: ['version_plans_version_type_status_idx'], sql: ` SELECT * FROM version_plans WHERE version_id = $1 ORDER BY type ASC, created_at DESC`, params: [args.versionId], }, { key: 'versionDetail.devTasks', label: 'V2.2 version detail dev tasks', budget: 'partition prune by version_id', partitionKey: 'dev_tasks.version_id', expectedIndexes: ['dev_tasks_version_status_updated_at_idx'], sql: ` SELECT * FROM dev_tasks WHERE version_id = $1 ORDER BY status ASC, updated_at DESC`, params: [args.versionId], }, { key: 'versionDetail.testCases', label: 'V2.2 version detail test cases', budget: 'partition prune by version_id', partitionKey: 'test_cases.version_id', expectedIndexes: ['test_cases_version_round_status_updated_at_desc_idx'], sql: ` SELECT * FROM test_cases WHERE version_id = $1 ORDER BY round_no DESC, status ASC, updated_at DESC`, params: [args.versionId], }, { key: 'versionDetail.bugs', label: 'V2.2 version detail bugs', budget: 'partition prune by version_id', partitionKey: 'bugs.version_id', expectedIndexes: ['bugs_version_status_priority_updated_at_idx'], sql: ` SELECT * FROM bugs WHERE version_id = $1 ORDER BY status ASC, priority ASC, updated_at DESC`, params: [args.versionId], }, { key: 'workspace.plans', label: 'V2.2 workspace plans', budget: 'owner unfinished lookup', partitionKey: 'version_plans.owner_id', expectedIndexes: ['version_plans_owner_open_due_idx'], sql: ` SELECT * FROM version_plans WHERE owner_id = $1 AND status <> 'completed' ORDER BY expected_end_at ASC, updated_at DESC`, params: [args.userId], }, { key: 'workspace.devTasks', label: 'V2.2 workspace dev tasks', budget: 'assignee unfinished lookup', partitionKey: 'dev_tasks.assignee_id', expectedIndexes: ['dev_tasks_assignee_open_priority_idx'], sql: ` SELECT * FROM dev_tasks WHERE assignee_id = $1 AND status <> 'submitted' ORDER BY priority ASC, updated_at DESC`, params: [args.userId], }, { key: 'workspace.testCases', label: 'V2.2 workspace test cases', budget: 'assignee unfinished lookup', partitionKey: 'test_cases.assignee_id', expectedIndexes: ['test_cases_assignee_open_priority_idx'], sql: ` SELECT * FROM test_cases WHERE assignee_id = $1 AND status NOT IN ('passed', 'failed', 'blocked') ORDER BY priority ASC, updated_at DESC`, params: [args.userId], }, { key: 'workspace.bugs', label: 'V2.2 workspace bugs', budget: 'assignee open lookup', partitionKey: 'bugs.assignee_id', expectedIndexes: ['bugs_assignee_open_priority_idx'], sql: ` SELECT * FROM bugs WHERE assignee_id = $1 AND status IN ('open', 'fixing', 'fixed', 'verifying') ORDER BY priority ASC, updated_at DESC`, params: [args.userId], }, { key: 'xiaobao.managerWarnings', label: 'V2.2 Xiaobao manager warning list', budget: 'HTTP p95 <= 1000ms; warning rows sorted by score', partitionKey: 'xiaobao_risk_summaries.version_id', expectedIndexes: ['xiaobao_risk_summaries_warning_score_idx'], sql: ` SELECT * FROM xiaobao_risk_summaries WHERE risk_level <> 'on_track' ORDER BY risk_score DESC, updated_at DESC`, params: [], }, { key: 'xiaobao.dirtyQueue', label: 'V2.6 Xiaobao dirty summary queue', budget: 'background worker batch scan', partitionKey: 'xiaobao_risk_summaries.version_id', expectedIndexes: ['xiaobao_risk_summaries_dirty_updated_at_idx'], sql: ` SELECT version_id FROM xiaobao_risk_summaries WHERE dirty = true ORDER BY updated_at ASC LIMIT 100`, params: [], }, { key: 'domain.projects', label: 'Project list by product', budget: 'domain list keeps product_id prefix', partitionKey: 'projects.product_id', expectedIndexes: ['projects_product_created_at_idx'], sql: ` SELECT * FROM projects WHERE product_id = $1 ORDER BY created_at DESC`, params: [args.productId], }, { key: 'domain.versionsByProduct', label: 'Version list by product', budget: 'domain list keeps product_id prefix', partitionKey: 'versions.product_id', expectedIndexes: ['versions_product_created_at_idx'], sql: ` SELECT * FROM versions WHERE product_id = $1 ORDER BY created_at DESC`, params: [args.productId], }, { key: 'domain.versionsByProject', label: 'Version list by product/project', budget: 'domain list keeps product_id + project_id prefix', partitionKey: 'versions.product_id, versions.project_id', expectedIndexes: ['versions_product_project_created_at_idx'], sql: ` SELECT * FROM versions WHERE product_id = $1 AND project_id = $2 ORDER BY created_at DESC`, params: [args.productId, args.projectId], }, { key: 'xiaobao.versionActivities', label: 'Xiaobao evidence activity scan', budget: 'background summary recompute by version', partitionKey: 'work_activities.version_id', expectedIndexes: ['work_activities_version_occurred_at_idx'], sql: ` SELECT * FROM work_activities WHERE version_id = $1 ORDER BY occurred_at DESC LIMIT 200`, params: [args.versionId], }, { key: 'audit.searchAdapter', label: 'Audit search adapter contract', budget: 'V2.5 audit module not landed; AiLog audit uses operation/status created_at indexes', partitionKey: 'ai_logs.created_at', expectedIndexes: ['ai_logs_operation_created_at_idx', 'ai_logs_status_created_at_idx'], sql: ` SELECT * FROM ai_logs WHERE operation = $1 ORDER BY created_at DESC LIMIT 100`, params: ['risk-interpret'], adapter: 'Replace with audit_events(actor_id, resource_type, created_at) once V2.5 audit contract lands.', }, ]; } function printDryRun(targets) { console.log('Hot query explain dry-run'); console.log('key\tpartition_key\texpected_indexes\tbudget'); for (const target of targets) { console.log([ target.key, target.partitionKey, target.expectedIndexes.join(',') || '-', target.budget, ].join('\t')); } } async function loadPrismaClient(databaseUrl) { if (databaseUrl) process.env.DATABASE_URL = databaseUrl; const serverRequire = createRequire(new URL('../apps/server/package.json', import.meta.url)); const { PrismaClient } = serverRequire('@prisma/client'); return new PrismaClient(); } async function auditIndexes(prisma, indexNames) { const uniqueNames = Array.from(new Set(indexNames)); const valuesSql = uniqueNames.map((_, index) => `($${index + 1})`).join(', '); const rows = await prisma.$queryRawUnsafe( ` WITH wanted(index_name) AS ( VALUES ${valuesSql} ) SELECT wanted.index_name, to_regclass('public.' || quote_ident(wanted.index_name)) IS NOT NULL AS present FROM wanted ORDER BY wanted.index_name `, ...uniqueNames, ); return rows.map((row) => ({ indexName: row.index_name, present: Boolean(row.present), })); } async function explainTarget(prisma, target) { if (!target.sql) { return { key: target.key, label: target.label, skipped: true, reason: 'no SQL query', }; } const rows = await prisma.$queryRawUnsafe( `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${target.sql}`, ...(target.params ?? []), ); const plan = readExplainPlan(rows); const summary = summarizePlan(plan); return { key: target.key, label: target.label, skipped: false, summary, seqScans: collectSeqScans(plan), }; } function readExplainPlan(rows) { const first = rows?.[0]; if (!first) return null; const raw = first['QUERY PLAN'] ?? first['QUERY PLAN'.toLowerCase()] ?? first.query_plan; if (Array.isArray(raw)) return raw[0]; if (typeof raw === 'string') return JSON.parse(raw)[0]; return raw?.[0] ?? raw; } function summarizePlan(plan) { const root = plan?.Plan ?? plan; return { nodeType: root?.['Node Type'] ?? 'unknown', totalCost: Number(root?.['Total Cost'] ?? 0), actualTotalTimeMs: Number(root?.['Actual Total Time'] ?? 0), actualRows: Number(root?.['Actual Rows'] ?? 0), sharedHitBlocks: Number(root?.['Shared Hit Blocks'] ?? 0), sharedReadBlocks: Number(root?.['Shared Read Blocks'] ?? 0), }; } function collectSeqScans(plan) { const found = []; visitPlan(plan?.Plan ?? plan, (node) => { if (node?.['Node Type'] === 'Seq Scan') { found.push({ relation: node['Relation Name'] ?? 'unknown', alias: node.Alias ?? '', filter: node.Filter ?? '', }); } }); return found; } function visitPlan(node, callback) { if (!node) return; callback(node); for (const child of node.Plans ?? []) visitPlan(child, callback); } function printResults(indexAudit, explainResults, strictPlan) { console.log('Index audit'); for (const item of indexAudit) { console.log(`${item.present ? 'ok' : 'missing'}\t${item.indexName}`); } console.log('\nExplain plans'); console.log('key\tnode\tactual_ms\tactual_rows\tshared_read_blocks\tseq_scan'); for (const result of explainResults) { if (result.skipped) { console.log(`${result.key}\tskipped\t-\t-\t-\t${result.reason}`); continue; } const seqScan = result.seqScans.map((scan) => scan.relation).join(',') || '-'; console.log([ result.key, result.summary.nodeType, result.summary.actualTotalTimeMs.toFixed(3), result.summary.actualRows, result.summary.sharedReadBlocks, seqScan, ].join('\t')); } if (strictPlan) { console.log('\nstrict-plan enabled: sequential scans on SQL targets are treated as failures.'); } } async function main() { const args = parseArgs(process.argv.slice(2)); const targets = buildExplainTargets(args); const expectedIndexes = [ ...REQUIRED_INDEXES, ...targets.flatMap((target) => target.expectedIndexes), ]; if (args.dryRun) { const payload = { targets: targets.map(({ sql, params, ...target }) => ({ ...target, sql: sql?.trim() ?? null, params, })), requiredIndexes: Array.from(new Set(expectedIndexes)).sort(), }; if (args.json) console.log(JSON.stringify(payload, null, 2)); else printDryRun(targets); return; } if (!args.databaseUrl) { throw new Error('DATABASE_URL is required unless --dry-run is used'); } const prisma = await loadPrismaClient(args.databaseUrl); try { const indexAudit = await auditIndexes(prisma, expectedIndexes); const explainResults = []; for (const target of targets) { explainResults.push(await explainTarget(prisma, target)); } if (args.json) { console.log(JSON.stringify({ indexAudit, explainResults }, null, 2)); } else { printResults(indexAudit, explainResults, args.strictPlan); } const hasMissingIndexes = indexAudit.some((item) => !item.present); const hasSeqScans = explainResults.some((result) => !result.skipped && result.seqScans.length > 0); if (hasMissingIndexes || (args.strictPlan && hasSeqScans)) { process.exitCode = 1; } } finally { await prisma.$disconnect(); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });