From 535f44175d59e0312d3eeea1060022f539b20b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:45:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(perf):=20=E5=A2=9E=E5=8A=A0=E5=A4=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8E=8B=E6=B5=8B=E8=84=9A=E6=89=8B=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance.md | 75 ++++ package.json | 2 + scripts/perf-check.mjs | 251 +++++++++++++ scripts/seed-large-dataset.mjs | 661 +++++++++++++++++++++++++++++++++ 4 files changed, 989 insertions(+) create mode 100644 docs/performance.md create mode 100644 scripts/perf-check.mjs create mode 100644 scripts/seed-large-dataset.mjs diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..ed6144c --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,75 @@ +# V2.6 Performance Harness + +V2.6 adds a deterministic large-data fixture and a small HTTP performance harness for the current hot paths. The goal is to make performance regressions visible before adding more background jobs and Xiaobao automation. + +## Fixture Sizes + +The fixture script creates only `perf-*` rows and can be rerun safely. It covers: + +- products +- projects +- versions +- requirements +- version plans +- dev tasks +- test cases +- bugs +- work activities +- Xiaobao risk summaries + +Preset sizes: + +| Size | Purpose | +| --- | --- | +| `small` | Local smoke fixture. Fast dry-run and minimal database seed. | +| `medium` | Default performance gate for V2.6 hot APIs. | +| `large` | Stress fixture for query/index audit work. | + +Stable anchors used by the harness: + +```text +productId=perf-product-001 +versionId=perf-version-001-001-001 +userId=perf-user-dev-01 +``` + +## Commands + +Dry-run without database access: + +```bash +node scripts/seed-large-dataset.mjs --size small --dry-run +node scripts/perf-check.mjs --base-url http://localhost:3001/api/v1 --dry-run +``` + +Seed a database: + +```bash +pnpm perf:seed -- --size small +``` + +Run the hot-path harness against a running NestJS API: + +```bash +pnpm perf:check -- --base-url http://localhost:3001/api/v1 +``` + +## 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. + +| Probe | Endpoint | p95 Budget | +| --- | --- | ---: | +| Runtime version | `/health/version` | 500ms | +| Requirement pool | `/v2.2/requirements?productId=perf-product-001&q=REQ&limit=50` | 1000ms | +| Version detail | `/v2.2/versions/perf-version-001-001-001/detail-data` | 1500ms | +| Workspace | `/v2.2/workspace?userId=perf-user-dev-01` | 1200ms | +| Xiaobao warning | `/v2.2/xiaobao-warning?manager=true` | 1000ms | + +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. + +## Notes + +- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable. +- Non-dry-run seeding deletes and recreates only `perf-*` rows. +- The harness intentionally depends on public API endpoints instead of calling Prisma directly; it measures the same path the frontend uses. diff --git a/package.json b/package.json index 0cd5de7..0b78011 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "lint": "turbo lint", "type-check": "turbo type-check", "test": "turbo test", + "perf:seed": "node scripts/seed-large-dataset.mjs", + "perf:check": "node scripts/perf-check.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/perf-check.mjs b/scripts/perf-check.mjs new file mode 100644 index 0000000..5982643 --- /dev/null +++ b/scripts/perf-check.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +import { performance } from 'node:perf_hooks'; + +const DEFAULT_THRESHOLDS = { + healthVersion: 500, + requirementPool: 1000, + versionDetail: 1500, + workspace: 1200, + xiaobaoWarning: 1000, +}; + +const DEFAULTS = { + baseUrl: 'http://localhost:3001/api/v1', + productId: 'perf-product-001', + versionId: 'perf-version-001-001-001', + userId: 'perf-user-dev-01', + iterations: 5, + warmup: 1, + dryRun: false, +}; + +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 === '--base-url') { + args.baseUrl = argv[++index] ?? args.baseUrl; + continue; + } + if (arg.startsWith('--base-url=')) { + args.baseUrl = arg.slice('--base-url='.length); + continue; + } + if (arg === '--product-id') { + args.productId = argv[++index] ?? args.productId; + 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 === '--iterations') { + args.iterations = parsePositiveInt(argv[++index], args.iterations); + continue; + } + if (arg === '--warmup') { + args.warmup = parsePositiveInt(argv[++index], args.warmup, 0); + continue; + } + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + args.baseUrl = args.baseUrl.replace(/\/+$/, ''); + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/perf-check.mjs [--base-url URL] [--dry-run] + +Runs the V2.6 hot API performance harness. + +Options: + --base-url API root. Defaults to ${DEFAULTS.baseUrl} + --product-id Requirement pool product id. Defaults to ${DEFAULTS.productId} + --version-id Version detail id. Defaults to ${DEFAULTS.versionId} + --user-id Workspace user id. Defaults to ${DEFAULTS.userId} + --iterations Timed iterations per probe. Defaults to ${DEFAULTS.iterations} + --warmup Warmup iterations per probe. Defaults to ${DEFAULTS.warmup} + --dry-run Print probes without making HTTP requests. +`); +} + +function parsePositiveInt(raw, fallback, min = 1) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function buildProbes(args) { + const requirementQuery = new URLSearchParams({ + productId: args.productId, + q: 'REQ', + limit: '50', + }); + const workspaceQuery = new URLSearchParams({ userId: args.userId }); + const xiaobaoQuery = new URLSearchParams({ manager: 'true' }); + + return [ + { + key: 'healthVersion', + label: 'health/version', + method: 'GET', + path: '/health/version', + thresholdMs: DEFAULT_THRESHOLDS.healthVersion, + }, + { + key: 'requirementPool', + label: 'v2.2 requirement query', + method: 'GET', + path: `/v2.2/requirements?${requirementQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.requirementPool, + }, + { + key: 'versionDetail', + label: 'v2.2 version detail', + method: 'GET', + path: `/v2.2/versions/${encodeURIComponent(args.versionId)}/detail-data`, + thresholdMs: DEFAULT_THRESHOLDS.versionDetail, + }, + { + key: 'workspace', + label: 'v2.2 workspace', + method: 'GET', + path: `/v2.2/workspace?${workspaceQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.workspace, + }, + { + key: 'xiaobaoWarning', + label: 'v2.2 xiaobao warning', + method: 'GET', + path: `/v2.2/xiaobao-warning?${xiaobaoQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.xiaobaoWarning, + }, + ].map((probe) => ({ + ...probe, + url: `${args.baseUrl}${probe.path}`, + })); +} + +async function measureProbe(probe, args) { + for (let index = 0; index < args.warmup; index += 1) { + await requestOnce(probe); + } + + const samples = []; + const statuses = new Map(); + const errors = []; + for (let index = 0; index < args.iterations; index += 1) { + const result = await requestOnce(probe); + samples.push(result.durationMs); + statuses.set(result.status, (statuses.get(result.status) ?? 0) + 1); + if (result.error) errors.push(result.error); + if (result.status < 200 || result.status >= 300) { + errors.push(`HTTP ${result.status}`); + } + } + + const p50 = percentile(samples, 50); + const p95 = percentile(samples, 95); + if (p95 > probe.thresholdMs) { + errors.push(`p95 ${p95.toFixed(1)}ms > budget ${probe.thresholdMs}ms`); + } + + return { + ...probe, + count: samples.length, + p50, + p95, + statuses: [...statuses.entries()].sort(([a], [b]) => a - b), + ok: errors.length === 0, + errors, + }; +} + +async function requestOnce(probe) { + const startedAt = performance.now(); + try { + const response = await fetch(probe.url, { method: probe.method }); + await response.arrayBuffer(); + return { + status: response.status, + durationMs: performance.now() - startedAt, + }; + } catch (error) { + return { + status: 0, + durationMs: performance.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function percentile(values, pct) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((pct / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; +} + +function formatStatuses(statuses) { + return statuses.map(([status, count]) => `${status}:${count}`).join(', '); +} + +function printProbePlan(probes) { + console.log('Performance harness dry-run'); + for (const probe of probes) { + console.log(`${probe.key}\t${probe.method}\t${probe.url}\tbudget_p95_ms=${probe.thresholdMs}`); + } +} + +function printResults(results) { + console.log('probe\tstatus\tp50_ms\tp95_ms\tbudget_p95_ms\tok'); + for (const result of results) { + console.log([ + result.key, + formatStatuses(result.statuses), + result.p50.toFixed(1), + result.p95.toFixed(1), + result.thresholdMs, + result.ok ? 'yes' : 'no', + ].join('\t')); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`${result.key}: ${error}`); + } + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const probes = buildProbes(args); + if (args.dryRun) { + printProbePlan(probes); + return; + } + + const results = []; + for (const probe of probes) { + results.push(await measureProbe(probe, args)); + } + printResults(results); + if (results.some((result) => !result.ok)) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); + diff --git a/scripts/seed-large-dataset.mjs b/scripts/seed-large-dataset.mjs new file mode 100644 index 0000000..f83a781 --- /dev/null +++ b/scripts/seed-large-dataset.mjs @@ -0,0 +1,661 @@ +#!/usr/bin/env node +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +const FIXTURE_VERSION = 'v2.6-large-data-fixture-1'; +const ID_PREFIX = 'perf-'; +const BASE_DATE = new Date('2026-07-01T09:00:00.000Z'); +const OUTPUT_PATH = '.tmp/perf-fixture.json'; + +const SIZE_PRESETS = { + small: { + products: 1, + projectsPerProduct: 2, + versionsPerProject: 2, + requirementsPerVersion: 20, + looseRequirementsPerProduct: 20, + plansPerVersion: 3, + devTasksPerRequirement: 2, + testCasesPerRequirement: 2, + bugsPerVersion: 12, + activitiesPerVersion: 16, + users: 8, + }, + medium: { + products: 2, + projectsPerProduct: 8, + versionsPerProject: 4, + requirementsPerVersion: 80, + looseRequirementsPerProduct: 300, + plansPerVersion: 3, + devTasksPerRequirement: 3, + testCasesPerRequirement: 3, + bugsPerVersion: 80, + activitiesPerVersion: 120, + users: 40, + }, + large: { + products: 4, + projectsPerProduct: 20, + versionsPerProject: 6, + requirementsPerVersion: 150, + looseRequirementsPerProduct: 2000, + plansPerVersion: 3, + devTasksPerRequirement: 4, + testCasesPerRequirement: 4, + bugsPerVersion: 180, + activitiesPerVersion: 300, + users: 120, + }, +}; + +const DEV_STATUSES = ['todo', 'in_progress', 'testing', 'submitted']; +const TEST_STATUSES = ['pending', 'running', 'passed', 'failed', 'blocked']; +const BUG_STATUSES = ['open', 'fixing', 'fixed', 'verifying', 'closed']; +const BUG_SEVERITIES = ['normal', 'major', 'critical']; +const PLAN_TYPES = ['research', 'product', 'ui']; +const REQUIREMENT_STATUSES = ['adopted', 'planned', 'developing', 'testing', 'released']; + +function parseArgs(argv) { + const args = { + size: 'small', + dryRun: false, + json: false, + output: OUTPUT_PATH, + }; + + 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 === '--size') { + args.size = argv[++index] ?? args.size; + continue; + } + if (arg.startsWith('--size=')) { + args.size = arg.slice('--size='.length); + continue; + } + if (arg === '--output') { + args.output = argv[++index] ?? args.output; + continue; + } + if (arg.startsWith('--output=')) { + args.output = arg.slice('--output='.length); + continue; + } + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!SIZE_PRESETS[args.size]) { + throw new Error(`Unsupported size "${args.size}". Use one of: ${Object.keys(SIZE_PRESETS).join(', ')}`); + } + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/seed-large-dataset.mjs [--size small|medium|large] [--dry-run] [--json] + +Creates deterministic V2.6 performance fixture data using perf-* ids. + +Options: + --size Fixture size. Defaults to small. + --dry-run Build and summarize rows without writing PostgreSQL. + --json Print the summary as JSON. + --output Metadata path written after a real seed. Defaults to ${OUTPUT_PATH}. +`); +} + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function hoursAfter(hours) { + return new Date(BASE_DATE.getTime() + hours * 60 * 60 * 1000); +} + +function daysAfter(days) { + return hoursAfter(days * 24); +} + +function priority(index) { + return index % 5; +} + +function buildFixture(sizeName) { + const preset = SIZE_PRESETS[sizeName]; + const users = buildUsers(preset.users); + const products = []; + const projects = []; + const versions = []; + const requirements = []; + const versionPlans = []; + const devTasks = []; + const testCases = []; + const bugs = []; + const workActivities = []; + const xiaobaoRiskSummaries = []; + + for (let productNo = 1; productNo <= preset.products; productNo += 1) { + const productId = `${ID_PREFIX}product-${pad(productNo)}`; + products.push({ + id: productId, + name: `Performance Product ${pad(productNo)}`, + description: `Deterministic ${sizeName} fixture product ${productNo}`, + createdAt: daysAfter(productNo), + updatedAt: daysAfter(productNo), + }); + + for (let looseNo = 1; looseNo <= preset.looseRequirementsPerProduct; looseNo += 1) { + requirements.push(buildRequirement({ + productId, + projectId: null, + versionId: null, + productNo, + projectNo: 0, + versionNo: 0, + requirementNo: looseNo, + loose: true, + creatorId: users[looseNo % users.length].id, + })); + } + + for (let projectNo = 1; projectNo <= preset.projectsPerProduct; projectNo += 1) { + const projectId = `${ID_PREFIX}project-${pad(productNo)}-${pad(projectNo)}`; + projects.push({ + id: projectId, + productId, + name: `Performance Project ${pad(productNo)}-${pad(projectNo)}`, + description: `Hot-path project ${projectNo}`, + createdAt: daysAfter(projectNo), + updatedAt: daysAfter(projectNo), + }); + + for (let versionNo = 1; versionNo <= preset.versionsPerProject; versionNo += 1) { + const versionId = `${ID_PREFIX}version-${pad(productNo)}-${pad(projectNo)}-${pad(versionNo)}`; + const version = buildVersion({ + productId, + projectId, + versionId, + productNo, + projectNo, + versionNo, + users, + }); + versions.push(version); + + for (let planNo = 1; planNo <= preset.plansPerVersion; planNo += 1) { + versionPlans.push(buildVersionPlan({ + productId, + projectId, + versionId, + planNo, + ownerId: users[(planNo + versionNo) % users.length].id, + })); + } + + const versionRequirementIds = []; + for (let requirementNo = 1; requirementNo <= preset.requirementsPerVersion; requirementNo += 1) { + const req = buildRequirement({ + productId, + projectId, + versionId, + productNo, + projectNo, + versionNo, + requirementNo, + loose: false, + creatorId: users[requirementNo % users.length].id, + }); + requirements.push(req); + versionRequirementIds.push(req.id); + + for (let taskNo = 1; taskNo <= preset.devTasksPerRequirement; taskNo += 1) { + devTasks.push(buildDevTask({ + productId, + projectId, + versionId, + requirementId: req.id, + requirementProductId: productId, + requirementNo, + taskNo, + assigneeId: users[(requirementNo + taskNo) % users.length].id, + })); + } + + for (let caseNo = 1; caseNo <= preset.testCasesPerRequirement; caseNo += 1) { + testCases.push(buildTestCase({ + productId, + projectId, + versionId, + requirementId: req.id, + requirementProductId: productId, + requirementNo, + caseNo, + assigneeId: users[(requirementNo + caseNo + 2) % users.length].id, + })); + } + } + + for (let bugNo = 1; bugNo <= preset.bugsPerVersion; bugNo += 1) { + bugs.push(buildBug({ + productId, + projectId, + versionId, + bugNo, + assigneeId: users[(bugNo + versionNo) % users.length].id, + testCaseId: testCases.find((row) => row.versionId === versionId)?.id, + })); + } + + for (let activityNo = 1; activityNo <= preset.activitiesPerVersion; activityNo += 1) { + const source = devTasks.find((row) => row.versionId === versionId && row.assigneeId === users[activityNo % users.length].id) + ?? devTasks.find((row) => row.versionId === versionId); + workActivities.push(buildWorkActivity({ + productId, + projectId, + versionId, + activityNo, + actor: users[activityNo % users.length], + source, + })); + } + + xiaobaoRiskSummaries.push(buildXiaobaoSummary({ + version, + bugCount: preset.bugsPerVersion, + unfinishedCount: versionRequirementIds.length, + })); + } + } + } + + return { + size: sizeName, + anchors: { + productId: `${ID_PREFIX}product-001`, + projectId: `${ID_PREFIX}project-001-001`, + versionId: `${ID_PREFIX}version-001-001-001`, + userId: `${ID_PREFIX}user-dev-01`, + }, + rows: { + users, + products, + projects, + versions, + requirements, + versionPlans, + devTasks, + testCases, + bugs, + workActivities, + xiaobaoRiskSummaries, + }, + }; +} + +function buildUsers(count) { + return Array.from({ length: count }, (_, index) => { + const no = index + 1; + return { + id: `${ID_PREFIX}user-dev-${pad(no, 2)}`, + email: `perf-user-${pad(no, 2)}@example.test`, + name: `Perf User ${pad(no, 2)}`, + username: `perf_user_${pad(no, 2)}`, + departmentId: no % 3 === 0 ? 'dept-2-3' : no % 2 === 0 ? 'dept-2-2' : 'dept-2-1', + roleId: no % 3 === 0 ? 'role-test' : 'role-dev', + phone: '', + password: 'Perf@2026', + isSystem: false, + createdAt: BASE_DATE, + updatedAt: BASE_DATE, + }; + }); +} + +function buildVersion(input) { + const releaseDate = daysAfter(20 + input.versionNo); + return { + id: input.versionId, + productId: input.productId, + projectId: input.projectId, + name: `V${input.productNo}.${input.projectNo}.${input.versionNo}`, + description: 'Performance hot-path version', + status: input.versionNo % 4 === 0 ? 'paused' : 'developing', + currentStage: input.versionNo % 2 === 0 ? 'testing' : 'development', + startDate: daysAfter(input.versionNo), + expectedReleaseDate: releaseDate, + releaseDate, + members: input.users.slice(0, Math.min(6, input.users.length)).map((user, index) => ({ + id: user.id, + name: user.name, + role: index % 3 === 0 ? 'testing' : index % 2 === 0 ? 'backend' : 'frontend', + })), + progress: [], + priority: priority(input.versionNo), + links: {}, + createdAt: daysAfter(input.versionNo), + updatedAt: daysAfter(input.versionNo + 1), + }; +} + +function buildRequirement(input) { + const scoped = input.loose + ? `LOOSE-${pad(input.productNo)}-${pad(input.requirementNo, 5)}` + : `${pad(input.productNo)}-${pad(input.projectNo)}-${pad(input.versionNo)}-${pad(input.requirementNo, 5)}`; + return { + id: `${ID_PREFIX}req-${scoped}`, + productId: input.productId, + projectId: input.projectId, + versionId: input.versionId, + code: `REQ-${scoped}`, + title: `Requirement ${scoped}`, + description: `Deterministic requirement ${scoped}`, + status: input.loose ? 'adopted' : REQUIREMENT_STATUSES[input.requirementNo % REQUIREMENT_STATUSES.length], + priority: priority(input.requirementNo), + type: input.requirementNo % 2 === 0 ? 'feature' : 'optimization', + sourceType: input.requirementNo % 3 === 0 ? 'customer' : 'internal', + sourceTarget: input.requirementNo % 3 === 0 ? 'perf-customer' : 'perf-team', + platform: input.requirementNo % 2 === 0 ? 'web,ios' : 'web', + creatorId: input.creatorId, + createdAt: hoursAfter(input.requirementNo), + updatedAt: hoursAfter(input.requirementNo + 1), + }; +} + +function buildVersionPlan(input) { + const type = PLAN_TYPES[(input.planNo - 1) % PLAN_TYPES.length]; + return { + id: `${ID_PREFIX}plan-${input.versionId}-${type}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + type, + title: `${type} plan for ${input.versionId}`, + status: input.planNo === 1 ? 'completed' : 'in_progress', + ownerId: input.ownerId, + expectedStartAt: daysAfter(input.planNo), + expectedEndAt: daysAfter(input.planNo + 3), + actualStartAt: daysAfter(input.planNo), + completedAt: input.planNo === 1 ? daysAfter(input.planNo + 2) : null, + resultUrl: input.planNo === 1 ? 'https://example.test/prototype' : null, + requirementCoverage: [], + logs: [], + createdAt: daysAfter(input.planNo), + updatedAt: daysAfter(input.planNo + 1), + }; +} + +function buildDevTask(input) { + const status = DEV_STATUSES[(input.requirementNo + input.taskNo) % DEV_STATUSES.length]; + const blocked = status !== 'submitted' && input.taskNo % 11 === 0; + return { + id: `${ID_PREFIX}dev-${input.versionId}-${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + requirementId: input.requirementId, + requirementProductId: input.requirementProductId, + categoryId: null, + code: `DEV-${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + title: `Dev task ${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + description: 'Performance fixture development task', + status, + priority: priority(input.requirementNo + input.taskNo), + assigneeId: input.assigneeId, + creatorId: input.assigneeId, + isBlocked: blocked, + blockReason: blocked ? 'Fixture blocker' : null, + expectedStartAt: daysAfter(input.taskNo), + expectedEndAt: daysAfter(input.taskNo + 2), + startDate: status === 'todo' ? null : daysAfter(input.taskNo), + completedAt: status === 'submitted' ? daysAfter(input.taskNo + 2) : null, + estimateHours: 4 + (input.taskNo % 5), + aiEstimateHours: 3 + (input.taskNo % 4), + references: [{ type: 'requirement', id: input.requirementId }], + aiDraft: false, + aiDraftAt: null, + createdAt: hoursAfter(input.requirementNo + input.taskNo), + updatedAt: hoursAfter(input.requirementNo + input.taskNo + 1), + }; +} + +function buildTestCase(input) { + const status = TEST_STATUSES[(input.requirementNo + input.caseNo) % TEST_STATUSES.length]; + return { + id: `${ID_PREFIX}case-${input.versionId}-${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + requirementId: input.requirementId, + requirementProductId: input.requirementProductId, + categoryId: null, + code: `TC-${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + title: `Test case ${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + description: 'Performance fixture test case', + status, + roundNo: input.caseNo % 3 === 0 ? 2 : 1, + priority: priority(input.requirementNo + input.caseNo), + assigneeId: input.assigneeId, + creatorId: input.assigneeId, + plannedTestAt: daysAfter(input.caseNo + 3), + plannedEndAt: daysAfter(input.caseNo + 4), + startedAt: status === 'pending' ? null : daysAfter(input.caseNo + 3), + completedAt: ['passed', 'failed', 'blocked'].includes(status) ? daysAfter(input.caseNo + 4) : null, + estimateHours: 2 + (input.caseNo % 4), + aiEstimateHours: 1 + (input.caseNo % 3), + references: [{ type: 'requirement', id: input.requirementId }], + aiDraft: false, + aiDraftAt: null, + createdAt: hoursAfter(input.requirementNo + input.caseNo), + updatedAt: hoursAfter(input.requirementNo + input.caseNo + 1), + }; +} + +function buildBug(input) { + const status = BUG_STATUSES[input.bugNo % BUG_STATUSES.length]; + const severity = BUG_SEVERITIES[input.bugNo % BUG_SEVERITIES.length]; + return { + id: `${ID_PREFIX}bug-${input.versionId}-${pad(input.bugNo, 5)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + testCaseId: input.testCaseId ?? null, + testCaseVersionId: input.testCaseId ? input.versionId : null, + code: `BUG-${pad(input.bugNo, 5)}`, + title: `Bug ${pad(input.bugNo, 5)}`, + description: 'Performance fixture bug', + status, + severity, + priority: priority(input.bugNo), + assigneeId: input.assigneeId, + reporterId: input.assigneeId, + plannedFixAt: daysAfter(input.bugNo % 10), + resolvedAt: ['fixed', 'verifying', 'closed'].includes(status) ? daysAfter((input.bugNo % 10) + 1) : null, + closedAt: status === 'closed' ? daysAfter((input.bugNo % 10) + 2) : null, + resolution: status === 'closed' ? 'fixed' : null, + createdAt: hoursAfter(input.bugNo), + updatedAt: hoursAfter(input.bugNo + 1), + }; +} + +function buildWorkActivity(input) { + const source = input.source; + return { + id: `${ID_PREFIX}activity-${input.versionId}-${pad(input.activityNo, 5)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + actorId: input.actor.id, + actorName: input.actor.name, + sourceType: 'dev_task', + sourceId: source?.id ?? `${ID_PREFIX}missing-source`, + sourceVersionId: input.versionId, + action: input.activityNo % 5 === 0 ? 'blocked' : 'progress', + title: `Activity ${pad(input.activityNo, 5)}`, + metadata: { + category: input.activityNo % 5 === 0 ? 'risk' : 'progress', + summary: `Performance activity ${input.activityNo}`, + }, + occurredAt: hoursAfter(input.activityNo), + createdAt: hoursAfter(input.activityNo), + }; +} + +function buildXiaobaoSummary(input) { + const riskScore = Math.min(100, 40 + (input.bugCount % 50)); + const riskLevel = riskScore >= 75 ? 'likely_delayed' : 'at_risk'; + const riskSignature = `${input.version.id}|${riskLevel}|${riskScore}|${input.unfinishedCount}`; + return { + versionId: input.version.id, + riskLevel, + riskScore, + confidence: 72, + forecastReleaseDate: daysAfter(24), + riskSignature, + summary: { + versionId: input.version.id, + versionName: input.version.name, + riskLevel, + riskScore, + confidence: 72, + riskSignature, + dirty: false, + signals: { + unfinishedCount: input.unfinishedCount, + openBugCount: input.bugCount, + criticalBugCount: Math.floor(input.bugCount / 12), + failedTestCount: Math.floor(input.unfinishedCount / 8), + blockedCount: Math.floor(input.unfinishedCount / 15), + silentRiskCount: Math.floor(input.unfinishedCount / 20), + }, + reasons: [ + { + key: 'remaining_work', + title: 'Remaining fixture work', + detail: `Fixture has ${input.unfinishedCount} scoped requirements.`, + severity: 'warning', + }, + ], + currentSnapshot: { + versionId: input.version.id, + date: BASE_DATE.toISOString().slice(0, 10), + riskScore, + riskLevel, + openBugCount: input.bugCount, + criticalBugCount: Math.floor(input.bugCount / 12), + failedTestCount: Math.floor(input.unfinishedCount / 8), + blockedCount: Math.floor(input.unfinishedCount / 15), + silentRiskCount: Math.floor(input.unfinishedCount / 20), + confidence: 72, + createdAt: BASE_DATE.toISOString(), + }, + }, + dirty: false, + recomputedAt: BASE_DATE, + updatedAt: BASE_DATE, + }; +} + +function summarizeFixture(fixture) { + return { + fixtureVersion: FIXTURE_VERSION, + size: fixture.size, + anchors: fixture.anchors, + counts: Object.fromEntries( + Object.entries(fixture.rows).map(([key, rows]) => [key, rows.length]), + ), + }; +} + +async function seedDatabase(fixture, outputPath) { + const serverRequire = createRequire(new URL('../apps/server/package.json', import.meta.url)); + const { PrismaClient } = serverRequire('@prisma/client'); + const prisma = new PrismaClient(); + + try { + await clearPerfRows(prisma); + await prisma.user.createMany({ data: fixture.rows.users, skipDuplicates: true }); + await prisma.product.createMany({ data: fixture.rows.products, skipDuplicates: true }); + await prisma.project.createMany({ data: fixture.rows.projects, skipDuplicates: true }); + await prisma.version.createMany({ data: fixture.rows.versions, skipDuplicates: true }); + await prisma.requirement.createMany({ data: fixture.rows.requirements, skipDuplicates: true }); + await prisma.versionPlan.createMany({ data: fixture.rows.versionPlans, skipDuplicates: true }); + await prisma.devTask.createMany({ data: fixture.rows.devTasks, skipDuplicates: true }); + await prisma.testCase.createMany({ data: fixture.rows.testCases, skipDuplicates: true }); + await prisma.bug.createMany({ data: fixture.rows.bugs, skipDuplicates: true }); + await prisma.workActivity.createMany({ data: fixture.rows.workActivities, skipDuplicates: true }); + await prisma.xiaobaoRiskSummary.createMany({ data: fixture.rows.xiaobaoRiskSummaries, skipDuplicates: true }); + } finally { + await prisma.$disconnect(); + } + + const summary = summarizeFixture(fixture); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + return summary; +} + +async function clearPerfRows(prisma) { + await prisma.$transaction([ + prisma.xiaobaoRiskSummary.deleteMany({ where: { versionId: { startsWith: `${ID_PREFIX}version-` } } }), + prisma.workActivity.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}activity-` } } }), + prisma.bug.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}bug-` } } }), + prisma.testCase.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}case-` } } }), + prisma.devTask.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}dev-` } } }), + prisma.versionPlan.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}plan-` } } }), + prisma.requirement.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}req-` } } }), + prisma.version.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}version-` } } }), + prisma.project.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}project-` } } }), + prisma.product.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}product-` } } }), + prisma.user.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}user-` } } }), + ]); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const fixture = buildFixture(args.size); + const summary = summarizeFixture(fixture); + + if (args.dryRun) { + if (args.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + printSummary(summary, 'dry-run'); + } + return; + } + + const outputPath = resolve(process.cwd(), args.output); + const seeded = await seedDatabase(fixture, outputPath); + if (args.json) { + console.log(JSON.stringify(seeded, null, 2)); + } else { + printSummary(seeded, `seeded; metadata=${args.output}`); + } +} + +function printSummary(summary, mode) { + console.log(`Large dataset fixture ${mode}`); + console.log(`version=${summary.fixtureVersion} size=${summary.size}`); + console.log(`anchors=${JSON.stringify(summary.anchors)}`); + for (const [name, count] of Object.entries(summary.counts)) { + console.log(`${name}=${count}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});