From 69f50ea1f5b602051e7d799571eb7afa807fe690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:55:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(perf):=20=E5=A2=9E=E5=8A=A0=E7=83=AD?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E7=B4=A2=E5=BC=95=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 44 ++ docs/performance-hot-queries.md | 79 +++ docs/performance.md | 9 + package.json | 1 + scripts/explain-hot-queries.mjs | 572 ++++++++++++++++++ 5 files changed, 705 insertions(+) create mode 100644 apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql create mode 100644 docs/performance-hot-queries.md create mode 100644 scripts/explain-hot-queries.mjs diff --git a/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql b/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql new file mode 100644 index 0000000..50fe1e5 --- /dev/null +++ b/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql @@ -0,0 +1,44 @@ +-- V2.6 hot query indexes for performance harness, query budget audit, +-- and background Xiaobao summary refresh scans. + +CREATE INDEX IF NOT EXISTS "projects_product_created_at_idx" + ON "projects"("product_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "versions_product_created_at_idx" + ON "versions"("product_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "versions_product_project_created_at_idx" + ON "versions"("product_id", "project_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "version_plans_owner_open_due_idx" + ON "version_plans"("owner_id", "expected_end_at" ASC, "updated_at" DESC) + WHERE "status" <> 'completed'; + +CREATE INDEX IF NOT EXISTS "dev_tasks_assignee_open_priority_idx" + ON "dev_tasks"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" <> 'submitted'; + +CREATE INDEX IF NOT EXISTS "test_cases_assignee_open_priority_idx" + ON "test_cases"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" NOT IN ('passed', 'failed', 'blocked'); + +CREATE INDEX IF NOT EXISTS "bugs_version_status_priority_updated_at_idx" + ON "bugs"("version_id", "status" ASC, "priority" ASC, "updated_at" DESC); + +CREATE INDEX IF NOT EXISTS "bugs_assignee_open_priority_idx" + ON "bugs"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying'); + +CREATE INDEX IF NOT EXISTS "test_cases_version_round_status_updated_at_desc_idx" + ON "test_cases"("version_id", "round_no" DESC, "status" ASC, "updated_at" DESC); + +CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_warning_score_idx" + ON "xiaobao_risk_summaries"("risk_score" DESC, "updated_at" DESC) + WHERE "risk_level" <> 'on_track'; + +CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_dirty_updated_at_idx" + ON "xiaobao_risk_summaries"("updated_at" ASC) + WHERE "dirty" = true; + +CREATE INDEX IF NOT EXISTS "work_activities_version_occurred_at_idx" + ON "work_activities"("version_id", "occurred_at" DESC); diff --git a/docs/performance-hot-queries.md b/docs/performance-hot-queries.md new file mode 100644 index 0000000..589e2c1 --- /dev/null +++ b/docs/performance-hot-queries.md @@ -0,0 +1,79 @@ +# V2.6 Hot Query Budget And Index Audit + +This document records the V2.6 query budget for the large-data harness and the index contracts that keep hot APIs on partition keys. + +## Commands + +Offline contract audit: + +```bash +node scripts/explain-hot-queries.mjs --dry-run +``` + +Database explain audit: + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain +``` + +Strict plan mode is available for seeded medium/large databases: + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain -- --strict-plan +``` + +`--strict-plan` fails on sequential scans. It is useful after the medium fixture is seeded and analyzed, but not required for empty or tiny local databases where PostgreSQL may choose a sequential scan correctly. + +## Budgets + +| Area | Query Shape | Budget | +| --- | --- | --- | +| `health/version` | no database query | HTTP p95 <= 500ms | +| Requirement pool | `requirements.product_id` + optional filters/search, cursor, `created_at` sort | HTTP p95 <= 1000ms | +| Version detail | root version plus child rows by `version_id` | HTTP p95 <= 1500ms | +| Workspace | owner/assignee unfinished rows | HTTP p95 <= 1200ms | +| Xiaobao warnings | non-`on_track` summaries by score | HTTP p95 <= 1000ms | +| Xiaobao dirty queue | `dirty=true` summaries ordered by `updated_at` | background batch <= 100 rows | +| Audit search adapter | V2.5 audit table pending; `ai_logs` is the current AI audit surface | explain-only contract | + +## Required Index Contracts + +V2.6 keeps the existing partition prefixes: + +- Requirement pool queries must include `productId`; `requirements` is hash-partitioned by `product_id`. +- Version detail child queries must include `versionId`; `dev_tasks`, `test_cases`, and `bugs` are hash-partitioned by `version_id`. +- Append evidence tables stay range-partitioned by `created_at`; background workers must still filter by `version_id`, `user_id`, or date before scanning. + +Added in migration `20260708030000_v26_hot_query_indexes`: + +| Index | Purpose | +| --- | --- | +| `projects_product_created_at_idx` | product-scoped project list | +| `versions_product_created_at_idx` | product-scoped version list | +| `versions_product_project_created_at_idx` | project-scoped version list | +| `version_plans_owner_open_due_idx` | workspace plan queue | +| `dev_tasks_assignee_open_priority_idx` | workspace dev task queue | +| `test_cases_assignee_open_priority_idx` | workspace test case queue | +| `bugs_version_status_priority_updated_at_idx` | version detail bug ordering | +| `bugs_assignee_open_priority_idx` | workspace bug queue | +| `test_cases_version_round_status_updated_at_desc_idx` | version detail test case ordering | +| `xiaobao_risk_summaries_warning_score_idx` | manager Xiaobao warning list | +| `xiaobao_risk_summaries_dirty_updated_at_idx` | background Xiaobao dirty summary queue | +| `work_activities_version_occurred_at_idx` | Xiaobao evidence recompute | + +Existing V2.2 indexes remain part of the contract, including requirement pool indexes, version child indexes, workspace partial indexes, task worklog date indexes, Xiaobao snapshot/insight indexes, and `ai_logs` operation/status indexes. + +## Audit Adapter Note + +The V2.5 RBAC/audit contract is not present in this branch. V2.6 therefore documents `audit.searchAdapter` as an adapter target instead of inventing a temporary audit table. When audit lands, the expected query shape should be: + +```sql +SELECT * +FROM audit_events +WHERE product_id = $1 + AND created_at >= $2 +ORDER BY created_at DESC +LIMIT 100; +``` + +Expected future index: `(product_id, created_at DESC)` plus actor/resource indexes required by the audit module. Until then, `ai_logs_operation_created_at_idx` and `ai_logs_status_created_at_idx` cover AI operation audit searches only. diff --git a/docs/performance.md b/docs/performance.md index ed6144c..8e03367 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -54,6 +54,13 @@ Run the hot-path harness against a running NestJS API: pnpm perf:check -- --base-url http://localhost:3001/api/v1 ``` +Audit SQL plans and hot-path indexes: + +```bash +pnpm perf:explain -- --dry-run +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain +``` + ## Hot Probes `perf-check` measures p50 and p95 latency, records HTTP status code counts, and exits non-zero when a request fails or p95 exceeds the current query budget. @@ -68,6 +75,8 @@ pnpm perf:check -- --base-url http://localhost:3001/api/v1 The medium fixture is the V2.6 acceptance target. Large fixture runs are for index audit and explain-plan work, not for every local commit. +See `docs/performance-hot-queries.md` for query budgets, partition-key contracts, and required indexes. + ## Notes - `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable. diff --git a/package.json b/package.json index 0b78011..bdd2ea9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test": "turbo test", "perf:seed": "node scripts/seed-large-dataset.mjs", "perf:check": "node scripts/perf-check.mjs", + "perf:explain": "node scripts/explain-hot-queries.mjs", "deploy:verify": "node scripts/verify-production-deploy.mjs", "deploy:check-runtime": "node scripts/check-runtime-version.mjs", "deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build", diff --git a/scripts/explain-hot-queries.mjs b/scripts/explain-hot-queries.mjs new file mode 100644 index 0000000..327bdf3 --- /dev/null +++ b/scripts/explain-hot-queries.mjs @@ -0,0 +1,572 @@ +#!/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; +});