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:
572
scripts/explain-hot-queries.mjs
Normal file
572
scripts/explain-hot-queries.mjs
Normal 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
251
scripts/perf-check.mjs
Normal 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;
|
||||
});
|
||||
|
||||
661
scripts/seed-large-dataset.mjs
Normal file
661
scripts/seed-large-dataset.mjs
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user