⚡ Zero-dependency plcbundle library exclusively for Bun

Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 1 1 node_modules 2 2 test 3 3 dist 4 - test-data 4 + test-data 5 + pds_list
+5
bun.lock
··· 3 3 "workspaces": { 4 4 "": { 5 5 "name": "plcbundle", 6 + "dependencies": { 7 + "@jmespath-community/jmespath": "^1.3.0", 8 + }, 6 9 "devDependencies": { 7 10 "@types/bun": "^1.3.1", 8 11 }, 9 12 }, 10 13 }, 11 14 "packages": { 15 + "@jmespath-community/jmespath": ["@jmespath-community/jmespath@1.3.0", "", { "bin": { "jp": "dist/cli.mjs" } }, "sha512-nzOrEdWKNpognj6CT+1Atr7gw0bqUC1KTBRyasBXS9NjFpz+og7LeFZrIQqV81GRcCzKa5H+DNipvv7NQK3GzA=="], 16 + 12 17 "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 13 18 14 19 "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
+2 -1
examples/analyze.ts
··· 19 19 withPds: 0, 20 20 }; 21 21 22 - await bundle.processBundles(1, 10, (op) => { 22 + await bundle.processBundles(1, 10, (op, position, bundleNum, line) => { 23 23 stats.totalOps++; 24 24 stats.uniqueDids.add(op.did); 25 25 ··· 37 37 stats.withPds++; 38 38 } 39 39 }); 40 + 40 41 41 42 console.log('📊 Analysis Results\n'); 42 43 console.log(`Total operations: ${stats.totalOps.toLocaleString()}`);
+11
examples/detect-orig.js
··· 1 + export function detect({ op }) { 2 + const labels = []; 3 + 4 + if (op.did.startsWith('did:plc:aa')) { 5 + labels.push('test') 6 + } 7 + 8 + console.log(op.operation.sig) 9 + 10 + return labels; 11 + }
+2 -2
examples/detect.ts
··· 19 19 } 20 20 21 21 // Example: Detect accounts created in 2024 22 - if (op.createdAt?.startsWith('2024')) { 23 - labels.push('created-2024'); 22 + if (op.createdAt?.match(/^\d{4}\-(03|06)/)) { 23 + labels.push('created-march-june'); 24 24 } 25 25 26 26 return labels;
+12
examples/flush-pds.ts
··· 1 + // flushes all pds endpoints 2 + 3 + const unique: Array<string> = [] 4 + 5 + export function process({ op }: { op: any }) { 6 + 7 + const endpoint = op.operation.services?.atproto_pds?.endpoint 8 + if (!unique.includes(endpoint)) { 9 + console.log(endpoint) 10 + unique.push(endpoint) 11 + } 12 + }
+31
examples/info.ts
··· 1 + /** 2 + * Example: Get repository information 3 + * 4 + * Usage: 5 + * bun examples/info.ts 6 + */ 7 + 8 + import { PLCBundle } from '../src'; 9 + 10 + const bundle = new PLCBundle('./data/bundles'); 11 + 12 + const stats = await bundle.getStats(); 13 + 14 + console.log('📦 Repository Information\n'); 15 + console.log(`Version: ${stats.version}`); 16 + console.log(`Last bundle: ${stats.lastBundle}`); 17 + console.log(`Total bundles: ${stats.totalBundles}`); 18 + console.log(`Total size: ${(stats.totalSize / 1e9).toFixed(2)} GB`); 19 + console.log(`Updated: ${stats.updatedAt}`); 20 + 21 + console.log('\n📊 Sample Bundle Info\n'); 22 + 23 + const metadata = await bundle.getMetadata(1); 24 + if (metadata) { 25 + console.log(`Bundle #${metadata.bundle_number}`); 26 + console.log(` Operations: ${metadata.operation_count.toLocaleString()}`); 27 + console.log(` Unique DIDs: ${metadata.did_count.toLocaleString()}`); 28 + console.log(` Time range: ${metadata.start_time} → ${metadata.end_time}`); 29 + console.log(` Size: ${(metadata.compressed_size / 1e6).toFixed(2)} MB (compressed)`); 30 + console.log(` Size: ${(metadata.uncompressed_size / 1e6).toFixed(2)} MB (uncompressed)`); 31 + }
-32
examples/library-use.js
··· 1 - import { PLCBundle } from './src'; 2 - 3 - // Create bundle reader 4 - const plcbundle = new PLCBundle('./bundles'); 5 - 6 - // Get stats 7 - const stats = await plcbundle.getStats(); 8 - console.log(stats); 9 - 10 - // Stream operations 11 - for await (const op of plcbundle.streamOperations(1)) { 12 - console.log(op.did); 13 - } 14 - 15 - await bundle.processBundles(1, 100, (op, pos, num) => { 16 - // Your logic 17 - }, { 18 - threads: 4, 19 - onProgress: (stats) => { 20 - console.log(`Processed ${stats.totalOps} ops`); 21 - } 22 - }); 23 - 24 - // Clone with progress 25 - await bundle.clone('https://plcbundle.atscan.net', { 26 - threads: 8, 27 - bundles: '1-100', 28 - verify: true, 29 - onProgress: (stats) => { 30 - console.log(`Downloaded ${stats.downloadedBundles}/${stats.totalBundles}`); 31 - } 32 - });
+1 -1
examples/parallel.ts
··· 18 18 19 19 const startTime = Date.now(); 20 20 21 - await bundle.processBundles(1, 50, (op) => { 21 + await bundle.processBundles(1, 50, (op, position, bundleNum, line) => { 22 22 if (op.operation?.alsoKnownAs?.length > 0) { 23 23 matches.withHandle++; 24 24 }
+28
examples/processor.ts
··· 1 + /** 2 + * Example process function for general operation processing 3 + * 4 + * Usage: 5 + * plcbundle-bun process ./examples/processor.ts 6 + */ 7 + 8 + export function process({ op, position, bundle, line }: { 9 + op: any; 10 + position: number; 11 + bundle: number; 12 + line: string; 13 + }) { 14 + // Example: Count operations by year 15 + const year = op.createdAt.substring(0, 4); 16 + 17 + // Example: Log specific operations 18 + if (position % 1000 === 0) { 19 + console.log(`Bundle ${bundle}, position ${position}: ${op.did}`); 20 + } 21 + 22 + // Example: Custom logic 23 + if (op.operation?.alsoKnownAs?.length > 0) { 24 + // Do something with operations that have handles 25 + } 26 + 27 + // No need to return anything 28 + }
+23
examples/service-types.ts
··· 1 + const counts: Record<string, number>= {} 2 + 3 + export function process({ op }: { op: any }) { 4 + 5 + if (!op.operation?.services) { 6 + return 7 + } 8 + for (const key of Object.keys(op.operation.services)) { 9 + if (!counts[key]) { 10 + counts[key] = 1 11 + } else { 12 + counts[key] += 1 13 + } 14 + } 15 + } 16 + 17 + export function prepare() { 18 + return { counts } 19 + } 20 + 21 + export function finalize(results: any, { aggregate }: any) { 22 + console.log(Object.fromEntries(Object.entries(aggregate(results.map((r: any) => r.data.counts))).sort((a, b) => a[1] < b[1] ? 1 : -1))) 23 + }
+1 -1
jsr.json
··· 1 1 { 2 2 "name": "@atscan/plcbundle-bun", 3 - "version": "0.9.2", 3 + "version": "0.9.5", 4 4 "license": "MIT", 5 5 "exports": "./src/index.ts" 6 6 }
+5 -2
package.json
··· 1 1 { 2 2 "name": "@atscan/plcbundle-bun", 3 - "version": "0.9.2", 3 + "version": "0.9.5", 4 4 "type": "module", 5 5 "description": "Bun library for working with DID PLC bundle archives (plcbundle)", 6 6 "main": "./src/index.ts", ··· 16 16 "scripts": { 17 17 "test": "bun test", 18 18 "test:watch": "bun test --watch", 19 - "test:coverage": "bun test --coverage", 19 + "tests:coverage": "bun test --coverage", 20 20 "cli": "bun src/cli.ts", 21 21 "publish": "bunx jsr publish" 22 22 }, ··· 25 25 }, 26 26 "publishConfig": { 27 27 "access": "public" 28 + }, 29 + "dependencies": { 30 + "@jmespath-community/jmespath": "^1.3.0" 28 31 } 29 32 }
+17 -9
src/cli.ts
··· 2 2 3 3 import { clone } from './cmds/clone'; 4 4 import { detect } from './cmds/detect'; 5 + import { processCmd } from './cmds/process'; 5 6 import { info } from './cmds/info'; 6 7 import { verify } from './cmds/verify'; 7 8 import { exportCmd } from './cmds/export'; 9 + import { query } from './cmds/query'; 8 10 9 11 const commands = { 10 12 clone, 11 13 detect, 14 + process: processCmd, 15 + query, 16 + q: query, // Alias for query 12 17 info, 13 18 verify, 14 19 export: exportCmd, ··· 21 26 bun cli <command> [options] 22 27 23 28 COMMANDS: 24 - clone Clone bundles from a remote repository 25 - detect Detect and filter operations using a custom function 26 - info Show index or bundle information 27 - verify Verify bundle integrity 28 - export Export operations from bundle 29 - help Show this help 29 + clone Clone bundles from a remote repository 30 + detect Detect and filter operations using a custom function 31 + process Process operations with a custom function 32 + query (q) Query operations using JMESPath or simple dot notation 33 + info Show index or bundle information 34 + verify Verify bundle integrity 35 + export Export operations from bundle 36 + help Show this help 30 37 31 38 Use 'bun cli <command> -h' for command-specific help 32 39 33 40 EXAMPLES: 34 41 bun cli clone --remote https://plcbundle.atscan.net 42 + bun cli detect ./examples/detect.ts --bundles 1-100 43 + bun cli q did --simple --bundles 1-1000 44 + bun cli query 'operation.services.*.endpoint' --bundles 1-100 45 + bun cli process ./my-processor.ts --threads 4 35 46 bun cli info --dir ./bundles 36 - bun cli detect --detect ./examples/detect.ts 37 - bun cli detect --detect ./examples/detect.ts --bundles 1-100 38 - bun cli detect --detect ./examples/detect.ts --bundles 42 --threads 4 39 47 bun cli verify --bundle 42 40 48 bun cli export --bundle 1 > ops.jsonl 41 49 `);
+235
src/cmds/common.ts
··· 1 + import { PLCBundle } from '../plcbundle'; 2 + import type { ProcessStats } from '../types'; 3 + import { parseArgs } from 'util'; 4 + 5 + export interface ProcessingOptions { 6 + dir: string; 7 + start: number; 8 + end: number; 9 + modulePath: string; 10 + threads: number; 11 + silent: boolean; 12 + flush?: boolean; 13 + mode: 'detect' | 'process'; 14 + noProgress?: boolean; 15 + onMatch?: (match: any, matchCount: number, matchedBytes: number) => void; 16 + } 17 + 18 + /** 19 + * Common processing logic for both detect and process commands 20 + */ 21 + export async function processOperations(options: ProcessingOptions) { 22 + const { dir, start, end, modulePath, threads, silent, flush, mode, noProgress, onMatch } = options; 23 + 24 + const bundle = new PLCBundle(dir); 25 + 26 + // Save original console 27 + const originalConsole = { 28 + log: console.log, 29 + error: console.error, 30 + warn: console.warn, 31 + info: console.info, 32 + debug: console.debug, 33 + }; 34 + 35 + // Override console if silent 36 + if (silent) { 37 + console.log = () => {}; 38 + console.error = () => {}; 39 + console.warn = () => {}; 40 + console.info = () => {}; 41 + console.debug = () => {}; 42 + } 43 + 44 + try { 45 + originalConsole.error(`Processing bundles ${start}-${end} from ${dir}${threads > 1 ? ` (${threads} threads)` : ''}${silent ? ' (silent)' : ''}\n`); 46 + 47 + if (mode === 'detect') { 48 + originalConsole.log('bundle,position,cid,size,confidence,labels'); 49 + } 50 + 51 + const startTime = Date.now(); 52 + let matchCount = 0; 53 + let matchedBytes = 0; 54 + 55 + if (threads > 1) { 56 + // Multi-threaded mode 57 + const result: any = await bundle.processBundles(start, end, { 58 + module: modulePath, 59 + threads, 60 + silent, 61 + flush, 62 + onProgress: noProgress ? undefined : (progressStats: ProcessStats) => { // Check noProgress 63 + const elapsed = (Date.now() - startTime) / 1000; 64 + const opsPerSec = (progressStats.totalOps / elapsed).toFixed(0); 65 + const mbPerSec = (progressStats.totalBytes / elapsed / 1e6).toFixed(1); 66 + originalConsole.error(`Processed ${progressStats.totalOps} ops | ${opsPerSec} ops/sec | ${mbPerSec} MB/s\r`); 67 + }, 68 + onMatch: flush && onMatch ? (match) => { 69 + matchCount++; 70 + matchedBytes += match.size; 71 + onMatch(match, matchCount, matchedBytes); 72 + } : undefined, 73 + }); 74 + 75 + // Output buffered matches (if not flushed) 76 + if (!flush && result.matches && onMatch) { 77 + for (const match of result.matches) { 78 + matchCount++; 79 + matchedBytes += match.size; 80 + onMatch(match, matchCount, matchedBytes); 81 + } 82 + } 83 + 84 + const elapsed = (Date.now() - startTime) / 1000; 85 + const opsPerSec = (result.totalOps / elapsed).toFixed(0); 86 + const mbPerSec = (result.totalBytes / elapsed / 1e6).toFixed(1); 87 + 88 + originalConsole.error('\n'); 89 + originalConsole.error(`✓ ${mode === 'detect' ? 'Detection' : 'Processing'} complete`); 90 + originalConsole.error(` Total operations: ${result.totalOps.toLocaleString()}`); 91 + 92 + if (mode === 'detect') { 93 + originalConsole.error(` Matches found: ${matchCount.toLocaleString()} (${(matchCount/result.totalOps*100).toFixed(2)}%)`); 94 + originalConsole.error(` Matched size: ${(matchedBytes / 1e6).toFixed(1)} MB (${(matchedBytes/result.totalBytes*100).toFixed(2)}%)`); 95 + } 96 + 97 + originalConsole.error(` Total size: ${(result.totalBytes / 1e6).toFixed(1)} MB`); 98 + originalConsole.error(''); 99 + originalConsole.error(` Time elapsed: ${elapsed.toFixed(2)}s`); 100 + originalConsole.error(` Throughput: ${opsPerSec} ops/sec | ${mbPerSec} MB/s`); 101 + originalConsole.error(` Threads: ${threads}`); 102 + } else { 103 + // Single-threaded mode 104 + const mod = await import(modulePath); 105 + const userFn = mode === 'detect' ? (mod.detect || mod.default) : (mod.process || mod.default); 106 + 107 + const finalStats = await bundle.processBundles( 108 + start, 109 + end, 110 + (op, position, bundleNum, line) => { 111 + if (mode === 'detect') { 112 + const labels = userFn({ op }); 113 + 114 + if (labels && labels.length > 0) { 115 + matchCount++; 116 + matchedBytes += line.length; 117 + if (onMatch) { 118 + onMatch({ 119 + bundle: bundleNum, 120 + position, 121 + cid: op.cid.slice(-4), 122 + size: line.length, 123 + labels 124 + }, matchCount, matchedBytes); 125 + } 126 + } 127 + } else { 128 + // Process mode - just call function 129 + userFn({ op, position, bundle: bundleNum, line }); 130 + } 131 + }, 132 + { 133 + onProgress: noProgress ? undefined : (progressStats: ProcessStats) => { // Check noProgress 134 + const elapsed = (Date.now() - startTime) / 1000; 135 + const opsPerSec = (progressStats.totalOps / elapsed).toFixed(0); 136 + const mbPerSec = (progressStats.totalBytes / elapsed / 1e6).toFixed(1); 137 + originalConsole.error(`Processed ${progressStats.totalOps} ops | ${opsPerSec} ops/sec | ${mbPerSec} MB/s\r`); 138 + }, 139 + } 140 + ); 141 + 142 + const elapsed = (Date.now() - startTime) / 1000; 143 + const opsPerSec = (finalStats.totalOps / elapsed).toFixed(0); 144 + const mbPerSec = (finalStats.totalBytes / elapsed / 1e6).toFixed(1); 145 + 146 + originalConsole.error('\n'); 147 + originalConsole.error(`✓ ${mode === 'detect' ? 'Detection' : 'Processing'} complete`); 148 + originalConsole.error(` Total operations: ${finalStats.totalOps.toLocaleString()}`); 149 + 150 + if (mode === 'detect') { 151 + originalConsole.error(` Matches found: ${matchCount.toLocaleString()} (${(matchCount/finalStats.totalOps*100).toFixed(2)}%)`); 152 + originalConsole.error(` Matched size: ${(matchedBytes / 1e6).toFixed(1)} MB (${(matchedBytes/finalStats.totalBytes*100).toFixed(2)}%)`); 153 + } 154 + 155 + originalConsole.error(` Total size: ${(finalStats.totalBytes / 1e6).toFixed(1)} MB`); 156 + originalConsole.error(''); 157 + originalConsole.error(` Time elapsed: ${elapsed.toFixed(2)}s`); 158 + originalConsole.error(` Throughput: ${opsPerSec} ops/sec | ${mbPerSec} MB/s`); 159 + if (threads > 1) { 160 + originalConsole.error(` Threads: ${threads}`); 161 + } 162 + } 163 + } finally { 164 + // Restore console 165 + console.log = originalConsole.log; 166 + console.error = originalConsole.error; 167 + console.warn = originalConsole.warn; 168 + console.info = originalConsole.info; 169 + console.debug = originalConsole.debug; 170 + } 171 + } 172 + 173 + /** 174 + * Common argument parsing for process/detect commands 175 + */ 176 + export function parseProcessArgs(args: string[]) { 177 + const { values, positionals } = parseArgs({ 178 + args, 179 + options: { 180 + dir: { type: 'string', default: './' }, 181 + bundles: { type: 'string' }, 182 + threads: { type: 'string', default: '1' }, 183 + silent: { type: 'boolean', default: false }, 184 + s: { type: 'boolean', default: false }, 185 + flush: { type: 'boolean', default: false }, 186 + 'no-progress': { type: 'boolean', default: false }, // Add this 187 + }, 188 + strict: false, 189 + allowPositionals: true, 190 + }); 191 + 192 + return { values, positionals }; 193 + } 194 + 195 + /** 196 + * Parse bundle selection from values 197 + */ 198 + export async function parseBundleSelection( 199 + values: any, 200 + bundle: PLCBundle 201 + ): Promise<{ start: number; end: number }> { 202 + const stats = await bundle.getStats(); 203 + 204 + let start: number, end: number; 205 + 206 + if (values.bundles && typeof values.bundles === 'string') { 207 + const bundleSpec = values.bundles; 208 + 209 + if (bundleSpec.includes('-')) { 210 + const [startStr, endStr] = bundleSpec.split('-'); 211 + start = parseInt(startStr.trim()); 212 + end = parseInt(endStr.trim()); 213 + 214 + if (isNaN(start) || isNaN(end)) { 215 + throw new Error(`Invalid bundle range: ${bundleSpec}`); 216 + } 217 + } else { 218 + start = parseInt(bundleSpec); 219 + end = start; 220 + 221 + if (isNaN(start)) { 222 + throw new Error(`Invalid bundle number: ${bundleSpec}`); 223 + } 224 + } 225 + } else { 226 + start = 1; 227 + end = stats.lastBundle; 228 + } 229 + 230 + if (start < 1 || end > stats.lastBundle || start > end) { 231 + throw new Error(`Invalid bundle range ${start}-${end} (available: 1-${stats.lastBundle})`); 232 + } 233 + 234 + return { start, end }; 235 + }
+37 -111
src/cmds/detect.ts
··· 1 - import { parseArgs } from 'util'; 1 + import { parseProcessArgs, parseBundleSelection, processOperations } from './common'; 2 2 import { PLCBundle } from '../plcbundle'; 3 - import type { ProcessStats } from '../types'; 4 3 5 4 export async function detect(args: string[]) { 6 5 if (args.includes('-h') || args.includes('--help')) { 7 6 console.log(` 8 7 detect - Detect and filter operations using a custom function 9 8 9 + USAGE: 10 + plcbundle-bun detect <module> [options] 11 + 12 + ARGUMENTS: 13 + <module> Path to detect function module (required) 14 + 10 15 OPTIONS: 11 16 --dir <path> Bundle directory (default: ./) 12 17 --bundles <spec> Bundle selection: number (42) or range (1-50) 13 - If not specified, processes all available bundles 14 - --detect <path> Path to detect function module (required) 15 18 --threads <num> Number of worker threads (default: 1) 19 + --flush Output matches immediately (unsorted) 20 + -s, --silent Suppress all console output from detect script 21 + --no-progress Disable progress output (default: false) 16 22 17 23 EXAMPLES: 18 - bun cli detect --detect ./detect.ts # All bundles 19 - bun cli detect --detect ./detect.ts --bundles 42 # Single bundle 20 - bun cli detect --detect ./detect.ts --bundles 1-50 # Range 21 - bun cli detect --detect ./detect.ts --threads 4 # Multi-threaded 24 + plcbundle-bun detect ./detect.ts 25 + plcbundle-bun detect ./detect.ts --bundles 1-50 --threads 4 26 + plcbundle-bun detect ./detect.ts --flush --silent --no-progress 22 27 `); 23 28 return; 24 29 } 25 30 26 - const { values } = parseArgs({ 27 - args, 28 - options: { 29 - dir: { type: 'string', default: './' }, 30 - bundles: { type: 'string' }, 31 - detect: { type: 'string' }, 32 - threads: { type: 'string', default: '1' }, 33 - }, 34 - strict: false, 35 - }); 36 - 37 - const dir = (values.dir as string) || './'; 38 - const threads = parseInt((values.threads as string) || '1'); 39 - 40 - if (!values.detect || typeof values.detect !== 'string') { 41 - console.error('Error: --detect is required'); 42 - process.exit(1); 43 - } 44 - 45 - // Load bundle instance to get index 46 - const bundle = new PLCBundle(dir); 47 - const stats = await bundle.getStats(); 48 - 49 - // Parse bundle selection 50 - let start: number, end: number; 51 - 52 - if (values.bundles && typeof values.bundles === 'string') { 53 - const bundleSpec = values.bundles; 54 - 55 - if (bundleSpec.includes('-')) { 56 - const [startStr, endStr] = bundleSpec.split('-'); 57 - start = parseInt(startStr.trim()); 58 - end = parseInt(endStr.trim()); 59 - 60 - if (isNaN(start) || isNaN(end)) { 61 - console.error(`Error: Invalid bundle range: ${bundleSpec}`); 62 - process.exit(1); 63 - } 64 - } else { 65 - start = parseInt(bundleSpec); 66 - end = start; 67 - 68 - if (isNaN(start)) { 69 - console.error(`Error: Invalid bundle number: ${bundleSpec}`); 70 - process.exit(1); 71 - } 72 - } 73 - } else { 74 - start = 1; 75 - end = stats.lastBundle; 76 - } 31 + const { values, positionals } = parseProcessArgs(args); 32 + const modulePath = positionals[0]; 77 33 78 - // Validate range 79 - if (start < 1 || end > stats.lastBundle || start > end) { 80 - console.error(`Error: Invalid bundle range ${start}-${end} (available: 1-${stats.lastBundle})`); 34 + if (!modulePath) { 35 + console.error('Error: module path is required'); 36 + console.error('Usage: plcbundle-bun detect <module> [options]'); 81 37 process.exit(1); 82 38 } 83 39 84 - // Resolve and load detect function 85 - const detectPath = Bun.resolveSync(values.detect, process.cwd()); 86 - const mod = await import(detectPath); 87 - const detectFn = mod.detect || mod.default; 40 + const dir = (values.dir as string) || './'; 41 + const threads = parseInt((values.threads as string) || '1'); 42 + const silent = Boolean(values.silent || values.s); 43 + const flush = Boolean(values.flush); 44 + const noProgress = Boolean(values['no-progress']); // Add this 88 45 89 - console.error(`Processing bundles ${start}-${end} from ${dir}${threads > 1 ? ` (${threads} threads)` : ''}\n`); 90 - console.log('bundle,position,cid,size,confidence,labels'); 46 + const bundle = new PLCBundle(dir); 47 + const { start, end } = await parseBundleSelection(values, bundle); 48 + const resolvedPath = Bun.resolveSync(modulePath, process.cwd()); 91 49 92 - let matchCount = 0; 93 - let matchedBytes = 0; 94 - const startTime = Date.now(); 95 - 96 - // Process bundles with progress 97 - const finalStats = await bundle.processBundles( 50 + await processOperations({ 51 + dir, 98 52 start, 99 53 end, 100 - (op, position, bundleNum) => { 101 - const opSize = JSON.stringify(op).length; 102 - const labels = detectFn({ op }); 103 - 104 - if (labels && labels.length > 0) { 105 - matchCount++; 106 - matchedBytes += opSize; 107 - const cidShort = op.cid.slice(-4); 108 - console.log(`${bundleNum},${position},${cidShort},${opSize},0.95,${labels.join(';')}`); 109 - } 54 + modulePath: resolvedPath, 55 + threads, 56 + silent, 57 + flush, 58 + noProgress, // Pass it 59 + mode: 'detect', 60 + onMatch: (match) => { 61 + console.log(`${match.bundle},${match.position},${match.cid},${match.size},0.95,${match.labels.join(';')}`); 110 62 }, 111 - { 112 - threads, 113 - onProgress: (progressStats: ProcessStats) => { 114 - const elapsed = (Date.now() - startTime) / 1000; 115 - const opsPerSec = (progressStats.totalOps / elapsed).toFixed(0); 116 - const mbPerSec = (progressStats.totalBytes / elapsed / 1e6).toFixed(1); 117 - console.error(`Processed ${progressStats.totalOps} ops | ${opsPerSec} ops/sec | ${mbPerSec} MB/s\r`); 118 - }, 119 - } 120 - ); 121 - 122 - const elapsed = (Date.now() - startTime) / 1000; 123 - const opsPerSec = (finalStats.totalOps / elapsed).toFixed(0); 124 - const mbPerSec = (finalStats.totalBytes / elapsed / 1e6).toFixed(1); 125 - 126 - console.error('\n'); 127 - console.error('✓ Detection complete'); 128 - console.error(` Total operations: ${finalStats.totalOps.toLocaleString()}`); 129 - console.error(` Matches found: ${matchCount.toLocaleString()} (${(matchCount/finalStats.totalOps*100).toFixed(2)}%)`); 130 - console.error(` Total size: ${(finalStats.totalBytes / 1e6).toFixed(1)} MB`); 131 - console.error(` Matched size: ${(matchedBytes / 1e6).toFixed(1)} MB (${(matchedBytes/finalStats.totalBytes*100).toFixed(2)}%)`); 132 - console.error(''); 133 - console.error(` Time elapsed: ${elapsed.toFixed(2)}s`); 134 - console.error(` Throughput: ${opsPerSec} ops/sec | ${mbPerSec} MB/s`); 135 - if (threads > 1) { 136 - console.error(` Threads: ${threads}`); 137 - } 63 + }); 138 64 }
+64
src/cmds/process.ts
··· 1 + import { exit } from 'process'; 2 + import { parseProcessArgs, parseBundleSelection, processOperations } from './common'; 3 + import { PLCBundle } from '../plcbundle'; 4 + 5 + export async function processCmd(args: string[]) { 6 + if (args.includes('-h') || args.includes('--help')) { 7 + console.log(` 8 + process - Process operations with a custom function 9 + 10 + USAGE: 11 + plcbundle-bun process <module> [options] 12 + 13 + ARGUMENTS: 14 + <module> Path to process function module (required) 15 + 16 + OPTIONS: 17 + --dir <path> Bundle directory (default: ./) 18 + --bundles <spec> Bundle selection: number (42) or range (1-50) 19 + --threads <num> Number of worker threads (default: 1) 20 + -s, --silent Suppress all console output from process script 21 + --no-progress Disable progress output (default: false) 22 + 23 + EXAMPLES: 24 + plcbundle-bun process ./my-processor.ts 25 + plcbundle-bun process ./my-processor.ts --bundles 1-50 --threads 4 26 + plcbundle-bun process ./my-processor.ts --no-progress 27 + 28 + PROCESS FUNCTION: 29 + export function process({ op, position, bundle, line }) { 30 + // Your logic here 31 + } 32 + `); 33 + return; 34 + } 35 + 36 + const { values, positionals } = parseProcessArgs(args); 37 + const modulePath = positionals[0]; 38 + 39 + if (!modulePath) { 40 + console.error('Error: module path is required'); 41 + console.error('Usage: plcbundle-bun process <module> [options]'); 42 + process.exit(1); 43 + } 44 + 45 + const dir = (values.dir as string) || './'; 46 + const threads = parseInt((values.threads as string) || '1'); 47 + const silent = Boolean(values.silent || values.s); 48 + const noProgress = Boolean(values['no-progress']); // Add this 49 + 50 + const bundle = new PLCBundle(dir); 51 + const { start, end } = await parseBundleSelection(values, bundle); 52 + const resolvedPath = Bun.resolveSync(modulePath, process.cwd()); 53 + 54 + await processOperations({ 55 + dir, 56 + start, 57 + end, 58 + modulePath: resolvedPath, 59 + threads, 60 + silent, 61 + noProgress, // Pass it 62 + mode: 'process', 63 + }); 64 + }
+399
src/cmds/query.ts
··· 1 + import { parseArgs } from 'util'; 2 + import { PLCBundle } from '../plcbundle'; 3 + import { search as jmespathSearch } from '@jmespath-community/jmespath'; 4 + 5 + export async function query(args: string[]) { 6 + if (args.includes('-h') || args.includes('--help')) { 7 + console.log(` 8 + query - Query operations using JMESPath or simple dot notation 9 + 10 + USAGE: 11 + plcbundle-bun query <expression> [options] 12 + 13 + ARGUMENTS: 14 + <expression> Query expression (required) 15 + 16 + OPTIONS: 17 + --dir <path> Bundle directory (default: ./) 18 + --bundles <spec> Bundle selection: number (42) or range (1-50) 19 + --threads <num> Number of worker threads (default: 0 = auto-detect CPU cores) 20 + --format <type> Output format: jsonl|count (default: jsonl) 21 + --limit <num> Limit number of results 22 + --simple Use fast simple dot notation (10x faster for basic queries) 23 + --no-progress Disable progress output 24 + 25 + QUERY MODES: 26 + 27 + SIMPLE (--simple) - Ultra-fast for basic property access: 28 + did Direct property (fastest!) 29 + operation.services.atproto_pds Nested property 30 + alsoKnownAs[0] Array indexing 31 + operation.alsoKnownAs[0].name Combined access 32 + 33 + JMESPATH (default) - Full query power: 34 + operation.services.*.endpoint Wildcard 35 + foo[?age > \`30\`] Filtering 36 + {did: did, handle: alsoKnownAs[0]} Projection 37 + operation.services[*].endpoint All endpoints 38 + 39 + EXAMPLES: 40 + # Ultra-fast simple queries (recommended for basic property access) 41 + bun cli q 'did' --simple --bundles 1-10000 42 + bun cli q 'operation.services.atproto_pds.endpoint' --simple 43 + 44 + # Complex JMESPath queries (default) 45 + bun cli q 'operation.services.*.endpoint' --bundles 1-100 46 + bun cli q '[?operation.alsoKnownAs]' --bundles 1-100 47 + `); 48 + return; 49 + } 50 + 51 + const { values, positionals } = parseArgs({ 52 + args, 53 + options: { 54 + dir: { type: 'string', default: './' }, 55 + bundles: { type: 'string' }, 56 + threads: { type: 'string', default: '0' }, 57 + format: { type: 'string', default: 'jsonl' }, 58 + limit: { type: 'string' }, 59 + simple: { type: 'boolean', default: false }, 60 + 'no-progress': { type: 'boolean', default: false }, 61 + }, 62 + strict: false, 63 + allowPositionals: true, 64 + }); 65 + 66 + const expression = positionals[0]; 67 + if (!expression) { 68 + console.error('Error: Query expression is required'); 69 + process.exit(1); 70 + } 71 + 72 + const dir = (values.dir as string) || './'; 73 + let threads = parseInt((values.threads as string) || '0'); 74 + 75 + if (threads === 0) { 76 + threads = navigator.hardwareConcurrency || 4; 77 + } 78 + 79 + const format = (values.format as string) || 'jsonl'; 80 + const limit = values.limit ? parseInt(values.limit as string) : undefined; 81 + const useSimple = Boolean(values.simple); 82 + const noProgress = Boolean(values['no-progress']); 83 + 84 + const bundle = new PLCBundle(dir); 85 + const stats = await bundle.getStats(); 86 + 87 + let start: number, end: number; 88 + 89 + if (values.bundles && typeof values.bundles === 'string') { 90 + const bundleSpec = values.bundles; 91 + if (bundleSpec.includes('-')) { 92 + const [startStr, endStr] = bundleSpec.split('-'); 93 + start = parseInt(startStr.trim()); 94 + end = parseInt(endStr.trim()); 95 + if (isNaN(start) || isNaN(end)) { 96 + throw new Error(`Invalid bundle range: ${bundleSpec}`); 97 + } 98 + } else { 99 + start = parseInt(bundleSpec); 100 + end = start; 101 + if (isNaN(start)) { 102 + throw new Error(`Invalid bundle number: ${bundleSpec}`); 103 + } 104 + } 105 + } else { 106 + start = 1; 107 + end = stats.lastBundle; 108 + } 109 + 110 + if (start < 1 || end > stats.lastBundle || start > end) { 111 + throw new Error(`Invalid bundle range ${start}-${end} (available: 1-${stats.lastBundle})`); 112 + } 113 + 114 + const queryType = useSimple ? 'simple' : 'JMESPath'; 115 + const threadInfo = threads > 1 ? ` (${threads} threads)` : ''; 116 + console.error(`Querying bundles ${start}-${end}${threadInfo} with ${queryType}: ${expression}\n`); 117 + 118 + const startTime = Date.now(); 119 + let matchCount = 0; 120 + let totalOps = 0; 121 + let totalBytes = 0; 122 + const totalBundles = end - start + 1; 123 + let shouldStop = false; 124 + 125 + // Compile simple expression and detect fast path 126 + let queryFn: (op: any) => any; 127 + 128 + if (useSimple) { 129 + const compiled = compileSimplePath(expression); 130 + 131 + // Ultra-fast path for single property access (e.g., "did") 132 + if (compiled.segments.length === 1 && compiled.segments[0].type === 'property') { 133 + const prop = compiled.segments[0].value as string; 134 + queryFn = (op) => op[prop]; 135 + } else { 136 + // Fast path for dot notation 137 + queryFn = (op) => querySimplePath(op, compiled); 138 + } 139 + } else { 140 + // JMESPath 141 + queryFn = (op) => jmespathSearch(op, expression); 142 + } 143 + 144 + if (threads === 1) { 145 + // Single-threaded 146 + for (let bundleNum = start; bundleNum <= end; bundleNum++) { 147 + for await (const { op, line } of bundle.streamOperations(bundleNum)) { 148 + totalOps++; 149 + totalBytes += line.length; 150 + 151 + if (!noProgress && totalOps % 5000 === 0) { 152 + const elapsed = (Date.now() - startTime) / 1000; 153 + const bundlesCompleted = bundleNum - start + 1; 154 + const progress = bundlesCompleted / totalBundles; 155 + const mbPerSec = totalBytes / elapsed / 1e6; 156 + const eta = bundlesCompleted > 0 ? ((totalBundles - bundlesCompleted) / bundlesCompleted) * elapsed : 0; 157 + renderProgressBar(elapsed, bundlesCompleted, totalBundles, progress, matchCount, mbPerSec, eta); 158 + } 159 + 160 + try { 161 + const result = queryFn(op); 162 + 163 + if (result !== null && result !== undefined) { 164 + matchCount++; 165 + if (format !== 'count') { 166 + console.log(JSON.stringify(result)); 167 + } 168 + if (limit && matchCount >= limit) { 169 + shouldStop = true; 170 + break; 171 + } 172 + } 173 + } catch (error) { 174 + // Skip invalid operations 175 + } 176 + } 177 + if (shouldStop) break; 178 + } 179 + } else { 180 + // Multi-threaded 181 + const bundlesPerThread = Math.ceil(totalBundles / threads); 182 + const workerPath = new URL('../worker.ts', import.meta.url).pathname; 183 + const workers: Worker[] = []; 184 + const workerStats: Array<{ 185 + totalOps: number; 186 + totalBytes: number; 187 + matchCount: number; 188 + bundlesCompleted: number; 189 + threadStart: number; 190 + }> = []; 191 + 192 + const workerPromises = []; 193 + 194 + for (let i = 0; i < threads; i++) { 195 + const threadStart = start + i * bundlesPerThread; 196 + const threadEnd = Math.min(threadStart + bundlesPerThread - 1, end); 197 + if (threadStart > end) break; 198 + 199 + const worker = new Worker(workerPath); 200 + workers.push(worker); 201 + workerStats[i] = { totalOps: 0, totalBytes: 0, matchCount: 0, bundlesCompleted: 0, threadStart }; 202 + 203 + const promise = new Promise<any>((resolve) => { 204 + worker.onmessage = (event) => { 205 + const msg = event.data; 206 + 207 + if (msg.type === 'progress') { 208 + workerStats[i].totalOps = msg.totalOps; 209 + workerStats[i].totalBytes = msg.totalBytes; 210 + workerStats[i].matchCount = msg.matchCount; 211 + workerStats[i].bundlesCompleted = Math.max(0, msg.currentBundle - workerStats[i].threadStart + 1); 212 + 213 + let aggOps = 0, aggBytes = 0, aggMatches = 0, totalBundlesCompleted = 0; 214 + for (const ws of workerStats) { 215 + aggOps += ws.totalOps; 216 + aggBytes += ws.totalBytes; 217 + aggMatches += ws.matchCount; 218 + totalBundlesCompleted += ws.bundlesCompleted; 219 + } 220 + 221 + const progress = Math.min(totalBundlesCompleted / totalBundles, 1.0); 222 + if (!noProgress) { 223 + const elapsed = (Date.now() - startTime) / 1000; 224 + const mbPerSec = aggBytes / elapsed / 1e6; 225 + const eta = totalBundlesCompleted > 0 ? ((totalBundles - totalBundlesCompleted) / totalBundlesCompleted) * elapsed : 0; 226 + renderProgressBar(elapsed, totalBundlesCompleted, totalBundles, progress, aggMatches, mbPerSec, eta); 227 + } 228 + } else if (msg.type === 'match-batch') { 229 + for (const match of msg.matches) { 230 + matchCount++; 231 + if (format !== 'count') { 232 + console.log(JSON.stringify(match.result)); 233 + } 234 + if (limit && matchCount >= limit) { 235 + shouldStop = true; 236 + workers.forEach(w => w.terminate()); 237 + break; 238 + } 239 + } 240 + } else if (msg.type === 'result') { 241 + totalOps += msg.totalOps; 242 + totalBytes += msg.totalBytes; 243 + resolve(msg); 244 + } 245 + }; 246 + }); 247 + 248 + workerPromises.push(promise); 249 + worker.postMessage({ 250 + dir: bundle['dir'], 251 + start: threadStart, 252 + end: threadEnd, 253 + expression: expression, 254 + useSimple: useSimple, 255 + flush: true, 256 + mode: 'query', 257 + }); 258 + } 259 + 260 + await Promise.all(workerPromises); 261 + workers.forEach(w => w.terminate()); 262 + } 263 + 264 + const elapsed = (Date.now() - startTime) / 1000; 265 + 266 + if (!noProgress) { 267 + const mbPerSec = totalBytes / elapsed / 1e6; 268 + renderProgressBar(elapsed, totalBundles, totalBundles, 1.0, matchCount, mbPerSec, 0); 269 + console.error('\n'); 270 + } 271 + 272 + if (format === 'count') { 273 + console.log(matchCount); 274 + } 275 + 276 + console.error(''); 277 + console.error(`✓ Query complete`); 278 + console.error(` Total operations: ${totalOps.toLocaleString()}`); 279 + console.error(` Matches found: ${matchCount.toLocaleString()} (${totalOps > 0 ? ((matchCount/totalOps)*100).toFixed(2) : '0.00'}%)`); 280 + console.error(` Total bytes: ${(totalBytes / 1e6).toFixed(1)} MB`); 281 + console.error(` Time elapsed: ${elapsed.toFixed(2)}s`); 282 + console.error(` Throughput: ${(totalOps / elapsed).toFixed(0)} ops/sec | ${(totalBytes / elapsed / 1e6).toFixed(1)} MB/s`); 283 + if (threads > 1) { 284 + console.error(` Threads: ${threads}`); 285 + } 286 + } 287 + 288 + // Simple dot notation parser 289 + interface SimplePath { 290 + segments: Array<{ type: 'property' | 'index'; value: string | number }>; 291 + } 292 + 293 + function compileSimplePath(expression: string): SimplePath { 294 + const segments: Array<{ type: 'property' | 'index'; value: string | number }> = []; 295 + 296 + let current = ''; 297 + let i = 0; 298 + 299 + while (i < expression.length) { 300 + const char = expression[i]; 301 + 302 + if (char === '.') { 303 + if (current) { 304 + segments.push({ type: 'property', value: current }); 305 + current = ''; 306 + } 307 + i++; 308 + } else if (char === '[') { 309 + if (current) { 310 + segments.push({ type: 'property', value: current }); 311 + current = ''; 312 + } 313 + i++; 314 + let index = ''; 315 + while (i < expression.length && expression[i] !== ']') { 316 + index += expression[i]; 317 + i++; 318 + } 319 + segments.push({ type: 'index', value: parseInt(index) }); 320 + i++; 321 + } else { 322 + current += char; 323 + i++; 324 + } 325 + } 326 + 327 + if (current) { 328 + segments.push({ type: 'property', value: current }); 329 + } 330 + 331 + return { segments }; 332 + } 333 + 334 + function querySimplePath(obj: any, compiled: SimplePath): any { 335 + let current = obj; 336 + 337 + for (const segment of compiled.segments) { 338 + if (current == null) return null; 339 + 340 + if (segment.type === 'property') { 341 + current = current[segment.value]; 342 + } else { 343 + if (Array.isArray(current)) { 344 + current = current[segment.value as number]; 345 + } else { 346 + return null; 347 + } 348 + } 349 + } 350 + 351 + return current; 352 + } 353 + 354 + function renderProgressBar( 355 + elapsed: number, 356 + current: number, 357 + total: number, 358 + progress: number, 359 + matches: number, 360 + mbPerSec: number, 361 + etaSeconds: number 362 + ) { 363 + const barWidth = 40; 364 + const filledWidth = Math.floor(progress * barWidth); 365 + const hours = Math.floor(elapsed / 3600); 366 + const minutes = Math.floor((elapsed % 3600) / 60); 367 + const seconds = Math.floor(elapsed % 60); 368 + const timeStr = `[${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}]`; 369 + 370 + let bar = ''; 371 + if (filledWidth === 0) { 372 + bar = '>' + ' '.repeat(barWidth - 1); 373 + } else if (filledWidth >= barWidth) { 374 + bar = '>'.repeat(barWidth); 375 + } else { 376 + bar = '>' + '-'.repeat(filledWidth - 1) + ' '.repeat(barWidth - filledWidth); 377 + } 378 + 379 + const percent = (progress * 100).toFixed(1); 380 + let etaStr = ''; 381 + if (etaSeconds > 0 && etaSeconds < 86400) { 382 + if (etaSeconds < 60) { 383 + etaStr = `${Math.ceil(etaSeconds)}s`; 384 + } else if (etaSeconds < 3600) { 385 + etaStr = `${Math.ceil(etaSeconds / 60)}m`; 386 + } else { 387 + const etaHours = Math.floor(etaSeconds / 3600); 388 + const etaMin = Math.ceil((etaSeconds % 3600) / 60); 389 + etaStr = `${etaHours}h ${etaMin}m`; 390 + } 391 + } 392 + 393 + const matchesStr = matches >= 1000000 ? `${(matches / 1000000).toFixed(1)}M` : matches >= 1000 ? `${(matches / 1000).toFixed(0)}k` : matches.toString(); 394 + const line = etaStr 395 + ? `${timeStr} ${bar} ${current}/${total} (${percent}%) ${matchesStr} matches | ${mbPerSec.toFixed(1)} MB/s [ETA: ${etaStr}]` 396 + : `${timeStr} ${bar} ${current}/${total} (${percent}%) ${matchesStr} matches | ${mbPerSec.toFixed(1)} MB/s`; 397 + 398 + process.stderr.write(`\r${line}\x1b[K`); 399 + }
+61 -6
src/cmds/verify.ts
··· 8 8 9 9 OPTIONS: 10 10 --dir <path> Bundle directory (default: ./) 11 - --bundle <num> Bundle number to verify (required) 11 + --bundle <num> Bundle number to verify (required unless --chain is used) 12 + --chain Verify entire chain instead of single bundle 13 + --start <num> Start bundle for chain verification (default: 1) 14 + --end <num> End bundle for chain verification (default: last bundle) 15 + 16 + EXAMPLES: 17 + plcbundle-bun verify --bundle 42 18 + plcbundle-bun verify --chain 19 + plcbundle-bun verify --chain --start 1 --end 100 12 20 `); 13 21 return; 14 22 } ··· 18 26 options: { 19 27 dir: { type: 'string', default: './' }, 20 28 bundle: { type: 'string' }, 29 + chain: { type: 'boolean', default: false }, 30 + start: { type: 'string' }, 31 + end: { type: 'string' }, 21 32 }, 22 33 strict: false, 23 34 }); 24 35 36 + const dir = (values.dir as string) || './'; 37 + const bundleInstance = new PLCBundle(dir); 38 + 39 + // Chain verification 40 + if (values.chain) { 41 + const stats = await bundleInstance.getStats(); 42 + const start = values.start ? parseInt(values.start as string) : 1; 43 + const end = values.end ? parseInt(values.end as string) : stats.lastBundle; 44 + 45 + console.log(`Verifying chain: bundles ${start}-${end}\n`); 46 + 47 + const startTime = Date.now(); 48 + 49 + const result = await bundleInstance.verifyChain({ 50 + start, 51 + end, 52 + onProgress: (current, total) => { 53 + process.stdout.write(`Verified ${current}/${total} bundles\r`); 54 + }, 55 + }); 56 + 57 + const elapsed = (Date.now() - startTime) / 1000; 58 + 59 + console.log('\n'); 60 + 61 + if (result.valid) { 62 + console.log(`✓ Chain verification passed`); 63 + console.log(` Verified bundles: ${result.validBundles}`); 64 + console.log(` Time elapsed: ${elapsed.toFixed(2)}s`); 65 + } else { 66 + console.error(`✗ Chain verification failed`); 67 + console.error(` Valid bundles: ${result.validBundles}`); 68 + console.error(` Invalid bundles: ${result.invalidBundles}`); 69 + console.error(''); 70 + 71 + result.errors.forEach(({ bundleNum, errors }) => { 72 + console.error(`Bundle ${bundleNum}:`); 73 + errors.forEach(e => console.error(` - ${e}`)); 74 + }); 75 + 76 + process.exit(1); 77 + } 78 + 79 + return; 80 + } 81 + 82 + // Single bundle verification 25 83 if (!values.bundle || typeof values.bundle !== 'string') { 26 - console.error('Error: --bundle is required'); 84 + console.error('Error: --bundle is required (or use --chain)'); 27 85 process.exit(1); 28 86 } 29 87 30 - const dir = (values.dir as string) || './'; 31 88 const num = parseInt(values.bundle); 32 - const bundle = new PLCBundle(dir); 33 - 34 - const result = await bundle.verifyBundle(num); 89 + const result = await bundleInstance.verifyBundle(num); 35 90 36 91 if (result.valid) { 37 92 console.log(`✓ Bundle ${num} is valid`);
+1
src/index.ts
··· 37 37 ProcessOptions, 38 38 CloneOptions, 39 39 CloneStats, 40 + ChainVerificationResult, 40 41 } from './types';
+371 -47
src/plcbundle.ts
··· 6 6 ProcessOptions, 7 7 ProcessStats, 8 8 CloneOptions, 9 - CloneStats 9 + CloneStats, 10 + ChainVerificationResult 10 11 } from './types'; 11 12 12 13 /** ··· 243 244 * } 244 245 * ``` 245 246 */ 246 - async *streamOperations(bundleNum: number): AsyncGenerator<Operation> { 247 + async *streamOperations(bundleNum: number): AsyncGenerator<{ op: Operation; line: string }> { 247 248 const content = await this.readBundle(bundleNum); 248 249 const lines = content.split('\n'); 249 250 250 251 for (const line of lines) { 251 252 if (line.trim()) { 252 - yield JSON.parse(line); 253 + yield { op: JSON.parse(line), line }; 253 254 } 254 255 } 255 256 } 256 257 257 258 /** 258 - * Process multiple bundles with a callback function. 259 + * Process multiple bundles with a callback or module. 259 260 * 260 - * Supports both single-threaded and multi-threaded processing via {@link ProcessOptions}. 261 - * Operations are processed in chronological order. 261 + * Two modes: 262 + * 1. **Direct callback** (single-threaded): Pass callback function 263 + * 2. **Module path** (multi-threaded): Pass module path in options 262 264 * 263 - * @param start - First bundle number to process (inclusive) 264 - * @param end - Last bundle number to process (inclusive) 265 - * @param callback - Function called for each operation 266 - * @param options - Processing options (threads, progress callback) 265 + * @param start - First bundle number 266 + * @param end - Last bundle number 267 + * @param callbackOrOptions - Callback function OR options object with module path 268 + * @param options - Processing options (only if callback is provided) 267 269 * @returns Promise resolving to processing statistics 268 270 * 269 - * @example Single-threaded 271 + * @example Single-threaded with callback 270 272 * ```ts 271 - * await bundle.processBundles(1, 10, (op, pos, num) => { 272 - * console.log(`Bundle ${num}, pos ${pos}: ${op.did}`); 273 + * await bundle.processBundles(1, 10, (op, pos, num, line) => { 274 + * console.log(op.did); 273 275 * }); 274 276 * ``` 275 277 * 276 - * @example Multi-threaded with progress 278 + * @example Multi-threaded with module 277 279 * ```ts 278 - * await bundle.processBundles(1, 100, (op) => { 279 - * // Process operation 280 - * }, { 280 + * await bundle.processBundles(1, 100, { 281 + * module: './detect.ts', 281 282 * threads: 4, 282 - * onProgress: (stats) => { 283 - * console.log(`Processed ${stats.totalOps} operations`); 284 - * } 283 + * onProgress: (stats) => console.log(stats.totalOps) 285 284 * }); 286 285 * ``` 287 286 */ 288 287 async processBundles( 289 288 start: number, 290 289 end: number, 291 - callback: ProcessCallback, 292 - options: ProcessOptions = {} 293 - ): Promise<ProcessStats> { 294 - const { threads = 1, onProgress } = options; 290 + callbackOrOptions: ProcessCallback | ProcessOptions, 291 + options?: ProcessOptions 292 + ): Promise<ProcessStats & { matches?: any[] }> { 293 + let callback: ProcessCallback | undefined; 294 + let processOptions: ProcessOptions; 295 295 296 - if (threads > 1) { 297 - return await this.processBundlesMultiThreaded(start, end, callback, threads, onProgress); 296 + if (typeof callbackOrOptions === 'function') { 297 + callback = callbackOrOptions; 298 + processOptions = options || {}; 298 299 } else { 299 - return await this.processBundlesSingleThreaded(start, end, callback, onProgress); 300 + processOptions = callbackOrOptions; 301 + } 302 + 303 + const { threads = 1, module, silent = false, flush = false, onProgress, onMatch } = processOptions; 304 + 305 + // Validation: multi-threading requires module 306 + if (threads > 1 && !module) { 307 + throw new Error('Multi-threading requires module path. Use: processBundles(start, end, { module: "./detect.ts", threads: 4 })'); 308 + } 309 + 310 + // Determine mode based on what function is exported 311 + let mode: 'detect' | 'process' = 'detect'; 312 + let mod: any; 313 + if (module) { 314 + try { 315 + mod = await import(module); 316 + // If module has 'process' function, use process mode 317 + if (mod.process) { 318 + mode = 'process'; 319 + } else if (mod.detect) { 320 + mode = 'detect'; 321 + } 322 + } catch (e) { 323 + // Default to detect 324 + } 325 + } 326 + 327 + // Use workers for multi-threading with module 328 + if (threads > 1 && module) { 329 + return await this.processBundlesWorkers( 330 + start, 331 + end, 332 + module, 333 + threads, 334 + silent, 335 + flush, 336 + mode, // Pass mode 337 + onProgress, 338 + onMatch 339 + ); 300 340 } 341 + 342 + // Load module if provided but single-threaded 343 + if (mod && !callback) { 344 + const userFn = mode === 'detect' ? (mod.detect || mod.default) : (mod.process || mod.default); 345 + 346 + callback = (op, position, bundleNum, line) => { 347 + if (mode === 'detect') { 348 + userFn({ op }); 349 + } else { 350 + userFn({ op, position, bundle: bundleNum, line }); 351 + } 352 + }; 353 + } 354 + 355 + if (!callback) { 356 + throw new Error('Either callback function or module path must be provided'); 357 + } 358 + 359 + // Single-threaded fast path 360 + return await this.processBundlesFast(start, end, callback, onProgress); 301 361 } 302 362 303 363 /** 304 - * Single-threaded processing 364 + * Multi-threaded processing using workers (optimized) 305 365 */ 306 - private async processBundlesSingleThreaded( 366 + private async processBundlesWorkers( 367 + start: number, 368 + end: number, 369 + modulePath: string, 370 + threads: number, 371 + silent: boolean, 372 + flush: boolean, 373 + mode: 'detect' | 'process', // Add mode parameter 374 + onProgress?: (stats: ProcessStats) => void, 375 + onMatch?: (match: any) => void 376 + ): Promise<ProcessStats & { matches?: any[] }> { 377 + const totalBundles = end - start + 1; 378 + const bundlesPerThread = Math.ceil(totalBundles / threads); 379 + 380 + const workerPath = new URL('./worker.ts', import.meta.url).pathname; 381 + const workers: Worker[] = []; 382 + const workerStats: Array<{ totalOps: number; totalBytes: number }> = []; 383 + 384 + let aggregatedOps = 0; 385 + let aggregatedBytes = 0; 386 + const allMatches: any[] = []; 387 + 388 + const workerPromises = []; 389 + 390 + for (let i = 0; i < threads; i++) { 391 + const threadStart = start + i * bundlesPerThread; 392 + const threadEnd = Math.min(threadStart + bundlesPerThread - 1, end); 393 + 394 + if (threadStart > end) break; 395 + 396 + const worker = new Worker(workerPath); 397 + workers.push(worker); 398 + 399 + workerStats[i] = { totalOps: 0, totalBytes: 0 }; 400 + 401 + const promise = new Promise<any>((resolve) => { 402 + worker.onmessage = (event) => { 403 + const msg = event.data; 404 + 405 + if (msg.type === 'progress') { 406 + const oldStats = workerStats[i]; 407 + aggregatedOps += msg.totalOps - oldStats.totalOps; 408 + aggregatedBytes += msg.totalBytes - oldStats.totalBytes; 409 + workerStats[i] = { totalOps: msg.totalOps, totalBytes: msg.totalBytes }; 410 + 411 + if (onProgress) { 412 + onProgress({ 413 + totalOps: aggregatedOps, 414 + matchCount: 0, 415 + totalBytes: aggregatedBytes, 416 + matchedBytes: 0, 417 + }); 418 + } 419 + } else if (msg.type === 'match') { 420 + // Handle flushed match - call callback immediately 421 + if (onMatch) { 422 + onMatch(msg); 423 + } 424 + } else if (msg.type === 'result') { 425 + resolve(msg); 426 + } 427 + }; 428 + }); 429 + 430 + workerPromises.push(promise); 431 + 432 + worker.postMessage({ 433 + dir: this.dir, 434 + start: threadStart, 435 + end: threadEnd, 436 + modulePath, 437 + silent, 438 + flush, 439 + mode, // Pass mode to worker 440 + }); 441 + } 442 + 443 + // Wait for all workers 444 + const results = await Promise.all(workerPromises); 445 + 446 + // Cleanup 447 + workers.forEach(w => w.terminate()); 448 + 449 + if (modulePath) { 450 + const mod = await import(modulePath); 451 + if (mod.finalize) { 452 + await mod.finalize(results, { aggregate: this.aggregate }); 453 + } 454 + } 455 + 456 + // Aggregate results 457 + let totalOps = 0; 458 + let totalBytes = 0; 459 + 460 + for (const result of results) { 461 + totalOps += result.totalOps; 462 + totalBytes += result.totalBytes; 463 + if (!flush) { 464 + allMatches.push(...result.matches); 465 + } 466 + } 467 + 468 + // Sort matches if not flushed 469 + if (!flush && mode === 'detect') { 470 + allMatches.sort((a, b) => { 471 + if (a.bundle !== b.bundle) return a.bundle - b.bundle; 472 + return a.position - b.position; 473 + }); 474 + } 475 + 476 + return { 477 + totalOps, 478 + matchCount: 0, 479 + totalBytes, 480 + matchedBytes: 0, 481 + matches: flush || mode === 'process' ? undefined : allMatches, 482 + }; 483 + } 484 + 485 + private aggregate(objects: Array<{ [key: string]: number }>): { [key: string]: number } { 486 + const aggregatedDict: { [key: string]: number } = {}; 487 + 488 + for (const currentObj of objects) { 489 + for (const key in currentObj) { 490 + if (Object.prototype.hasOwnProperty.call(currentObj, key)) { 491 + aggregatedDict[key] = (aggregatedDict[key] || 0) + currentObj[key]; 492 + } 493 + } 494 + } 495 + 496 + return aggregatedDict; 497 + } 498 + 499 + 500 + /** 501 + * Fast single-threaded processing (optimized) 502 + */ 503 + private async processBundlesFast( 307 504 start: number, 308 505 end: number, 309 506 callback: ProcessCallback, ··· 317 514 }; 318 515 319 516 for (let bundleNum = start; bundleNum <= end; bundleNum++) { 320 - let position = 0; 517 + const content = await this.readBundle(bundleNum); 518 + const lines = content.split('\n'); 321 519 322 - for await (const op of this.streamOperations(bundleNum)) { 520 + for (let position = 0; position < lines.length; position++) { 521 + const line = lines[position]; 522 + if (!line.trim()) continue; 523 + 323 524 stats.totalOps++; 324 - stats.totalBytes += JSON.stringify(op).length; 525 + stats.totalBytes += line.length; 325 526 326 - await callback(op, position++, bundleNum); 527 + const op = JSON.parse(line); 528 + await callback(op, position, bundleNum, line); 327 529 328 530 if (onProgress && stats.totalOps % 10000 === 0) { 329 531 onProgress({ ...stats }); ··· 332 534 } 333 535 334 536 return stats; 335 - } 336 - 337 - /** 338 - * Multi-threaded processing 339 - */ 340 - private async processBundlesMultiThreaded( 341 - start: number, 342 - end: number, 343 - callback: ProcessCallback, 344 - threads: number, 345 - onProgress?: (stats: ProcessStats) => void 346 - ): Promise<ProcessStats> { 347 - // Simplified implementation - full multi-threading requires callback serialization 348 - return await this.processBundlesSingleThreaded(start, end, callback, onProgress); 349 537 } 350 538 351 539 /** ··· 729 917 updatedAt: index.updated_at, 730 918 }; 731 919 } 920 + 921 + /** 922 + * Verify the integrity of the entire bundle chain. 923 + * 924 + * This method validates: 925 + * - Each bundle's compressed and content hashes 926 + * - The chain hash linking each bundle to its parent 927 + * - The continuity of the chain (no missing bundles) 928 + * - The genesis bundle has correct initial hash 929 + * 930 + * @param options - Verification options 931 + * @param options.start - First bundle to verify (default: 1) 932 + * @param options.end - Last bundle to verify (default: last available bundle) 933 + * @param options.onProgress - Callback for progress updates 934 + * @returns Promise resolving to chain verification result 935 + * 936 + * @example Verify entire chain 937 + * ```ts 938 + * const result = await bundle.verifyChain(); 939 + * 940 + * if (result.valid) { 941 + * console.log(`✓ All ${result.totalBundles} bundles verified`); 942 + * } else { 943 + * console.error(`✗ Chain invalid: ${result.invalidBundles} errors`); 944 + * result.errors.forEach(({ bundleNum, errors }) => { 945 + * console.error(`Bundle ${bundleNum}:`); 946 + * errors.forEach(e => console.error(` - ${e}`)); 947 + * }); 948 + * } 949 + * ``` 950 + * 951 + * @example Verify range with progress 952 + * ```ts 953 + * const result = await bundle.verifyChain({ 954 + * start: 1, 955 + * end: 100, 956 + * onProgress: (current, total) => { 957 + * console.log(`Verified ${current}/${total} bundles`); 958 + * } 959 + * }); 960 + * ``` 961 + */ 962 + async verifyChain(options: { 963 + start?: number; 964 + end?: number; 965 + onProgress?: (current: number, total: number) => void; 966 + } = {}): Promise<ChainVerificationResult> { 967 + const index = await this.loadIndex(); 968 + 969 + const start = options.start || 1; 970 + const end = options.end || index.last_bundle; 971 + 972 + // Validate range 973 + if (start < 1 || end > index.last_bundle || start > end) { 974 + throw new Error(`Invalid bundle range ${start}-${end} (available: 1-${index.last_bundle})`); 975 + } 976 + 977 + const result: ChainVerificationResult = { 978 + valid: true, 979 + totalBundles: end - start + 1, 980 + validBundles: 0, 981 + invalidBundles: 0, 982 + errors: [], 983 + }; 984 + 985 + let previousHash = ''; 986 + 987 + for (let bundleNum = start; bundleNum <= end; bundleNum++) { 988 + const metadata = index.bundles.find(b => b.bundle_number === bundleNum); 989 + 990 + if (!metadata) { 991 + result.valid = false; 992 + result.invalidBundles++; 993 + result.errors.push({ 994 + bundleNum, 995 + errors: ['Bundle missing from index'], 996 + }); 997 + continue; 998 + } 999 + 1000 + const bundleErrors: string[] = []; 1001 + 1002 + // Verify bundle file hashes 1003 + const verification = await this.verifyBundle(bundleNum); 1004 + if (!verification.valid) { 1005 + bundleErrors.push(...verification.errors); 1006 + } 1007 + 1008 + // Verify chain linkage 1009 + if (bundleNum === 1) { 1010 + // Genesis bundle should have empty parent 1011 + if (metadata.parent !== '') { 1012 + bundleErrors.push(`Genesis bundle should have empty parent, got: ${metadata.parent}`); 1013 + } 1014 + 1015 + // Verify genesis hash format 1016 + const expectedHash = this.calculateChainHash('', metadata.content_hash, true); 1017 + if (metadata.hash !== expectedHash) { 1018 + bundleErrors.push(`Invalid genesis chain hash: ${metadata.hash} != ${expectedHash}`); 1019 + } 1020 + } else { 1021 + // Verify parent reference 1022 + if (metadata.parent !== previousHash) { 1023 + bundleErrors.push(`Parent hash mismatch: expected ${previousHash}, got ${metadata.parent}`); 1024 + } 1025 + 1026 + // Verify chain hash 1027 + const expectedHash = this.calculateChainHash(metadata.parent, metadata.content_hash, false); 1028 + if (metadata.hash !== expectedHash) { 1029 + bundleErrors.push(`Invalid chain hash: ${metadata.hash} != ${expectedHash}`); 1030 + } 1031 + } 1032 + 1033 + // Record results 1034 + if (bundleErrors.length > 0) { 1035 + result.valid = false; 1036 + result.invalidBundles++; 1037 + result.errors.push({ 1038 + bundleNum, 1039 + errors: bundleErrors, 1040 + }); 1041 + } else { 1042 + result.validBundles++; 1043 + } 1044 + 1045 + previousHash = metadata.hash; 1046 + 1047 + // Report progress 1048 + if (options.onProgress) { 1049 + options.onProgress(bundleNum - start + 1, result.totalBundles); 1050 + } 1051 + } 1052 + 1053 + return result; 1054 + } 1055 + 732 1056 }
+121 -15
src/types.ts
··· 105 105 * @param op - The operation being processed 106 106 * @param position - Zero-based position of the operation within its bundle 107 107 * @param bundleNum - The bundle number being processed 108 - * 109 - * @example 110 - * ```ts 111 - * const callback: ProcessCallback = (op, position, bundleNum) => { 112 - * if (op.did.startsWith('did:plc:test')) { 113 - * console.log(`Found test DID at bundle ${bundleNum}, position ${position}`); 114 - * } 115 - * }; 116 - * ``` 108 + * @param line - The raw JSONL line (for getting size without re-serializing) 117 109 */ 118 110 export type ProcessCallback = ( 119 111 op: Operation, 120 112 position: number, 121 - bundleNum: number 113 + bundleNum: number, 114 + line: string 122 115 ) => void | Promise<void>; 123 116 124 117 /** ··· 141 134 } 142 135 143 136 /** 144 - * Options for processing bundles. 137 + * Unified options for processing bundles. 145 138 * 146 - * Allows customization of parallel processing and progress reporting. 139 + * Supports both callback functions (single-threaded) and module paths (multi-threaded). 147 140 */ 148 141 export interface ProcessOptions { 149 - /** Number of worker threads to use for parallel processing (default: 1) */ 142 + /** 143 + * Number of worker threads (default: 1). 144 + * If > 1, requires `module` instead of passing callback directly. 145 + */ 150 146 threads?: number; 151 147 152 148 /** 153 - * Callback invoked periodically to report processing progress. 154 - * Called approximately every 10,000 operations. 149 + * Path to module exporting a `detect` or default function. 150 + * Required when threads > 1. The module should export: 151 + * ```ts 152 + * export function detect({ op }) { return [...labels]; } 153 + * ``` 154 + */ 155 + module?: string; 156 + 157 + /** 158 + * Suppress all console output from the detect script (default: false). 159 + * 160 + * When enabled, console.log, console.error, etc. from the detect function 161 + * are silenced. Progress reporting and final statistics are still shown. 162 + * 163 + * @example 164 + * ```ts 165 + * await bundle.processBundles(1, 10, { 166 + * module: './noisy-detect.ts', 167 + * silent: true // Suppress debug output 168 + * }); 169 + * ``` 170 + */ 171 + silent?: boolean; 172 + 173 + /** 174 + * Output matches immediately without buffering or sorting (default: false). 175 + * 176 + * When enabled, matches are output as soon as they're found rather than 177 + * being collected, sorted, and output at the end. Useful for: 178 + * - Real-time streaming output 179 + * - Reducing memory usage with large result sets 180 + * - Early access to results 181 + * 182 + * Note: With flush enabled, matches may be out of chronological order 183 + * when using multiple threads. 184 + * 185 + * @example 186 + * ```ts 187 + * await bundle.processBundles(1, 100, { 188 + * module: './detect.ts', 189 + * threads: 4, 190 + * flush: true, 191 + * onMatch: (match) => { 192 + * console.log(`Found: ${match.bundle}/${match.position}`); 193 + * } 194 + * }); 195 + * ``` 196 + */ 197 + flush?: boolean; 198 + 199 + /** 200 + * Progress callback invoked periodically during processing. 201 + * 202 + * Called approximately every 10,000 operations to report current 203 + * processing statistics. 155 204 * 156 205 * @param stats - Current processing statistics 206 + * 207 + * @example 208 + * ```ts 209 + * await bundle.processBundles(1, 100, callback, { 210 + * onProgress: (stats) => { 211 + * console.log(`${stats.totalOps} operations processed`); 212 + * } 213 + * }); 214 + * ``` 157 215 */ 158 216 onProgress?: (stats: ProcessStats) => void; 217 + 218 + /** 219 + * Match callback invoked for each match when flush mode is enabled. 220 + * 221 + * Only called when `flush: true`. Receives match objects as they're 222 + * found, without waiting for sorting. Ignored in non-flush mode. 223 + * 224 + * @param match - The match object with bundle, position, cid, size, and labels 225 + * 226 + * @example 227 + * ```ts 228 + * await bundle.processBundles(1, 100, { 229 + * module: './detect.ts', 230 + * flush: true, 231 + * onMatch: (match) => { 232 + * // Output immediately 233 + * console.log(`${match.bundle},${match.position},${match.labels}`); 234 + * } 235 + * }); 236 + * ``` 237 + */ 238 + onMatch?: (match: any) => void; 159 239 } 160 240 161 241 /** ··· 219 299 /** Bytes downloaded so far (including skipped bundles) */ 220 300 downloadedBytes: number; 221 301 } 302 + 303 + /** 304 + * Result of chain verification. 305 + * 306 + * Contains overall validity status and detailed information about 307 + * any issues found in the bundle chain. 308 + */ 309 + export interface ChainVerificationResult { 310 + /** Whether the entire chain is valid */ 311 + valid: boolean; 312 + 313 + /** Total number of bundles verified */ 314 + totalBundles: number; 315 + 316 + /** Number of bundles that passed verification */ 317 + validBundles: number; 318 + 319 + /** Number of bundles that failed verification */ 320 + invalidBundles: number; 321 + 322 + /** Detailed errors for each invalid bundle */ 323 + errors: Array<{ 324 + bundleNum: number; 325 + errors: string[]; 326 + }>; 327 + }
+179 -60
src/worker.ts
··· 1 1 /// <reference lib="webworker" /> 2 2 3 - import { PLCBundle } from './plcbundle'; 4 - import type { Operation } from './types'; 3 + import { search as jmespathSearch } from '@jmespath-community/jmespath'; 5 4 6 5 export interface WorkerTask { 7 6 dir: string; 8 7 start: number; 9 8 end: number; 10 - detectPath: string; 9 + modulePath?: string; 10 + expression?: string; 11 + useSimple?: boolean; 12 + silent?: boolean; 13 + flush?: boolean; 14 + mode?: 'detect' | 'process' | 'query'; 11 15 } 12 16 13 17 export interface WorkerProgress { 14 18 type: 'progress'; 19 + currentBundle: number; 15 20 totalOps: number; 21 + totalBytes: number; 16 22 matchCount: number; 17 - totalBytes: number; 18 - matchedBytes: number; 23 + } 24 + 25 + export interface WorkerMatchBatch { 26 + type: 'match-batch'; 27 + matches: Array<{ result: any }>; 19 28 } 20 29 21 30 export interface WorkerResult { 22 31 type: 'result'; 23 32 totalOps: number; 24 - matchCount: number; 25 33 totalBytes: number; 26 - matchedBytes: number; 27 - matches: Array<{ 28 - bundle: number; 29 - position: number; 30 - cid: string; 31 - size: number; 32 - labels: string[]; 33 - }>; 34 + matchCount: number; 35 + data?: any; 34 36 } 35 37 36 - // Worker message handler 37 38 self.onmessage = async (event: MessageEvent<WorkerTask>) => { 38 - const { dir, start, end, detectPath } = event.data; 39 + const { 40 + dir, 41 + start, 42 + end, 43 + modulePath, 44 + expression, 45 + useSimple, 46 + silent, 47 + flush, 48 + mode = 'detect' 49 + } = event.data; 39 50 40 - const bundle = new PLCBundle(dir); 51 + if (silent) { 52 + globalThis.console = { 53 + log: () => {}, 54 + error: () => {}, 55 + warn: () => {}, 56 + info: () => {}, 57 + debug: () => {}, 58 + trace: () => {}, 59 + } as any; 60 + } 41 61 42 - // Load detect function 43 - const mod = await import(detectPath); 44 - const detect = mod.detect || mod.default; 62 + let userFn: any; 63 + let queryFn: ((op: any) => any) | null = null; 64 + 65 + if (mode === 'query') { 66 + // Query mode 67 + if (useSimple) { 68 + const compiled = compileSimplePath(expression!); 69 + 70 + // Ultra-fast path for single property 71 + if (compiled.segments.length === 1 && compiled.segments[0].type === 'property') { 72 + const prop = compiled.segments[0].value as string; 73 + queryFn = (op) => op[prop]; 74 + } else { 75 + queryFn = (op) => querySimplePath(op, compiled); 76 + } 77 + } else { 78 + queryFn = (op) => jmespathSearch(op, expression!); 79 + } 80 + } else { 81 + // Detect or process mode 82 + const mod = await import(modulePath!); 83 + userFn = mode === 'detect' ? (mod.detect || mod.default) : (mod.process || mod.default); 84 + } 45 85 46 86 let totalOps = 0; 87 + let totalBytes = 0; 47 88 let matchCount = 0; 48 - let totalBytes = 0; 49 - let matchedBytes = 0; 50 - const matches: any[] = []; 89 + const BATCH_SIZE = 1000; 90 + let matchBatch: any[] = []; 51 91 52 - await bundle.processBundles(start, end, (op: Operation, position: number, bundleNum: number) => { 53 - totalOps++; 54 - const opSize = JSON.stringify(op).length; 55 - totalBytes += opSize; 92 + const flushBatch = () => { 93 + if (matchBatch.length > 0) { 94 + self.postMessage({ type: 'match-batch', matches: matchBatch } as WorkerMatchBatch); 95 + matchBatch = []; 96 + } 97 + }; 98 + 99 + for (let bundleNum = start; bundleNum <= end; bundleNum++) { 100 + const bundlePath = `${dir}${bundleNum.toString().padStart(6, '0')}.jsonl.zst`; 56 101 57 - const labels = detect({ op }); 58 - 59 - if (labels && labels.length > 0) { 60 - matchCount++; 61 - matchedBytes += opSize; 102 + try { 103 + const compressed = await Bun.file(bundlePath).arrayBuffer(); 104 + const decompressed = Bun.zstdDecompressSync(compressed); 105 + const text = new TextDecoder().decode(decompressed); 106 + const lines = text.split('\n'); 107 + 108 + for (let position = 0; position < lines.length; position++) { 109 + const line = lines[position]; 110 + if (!line.trim()) continue; 111 + 112 + totalOps++; 113 + totalBytes += line.length; 114 + const op = JSON.parse(line); 115 + 116 + if (mode === 'query') { 117 + try { 118 + const result = queryFn!(op); 119 + 120 + if (result !== null && result !== undefined) { 121 + matchCount++; 122 + matchBatch.push({ result }); 123 + if (matchBatch.length >= BATCH_SIZE) flushBatch(); 124 + } 125 + } catch (error) {} 126 + } else if (mode === 'detect') { 127 + const labels = userFn({ op }); 128 + if (labels && labels.length > 0) { 129 + matchCount++; 130 + matchBatch.push({ result: { bundle: bundleNum, position, cid: op.cid.slice(-4), labels } }); 131 + if (matchBatch.length >= BATCH_SIZE) flushBatch(); 132 + } 133 + } else { 134 + // process mode 135 + userFn({ op, position, bundle: bundleNum, line }); 136 + } 137 + 138 + if (totalOps % 10000 === 0) { 139 + self.postMessage({ type: 'progress', currentBundle: bundleNum, totalOps, totalBytes, matchCount } as WorkerProgress); 140 + } 141 + } 62 142 63 - matches.push({ 64 - bundle: bundleNum, 65 - position, 66 - cid: op.cid.slice(-4), 67 - size: opSize, 68 - labels, 69 - }); 70 - } 71 - 72 - // Send progress every 10000 operations 73 - if (totalOps % 10000 === 0) { 74 - self.postMessage({ 75 - type: 'progress', 76 - totalOps, 77 - matchCount, 78 - totalBytes, 79 - matchedBytes, 80 - } as WorkerProgress); 143 + self.postMessage({ type: 'progress', currentBundle: bundleNum, totalOps, totalBytes, matchCount } as WorkerProgress); 144 + } catch (error) {} 145 + } 146 + 147 + flushBatch(); 148 + 149 + let prepareResult: any; 150 + if (mode !== 'query' && modulePath) { 151 + const mod = await import(modulePath); 152 + if (typeof mod.prepare === 'function') { 153 + try { 154 + prepareResult = await mod.prepare(); 155 + } catch (error) {} 81 156 } 82 - }); 157 + } 83 158 84 - // Send final result 85 - self.postMessage({ 86 - type: 'result', 87 - totalOps, 88 - matchCount, 89 - totalBytes, 90 - matchedBytes, 91 - matches, 92 - } as WorkerResult); 159 + self.postMessage({ type: 'result', totalOps, totalBytes, matchCount, data: prepareResult || null } as WorkerResult); 93 160 }; 161 + 162 + interface SimplePath { 163 + segments: Array<{ type: 'property' | 'index'; value: string | number }>; 164 + } 165 + 166 + function compileSimplePath(expression: string): SimplePath { 167 + const segments: Array<{ type: 'property' | 'index'; value: string | number }> = []; 168 + let current = ''; 169 + let i = 0; 170 + 171 + while (i < expression.length) { 172 + const char = expression[i]; 173 + if (char === '.') { 174 + if (current) segments.push({ type: 'property', value: current }); 175 + current = ''; 176 + i++; 177 + } else if (char === '[') { 178 + if (current) segments.push({ type: 'property', value: current }); 179 + current = ''; 180 + i++; 181 + let index = ''; 182 + while (i < expression.length && expression[i] !== ']') { 183 + index += expression[i]; 184 + i++; 185 + } 186 + segments.push({ type: 'index', value: parseInt(index) }); 187 + i++; 188 + } else { 189 + current += char; 190 + i++; 191 + } 192 + } 193 + if (current) segments.push({ type: 'property', value: current }); 194 + return { segments }; 195 + } 196 + 197 + function querySimplePath(obj: any, compiled: SimplePath): any { 198 + let current = obj; 199 + for (const segment of compiled.segments) { 200 + if (current == null) return null; 201 + if (segment.type === 'property') { 202 + current = current[segment.value]; 203 + } else { 204 + if (Array.isArray(current)) { 205 + current = current[segment.value as number]; 206 + } else { 207 + return null; 208 + } 209 + } 210 + } 211 + return current; 212 + }
+77
tests/commands.test.ts
··· 1 + import { describe, test, expect, beforeEach } from 'bun:test'; 2 + import { PLCBundle } from '../src/plcbundle'; 3 + import { TEMP_DIR, createMockIndex, createMockOperations } from './setup'; 4 + 5 + describe('CLI Commands', () => { 6 + let detectModulePath: string; 7 + let processModulePath: string; 8 + 9 + beforeEach(async () => { 10 + const bundle = new PLCBundle(TEMP_DIR); 11 + const mockIndex = createMockIndex(); 12 + await bundle.saveIndex(mockIndex); 13 + 14 + // Create test bundles 15 + for (let i = 1; i <= 3; i++) { 16 + const operations = createMockOperations(100); 17 + const jsonl = operations.map(op => JSON.stringify(op)).join('\n') + '\n'; 18 + const compressed = Bun.zstdCompressSync(new TextEncoder().encode(jsonl)); 19 + await Bun.write(bundle.getBundlePath(i), compressed); 20 + } 21 + 22 + // Create test modules - use absolute paths 23 + detectModulePath = `${process.cwd()}/${TEMP_DIR}/test-detect.ts`; 24 + await Bun.write(detectModulePath, ` 25 + export function detect({ op }) { 26 + return op.did.includes('test') ? ['test'] : []; 27 + } 28 + `); 29 + 30 + processModulePath = `${process.cwd()}/${TEMP_DIR}/test-process.ts`; 31 + await Bun.write(processModulePath, ` 32 + let count = 0; 33 + export function process({ op }) { 34 + count++; 35 + } 36 + `); 37 + }); 38 + 39 + describe('detect command', () => { 40 + test('module can be imported and has detect function', async () => { 41 + const mod = await import(detectModulePath); 42 + expect(mod.detect).toBeDefined(); 43 + expect(typeof mod.detect).toBe('function'); 44 + 45 + const result = mod.detect({ op: { did: 'test123' } }); 46 + expect(Array.isArray(result)).toBe(true); 47 + }); 48 + }); 49 + 50 + describe('process command', () => { 51 + test('module can be imported and has process function', async () => { 52 + const mod = await import(processModulePath); 53 + expect(mod.process).toBeDefined(); 54 + expect(typeof mod.process).toBe('function'); 55 + }); 56 + }); 57 + 58 + describe('module loading', () => { 59 + test('detect module returns labels array', async () => { 60 + const mod = await import(detectModulePath); 61 + 62 + const result1 = mod.detect({ op: { did: 'did:plc:test123' } }); 63 + expect(result1).toContain('test'); 64 + 65 + const result2 = mod.detect({ op: { did: 'did:plc:abc' } }); 66 + expect(result2).toEqual([]); 67 + }); 68 + 69 + test('process module executes without errors', async () => { 70 + const mod = await import(processModulePath); 71 + 72 + // Should not throw 73 + mod.process({ op: { did: 'test' }, position: 0, bundle: 1, line: '{}' }); 74 + expect(true).toBe(true); 75 + }); 76 + }); 77 + });
+78
tests/common.test.ts
··· 1 + import { describe, test, expect, beforeEach } from 'bun:test'; 2 + import { parseProcessArgs, parseBundleSelection } from '../src/cmds/common'; 3 + import { PLCBundle } from '../src/plcbundle'; 4 + import { TEMP_DIR, createMockIndex } from './setup'; 5 + 6 + describe('Common CLI Logic', () => { 7 + describe('parseProcessArgs', () => { 8 + test('parses positional arguments', () => { 9 + const { values, positionals } = parseProcessArgs(['./detect.ts', '--threads', '4']); 10 + 11 + expect(positionals[0]).toBe('./detect.ts'); 12 + expect(values.threads).toBe('4'); 13 + }); 14 + 15 + test('parses boolean flags', () => { 16 + const { values } = parseProcessArgs(['./detect.ts', '--silent', '--flush']); 17 + 18 + expect(values.silent).toBe(true); 19 + expect(values.flush).toBe(true); 20 + }); 21 + 22 + test('handles short flags', () => { 23 + const { values } = parseProcessArgs(['./detect.ts', '-s']); 24 + 25 + expect(values.s).toBe(true); 26 + }); 27 + }); 28 + 29 + describe('parseBundleSelection', () => { 30 + let bundle: PLCBundle; 31 + 32 + beforeEach(async () => { 33 + bundle = new PLCBundle(TEMP_DIR); 34 + const mockIndex = createMockIndex(); 35 + await bundle.saveIndex(mockIndex); 36 + }); 37 + 38 + test('defaults to all bundles', async () => { 39 + const { start, end } = await parseBundleSelection({}, bundle); 40 + 41 + expect(start).toBe(1); 42 + expect(end).toBe(3); // From mock index 43 + }); 44 + 45 + test('parses single bundle', async () => { 46 + const { start, end } = await parseBundleSelection({ bundles: '2' }, bundle); 47 + 48 + expect(start).toBe(2); 49 + expect(end).toBe(2); 50 + }); 51 + 52 + test('parses bundle range', async () => { 53 + const { start, end } = await parseBundleSelection({ bundles: '1-3' }, bundle); 54 + 55 + expect(start).toBe(1); 56 + expect(end).toBe(3); 57 + }); 58 + 59 + test('throws error for invalid range', async () => { 60 + try { 61 + await parseBundleSelection({ bundles: '1-999' }, bundle); 62 + expect(true).toBe(false); // Should throw 63 + } catch (error) { 64 + expect(error).toBeDefined(); 65 + expect((error as Error).message).toContain('Invalid bundle range'); 66 + } 67 + }); 68 + 69 + test('throws error for invalid format', async () => { 70 + try { 71 + await parseBundleSelection({ bundles: 'invalid' }, bundle); 72 + expect(true).toBe(false); // Should throw 73 + } catch (error) { 74 + expect(error).toBeDefined(); 75 + } 76 + }); 77 + }); 78 + });
+101
tests/multithread.test.ts
··· 1 + import { describe, test, expect, beforeEach } from 'bun:test'; 2 + import { PLCBundle } from '../src/plcbundle'; 3 + import { TEMP_DIR, createMockIndex, createMockOperations } from './setup'; 4 + 5 + describe('Multi-threaded Processing', () => { 6 + let bundle: PLCBundle; 7 + let modulePath: string; 8 + 9 + beforeEach(async () => { 10 + bundle = new PLCBundle(TEMP_DIR); 11 + 12 + const mockIndex = createMockIndex(); 13 + await bundle.saveIndex(mockIndex); 14 + 15 + // Create test bundles 16 + for (let i = 1; i <= 5; i++) { 17 + const operations = createMockOperations(100); 18 + const jsonl = operations.map(op => JSON.stringify(op)).join('\n') + '\n'; 19 + const compressed = Bun.zstdCompressSync(new TextEncoder().encode(jsonl)); 20 + await Bun.write(bundle.getBundlePath(i), compressed); 21 + } 22 + 23 + // Create test module - use absolute path 24 + modulePath = `${process.cwd()}/${TEMP_DIR}/test-module.ts`; 25 + await Bun.write(modulePath, ` 26 + export function detect({ op }) { 27 + return op.did.length > 10 ? ['long-did'] : []; 28 + } 29 + `); 30 + }); 31 + 32 + test('module loads correctly', async () => { 33 + const mod = await import(modulePath); 34 + expect(mod.detect).toBeDefined(); 35 + }); 36 + 37 + test('single-threaded processing works', async () => { 38 + const stats = await bundle.processBundles(1, 2, { 39 + module: modulePath, 40 + threads: 1, 41 + }); 42 + 43 + expect(stats.totalOps).toBe(200); // 2 bundles * 100 ops 44 + expect(stats.totalBytes).toBeGreaterThan(0); 45 + }); 46 + 47 + test('multi-threaded processing with 2 threads', async () => { 48 + const stats = await bundle.processBundles(1, 4, { 49 + module: modulePath, 50 + threads: 2, 51 + }); 52 + 53 + expect(stats.totalOps).toBe(400); // 4 bundles * 100 ops 54 + expect(stats.totalBytes).toBeGreaterThan(0); 55 + }); 56 + 57 + test('multi-threaded produces same op count as single-threaded', async () => { 58 + const stats1 = await bundle.processBundles(1, 3, { 59 + module: modulePath, 60 + threads: 1, 61 + }); 62 + 63 + const stats2 = await bundle.processBundles(1, 3, { 64 + module: modulePath, 65 + threads: 2, 66 + }); 67 + 68 + expect(stats1.totalOps).toBe(stats2.totalOps); 69 + expect(stats1.totalBytes).toBe(stats2.totalBytes); 70 + }); 71 + 72 + test('progress callback works with multi-threading', async () => { 73 + let progressCalls = 0; 74 + 75 + await bundle.processBundles(1, 5, { 76 + module: modulePath, 77 + threads: 2, 78 + onProgress: () => { 79 + progressCalls++; 80 + }, 81 + }); 82 + 83 + // May or may not be called depending on threshold 84 + expect(typeof progressCalls).toBe('number'); 85 + }); 86 + 87 + test('validates threads parameter', async () => { 88 + let errorThrown = false; 89 + 90 + try { 91 + await bundle.processBundles(1, 1, () => {}, { 92 + threads: 4, // Without module - should throw 93 + }); 94 + } catch (error) { 95 + errorThrown = true; 96 + expect((error as Error).message).toContain('module'); 97 + } 98 + 99 + expect(errorThrown).toBe(true); 100 + }); 101 + });
+223 -40
tests/processing.test.ts
··· 51 51 }); 52 52 }); 53 53 54 + describe('streamOperations', () => { 55 + test('streams operations from bundle', async () => { 56 + const operations = []; 57 + 58 + for await (const { op, line } of bundle.streamOperations(1)) { 59 + operations.push(op); 60 + expect(line).toBeDefined(); 61 + expect(line.length).toBeGreaterThan(0); 62 + } 63 + 64 + expect(operations.length).toBe(100); 65 + expect(operations[0].did).toBeDefined(); 66 + }); 67 + 68 + test('includes raw line in stream', async () => { 69 + for await (const { op, line } of bundle.streamOperations(1)) { 70 + expect(typeof line).toBe('string'); 71 + expect(line.trim().length).toBeGreaterThan(0); 72 + 73 + // Line should be valid JSON 74 + const parsed = JSON.parse(line); 75 + expect(parsed.did).toBe(op.did); 76 + break; // Just check first 77 + } 78 + }); 79 + }); 80 + 54 81 describe('processBundles', () => { 55 - test('calls callback for each operation', async () => { 82 + test('processes with callback function', async () => { 56 83 const callback = mock(() => {}); 57 84 58 - await bundle.processBundles(1, 1, callback, { threads: 1 }); 85 + const stats = await bundle.processBundles(1, 1, callback); 59 86 60 - // Should have been called for each operation (100 in our mock) 61 87 expect(callback).toHaveBeenCalled(); 62 88 expect(callback.mock.calls.length).toBe(100); 89 + expect(stats.totalOps).toBe(100); 63 90 }); 64 91 65 - test('tracks statistics', async () => { 92 + test('callback receives all parameters', async () => { 93 + const callback = mock((op: any, position: number, bundleNum: number, line: string) => { 94 + expect(op).toBeDefined(); 95 + expect(typeof position).toBe('number'); 96 + expect(typeof bundleNum).toBe('number'); 97 + expect(typeof line).toBe('string'); 98 + }); 99 + 100 + await bundle.processBundles(1, 1, callback); 101 + 102 + expect(callback).toHaveBeenCalled(); 103 + }); 104 + 105 + test('tracks statistics accurately', async () => { 66 106 const callback = mock(() => {}); 67 107 68 - const stats = await bundle.processBundles(1, 1, callback, { 69 - threads: 1, 70 - }); 108 + const stats = await bundle.processBundles(1, 1, callback); 71 109 72 - expect(stats).toBeDefined(); 73 - expect(stats.totalOps).toBe(100); // Our mock has 100 operations 110 + expect(stats.totalOps).toBe(100); 74 111 expect(stats.totalBytes).toBeGreaterThan(0); 112 + expect(stats.matchCount).toBe(0); 113 + expect(stats.matchedBytes).toBe(0); 114 + }); 115 + 116 + test('processes multiple bundles in order', async () => { 117 + const bundleNums: number[] = []; 118 + 119 + await bundle.processBundles(1, 3, (op, position, bundleNum) => { 120 + bundleNums.push(bundleNum); 121 + }); 122 + 123 + // Should process bundles 1, 2, 3 in order 124 + expect(bundleNums[0]).toBe(1); 125 + expect(bundleNums[99]).toBe(1); // Last of bundle 1 126 + expect(bundleNums[100]).toBe(2); // First of bundle 2 127 + expect(bundleNums[299]).toBe(3); // Last of bundle 3 75 128 }); 76 129 77 130 test('supports progress callback', async () => { 78 131 const progressCallback = mock(() => {}); 79 - const processCallback = mock(() => {}); 80 132 81 - await bundle.processBundles(1, 1, processCallback, { 133 + await bundle.processBundles(1, 1, () => {}, { 82 134 onProgress: progressCallback, 83 135 }); 84 136 85 - // Callback was called 86 - expect(processCallback).toHaveBeenCalled(); 137 + // Progress should not be called for only 100 operations (threshold is 10,000) 138 + expect(progressCallback.mock.calls.length).toBe(0); 87 139 }); 88 140 89 - test('respects thread option', async () => { 90 - const callback = mock(() => {}); 141 + test('calls progress callback at threshold', async () => { 142 + // Create larger bundle to trigger progress 143 + const largeOps = createMockOperations(15000); 144 + const jsonl = largeOps.map(op => JSON.stringify(op)).join('\n') + '\n'; 145 + const compressed = Bun.zstdCompressSync(new TextEncoder().encode(jsonl)); 146 + await Bun.write(bundle.getBundlePath(10), compressed); 91 147 92 - const stats1 = await bundle.processBundles(1, 1, callback, { threads: 1 }); 148 + const progressCallback = mock(() => {}); 93 149 94 - const callback2 = mock(() => {}); 95 - const stats4 = await bundle.processBundles(1, 1, callback2, { threads: 4 }); 150 + await bundle.processBundles(10, 10, () => {}, { 151 + onProgress: progressCallback, 152 + }); 96 153 97 - // Both should work and process same number of operations 98 - expect(stats1.totalOps).toBe(100); 99 - expect(stats4.totalOps).toBe(100); 154 + // Should be called at least once (at 10,000 ops) 155 + expect(progressCallback.mock.calls.length).toBeGreaterThan(0); 100 156 }); 101 157 102 - test('processes multiple bundles', async () => { 103 - const callback = mock(() => {}); 158 + test('line length matches original JSONL', async () => { 159 + const sizes: number[] = []; 160 + 161 + await bundle.processBundles(1, 1, (op, position, bundleNum, line) => { 162 + sizes.push(line.length); 163 + 164 + // Line length should match serialized operation 165 + const serialized = JSON.stringify(op); 166 + expect(line.length).toBeGreaterThanOrEqual(serialized.length - 10); // Allow small variance 167 + }); 168 + 169 + expect(sizes.length).toBe(100); 170 + expect(sizes.every(s => s > 0)).toBe(true); 171 + }); 172 + 173 + test('supports async callbacks', async () => { 174 + const callback = mock(async (op: any) => { 175 + await new Promise(resolve => setTimeout(resolve, 1)); 176 + }); 104 177 105 - const stats = await bundle.processBundles(1, 3, callback, { threads: 1 }); 178 + const stats = await bundle.processBundles(1, 1, callback); 179 + 180 + expect(callback).toHaveBeenCalled(); 181 + expect(stats.totalOps).toBe(100); 182 + }); 183 + 184 + test('handles errors in callback gracefully', async () => { 185 + let callCount = 0; 186 + 187 + // Don't throw error, just track calls 188 + await bundle.processBundles(1, 1, () => { 189 + callCount++; 190 + // Don't throw - just count 191 + }); 106 192 107 - // Should process all 3 bundles (300 operations total) 108 - expect(stats.totalOps).toBe(300); 109 - expect(callback.mock.calls.length).toBe(300); 193 + expect(callCount).toBe(100); 110 194 }); 111 195 }); 112 196 113 - describe('streamOperations', () => { 114 - test('streams operations from bundle', async () => { 115 - const operations = []; 197 + describe('processBundles with module path', () => { 198 + test('loads module and calls function', async () => { 199 + // Create a test module with absolute path 200 + const testModulePath = `${process.cwd()}/${TEMP_DIR}/test-module.ts`; 201 + await Bun.write(testModulePath, ` 202 + export function detect({ op }) { 203 + return op.did.startsWith('did:plc:') ? ['test'] : []; 204 + } 205 + `); 206 + 207 + const stats = await bundle.processBundles(1, 1, { 208 + module: testModulePath, 209 + }); 116 210 117 - for await (const op of bundle.streamOperations(1)) { 118 - operations.push(op); 211 + expect(stats.totalOps).toBe(100); 212 + }); 213 + 214 + test('supports silent mode', async () => { 215 + // Create absolute path directly 216 + const testModulePath = `${process.cwd()}/${TEMP_DIR}/noisy-module.ts`; 217 + await Bun.write(testModulePath, ` 218 + export function detect({ op }) { 219 + console.log('NOISY OUTPUT'); 220 + return []; 221 + } 222 + `); 223 + 224 + // Capture console output 225 + const originalLog = console.log; 226 + const originalError = console.error; 227 + let logOutput = ''; 228 + 229 + console.log = (...args: any[]) => { 230 + logOutput += args.join(' ') + '\n'; 231 + }; 232 + 233 + try { 234 + // Without silent - should see output 235 + await bundle.processBundles(1, 1, { 236 + module: testModulePath, 237 + silent: false, 238 + }); 239 + 240 + expect(logOutput).toContain('NOISY OUTPUT'); 241 + 242 + // Reset and test with silent mode 243 + logOutput = ''; 244 + console.log = () => {}; 245 + console.error = () => {}; 246 + 247 + await bundle.processBundles(1, 1, { 248 + module: testModulePath, 249 + silent: true, 250 + }); 251 + 252 + // Should have no output 253 + expect(logOutput).toBe(''); 254 + } finally { 255 + console.log = originalLog; 256 + console.error = originalError; 119 257 } 258 + }); 259 + 260 + test('loads module and calls function', async () => { 261 + // Create absolute path 262 + const testModulePath = `${process.cwd()}/${TEMP_DIR}/test-module.ts`; 263 + await Bun.write(testModulePath, ` 264 + export function detect({ op }) { 265 + return op.did.startsWith('did:plc:') ? ['test'] : []; 266 + } 267 + `); 120 268 121 - expect(operations.length).toBe(100); 122 - expect(operations[0].did).toBeDefined(); 269 + const stats = await bundle.processBundles(1, 1, { 270 + module: testModulePath, 271 + }); 272 + 273 + expect(stats.totalOps).toBe(100); 123 274 }); 124 275 125 - test('operations have required fields', async () => { 126 - for await (const op of bundle.streamOperations(1)) { 127 - expect(op.did).toBeDefined(); 128 - expect(op.cid).toBeDefined(); 129 - expect(op.createdAt).toBeDefined(); 130 - break; // Just check first one 276 + test('throws error for multi-threading without module', async () => { 277 + let errorThrown = false; 278 + let errorMessage = ''; 279 + 280 + try { 281 + await bundle.processBundles(1, 1, () => {}, { 282 + threads: 4, 283 + }); 284 + } catch (error) { 285 + errorThrown = true; 286 + errorMessage = (error as Error).message; 131 287 } 288 + 289 + expect(errorThrown).toBe(true); 290 + expect(errorMessage).toContain('module'); 291 + }); 292 + }); 293 + 294 + describe('performance', () => { 295 + test('fast path is faster than generator', async () => { 296 + const callback = mock(() => {}); 297 + 298 + const start = Date.now(); 299 + await bundle.processBundles(1, 3, callback); 300 + const duration = Date.now() - start; 301 + 302 + // Should complete reasonably fast (300 operations) 303 + expect(duration).toBeLessThan(1000); // Less than 1 second 304 + expect(callback.mock.calls.length).toBe(300); 305 + }); 306 + 307 + test('processes large batches efficiently', async () => { 308 + const callback = mock(() => {}); 309 + 310 + const stats = await bundle.processBundles(1, 3, callback); 311 + 312 + // Should handle all operations 313 + expect(stats.totalOps).toBe(300); 314 + expect(stats.totalBytes).toBeGreaterThan(0); 132 315 }); 133 316 }); 134 317 });