merge: 集成V2.6 性能增强与小宝后台化

# Conflicts:
#	apps/server/prisma/schema.prisma
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
This commit is contained in:
2026-07-08 18:04:39 +08:00
60 changed files with 4933 additions and 46 deletions

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

251
scripts/perf-check.mjs Normal file
View File

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

View File

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