super-productivity/packages/super-sync-server/scripts/analyze-storage.ts
Johannes Millan d32f7037a3 fix(sync): preserve own vector clock counter across full-state op resets
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.

Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.

Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.

Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).

Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
2026-04-01 15:41:13 +02:00

800 lines
25 KiB
TypeScript

#!/usr/bin/env tsx
/**
* SuperSync Storage Analysis Tool
*
* A reusable investigation script for analyzing operation patterns, user behavior,
* and storage anomalies in the SuperSync database.
*
* Usage:
* npm run analyze-storage -- <command> [options]
*
* Commands:
* operation-sizes [--user <id>] Analyze operation size distribution
* operation-timeline [--user <id>] Analyze temporal patterns (bursts, loops)
* operation-types [--user <id>] Breakdown by operation type
* large-ops [--limit <n>] Find and analyze largest operations
* rapid-fire [--threshold <n>] Detect potential sync loops
* snapshot-analysis Analyze snapshot usage patterns
* user-deep-dive --user <id> Complete analysis for one user
* export-ops --user <id> [--limit <n>] Export operations to JSON for analysis
* compare-users <id1> <id2> Compare two users' patterns
*/
import { Prisma } from '@prisma/client';
import { prisma, disconnectDb } from '../src/db';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Utility Functions
// ============================================================================
const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
};
const saveToFile = (filename: string, data: any): string => {
const outputDir = path.join(process.cwd(), 'analysis-output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
return filepath;
};
// ============================================================================
// Analysis Commands
// ============================================================================
/**
* Analyze operation size distribution to identify outliers
*/
const analyzeOperationSizes = async (userId?: number): Promise<void> => {
console.log('\n=== Operation Size Distribution ===\n');
const userWhere = userId
? Prisma.sql`WHERE user_id = ${userId}`
: Prisma.empty;
const userAnd = userId
? Prisma.sql`AND user_id = ${userId}`
: Prisma.empty;
// Get percentile distribution
const sizeDistribution: any[] = await prisma.$queryRaw`
SELECT
percentile_cont(0.10) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p10,
percentile_cont(0.25) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p25,
percentile_cont(0.50) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p50,
percentile_cont(0.75) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p75,
percentile_cont(0.90) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p90,
percentile_cont(0.95) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p95,
percentile_cont(0.99) WITHIN GROUP (ORDER BY pg_column_size(payload)) as p99,
MIN(pg_column_size(payload)) as min_size,
MAX(pg_column_size(payload)) as max_size,
AVG(pg_column_size(payload)) as avg_size,
COUNT(*) as total_ops
FROM operations
${userWhere}
`;
const stats = sizeDistribution[0];
console.log('Size Percentiles:');
console.log(` Min: ${formatBytes(Number(stats.min_size))}`);
console.log(` P10: ${formatBytes(Number(stats.p10))}`);
console.log(` P25: ${formatBytes(Number(stats.p25))}`);
console.log(` P50: ${formatBytes(Number(stats.p50))} (median)`);
console.log(` P75: ${formatBytes(Number(stats.p75))}`);
console.log(` P90: ${formatBytes(Number(stats.p90))}`);
console.log(` P95: ${formatBytes(Number(stats.p95))}`);
console.log(` P99: ${formatBytes(Number(stats.p99))}`);
console.log(` Max: ${formatBytes(Number(stats.max_size))}`);
console.log(` Avg: ${formatBytes(Number(stats.avg_size))}`);
console.log(` Total Operations: ${Number(stats.total_ops)}`);
// Size buckets
console.log('\nSize Distribution:');
const buckets: any[] = await prisma.$queryRaw`
SELECT
CASE
WHEN pg_column_size(payload) < 512 THEN '0-512B'
WHEN pg_column_size(payload) < 1024 THEN '512B-1KB'
WHEN pg_column_size(payload) < 5120 THEN '1KB-5KB'
WHEN pg_column_size(payload) < 10240 THEN '5KB-10KB'
WHEN pg_column_size(payload) < 51200 THEN '10KB-50KB'
WHEN pg_column_size(payload) < 102400 THEN '50KB-100KB'
ELSE '100KB+'
END as size_bucket,
COUNT(*) as count,
SUM(pg_column_size(payload)) as total_bytes
FROM operations
${userWhere}
GROUP BY size_bucket
ORDER BY MIN(pg_column_size(payload))
`;
console.table(
buckets.map((b) => ({
Bucket: b.size_bucket,
Count: Number(b.count),
Total: formatBytes(Number(b.total_bytes)),
AvgSize: formatBytes(Number(b.total_bytes) / Number(b.count)),
})),
);
};
/**
* Analyze temporal patterns - detect bursts, sync loops, etc.
*/
const analyzeOperationTimeline = async (userId?: number): Promise<void> => {
console.log('\n=== Operation Timeline Analysis ===\n');
const userAnd = userId
? Prisma.sql`AND user_id = ${userId}`
: Prisma.empty;
// Operations per day
console.log('Operations per Day (last 30 days):');
const perDay: any[] = await prisma.$queryRaw`
SELECT
DATE(to_timestamp(received_at / 1000)) as date,
COUNT(*) as ops_count,
COUNT(DISTINCT client_id) as unique_devices,
SUM(pg_column_size(payload)) as total_bytes
FROM operations
WHERE received_at > EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days') * 1000
${userAnd}
GROUP BY DATE(to_timestamp(received_at / 1000))
ORDER BY date DESC
LIMIT 30
`;
if (perDay.length > 0) {
console.table(
perDay.map((d) => ({
Date: String(d.date).split('T')[0],
Ops: Number(d.ops_count),
Devices: Number(d.unique_devices),
Size: formatBytes(Number(d.total_bytes)),
})),
);
} else {
console.log('No recent operations found.');
}
// Operations per hour (last 24 hours)
console.log('\nOperations per Hour (last 24 hours):');
const perHour: any[] = await prisma.$queryRaw`
SELECT
DATE_TRUNC('hour', to_timestamp(received_at / 1000)) as hour,
COUNT(*) as ops_count,
AVG(pg_column_size(payload)) as avg_size
FROM operations
WHERE received_at > EXTRACT(EPOCH FROM NOW() - INTERVAL '24 hours') * 1000
${userAnd}
GROUP BY hour
ORDER BY hour DESC
LIMIT 24
`;
if (perHour.length > 0) {
console.table(
perHour.map((h) => ({
Hour: new Date(h.hour).toLocaleString(),
Ops: Number(h.ops_count),
AvgSize: formatBytes(Number(h.avg_size)),
})),
);
}
};
/**
* Breakdown operations by type
*/
const analyzeOperationTypes = async (userId?: number): Promise<void> => {
console.log('\n=== Operation Type Analysis ===\n');
const userWhere = userId
? Prisma.sql`WHERE user_id = ${userId}`
: Prisma.empty;
// By opType
console.log('By Operation Type:');
const byOpType: any[] = await prisma.$queryRaw`
SELECT
op_type,
COUNT(*) as count,
SUM(pg_column_size(payload)) as total_bytes,
AVG(pg_column_size(payload)) as avg_bytes,
MAX(pg_column_size(payload)) as max_bytes,
COUNT(DISTINCT user_id) as unique_users
FROM operations
${userWhere}
GROUP BY op_type
ORDER BY total_bytes DESC
`;
console.table(
byOpType.map((t) => ({
Type: t.op_type,
Count: Number(t.count),
Total: formatBytes(Number(t.total_bytes)),
Avg: formatBytes(Number(t.avg_bytes)),
Max: formatBytes(Number(t.max_bytes)),
Users: Number(t.unique_users),
})),
);
// By entityType
console.log('\nBy Entity Type:');
const byEntityType: any[] = await prisma.$queryRaw`
SELECT
entity_type,
COUNT(*) as count,
SUM(pg_column_size(payload)) as total_bytes,
AVG(pg_column_size(payload)) as avg_bytes
FROM operations
${userWhere}
GROUP BY entity_type
ORDER BY total_bytes DESC
`;
console.table(
byEntityType.map((t) => ({
Entity: t.entity_type,
Count: Number(t.count),
Total: formatBytes(Number(t.total_bytes)),
Avg: formatBytes(Number(t.avg_bytes)),
})),
);
// By actionType (top 10)
console.log('\nBy Action Type (Top 10):');
const byActionType: any[] = await prisma.$queryRaw`
SELECT
action_type,
COUNT(*) as count,
SUM(pg_column_size(payload)) as total_bytes,
AVG(pg_column_size(payload)) as avg_bytes
FROM operations
${userWhere}
GROUP BY action_type
ORDER BY total_bytes DESC
LIMIT 10
`;
console.table(
byActionType.map((t) => ({
Action: t.action_type.substring(0, 50),
Count: Number(t.count),
Total: formatBytes(Number(t.total_bytes)),
Avg: formatBytes(Number(t.avg_bytes)),
})),
);
};
/**
* Find and analyze the largest operations
*/
const analyzeLargeOperations = async (limit = 20): Promise<void> => {
console.log(`\n=== Top ${limit} Largest Operations ===\n`);
const largeOps: any[] = await prisma.$queryRaw`
SELECT
o.id,
o.user_id,
u.email,
o.op_type,
o.action_type,
o.entity_type,
o.entity_id,
pg_column_size(o.payload) as payload_bytes,
o.received_at,
o.is_payload_encrypted
FROM operations o
JOIN users u ON o.user_id = u.id
ORDER BY pg_column_size(o.payload) DESC
LIMIT ${limit};
`;
console.table(
largeOps.map((op) => ({
User: `${op.user_id} (${op.email})`,
OpType: op.op_type,
Entity: op.entity_type,
Size: formatBytes(Number(op.payload_bytes)),
Encrypted: op.is_payload_encrypted,
When: new Date(Number(op.received_at)).toLocaleString(),
})),
);
// Detailed analysis of the largest one
if (largeOps.length > 0) {
const largest = largeOps[0];
console.log('\n=== Largest Operation Details ===');
console.log(`ID: ${largest.id}`);
console.log(`User: ${largest.user_id} (${largest.email})`);
console.log(`Type: ${largest.op_type}`);
console.log(`Action: ${largest.action_type}`);
console.log(`Entity: ${largest.entity_type}:${largest.entity_id || 'N/A'}`);
console.log(`Size: ${formatBytes(Number(largest.payload_bytes))}`);
console.log(`Encrypted: ${largest.is_payload_encrypted}`);
// Get the actual payload to analyze structure
const fullOp = await prisma.operation.findUnique({
where: { id: largest.id },
select: { payload: true },
});
if (fullOp && !largest.is_payload_encrypted) {
console.log('\nPayload Structure:');
const analyzeObject = (obj: any, indent = 0): void => {
const prefix = ' '.repeat(indent);
for (const [key, value] of Object.entries(obj)) {
const valueStr = JSON.stringify(value);
const size = new TextEncoder().encode(valueStr).length;
if (Array.isArray(value)) {
console.log(`${prefix}${key}: Array[${value.length}] - ${formatBytes(size)}`);
if (value.length > 0 && typeof value[0] === 'object') {
console.log(
`${prefix} Sample item keys: ${Object.keys(value[0]).join(', ')}`,
);
}
} else if (typeof value === 'object' && value !== null) {
console.log(`${prefix}${key}: Object - ${formatBytes(size)}`);
if (size > 1024) {
analyzeObject(value, indent + 1);
}
} else {
if (size > 100) {
console.log(`${prefix}${key}: ${typeof value} - ${formatBytes(size)}`);
}
}
}
};
analyzeObject(fullOp.payload as any);
} else if (largest.is_payload_encrypted) {
console.log('\nPayload is encrypted, cannot analyze structure.');
}
}
};
/**
* Detect potential sync loops or rapid-fire operations
*/
const detectRapidFire = async (thresholdPerSecond = 5): Promise<void> => {
console.log(`\n=== Rapid Fire Detection (>${thresholdPerSecond} ops/second) ===\n`);
const rapidFire: any[] = await prisma.$queryRaw`
SELECT
user_id,
client_id,
DATE_TRUNC('second', to_timestamp(received_at / 1000)) as second,
COUNT(*) as ops_in_second,
array_agg(DISTINCT op_type) as op_types,
array_agg(DISTINCT entity_type) as entity_types
FROM operations
GROUP BY user_id, client_id, second
HAVING COUNT(*) > ${thresholdPerSecond}
ORDER BY ops_in_second DESC
LIMIT 50;
`;
if (rapidFire.length === 0) {
console.log('No rapid-fire patterns detected.');
return;
}
console.table(
rapidFire.map((rf) => ({
User: rf.user_id,
Client: rf.client_id.substring(0, 12) + '...',
Second: new Date(rf.second).toLocaleString(),
Ops: Number(rf.ops_in_second),
OpTypes: rf.op_types.join(', '),
})),
);
// Group by user to find habitual offenders
const byUser = new Map<number, number>();
rapidFire.forEach((rf) => {
const current = byUser.get(rf.user_id) || 0;
byUser.set(rf.user_id, current + 1);
});
console.log('\nUsers with Most Rapid-Fire Incidents:');
const userIncidents = Array.from(byUser.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
for (const [userId, count] of userIncidents) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
console.log(` User ${userId} (${user?.email}): ${count} incidents`);
}
};
/**
* Analyze snapshot usage patterns
*/
const analyzeSnapshots = async (): Promise<void> => {
console.log('\n=== Snapshot Analysis ===\n');
const snapshotStats: any[] = await prisma.$queryRaw`
SELECT
COUNT(CASE WHEN snapshot_data IS NOT NULL THEN 1 END) as users_with_snapshot,
COUNT(CASE WHEN snapshot_data IS NULL THEN 1 END) as users_without_snapshot,
AVG(LENGTH(snapshot_data)) FILTER (WHERE snapshot_data IS NOT NULL) as avg_snapshot_size,
MAX(LENGTH(snapshot_data)) as max_snapshot_size,
SUM(LENGTH(snapshot_data)) as total_snapshot_size
FROM user_sync_state;
`;
const stats = snapshotStats[0];
console.log('Snapshot Statistics:');
console.log(` Users with snapshot: ${Number(stats.users_with_snapshot)}`);
console.log(` Users without snapshot: ${Number(stats.users_without_snapshot)}`);
console.log(` Average size: ${formatBytes(Number(stats.avg_snapshot_size || 0))}`);
console.log(` Max size: ${formatBytes(Number(stats.max_snapshot_size || 0))}`);
console.log(` Total size: ${formatBytes(Number(stats.total_snapshot_size || 0))}`);
// Correlation with operation count
console.log('\nSnapshot Size vs Operation Count:');
const correlation: any[] = await prisma.$queryRaw`
SELECT
u.id,
u.email,
(SELECT COUNT(*) FROM operations o WHERE o.user_id = u.id) as op_count,
LENGTH(s.snapshot_data) as snapshot_size,
s.last_snapshot_seq
FROM users u
LEFT JOIN user_sync_state s ON u.id = s.user_id
WHERE s.snapshot_data IS NOT NULL
ORDER BY LENGTH(s.snapshot_data) DESC
LIMIT 20;
`;
console.table(
correlation.map((c) => ({
User: `${c.id} (${c.email})`,
Ops: Number(c.op_count),
SnapshotSize: formatBytes(Number(c.snapshot_size)),
LastSeq: Number(c.last_snapshot_seq),
})),
);
};
/**
* Complete deep-dive analysis for a single user
*/
const userDeepDive = async (userId: number): Promise<void> => {
console.log(`\n${'='.repeat(80)}`);
console.log(`DEEP DIVE: User ${userId}`);
console.log('='.repeat(80));
// User info
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
syncState: true,
devices: true,
},
});
if (!user) {
console.log('User not found.');
return;
}
console.log('\n--- User Profile ---');
console.log(`Email: ${user.email}`);
console.log(`Created: ${user.createdAt.toLocaleString()}`);
console.log(`Verified: ${user.isVerified === 1 ? 'Yes' : 'No'}`);
console.log(`Devices: ${user.devices.length}`);
if (user.devices.length > 0) {
console.log('\nDevices:');
console.table(
user.devices.map((d) => ({
ClientId: d.clientId.substring(0, 20) + '...',
Name: d.deviceName || 'Unknown',
LastSeen: new Date(Number(d.lastSeenAt)).toLocaleString(),
LastAckedSeq: d.lastAckedSeq,
})),
);
}
if (user.syncState) {
console.log('\n--- Sync State ---');
console.log(`Last Seq: ${user.syncState.lastSeq}`);
console.log(`Last Snapshot Seq: ${user.syncState.lastSnapshotSeq || 'N/A'}`);
console.log(
`Snapshot Size: ${formatBytes(user.syncState.snapshotData?.length || 0)}`,
);
console.log(
`Snapshot Age: ${user.syncState.snapshotAt ? formatDuration(Date.now() - Number(user.syncState.snapshotAt)) : 'N/A'}`,
);
}
// Run all analyses for this user
await analyzeOperationSizes(userId);
await analyzeOperationTimeline(userId);
await analyzeOperationTypes(userId);
// Device-specific patterns
console.log('\n--- Per-Device Operation Breakdown ---');
const perDevice: any[] = await prisma.$queryRaw`
SELECT
client_id,
COUNT(*) as ops,
SUM(pg_column_size(payload)) as total_bytes,
MIN(received_at) as first_op,
MAX(received_at) as last_op
FROM operations
WHERE user_id = ${userId}
GROUP BY client_id
ORDER BY ops DESC;
`;
console.table(
perDevice.map((d) => ({
Device: d.client_id.substring(0, 20) + '...',
Ops: Number(d.ops),
Size: formatBytes(Number(d.total_bytes)),
FirstOp: new Date(Number(d.first_op)).toLocaleString(),
LastOp: new Date(Number(d.last_op)).toLocaleString(),
})),
);
};
/**
* Export operations to JSON for external analysis
*/
const exportOperations = async (userId: number, limit = 1000): Promise<void> => {
console.log(`\n=== Exporting Operations for User ${userId} ===\n`);
const ops = await prisma.operation.findMany({
where: { userId },
orderBy: { serverSeq: 'desc' },
take: limit,
select: {
id: true,
clientId: true,
serverSeq: true,
actionType: true,
opType: true,
entityType: true,
entityId: true,
payload: true,
vectorClock: true,
schemaVersion: true,
clientTimestamp: true,
receivedAt: true,
isPayloadEncrypted: true,
},
});
const filename = `user-${userId}-ops-${new Date().toISOString().split('T')[0]}.json`;
const filepath = saveToFile(filename, ops);
console.log(`Exported ${ops.length} operations to: ${filepath}`);
// Basic stats
const totalSize = new TextEncoder().encode(JSON.stringify(ops)).length;
console.log(`Total export size: ${formatBytes(totalSize)}`);
console.log(`Average op size: ${formatBytes(totalSize / ops.length)}`);
};
/**
* Compare two users' operation patterns
*/
const compareUsers = async (userId1: number, userId2: number): Promise<void> => {
console.log(`\n=== Comparing Users ${userId1} vs ${userId2} ===\n`);
const getStats = async (userId: number) => {
const stats: any[] = await prisma.$queryRaw`
SELECT
COUNT(*) as total_ops,
SUM(pg_column_size(payload)) as total_bytes,
AVG(pg_column_size(payload)) as avg_bytes,
MIN(received_at) as first_op,
MAX(received_at) as last_op,
COUNT(DISTINCT client_id) as device_count,
COUNT(DISTINCT op_type) as unique_op_types,
COUNT(DISTINCT entity_type) as unique_entity_types
FROM operations
WHERE user_id = ${userId};
`;
return stats[0];
};
const [stats1, stats2] = await Promise.all([getStats(userId1), getStats(userId2)]);
const [user1, user2] = await Promise.all([
prisma.user.findUnique({ where: { id: userId1 }, select: { email: true } }),
prisma.user.findUnique({ where: { id: userId2 }, select: { email: true } }),
]);
console.table([
{
Metric: 'Email',
User1: user1?.email || 'Unknown',
User2: user2?.email || 'Unknown',
},
{
Metric: 'Total Operations',
User1: Number(stats1.total_ops),
User2: Number(stats2.total_ops),
},
{
Metric: 'Total Size',
User1: formatBytes(Number(stats1.total_bytes)),
User2: formatBytes(Number(stats2.total_bytes)),
},
{
Metric: 'Avg Op Size',
User1: formatBytes(Number(stats1.avg_bytes)),
User2: formatBytes(Number(stats2.avg_bytes)),
},
{
Metric: 'Devices',
User1: Number(stats1.device_count),
User2: Number(stats2.device_count),
},
{
Metric: 'Unique Op Types',
User1: Number(stats1.unique_op_types),
User2: Number(stats2.unique_op_types),
},
{
Metric: 'First Operation',
User1: new Date(Number(stats1.first_op)).toLocaleDateString(),
User2: new Date(Number(stats2.first_op)).toLocaleDateString(),
},
{
Metric: 'Last Operation',
User1: new Date(Number(stats1.last_op)).toLocaleDateString(),
User2: new Date(Number(stats2.last_op)).toLocaleDateString(),
},
]);
};
// ============================================================================
// CLI
// ============================================================================
const showHelp = (): void => {
console.log(`
SuperSync Storage Analysis Tool
Usage:
npm run analyze-storage -- <command> [options]
Commands:
operation-sizes [--user <id>] Analyze operation size distribution
operation-timeline [--user <id>] Analyze temporal patterns (bursts, loops)
operation-types [--user <id>] Breakdown by operation type
large-ops [--limit <n>] Find and analyze largest operations (default: 20)
rapid-fire [--threshold <n>] Detect potential sync loops (default: 5 ops/sec)
snapshot-analysis Analyze snapshot usage patterns
user-deep-dive --user <id> Complete analysis for one user
export-ops --user <id> [--limit <n>] Export operations to JSON (default: 1000)
compare-users <id1> <id2> Compare two users' patterns
Examples:
npm run analyze-storage -- operation-sizes --user 29
npm run analyze-storage -- large-ops --limit 50
npm run analyze-storage -- rapid-fire --threshold 10
npm run analyze-storage -- user-deep-dive --user 27
npm run analyze-storage -- compare-users 27 29
`);
};
const main = async (): Promise<void> => {
const args = process.argv.slice(2);
const command = args[0];
const getOption = (flag: string): string | undefined => {
const index = args.indexOf(flag);
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
};
const getUserId = (): number | undefined => {
const userId = getOption('--user');
return userId ? parseInt(userId, 10) : undefined;
};
const getLimit = (): number => {
const limit = getOption('--limit');
return limit ? parseInt(limit, 10) : 20;
};
const getThreshold = (): number => {
const threshold = getOption('--threshold');
return threshold ? parseInt(threshold, 10) : 5;
};
try {
switch (command) {
case 'operation-sizes':
await analyzeOperationSizes(getUserId());
break;
case 'operation-timeline':
await analyzeOperationTimeline(getUserId());
break;
case 'operation-types':
await analyzeOperationTypes(getUserId());
break;
case 'large-ops':
await analyzeLargeOperations(getLimit());
break;
case 'rapid-fire':
await detectRapidFire(getThreshold());
break;
case 'snapshot-analysis':
await analyzeSnapshots();
break;
case 'user-deep-dive': {
const userId = getUserId();
if (!userId) {
console.error('Error: --user <id> is required for user-deep-dive');
process.exit(1);
}
await userDeepDive(userId);
break;
}
case 'export-ops': {
const userId = getUserId();
if (!userId) {
console.error('Error: --user <id> is required for export-ops');
process.exit(1);
}
await exportOperations(userId, getLimit());
break;
}
case 'compare-users': {
const userId1 = args[1] ? parseInt(args[1], 10) : undefined;
const userId2 = args[2] ? parseInt(args[2], 10) : undefined;
if (!userId1 || !userId2) {
console.error('Error: Two user IDs required: compare-users <id1> <id2>');
process.exit(1);
}
await compareUsers(userId1, userId2);
break;
}
default:
showHelp();
}
} catch (error) {
console.error('Error:', error);
process.exit(1);
} finally {
await disconnectDb();
}
};
main();