feat(perf): 增加热查询索引审计

This commit is contained in:
2026-07-08 16:55:02 +08:00
parent 535f44175d
commit 69f50ea1f5
5 changed files with 705 additions and 0 deletions

View File

@@ -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);

View File

@@ -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.

View File

@@ -54,6 +54,13 @@ Run the hot-path harness against a running NestJS API:
pnpm perf:check -- --base-url http://localhost:3001/api/v1 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 ## 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. `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. 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 ## Notes
- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable. - `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable.

View File

@@ -9,6 +9,7 @@
"test": "turbo test", "test": "turbo test",
"perf:seed": "node scripts/seed-large-dataset.mjs", "perf:seed": "node scripts/seed-large-dataset.mjs",
"perf:check": "node scripts/perf-check.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:verify": "node scripts/verify-production-deploy.mjs",
"deploy:check-runtime": "node scripts/check-runtime-version.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", "deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build",

View File

@@ -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;
});