crashlab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,390 @@
1
+ # CrashLab — Deterministic Simulation Testing for Node.js
2
+
3
+ **Find 1-in-a-million race conditions in milliseconds, not months.**
4
+
5
+ CrashLab runs your application code inside a fully controlled simulation: virtual time, seeded randomness, and deterministic I/O scheduling. Every concurrency bug that would normally require weeks of load testing to surface can be reproduced on demand, debugged with a single seed, and guarded against regression forever.
6
+
7
+ ---
8
+
9
+ ## The Problem with Conventional Testing
10
+
11
+ Imagine a payment handler:
12
+
13
+ ```typescript
14
+ async function charge(userId: string, amount: number) {
15
+ const balance = await db.query('SELECT balance FROM accounts WHERE id = $1', [userId]);
16
+ if (balance.rows[0].balance < amount) throw new Error('Insufficient funds');
17
+ await stripe.charge(userId, amount); // ~200ms network call
18
+ await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, userId]);
19
+ }
20
+ ```
21
+
22
+ A **double-payment race condition** is buried here. Two concurrent requests both read the same balance, both pass the guard, and both charge the card — but only one debits the account. This bug requires two requests to arrive within a ~200ms window. In a Jest or Vitest test suite, your async calls resolve sequentially; the window never opens and the test always passes.
23
+
24
+ **CrashLab compresses virtual time and shuffles I/O resolution order.** Across 1,000 seeds it explores every possible interleaving of those two awaits. Seed 847 opens the exact window. You get a failing test, a full timeline, and a replay command — before this ships.
25
+
26
+ ---
27
+
28
+ ## Example
29
+
30
+ **`scenarios/charge.scenario.ts`** — the file your team ships alongside the code:
31
+
32
+ ```typescript
33
+ import type { SimEnv } from 'crashlab';
34
+
35
+ export default async function chargeScenario(env: SimEnv) {
36
+ // Mock Stripe: 200ms virtual latency, deterministic response
37
+ env.http.mock('POST https://api.stripe.com/v1/charges', {
38
+ status: 200,
39
+ body: JSON.stringify({ id: 'ch_sim', status: 'succeeded' }),
40
+ latency: 200,
41
+ });
42
+
43
+ // Seed Postgres with a user who has $100
44
+ env.pg.seedData('accounts', [{ id: 'user_1', balance: 100 }]);
45
+ await env.pg.ready();
46
+
47
+ // Fire two concurrent charge requests at the same virtual instant
48
+ const req = () =>
49
+ fetch('http://localhost:3000/charge', {
50
+ method: 'POST',
51
+ body: JSON.stringify({ userId: 'user_1', amount: 100 }),
52
+ });
53
+
54
+ const [r1, r2] = await Promise.all([req(), req()]);
55
+
56
+ // Advance virtual clock past the Stripe latency — both callbacks resolve
57
+ await env.clock.advance(250);
58
+
59
+ const result = await env.pg.query<{ balance: number }>(
60
+ 'SELECT balance FROM accounts WHERE id = $1', ['user_1']
61
+ );
62
+
63
+ env.timeline.record({
64
+ timestamp: env.clock.now(),
65
+ type: 'ASSERT',
66
+ detail: `final balance: ${result.rows[0].balance}`,
67
+ });
68
+
69
+ // The balance must be 0 — any other value is a double-charge
70
+ if (result.rows[0].balance !== 0) {
71
+ throw new Error(`Double charge detected! Balance is ${result.rows[0].balance}, expected 0`);
72
+ }
73
+ }
74
+ ```
75
+
76
+ **`crashlab.config.js`** — wire it to the harness:
77
+
78
+ ```javascript
79
+ import { Simulation } from 'crashlab';
80
+ import { resolve } from 'node:path';
81
+
82
+ const sim = new Simulation({ timeout: 15_000 });
83
+
84
+ sim.scenario('double charge guard', resolve('./scenarios/charge.scenario.ts'));
85
+
86
+ export default sim;
87
+ ```
88
+
89
+ ---
90
+
91
+ ## How It Works — The Three Pillars
92
+
93
+ ### 1. Virtual Clock
94
+ `Date.now()`, `performance.now()`, `setTimeout`, and `setInterval` are replaced with a fully controllable `VirtualClock`. Time only moves when you call `env.clock.advance(ms)`. A scenario that would take 200ms in production takes **0 wall-clock milliseconds** in simulation.
95
+
96
+ ### 2. Seeded PRNG
97
+ `Math.random()` and `crypto.randomBytes()` are replaced with a deterministic Mulberry32-based generator seeded per-run. Given the same seed, every random value produced during the scenario is identical — every time, on every machine.
98
+
99
+ ### 3. I/O Scheduler
100
+ Concurrent `await` calls that resolve at the same virtual timestamp are queued and **shuffled by the seed** before being delivered. Seed 0 might resolve DB-then-Stripe. Seed 847 resolves Stripe-then-DB. Running 1,000 seeds explores 1,000 distinct interleavings of every concurrent I/O operation in your code.
101
+
102
+ These three pillars together mean: **if a race condition is possible, a seed will find it.**
103
+
104
+ ---
105
+
106
+ ## CLI Usage
107
+
108
+ CrashLab has two operating modes and a replay command:
109
+
110
+ | | `run` | `hunt` |
111
+ |---|---|---|
112
+ | **What you specify** | Seed count | Time budget |
113
+ | **When it stops** | After N seeds (or first failure by default) | On first failure or timeout |
114
+ | **What it outputs** | Pass/fail summary with counts | Live per-seed status, full failure report |
115
+ | **Memory** | Only failures retained | Never accumulates passing results |
116
+ | **When to use** | CI / regression suites | Local debugging, "find me a bug" sessions |
117
+
118
+ ---
119
+
120
+ ### `crashlab run` — fixed seed count, CI mode
121
+
122
+ ```sh
123
+ npx crashlab run --seeds=1000
124
+ ```
125
+
126
+ Stops at the **first failure** by default (`stopOnFirstFailure: true`). To collect all failures across all seeds:
127
+
128
+ ```sh
129
+ npx crashlab run --seeds=1000 --stop-on-first-failure=false
130
+ ```
131
+
132
+ **Output:**
133
+ ```
134
+ ✗ [seed=847] double charge guard: Double charge detected! Balance is 100, expected 0
135
+ Timeline:
136
+ [0ms] START: Scenario: double charge guard, seed: 847
137
+ [0ms] DB: SELECT balance → 100 (request A)
138
+ [0ms] DB: SELECT balance → 100 (request B)
139
+ [200ms] HTTP: POST /v1/charges → succeeded (request A)
140
+ [200ms] HTTP: POST /v1/charges → succeeded (request B)
141
+ [200ms] DB: UPDATE balance = 0 (request A)
142
+ [200ms] DB: UPDATE balance = 0 (request B)
143
+ [200ms] ASSERT: final balance: 100
144
+ [200ms] FAIL: Double charge detected! Balance is 100, expected 0
145
+
146
+ 0/1000 passed, 1 failed
147
+ ```
148
+
149
+ ---
150
+
151
+ ### `crashlab hunt` — time-budget mode, local debugging
152
+
153
+ Hunt mode runs as many seeds as it can fit within a time budget and stops the moment it finds a failure. There is no seed count — just run until you find something.
154
+
155
+ ```sh
156
+ npx crashlab hunt ./scenarios/charge.scenario.ts
157
+ npx crashlab hunt ./scenarios/charge.scenario.ts --timeout=10m
158
+ ```
159
+
160
+ Duration format: `30s` | `5m` | `1h`. Default: `5m`.
161
+
162
+ **Live output:**
163
+ ```
164
+ Hunting: charge.scenario.ts (timeout: 5m)
165
+
166
+ [OK ] Seed 482910341
167
+ [OK ] Seed 482910342
168
+ [OK ] Seed 482910343
169
+ [FAIL] Seed 482910344
170
+
171
+ ────────────────────────────────────────────────────────────
172
+ FAILURE FOUND after 4 seeds in 2s
173
+ Scenario : charge.scenario
174
+ Seed : 482910344
175
+ Error : Double charge detected! Balance is 100, expected 0
176
+
177
+ Timeline:
178
+ [0ms] START: ...
179
+ ...
180
+
181
+ Replay command:
182
+ crashlab replay --seed=482910344 --scenario="charge.scenario" --config=crashlab.config.js
183
+ ```
184
+
185
+ If no failure is found within the budget:
186
+ ```
187
+ No failure found after 1247 seeds in 5m 0s (timeout after 5m 0s).
188
+ Your scenario may be correct, or the bug requires a specific condition not yet explored.
189
+ ```
190
+
191
+ Press **Ctrl+C** to stop early — CrashLab finishes the current seed, discards its result (it may have been interrupted mid-flight), and exits cleanly with code `0`:
192
+ ```
193
+ No failure found after 1247 seeds in 2m 14s (interrupted by Ctrl+C).
194
+ ```
195
+
196
+ ---
197
+
198
+ ### `crashlab replay` — reproduce a specific failure
199
+
200
+ ```sh
201
+ npx crashlab replay --seed=847 --scenario="double charge guard"
202
+ ```
203
+
204
+ > [!TIP]
205
+ > **The same seed always produces the same failure.** You can share `--seed=847` with a colleague, add it to a CI regression suite, or step through it in a debugger. The entire execution is deterministic.
206
+
207
+ ---
208
+
209
+ ### `sim.run()` API — programmatic use
210
+
211
+ ```typescript
212
+ // Default: stop on first failure, only store failing results
213
+ const result = await sim.run({ seeds: 1000 });
214
+ // result.passed → boolean
215
+ // result.passes → number of seeds that passed
216
+ // result.failures → ScenarioResult[] (only failures; passing results are not retained)
217
+
218
+ // Opt out of early stop to collect all failures
219
+ const result = await sim.run({ seeds: 1000, stopOnFirstFailure: false });
220
+
221
+ // Replay always returns the full result regardless of pass/fail
222
+ const replay = await sim.replay({ seed: 847, scenario: 'double charge guard' });
223
+ // replay.passed → boolean
224
+ // replay.result → ScenarioResult (always present, including timeline)
225
+ ```
226
+
227
+ ### Custom config path
228
+
229
+ ```sh
230
+ npx crashlab run --config=./tests/sim/crashlab.config.js --seeds=500
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Installation
236
+
237
+ ### Batteries-included (recommended)
238
+
239
+ ```sh
240
+ npm install --save-dev crashlab
241
+ ```
242
+
243
+ `crashlab` includes every mock: Postgres (PGlite), MongoDB (MongoMemoryServer), Redis (ioredis-mock), HTTP, TCP, virtual clock, PRNG, filesystem. Install this and you are done.
244
+
245
+ ### À la carte
246
+
247
+ If you only need a subset of the mocks — say, virtual time and HTTP interception with no database overhead — install the lightweight engine and only the layers you need:
248
+
249
+ ```sh
250
+ npm install --save-dev @crashlab/core @crashlab/clock @crashlab/http-proxy
251
+ ```
252
+
253
+ `@crashlab/core` ships the `Simulation` class, CLI runner, and worker engine. It has **no dependency on PGlite, MongoDB, or Redis**. Mocks that are not installed simply appear as `null` on `env.pg`, `env.redis`, and `env.mongo`.
254
+
255
+ Available sub-packages:
256
+
257
+ | Package | What it provides |
258
+ |---|---|
259
+ | `@crashlab/core` | `Simulation` class, CLI, worker engine |
260
+ | `@crashlab/clock` | `VirtualClock` |
261
+ | `@crashlab/random` | `SeededRandom` |
262
+ | `@crashlab/scheduler` | `Scheduler` |
263
+ | `@crashlab/http-proxy` | `HttpInterceptor` |
264
+ | `@crashlab/tcp` | `TcpInterceptor` |
265
+ | `@crashlab/filesystem` | `VirtualFS` |
266
+ | `@crashlab/pg-mock` | `PgMock` (PGlite) |
267
+ | `@crashlab/redis-mock` | `RedisMock` (ioredis-mock) |
268
+ | `@crashlab/mongo` | `MongoMock` (MongoMemoryServer) |
269
+
270
+ ---
271
+
272
+ ## Support Matrix
273
+
274
+ | Protocol / Driver | CrashLab Support | Notes |
275
+ |---|---|---|
276
+ | **PostgreSQL** | ✅ Full | PGlite in-process — wire-protocol compatible |
277
+ | **MongoDB** | ✅ Full | Proxied to MongoMemoryServer per-run |
278
+ | **Redis** | ✅ Full | In-process RESP protocol handler |
279
+ | **HTTP / Fetch** | ✅ Full | `http.request`, `https.request`, `globalThis.fetch` |
280
+ | **Prisma** | ✅ Compatible | Loopback TCP servers on 5432 / 27017 / 6379 |
281
+ | **ioredis / mongoose / pg** | ✅ Compatible | Client-side module patch — zero config |
282
+ | **MySQL** | ❌ Not supported | Port 3306 throws `CrashLabUnsupportedProtocolError` in v1.0 |
283
+
284
+ > [!NOTE]
285
+ > **Prisma compatibility:** CrashLab binds real loopback TCP servers on 127.0.0.1:5432, :6379, and :27017, so Prisma's out-of-process Rust query engine connects to the same mocks as your in-process drivers. If a real database is running on those ports, CrashLab records a `WARNING` in the timeline and falls back to the client-side interceptor.
286
+
287
+ ---
288
+
289
+ ## Honest Limitations
290
+
291
+ CrashLab is precise about what it controls. Senior engineers deserve a straight answer:
292
+
293
+ | Limitation | Reason |
294
+ |---|---|
295
+ | **Native C++ addons** | Native code runs outside the V8 sandbox. `require('better-sqlite3')` or `bcrypt` bypass all module patches. Use pure-JS alternatives in scenarios, or wrap them in an HTTP service that CrashLab can mock. |
296
+ | **Engine-level microtask interleaving** | V8's microtask queue is not observable from userland. CrashLab controls macro-task and I/O scheduling; it cannot reorder `Promise.resolve()` chains that don't yield to the event loop. |
297
+ | **`worker_threads` spawned by your app** | Child workers inherit real globals, not CrashLab's patched ones. Scenarios should avoid code paths that spawn workers; use the simulation environment's own concurrency tools instead. |
298
+ | **True wall-clock timers** | Any library that calls the real `setTimeout` before CrashLab installs its patch (e.g. at module evaluation time) will use real time. Import order matters. |
299
+
300
+ ---
301
+
302
+ ## Scenario API Reference
303
+
304
+ ```typescript
305
+ import type { SimEnv } from 'crashlab';
306
+
307
+ export default async function myScenario(env: SimEnv) {
308
+ env.clock // VirtualClock — advance(), now(), setTimeout(), setInterval()
309
+ env.random // SeededRandom — next() → [0,1), nextInt(n)
310
+ env.scheduler // Scheduler — enqueueCompletion(), runTick()
311
+ env.http // HttpInterceptor — mock(), calls, unmatched handling
312
+ env.tcp // TcpInterceptor — mock(), addLocalServer()
313
+ env.pg // PgMock — seedData(), query(), ready(), createHandler()
314
+ env.redis // RedisMock — seedData(), createHandler()
315
+ env.mongo // MongoMock — find(), drop(), createHandler()
316
+ env.fs // VirtualFS — readFileSync(), writeFileSync(), existsSync()
317
+ env.faults // FaultInjector — diskFull(), clockSkew(), networkPartition()
318
+ env.timeline // Timeline — record({ timestamp, type, detail })
319
+ env.seed // number — the current run's seed value
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Architecture
326
+
327
+ ### Package split
328
+
329
+ ```
330
+ npm install crashlab ← batteries-included (re-exports @crashlab/core + all mocks)
331
+ npm install @crashlab/core ← lightweight engine only (no PGlite / MongoDB / Redis)
332
+ ```
333
+
334
+ `@crashlab/core` declares the heavy mock packages as **optional peer dependencies**. If they are not installed `env.pg`, `env.redis`, and `env.mongo` are `null`. The `crashlab` wrapper lists them as required dependencies, guaranteeing they are always present.
335
+
336
+ ### Runtime flow
337
+
338
+ ```
339
+ Simulation.run({ seeds: N }) ← lives in @crashlab/core
340
+
341
+ ├─ _startMongo() → MongoMemoryServer (skipped if not installed)
342
+
343
+ └─ for each seed × scenario
344
+ └─ Worker thread (isolated globals)
345
+ ├─ createEnv(seed) → VirtualClock, PRNG, Scheduler, lightweight mocks
346
+ │ ├─ try import('@crashlab/pg-mock') → PgMock | null
347
+ │ ├─ try import('@crashlab/redis-mock') → RedisMock | null
348
+ │ └─ try import('@crashlab/mongo') → MongoMock | null
349
+ ├─ install patches → Date.now, Math.random, net.createConnection, fetch
350
+ ├─ import(scenario) → dynamic ES module load (file-based)
351
+ ├─ await scenarioFn(env)
352
+ ├─ timeline.toString() → posted to parent
353
+ └─ finally: uninstall patches, drop mongo db, worker.terminate()
354
+ ```
355
+
356
+ Each worker is fully isolated. Patches applied inside one worker never leak to the main thread or sibling workers. After `run()` returns there are zero zombie workers and zero mongod instances.
357
+
358
+ ---
359
+
360
+ ## Contributing
361
+
362
+ ```sh
363
+ git clone https://github.com/your-org/crashlab
364
+ npm install
365
+ npm run build
366
+ npm test # vitest — 176 tests
367
+ ```
368
+
369
+ All packages live under `packages/`. The monorepo uses npm workspaces + tsup for building. PRs must pass `npm test` with zero failures.
370
+
371
+ ### Releasing
372
+
373
+ This repo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing. All packages are versioned together (fixed group).
374
+
375
+ ```sh
376
+ # 1. Describe your change (prompts for bump type + summary)
377
+ npx changeset
378
+
379
+ # 2. Apply version bumps — updates all package.json versions and cross-package pins
380
+ npm run version
381
+
382
+ # 3. Build and publish to npm
383
+ npm run release
384
+ ```
385
+
386
+ ---
387
+
388
+ ## License
389
+
390
+ MIT
package/dist/cli.cjs ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/cli.ts
5
+ var import_cli = require("@crashlab/core/cli");
6
+ //# sourceMappingURL=cli.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport '@crashlab/core/cli';\n"],"mappings":";;;;AACA,iBAAO;","names":[]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import '@crashlab/core/cli';
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,oBAAoB,CAAC"}
package/dist/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import "@crashlab/core/cli";
5
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport '@crashlab/core/cli';\n"],"mappings":";;;AACA,OAAO;","names":[]}
package/dist/index.cjs ADDED
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __copyProps = (to, from, except, desc) => {
7
+ if (from && typeof from === "object" || typeof from === "function") {
8
+ for (let key of __getOwnPropNames(from))
9
+ if (!__hasOwnProp.call(to, key) && key !== except)
10
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ }
12
+ return to;
13
+ };
14
+ var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
15
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
16
+
17
+ // src/index.ts
18
+ var index_exports = {};
19
+ module.exports = __toCommonJS(index_exports);
20
+ __reExport(index_exports, require("@crashlab/core"), module.exports);
21
+ // Annotate the CommonJS export names for ESM import in node:
22
+ 0 && (module.exports = {
23
+ ...require("@crashlab/core")
24
+ });
25
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from '@crashlab/core';\n"],"mappings":";;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA,0BAAc,2BAAd;","names":[]}
@@ -0,0 +1,2 @@
1
+ export * from '@crashlab/core';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // src/index.ts
2
+ export * from "@crashlab/core";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from '@crashlab/core';\n"],"mappings":";AAAA,cAAc;","names":[]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "crashlab",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "keywords": [
6
+ "crashlab",
7
+ "simulation",
8
+ "deterministic",
9
+ "testing",
10
+ "race-conditions",
11
+ "nodejs",
12
+ "virtual-clock",
13
+ "fault-injection"
14
+ ],
15
+ "type": "module",
16
+ "main": "dist/index.cjs",
17
+ "module": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "bin": {
20
+ "crashlab": "dist/cli.js"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsup && tsc --build tsconfig.json --force",
34
+ "clean": "rimraf dist",
35
+ "prepack": "npm run build",
36
+ "build:pkg": "tsup && tsc --build tsconfig.json --force"
37
+ },
38
+ "dependencies": {
39
+ "@crashlab/core": "0.1.0",
40
+ "@crashlab/mongo": "0.1.0",
41
+ "@crashlab/pg-mock": "0.1.0",
42
+ "@crashlab/redis-mock": "0.1.0",
43
+ "mongodb-memory-server": "^10.0.0"
44
+ }
45
+ }