# Synode — Complete Documentation > Synthetic data engine for Customer Data Platforms. > This file contains the full documentation as a single markdown document for LLM consumption. --- # Getting Started Get from zero to generated events in five minutes. ## Install ```bash npm install @synode/core ``` Requires Node.js 18+. ## Core Concepts Synode organizes generation around three pillars: | Pillar | Purpose | Required? | | ------------ | ------------------------------------------------- | --------- | | **Users** | Synthetic identities with personas | Yes | | **Datasets** | Pre-generated entity tables (products, locations) | No | | **Journeys** | Behavioral flows that emit events | No | Events are produced through a four-level hierarchy: **Journey** (user goal) -> **Adventure** (session) -> **Action** (behavior) -> **Event[]** (output). ```mermaid graph TD U[Users & Personas] --> J[Journeys] D[Datasets] --> J J --> Adv[Adventures] Adv --> Act[Actions] Act --> Evt[Events] style U fill:#fab957,stroke:#d49e4b,color:#1b1b1f style D fill:#fab957,stroke:#d49e4b,color:#1b1b1f style J fill:#8b5cf6,stroke:#7c3aed,color:#fff style Adv fill:#a78bfa,stroke:#8b5cf6,color:#fff style Act fill:#c4b5fd,stroke:#a78bfa,color:#1e1b4b style Evt fill:#e0e7ff,stroke:#c4b5fd,color:#1e1b4b ``` ## Your First Journey ### 1. Define an action The `fields` shorthand generates a single event with the given payload: ```typescript import { defineAction } from '@synode/core'; const pageView = defineAction({ id: 'page-view', name: 'page_viewed', fields: { url: '/home', title: 'Home Page', referrer: (ctx) => ctx.faker.internet.url(), }, }); ``` ### 2. Define an adventure Group actions into a session with timing and optional bounce behavior: ```typescript import { defineAdventure } from '@synode/core'; const browsing = defineAdventure({ id: 'browse', name: 'Browse Website', actions: [pageView], timeSpan: { min: 1000, max: 3000 }, bounceChance: 0.2, }); ``` ### 3. Define a journey Tie adventures into a complete user flow: ```typescript import { defineJourney } from '@synode/core'; const visit = defineJourney({ id: 'website-visit', name: 'Website Visit', adventures: [browsing], }); ``` ### 4. Generate Collect events with `InMemoryAdapter`: ```typescript import { generate, InMemoryAdapter } from '@synode/core'; const adapter = new InMemoryAdapter(); await generate(visit, { users: 50, adapter }); console.log(`Generated ${adapter.events.length} events`); ``` ## Adding a Dataset Datasets are pre-generated entity tables you can reference inside actions. Define a catalog, then use `ctx.dataset()` in a handler: ```typescript import { defineDataset, defineAction, oneOf } from '@synode/core'; const products = defineDataset({ id: 'products', name: 'Product Catalog', count: 50, fields: { productId: (_ctx, row) => `prod-${row.index}`, name: (ctx) => ctx.faker.commerce.productName(), price: (ctx) => ctx.faker.number.float({ min: 9.99, max: 499.99 }), category: oneOf(['electronics', 'clothing', 'home', 'sports']), }, }); const viewProduct = defineAction({ id: 'view-product', name: 'product_viewed', handler: (ctx) => { const product = ctx.dataset('products').randomRow(); return [ { id: ctx.generateId(), userId: ctx.userId, sessionId: ctx.sessionId, name: 'product_viewed', timestamp: ctx.now(), payload: { productId: product.productId, price: product.price }, }, ]; }, }); await generate(visit, { users: 50, datasets: [products], adapter }); ``` ## Adding a Persona Personas assign weighted attributes to each user. The `locale` attribute configures Faker.js automatically: ```typescript import { definePersona, weighted } from '@synode/core'; const shopper = definePersona({ id: 'shopper', name: 'Online Shopper', attributes: { locale: weighted({ en: 50, es: 20, fr: 15, de: 15 }), deviceType: weighted({ mobile: 60, desktop: 30, tablet: 10 }), }, }); await generate(visit, { users: 50, persona: shopper, datasets: [products], adapter }); ``` Each user's `ctx.locale` and `ctx.faker` reflect their assigned locale. Access persona attributes with `ctx.get('deviceType')`. ## Next Steps - [Personas](guides/personas.md) -- attribute distributions and locale control - [Datasets](guides/datasets.md) -- typed datasets with `InferDatasetRow` - [Multi-journey](guides/multi-journey.md) -- prerequisites and suppression - [Parallel processing](guides/parallel-processing.md) -- lanes and worker threads - [Event validation](guides/event-validation.md) -- Zod schema enforcement - [Output adapters](guides/output-adapters.md) -- File, HTTP, Stream, and more - [CLI](guides/cli.md) -- config-driven generation from the terminal - [Cookbook](cookbook.md) -- real-world recipes --- # Personas Personas define weighted attribute distributions for synthetic users. When generation runs, each user gets a concrete set of attributes sampled from the persona's distributions. Attributes are stored in the user's context and persist across all journeys. ## Defining a Persona Use `definePersona` with an `attributes` map. Each attribute can be a static value, a field generator (`weighted`, `oneOf`, `fake`, `chance`), or a function receiving the context. ```ts import { definePersona, weighted, oneOf, fake, chance } from '@synode/core'; const ecommerceUser = definePersona({ id: 'ecommerce-user', name: 'E-Commerce User', attributes: { locale: weighted({ en: 0.5, de: 0.25, fr: 0.15, ja: 0.1 }), deviceType: weighted({ mobile: 0.6, desktop: 0.3, tablet: 0.1 }), customerTier: weighted({ free: 0.7, premium: 0.2, enterprise: 0.1 }), isReturning: chance(0.4), preferredCategory: oneOf(['electronics', 'clothing', 'home', 'sports']), displayName: fake((faker) => faker.person.fullName()), email: fake((faker) => faker.internet.email()), }, }); ``` ## Field Generators ### `weighted(options: Record)` Returns a value sampled by weight. Weights are normalized automatically -- they do not need to sum to 1. ```ts weighted({ mobile: 60, desktop: 30, tablet: 10 }); // same as 0.6, 0.3, 0.1 ``` ### `oneOf(options: T[])` Returns a uniformly random element from the array. ```ts oneOf(['chrome', 'firefox', 'safari', 'edge']); ``` ### `fake(generator: (faker: Faker) => T)` Runs a function against the Faker.js instance. The faker locale is set from the persona's `locale` attribute. ```ts fake((faker) => faker.commerce.productName()); fake((faker) => faker.location.city()); ``` ### `chance(probability: number)` Returns `true` with the given probability (0-1). ```ts chance(0.3); // 30% chance of true ``` ## Locale Support Set the `locale` attribute to control the Faker.js locale for the user. When a persona includes `locale`, the context's `faker` instance uses that locale for all subsequent `fake()` calls and `ctx.faker` access. ```ts const germanUser = definePersona({ id: 'de-user', name: 'German User', attributes: { locale: 'de', // static: all users get German locale city: fake((faker) => faker.location.city()), // generates German city names name: fake((faker) => faker.person.fullName()), // generates German names }, }); const multiLocale = definePersona({ id: 'multi-locale', name: 'Multi-Locale User', attributes: { locale: weighted({ en: 0.5, de: 0.3, ja: 0.2 }), // varies per user name: fake((faker) => faker.person.fullName()), // locale-appropriate name }, }); ``` ## Accessing Persona Attributes in Journeys Persona attributes are stored in the user's context. Access them with `ctx.get(key)`. ```ts import { defineAction } from '@synode/core'; const browseAction = defineAction({ id: 'browse', name: 'page_view', handler: (ctx) => { const tier = ctx.get('customerTier'); const device = ctx.get('deviceType'); return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'page_view', timestamp: ctx.now(), payload: { url: tier === 'enterprise' ? '/dashboard' : '/products', deviceType: device, locale: ctx.locale, }, }, ]; }, }); ``` ## Using Personas with `generate` Pass the persona definition to `generate` via the `persona` option. ```ts import { generate, defineJourney } from '@synode/core'; await generate(browseJourney, { users: 1000, persona: ecommerceUser, lanes: 4, }); ``` ## Custom Generator Functions For complex attribute logic, use a raw function. It receives the context and the partially-built attributes object. ```ts const advancedUser = definePersona({ id: 'advanced', name: 'Advanced User', attributes: { region: oneOf(['us-east', 'us-west', 'eu-west', 'ap-south']), isPremium: chance(0.25), maxSessions: (ctx, attrs) => { return attrs.isPremium ? ctx.faker.number.int({ min: 5, max: 20 }) : ctx.faker.number.int({ min: 1, max: 3 }); }, }, }); ``` Attributes are resolved in order, so later attributes can reference earlier ones through the second argument. --- # Typed Datasets Datasets are pre-generated entity tables (products, locations, campaigns) that journeys reference during execution. They are generated once before any journey runs and are immutable during execution. ## Defining a Dataset Use `defineDataset` with field generators. Each field can be a static value or a function receiving the context and row metadata. ```ts import { defineDataset, oneOf } from '@synode/core'; const productsDef = defineDataset({ id: 'products', name: 'Product Catalog', count: 500, fields: { id: (ctx, row) => `prod-${row.index}`, name: (ctx) => ctx.faker.commerce.productName(), price: (ctx) => ctx.faker.number.float({ min: 9.99, max: 499.99, fractionDigits: 2 }), category: oneOf(['electronics', 'clothing', 'home', 'sports', 'books']), inStock: (ctx) => ctx.faker.datatype.boolean({ probability: 0.85 }), }, }); ``` Field generators receive `(context, { index, data })` where `index` is the row number and `data` is the partially-built row (for referencing earlier fields). ## Type Inference with `InferDatasetRow` Extract the row type from a dataset definition for type-safe access downstream. ```ts import { type InferDatasetRow } from '@synode/core'; type Product = InferDatasetRow; // Result: { id: string; name: string; price: number; category: string; inStock: boolean } ``` This works by unwrapping generator functions and Promises -- if a field is `(ctx) => string`, the inferred type is `string`. ## Accessing Datasets in Journeys ### Untyped Access Use `ctx.dataset('id')` for a `DatasetHandle` with unknown row types. ```ts const product = ctx.dataset('products').randomRow(); const allProducts = ctx.dataset('products').getAllRows(); const count = ctx.dataset('products').size(); ``` ### Typed Access Use `ctx.typedDataset('id')` with the inferred type for full type safety. ```ts import { defineAction, type InferDatasetRow } from '@synode/core'; type Product = InferDatasetRow; const addToCart = defineAction({ id: 'add-to-cart', name: 'add_to_cart', handler: (ctx) => { const product = ctx.typedDataset('products').randomRow(); return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'add_to_cart', timestamp: ctx.now(), payload: { productId: product.id, productName: product.name, price: product.price }, }, ]; }, }); ``` ### DatasetHandle Methods | Method | Return | Description | | ------------------ | ------------------- | --------------------------- | | `randomRow()` | `TRow` | Random row from the dataset | | `getRowById(id)` | `TRow \| undefined` | Lookup by `id` field value | | `getRowByIndex(i)` | `TRow \| undefined` | Lookup by zero-based index | | `getAllRows()` | `TRow[]` | All rows | | `size()` | `number` | Row count | ## Registering Datasets with `generate` Pass dataset definitions to `generate`. They are hydrated before any journey runs. ```ts import { generate } from '@synode/core'; await generate(purchaseJourney, { users: 1000, datasets: [productsDef, locationsDef], lanes: 4, }); ``` ## Import and Export Use `importDataset` and `exportDataset` for file-based dataset I/O. Supported formats: `csv`, `json`, `jsonl`. ```ts import { exportDataset, importDataset } from '@synode/core'; // Export to file (formats: 'csv', 'json', 'jsonl') await exportDataset(dataset, './data/products.csv', 'csv'); // Import from file const products = await importDataset('products', 'Product Catalog', './data/products.csv', 'csv'); ``` Pass imported datasets via `preloadedDatasets` -- these skip generation and are injected directly. ```ts const products = await importDataset('products', 'Products', './products.json', 'json'); await generate(journey, { users: 500, preloadedDatasets: [products] }); ``` For in-memory operations, use `exportDatasetToString` and `importDatasetFromString`. ## Cross-Field References Later fields can reference earlier fields in the same row via the `data` parameter. ```ts const orders = defineDataset({ id: 'orders', name: 'Orders', count: 200, fields: { quantity: (ctx) => ctx.faker.number.int({ min: 1, max: 10 }), unitPrice: (ctx) => ctx.faker.number.float({ min: 5, max: 100, fractionDigits: 2 }), total: (_ctx, row) => (row.data.quantity as number) * (row.data.unitPrice as number), }, }); ``` --- # Multi-Journey Prerequisites Journeys can depend on other journeys via `requires`. The engine checks prerequisites before starting each journey and skips journeys whose dependencies have not been completed. Combined with bounce and suppression, this creates realistic funnel drop-off behavior. ## Basic Prerequisites Set `requires` to an array of journey IDs that must complete before this journey starts. ```ts import { defineJourney, defineAdventure, defineAction, fake } from '@synode/core'; const signupJourney = defineJourney({ id: 'signup', name: 'Signup Flow', adventures: [ defineAdventure({ id: 'registration', name: 'Registration', timeSpan: { min: 2000, max: 5000 }, actions: [ defineAction({ id: 'signup-form', name: 'sign_up', fields: { method: 'email', source: fake((f) => f.helpers.arrayElement(['organic', 'paid', 'referral'])), }, }), ], }), ], }); const browseJourney = defineJourney({ id: 'browse', name: 'Browse Products', requires: ['signup'], // must complete signup first adventures: [ /* ... */ ], }); const purchaseJourney = defineJourney({ id: 'purchase', name: 'Purchase Flow', requires: ['signup', 'browse'], // must complete both adventures: [ /* ... */ ], }); ``` ## How Prerequisites Work 1. Journeys are passed to `generate` as an array and executed in order for each user 2. Before starting a journey, the engine checks `ctx.hasCompletedJourney(id)` for each required ID 3. If any prerequisite is not met, the journey is silently skipped 4. When a journey completes, `ctx.markJourneyComplete(id)` is called automatically The order you pass journeys to `generate` matters -- place prerequisites earlier in the array. ```ts import { generate } from '@synode/core'; // Order matters: signup -> browse -> purchase await generate([signupJourney, browseJourney, purchaseJourney], { users: 1000, lanes: 4, }); ``` ## Shared Context Across Journeys Context persists across all journeys for a single user. Data set in one journey is available in later journeys. ```ts // In signup journey: store data for downstream journeys const signupAction = defineAction({ id: 'signup-complete', name: 'sign_up_complete', handler: (ctx) => { ctx.set('accountType', 'premium'); return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'sign_up_complete', timestamp: ctx.now(), payload: { accountType: 'premium' }, }, ]; }, }); // In purchase journey: read data set by signup journey const purchaseAction = defineAction({ id: 'checkout', name: 'checkout', handler: (ctx) => { const accountType = ctx.get('accountType'); const discount = accountType === 'premium' ? 0.1 : 0; return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'checkout', timestamp: ctx.now(), payload: { discount, accountType }, }, ]; }, }); ``` ## Context Scoping Fields can be scoped to automatically clean up when a scope ends. Global fields (no scope) persist across all journeys. ```ts // Persists across all journeys (default) ctx.set('accountId', 'acc-123'); // Cleared after the current journey completes ctx.set('cartItems', [], { scope: 'journey' }); // Cleared after the current adventure completes ctx.set('searchQuery', 'shoes', { scope: 'adventure' }); // Cleared after the current action completes ctx.set('tempCalc', 42, { scope: 'action' }); ``` ## Bounce Behavior Bounce creates realistic funnel drop-off. Users who bounce on a journey never complete it, so downstream journeys with `requires` are also skipped. Bounce applies at three levels: | Level | `bounceChance` | Effect | | --------- | ------------------------ | --------------------------------------------------------------------- | | Journey | `Journey.bounceChance` | Journey never starts, no events generated | | Adventure | `Adventure.bounceChance` | `onBounce: 'stop'` ends journey, `'skip'` continues to next adventure | | Action | `Action.bounceChance` | Stops the current adventure | `onBounce` options: `'stop'` (default) ends the entire journey (NOT marked complete), `'skip'` skips to the next adventure. ## Suppression Periods After a journey completes or bounces, a suppression period advances the user's clock. This creates realistic gaps between journeys. ```ts const signupJourney = defineJourney({ id: 'signup', name: 'Signup', suppressionPeriod: { min: 3600000, max: 86400000 }, // 1 hour to 1 day adventures: [ /* ... */ ], }); ``` Suppression is applied in both cases: - Journey completes normally: user waits before starting the next journey - Journey bounces at the journey level: user waits before the next journey is attempted --- # Parallel Processing Synode offers two parallelism strategies: **lanes** for concurrent async execution in the main thread, and **worker threads** for multi-core parallelism. ```mermaid graph TD subgraph Lanes["Promise.all Lanes (single thread)"] L1[Lane 1: Users 1-25] L2[Lane 2: Users 26-50] L3[Lane 3: Users 51-75] L4[Lane 4: Users 76-100] end subgraph Workers["Worker Threads (multi-core)"] W1[Worker 1: Users 1-25] W2[Worker 2: Users 26-50] W3[Worker 3: Users 51-75] W4[Worker 4: Users 76-100] end L1 & L2 & L3 & L4 --> A1[Adapter] W1 & W2 & W3 & W4 --> A2[Adapter] style A1 fill:#fab957,stroke:#d49e4b,color:#1b1b1f style A2 fill:#fab957,stroke:#d49e4b,color:#1b1b1f ``` ## Lanes Lanes split users across concurrent async tasks within a single thread. Each lane gets an equal share of users and processes them independently with full context isolation. ```ts import { generate } from '@synode/core'; await generate(journey, { users: 10000, lanes: 4, // 4 concurrent lanes, ~2500 users each }); ``` Lanes use `Promise.all` internally. They share the same event loop but interleave I/O operations. Good for I/O-bound workloads (file/HTTP adapters). ### When to Use Lanes - Moderate user counts (1k-100k) - I/O-bound adapters (file writes, HTTP calls) - Simple setup -- no separate module needed - Datasets shared in-memory across all lanes ## Worker Threads Worker threads spawn separate V8 isolates for true multi-core parallelism. Each worker loads journeys from a module file and processes its assigned user range. ```ts await generate([], { users: 100000, workerModule: './config.ts', workers: 4, }); ``` When `workerModule` is set, the journey array passed to `generate` is ignored -- workers load journeys from the module. ### Worker Module Contract The worker module must export a `journeys` array. All other exports are optional. ```ts // config.ts import { defineJourney, defineAdventure, defineAction, definePersona, weighted, } from '@synode/core'; import type { Journey, PersonaDefinition } from '@synode/core'; export const persona: PersonaDefinition = definePersona({ id: 'user', name: 'User', attributes: { locale: weighted({ en: 0.7, de: 0.3 }) }, }); export const journeys: Journey[] = [ defineJourney({ id: 'browse', name: 'Browse', adventures: [ defineAdventure({ id: 'view', name: 'View Products', actions: [ defineAction({ id: 'page-view', name: 'page_view', fields: { url: '/products' } }), ], }), ], }), ]; ``` ### Worker Module Exports | Export | Type | Required | Description | | ------------------- | --------------------- | -------- | ------------------------------------------ | | `journeys` | `Journey[]` | Yes | Journey definitions to execute | | `persona` | `PersonaDefinition` | No | Persona for user generation | | `datasets` | `DatasetDefinition[]` | No | Dataset definitions to generate per worker | | `preloadedDatasets` | `Dataset[]` | No | Pre-built datasets to inject | ### Worker Count Default is `os.cpus().length`. Override with `workers`. Maximum: 1024. ```ts await generate([], { users: 50000, workerModule: './config.ts', workers: 8, }); ``` ### Dataset Handling with Workers Datasets defined in `generate({ datasets })` are pre-generated in the main thread and serialized to each worker. Datasets exported from the worker module are generated independently in each worker. For large shared datasets, pre-generate in the main thread: ```ts await generate([], { users: 50000, workerModule: './config.ts', workers: 8, datasets: [largeProductCatalog], // generated once, shared with all workers }); ``` ## Date Ranges Assign each user a random start time within a date range. All event timestamps for that user flow forward from their start time. ```ts await generate(journey, { users: 10000, lanes: 4, startDate: new Date('2026-01-01'), endDate: new Date('2026-03-31'), }); ``` Both `startDate` and `endDate` must be provided together. `startDate` must be before `endDate`. ## Debug Telemetry Enable `debug` to collect detailed metrics about the generation run. Saves a JSON report to `telemetryPath`. ```ts await generate(journey, { users: 5000, lanes: 4, debug: true, telemetryPath: './telemetry.json', }); ``` Default telemetry path: `./telemetry-report.json`. The telemetry report includes: - Total events generated - Users started/completed - Duration and throughput - Event validation summary (if schemas configured) ## Choosing a Strategy | Scenario | Strategy | Config | | -------------------------- | ------------------------- | --------------------------- | | < 10k users, simple setup | Sequential | `lanes: 1` (default) | | 10k-100k users, I/O-bound | Lanes | `lanes: 4-8` | | > 100k users, CPU-bound | Workers | `workerModule + workers: N` | | Large datasets, many users | Workers + shared datasets | `workerModule + datasets` | --- # Real-Time Streaming Stream generated events with concurrent user sessions, timestamp-ordered interleaving, and configurable pacing. ## Basic Usage ```typescript import { stream, defineJourney } from '@synode/core'; for await (const event of stream(journey, { users: 50 })) { console.log(event.name, event.timestamp); } ``` ## Pacing Modes Control how fast events are emitted: ```typescript // Real-time: actual timeSpan delays between events { mode: 'realtime' } // Accelerated: 10x faster than real-time { mode: 'realtime', speed: 10 } // Fixed: constant delay between every event { mode: 'fixed', delayMs: 100 } // None: as fast as possible (default) { mode: 'none' } ``` ## Concurrent Sessions Simulate multiple users generating events simultaneously. Events are interleaved by timestamp — like observing live platform traffic. ```typescript for await (const event of stream(journey, { users: 200, concurrency: 20, // 20 sessions at once pacing: { mode: 'realtime' }, })) { await pushToKafka(event); } ``` With `concurrency: 20`, the stream maintains 20 active user sessions. When one finishes, the next user starts. Events from all sessions are merged in timestamp order. ## Continuous Mode Set `users: Infinity` for an endless stream that keeps spawning new users: ```typescript const controller = new AbortController(); setTimeout(() => controller.abort(), 60_000); // stop after 1 minute for await (const event of stream(journey, { users: Infinity, concurrency: 50, spawnRate: 5, // 5 new users per second pacing: { mode: 'realtime', speed: 10 }, signal: controller.signal, })) { await ingest(event); } ``` ### Stopping Continuous Streams - `signal: controller.signal` + `controller.abort()` — clean external stop - `maxEvents: 10_000` — automatic stop after N events ## Load Testing For maximum throughput, skip pacing and use high concurrency: ```typescript let count = 0; for await (const event of stream(journey, { users: 50_000, concurrency: 200, maxEvents: 100_000, })) { count++; } console.log(`Generated ${count} events`); ``` ## StreamOptions Reference | Option | Type | Default | Description | |---|---|---|---| | `users` | `number` | `1` | Total users. `Infinity` for continuous. | | `concurrency` | `number` | `1` | Simultaneous user sessions | | `pacing` | `PacingOptions` | `{ mode: 'none' }` | Event emission timing | | `spawnRate` | `number` | `1` | Users/second in continuous mode | | `maxEvents` | `number` | `Infinity` | Event count safety limit | | `signal` | `AbortSignal` | — | External stop control | | `persona` | `PersonaDefinition` | — | User persona | | `datasets` | `DatasetDefinition[]` | — | Datasets to pre-generate | | `preloadedDatasets` | `Dataset[]` | — | Pre-loaded datasets | | `startDate` / `endDate` | `Date` | — | Date range for user start times | | `eventSchema` | `EventSchemaConfig` | — | Schema validation | ## How It Works Internally, `stream()` uses a priority queue (min-heap) to merge events from concurrent sessions by timestamp: ```mermaid graph LR S1[Session 1] --> Heap{Min-Heap} S2[Session 2] --> Heap S3[Session 3] --> Heap Heap --> |earliest timestamp| Out[yield event] Out --> |pacing delay| Consumer[for await...of] style Heap fill:#fab957,stroke:#d49e4b,color:#1b1b1f style Out fill:#22c55e,stroke:#16a34a,color:#fff ``` When a session ends, a new user is spawned (if any remain) and pushed into the heap. --- # Output Adapters Adapters receive events one at a time during generation and route them to their destination. Synode ships 7 built-in adapters. ```mermaid graph LR Engine[Engine] --> |"yield Event"| Runner[Runner] Runner --> |"write(event)"| Adapter{OutputAdapter} Adapter --> Console[ConsoleAdapter] Adapter --> Memory[InMemoryAdapter] Adapter --> File[FileAdapter] Adapter --> HTTP[HttpAdapter] Adapter --> Stream[StreamAdapter] Adapter --> Composite[CompositeAdapter] Adapter --> Callback[CallbackAdapter] style Engine fill:#fab957,stroke:#d49e4b,color:#1b1b1f style Runner fill:#8b5cf6,stroke:#7c3aed,color:#fff style Adapter fill:#a78bfa,stroke:#8b5cf6,color:#fff ``` ## Adapter Summary | Adapter | Use Case | Constructor | | ------------------ | ------------------------------------ | ------------------------------------- | | `ConsoleAdapter` | Debugging, quick inspection | `new ConsoleAdapter()` | | `InMemoryAdapter` | Testing, assertions, dry runs | `new InMemoryAdapter()` | | `FileAdapter` | Local file output (JSONL, JSON, CSV) | `new FileAdapter({ path, format })` | | `HttpAdapter` | Webhook/API ingestion with batching | `new HttpAdapter({ url, batchSize })` | | `CallbackAdapter` | Custom per-event logic | `new CallbackAdapter(fn)` | | `CompositeAdapter` | Fan-out to multiple adapters | `new CompositeAdapter([...adapters])` | | `StreamAdapter` | Node.js Writable streams | `new StreamAdapter(stream)` | ## ConsoleAdapter Prints each event as pretty-printed JSON to stdout. Default adapter when none is specified. ```ts import { generate, ConsoleAdapter } from '@synode/core'; await generate(journey, { users: 10, adapter: new ConsoleAdapter(), }); ``` ## InMemoryAdapter Stores events in an array. Access via `adapter.events`. Call `adapter.clear()` to reset. ```ts import { generate, InMemoryAdapter } from '@synode/core'; const adapter = new InMemoryAdapter(); await generate(journey, { users: 50, adapter }); console.log(adapter.events.length); // total event count console.log(adapter.events[0].name); // first event name adapter.clear(); // reset for next run ``` ## FileAdapter Writes events to disk. Supports three formats and optional daily partitioning. ```ts import { generate } from '@synode/core'; import { FileAdapter } from '@synode/adapter-file'; // JSONL: one JSON object per line, appended per event const jsonl = new FileAdapter({ path: './out/events.jsonl', format: 'jsonl' }); // JSON: buffered array, written on close const json = new FileAdapter({ path: './out/events.json', format: 'json' }); // CSV: header + rows, appended per event const csv = new FileAdapter({ path: './out/events.csv', format: 'csv' }); await generate(journey, { users: 1000, adapter: jsonl }); ``` ### Daily Partitioning Split output into date-based files using the event timestamp. ```ts const adapter = new FileAdapter({ path: './out', // base directory for partitioned files format: 'csv', partition: 'daily', filePattern: 'events-{date}.{ext}', // default pattern }); // Creates: ./out/events-2026-01-15.csv, ./out/events-2026-01-16.csv, etc. ``` Options: `path` (required), `format` (`'jsonl'|'json'|'csv'`, required), `partition` (`'daily'|'none'`, default `'none'`), `filePattern` (template with `{date}` and `{ext}`, default `'events-{date}.{ext}'`). ## HttpAdapter Sends events to an HTTP endpoint with batching, flush intervals, and retry with exponential backoff. ```ts import { generate } from '@synode/core'; import { HttpAdapter } from '@synode/adapter-http'; const adapter = new HttpAdapter({ url: 'https://api.example.com/ingest', batchSize: 50, flushInterval: 3000, maxRetries: 3, headers: { Authorization: 'Bearer token-123' }, }); await generate(journey, { users: 5000, adapter }); await adapter.close(); // flush remaining buffer ``` Options: `method` (`'POST'|'PUT'`, default `'POST'`), `headers`, `batchSize` (default `1`), `flushInterval` (ms, default `5000`), `maxRetries` (default `3`), `transform` (custom body shape function). Retries with exponential backoff on 5xx/429. ## CallbackAdapter Invokes a function for each event. Supports sync and async callbacks. ```ts import { generate, CallbackAdapter } from '@synode/core'; const adapter = new CallbackAdapter(async (event) => { await db.insert('events', { name: event.name, userId: event.userId, payload: event.payload }); }); await generate(journey, { users: 100, adapter }); ``` ## CompositeAdapter Fans out each event to multiple child adapters in parallel. Calls `close()` on all children. ```ts import { generate, ConsoleAdapter } from '@synode/core'; import { FileAdapter } from '@synode/adapter-file'; import { HttpAdapter } from '@synode/adapter-http'; import { CompositeAdapter } from '@synode/adapter-composite'; const adapter = new CompositeAdapter([ new ConsoleAdapter(), new FileAdapter({ path: './out/events.jsonl', format: 'jsonl' }), new HttpAdapter({ url: 'https://api.example.com/ingest', batchSize: 50 }), ]); await generate(journey, { users: 1000, adapter }); await adapter.close(); // closes all children ``` ## StreamAdapter Writes events to any Node.js `Writable` stream. Default format `jsonl` streams per event; `json` buffers and writes on close. ```ts import { createWriteStream } from 'node:fs'; import { generate } from '@synode/core'; import { StreamAdapter } from '@synode/adapter-stream'; const stream = createWriteStream('./events.jsonl'); const adapter = new StreamAdapter(stream); await generate(journey, { users: 500, adapter }); await adapter.close(); ``` ## close() Lifecycle Adapters with buffered state (`FileAdapter` JSON mode, `HttpAdapter`, `StreamAdapter`) require `close()` to flush. `generate` calls `close()` automatically after all users are processed. If using an adapter outside `generate`, call `close()` manually. --- # Event Schema Validation Synode validates generated events against Zod schemas before they reach the output adapter. This catches data quality issues during generation rather than downstream. ```mermaid graph LR Handler[Action Handler] --> |"Event[]"| Validate{Schema Validation} Validate --> |valid| Write[adapter.write] Validate --> |"strict: throw"| Error[SynodeValidationError] Validate --> |"warn: log + write"| Write Validate --> |"skip: drop"| Drop[Event dropped] style Handler fill:#fab957,stroke:#d49e4b,color:#1b1b1f style Validate fill:#8b5cf6,stroke:#7c3aed,color:#fff style Write fill:#22c55e,stroke:#16a34a,color:#fff style Error fill:#ef4444,stroke:#dc2626,color:#fff style Drop fill:#f59e0b,stroke:#d97706,color:#fff ``` ## Defining Event Schemas Use `defineEventSchema` to create a Zod object schema for an event's payload. ```ts import { z } from 'zod'; import { defineEventSchema } from '@synode/core'; const pageViewSchema = defineEventSchema({ url: z.string().url(), title: z.string().min(1), referrer: z.string().optional(), }); const purchaseSchema = defineEventSchema({ orderId: z.string(), total: z.number().positive(), currency: z.enum(['USD', 'EUR', 'GBP']), items: z.array( z.object({ productId: z.string(), quantity: z.number().int().positive(), }), ), }); ``` `defineEventSchema` is a thin wrapper around `z.object()`. You can also pass any `z.ZodType` directly. ## Configuring Validation Pass an `EventSchemaConfig` to `generate` via the `eventSchema` option. ### Single Schema (All Events) Apply one schema to every event: ```ts import { generate } from '@synode/core'; await generate(journey, { users: 1000, eventSchema: { schema: pageViewSchema, mode: 'strict', }, }); ``` ### Per-Event-Name Schema Map Map event names to their specific schemas. Events not in the map pass through without validation. ```ts await generate(journeys, { users: 1000, eventSchema: { schema: { page_view: pageViewSchema, purchase: purchaseSchema, add_to_cart: addToCartSchema, }, mode: 'warn', }, }); ``` ## Validation Modes | Mode | On Failure | Effect | | ---------- | ------------------------------ | --------------------------------------------- | | `'strict'` | Throws `SynodeValidationError` | Generation stops immediately | | `'warn'` | Logs summary, keeps event | Event reaches adapter, failure recorded | | `'skip'` | Drops event silently | Event never reaches adapter, failure recorded | Default mode is `'strict'`. - **strict**: Throws `SynodeValidationError` on first invalid event. Best for development. - **warn**: Keeps all events, prints summary to stderr. Best for staging/CI. - **skip**: Drops invalid events silently. Best for producing clean output. ## SynodeValidationError Thrown in strict mode. Contains the failed event and structured validation issues. ```ts import { SynodeValidationError } from '@synode/core'; try { await generate(journey, { users: 100, eventSchema: { schema: purchaseSchema, mode: 'strict' }, }); } catch (err) { if (err instanceof SynodeValidationError) { console.error('Event:', err.event.name); console.error('Issues:', err.issues); // issues: [{ path: ['total'], message: 'Expected number, received string', code: 'invalid_type' }] } } ``` ### ValidationIssue Shape ```ts interface ValidationIssue { path: (string | number)[]; // field path in the payload message: string; // human-readable error code: string; // Zod error code } ``` ## Telemetry Integration When both `debug: true` and `eventSchema` are configured, the validation summary is included in the telemetry report. ```ts await generate(journey, { users: 5000, eventSchema: { schema: { page_view: pageViewSchema, purchase: purchaseSchema }, mode: 'warn', }, debug: true, telemetryPath: './telemetry.json', }); ``` The telemetry report includes: ```json { "validation": { "eventsValidated": 5000, "eventsValid": 4850, "eventsInvalid": 150, "validationErrors": [ { "eventName": "purchase", "path": "total", "message": "Expected number, received string" } ] } } ``` Validation errors are capped at 50 entries to prevent unbounded memory growth. --- # CLI Usage Synode includes a CLI for generating synthetic data from config files. Three commands: `generate`, `validate`, and `init`. ## Quick Start ```bash # Scaffold a starter config npx synode init # Edit synode.config.ts to define your journeys # Generate data npx synode generate synode.config.ts # Validate config without generating npx synode validate synode.config.ts ``` ## Commands ### `generate ` Loads the config file and runs event generation. ```bash npx synode generate synode.config.ts npx synode generate synode.config.ts --users 5000 --lanes 4 npx synode generate synode.config.ts --output ./out/events.jsonl --format jsonl npx synode generate synode.config.ts --workers 8 --debug ``` ### `validate ` Validates the config file structure without generating any events. Exits with code 0 on success, 1 on failure. ```bash npx synode validate synode.config.ts ``` ### `init` Creates a `synode.config.ts` starter file in the current directory. Fails if the file already exists. ```bash npx synode init ``` ## CLI Flags | Flag | Short | Type | Description | | ----------- | ----- | --------- | ------------------------------------- | | `--users` | `-u` | `number` | Override user count | | `--lanes` | `-l` | `number` | Override lane count | | `--workers` | `-w` | `number` | Enable worker threads with N workers | | `--output` | `-o` | `string` | Output file path (default: stdout) | | `--format` | `-f` | `string` | Output format: `json`, `jsonl`, `csv` | | `--debug` | | `boolean` | Enable telemetry collection | | `--dry-run` | | `boolean` | Validate config, generate 1 user only | | `--quiet` | `-q` | `boolean` | Suppress progress output | | `--help` | `-h` | `boolean` | Show usage | CLI flags override config file values. ## Config File Format The config file must export (default or named) a `SynodeConfig` object. Run `npx synode init` to scaffold a starter config, then customize it. ```ts import { defineJourney, definePersona, weighted } from '@synode/core'; import type { SynodeConfig } from '@synode/cli'; const config: SynodeConfig = { journeys: [browseJourney, purchaseJourney], persona: myPersona, datasets: [productsDef], options: { users: 1000, lanes: 4, debug: true, adapter: { type: 'file', path: './out/events.jsonl', format: 'jsonl' }, }, }; export default config; ``` ### SynodeConfig Shape ```ts interface SynodeConfig { journeys: Journey[]; // required, non-empty persona?: PersonaDefinition; datasets?: DatasetDefinition[]; preloadedDatasets?: Dataset[]; options?: { users?: number; lanes?: number; adapter?: AdapterConfig; // { type: 'file'|'console', path?, format? } debug?: boolean; telemetryPath?: string; startDate?: Date; endDate?: Date; eventSchema?: EventSchemaConfig; workerModule?: string; workers?: number; }; } ``` ### AdapterConfig The CLI supports two adapter types via config: ```ts // Console output (default) adapter: { type: 'console' } // File output adapter: { type: 'file', path: './out/events.jsonl', format: 'jsonl' } ``` When `--output` is passed as a flag, it creates a file adapter. Format is inferred from the file extension or from the `--format` flag. ## Example Workflow ```bash # 1. Scaffold config npx synode init # 2. Edit synode.config.ts with your journeys, persona, datasets # 3. Dry run to validate npx synode generate synode.config.ts --dry-run # 4. Generate small sample npx synode generate synode.config.ts --users 100 --output ./sample.jsonl # 5. Full generation with parallelism npx synode generate synode.config.ts --users 50000 --lanes 8 --output ./out/events.jsonl # 6. Validate config structure npx synode validate synode.config.ts # 7. Worker thread generation npx synode generate synode.config.ts --users 100000 --workers 4 --debug ``` --- # Building Custom Adapters Implement the `OutputAdapter` interface to route events to any destination: databases, message queues, cloud storage, or custom pipelines. ## OutputAdapter Interface ```ts interface OutputAdapter { write(event: Event): Promise | void; close?(): Promise | void; } ``` - `write(event)` -- called once per generated event. Can be sync or async. - `close()` -- optional. Called once after all events are written. Use for flushing buffers, closing connections, releasing resources. ## Example: Database Adapter Insert events directly into a database. ```ts import type { OutputAdapter, Event } from '@synode/core'; interface DatabaseAdapterOptions { connectionString: string; tableName: string; batchSize?: number; } class DatabaseAdapter implements OutputAdapter { private buffer: Event[] = []; private readonly batchSize: number; private db: DatabaseConnection; constructor(private options: DatabaseAdapterOptions) { this.batchSize = options.batchSize ?? 100; this.db = createConnection(options.connectionString); } async write(event: Event): Promise { this.buffer.push(event); if (this.buffer.length >= this.batchSize) { await this.flush(); } } async close(): Promise { if (this.buffer.length > 0) { await this.flush(); } await this.db.close(); } private async flush(): Promise { const batch = this.buffer; this.buffer = []; await this.db.insertMany( this.options.tableName, batch.map((e) => ({ id: e.id, user_id: e.userId, event_name: e.name, timestamp: e.timestamp, payload: JSON.stringify(e.payload), })), ); } } ``` Usage: ```ts import { generate } from '@synode/core'; const adapter = new DatabaseAdapter({ connectionString: 'postgres://localhost:5432/analytics', tableName: 'events', batchSize: 500, }); await generate(journey, { users: 10000, adapter }); // close() is called automatically by generate ``` ## Example: Filter Adapter Wraps another adapter and filters events based on a predicate. Demonstrates the decorator pattern. ```ts import type { OutputAdapter, Event } from '@synode/core'; class FilterAdapter implements OutputAdapter { constructor( private inner: OutputAdapter, private predicate: (event: Event) => boolean, ) {} async write(event: Event): Promise { if (this.predicate(event)) { await this.inner.write(event); } } async close(): Promise { await this.inner.close?.(); } } ``` Usage: ```ts import { FileAdapter } from '@synode/adapter-file'; // Only write purchase events to file const adapter = new FilterAdapter( new FileAdapter({ path: './purchases.jsonl', format: 'jsonl' }), (event) => event.name === 'purchase', ); await generate(journeys, { users: 5000, adapter }); ``` ## Composing Custom Adapters Use `CompositeAdapter` to combine custom adapters with built-in ones. ```ts import { CompositeAdapter } from '@synode/adapter-composite'; import { FileAdapter } from '@synode/adapter-file'; const adapter = new CompositeAdapter([ new DatabaseAdapter({ connectionString: '...', tableName: 'events' }), new FileAdapter({ path: './backup.jsonl', format: 'jsonl' }), new CountingAdapter(), ]); await generate(journey, { users: 10000, adapter }); ``` ## close() Lifecycle The `generate` function calls `adapter.close()` automatically after all users are processed. If you use an adapter outside of `generate`, call `close()` manually. Key patterns for `close()`: - Flush any buffered data - Close database connections, file handles, network sockets - Log summary statistics - Release resources --- # BigQuery Adapter Import datasets from BigQuery and export generated events back to BigQuery. ## Install ```bash npm install @synode/adapter-bigquery @google-cloud/bigquery ``` Both `@synode/core` and `@google-cloud/bigquery` are peer dependencies. ## Exporting Events Write generated events to a BigQuery table using `BigQueryAdapter`: ```typescript import { BigQueryAdapter } from '@synode/adapter-bigquery'; import { generate, defineJourney, defineAdventure, defineAction } from '@synode/core'; const adapter = new BigQueryAdapter({ projectId: 'my-gcp-project', datasetId: 'analytics', tableId: 'events', batchSize: 200, flushInterval: 3000, }); await generate(journey, { users: 1000, adapter }); ``` ### BigQueryAdapterOptions | Option | Type | Default | Description | |---|---|---|---| | `projectId` | `string` | required | GCP project ID | | `datasetId` | `string` | required | BigQuery dataset ID | | `tableId` | `string` | required | BigQuery table ID | | `batchSize` | `number` | `100` | Events to buffer before inserting | | `flushInterval` | `number` | `5000` | Max ms before flushing partial batch | | `autoCreateTable` | `boolean` | `false` | Create table if missing | | `transform` | `(row) => row` | none | Transform each row before insert | ### Row Format Events are serialized as flat rows: | Column | Type | Source | |---|---|---| | `id` | `STRING` | `event.id` | | `user_id` | `STRING` | `event.userId` | | `session_id` | `STRING` | `event.sessionId` | | `name` | `STRING` | `event.name` | | `timestamp` | `STRING` | `event.timestamp` (ISO 8601) | | `payload` | `STRING` | `JSON.stringify(event.payload)` | Use `transform` to customize the schema. ## Importing Datasets Load a BigQuery table as a synode dataset for use during generation: ```typescript import { importFromBigQuery } from '@synode/adapter-bigquery'; import { generate } from '@synode/core'; const products = await importFromBigQuery({ projectId: 'my-gcp-project', datasetId: 'ecommerce', tableId: 'products', id: 'products', name: 'Product Catalog', where: 'active = true', limit: 5000, }); await generate(journey, { users: 1000, preloadedDatasets: [products], adapter, }); ``` ### BigQueryImportOptions | Option | Type | Default | Description | |---|---|---|---| | `projectId` | `string` | required | GCP project ID | | `datasetId` | `string` | required | BigQuery dataset ID | | `tableId` | `string` | required | Source table | | `id` | `string` | required | Synode dataset ID | | `name` | `string` | required | Synode dataset name | | `where` | `string` | none | SQL WHERE clause | | `limit` | `number` | unlimited | Max rows to import | --- # Types Module > Source: `packages/core/src/core/types.ts` Core type definitions for the Synode domain model. All types are re-exported from the package root (`import { Event, Journey, ... } from '@synode/core'`). ## Event The atomic output unit -- a single tracking event generated by an action handler. ```typescript interface Event { id: string; userId: string; sessionId: string; name: string; timestamp: Date; payload: Record; } ``` ## Context Per-user execution context passed to every action handler. Provides access to faker, state, timing, datasets, and identity. ```typescript interface Context { readonly userId: string; readonly sessionId: string; readonly locale: string; readonly faker: Faker; get(key: string): T | undefined; set(key: string, value: T, options?: ContextSetOptions): void; now(): Date; generateId(prefix?: string): string; hasCompletedJourney(journeyId: string): boolean; markJourneyComplete(journeyId: string): void; dataset(id: string): DatasetHandle; typedDataset(id: string): DatasetHandle; } ``` ## ContextScope Lifecycle scope for automatic field cleanup: `'action' | 'adventure' | 'journey'`. ## ContextSetOptions Options passed to `ctx.set()` for scoped fields. ```typescript interface ContextSetOptions { scope?: ContextScope; } ``` ## Journey Top-level behavioral flow containing adventures. ```typescript interface Journey { id: string; name: string; requires?: string[]; adventures: Adventure[]; bounceChance?: number; // 0-1 suppressionPeriod?: SuppressionPeriod; } ``` ## Adventure Session or interaction period containing actions. Runs sequentially within a journey. ```typescript interface Adventure { id: string; name: string; actions: Action[]; timeSpan?: TimeSpan; bounceChance?: number; // 0-1 onBounce?: 'stop' | 'skip'; } ``` ## Action A resolved action with a handler function. Created by `defineAction()`. ```typescript interface Action { id: string; name: string; handler: (context: Context) => Event[] | Promise; timeSpan?: TimeSpan; bounceChance?: number; } ``` ## ActionDefinition Configuration object accepted by `defineAction()`. Supports either `fields` (declarative) or `handler` (imperative). When `handler` is provided, `fields` is ignored. ```typescript interface ActionDefinition { id: string; name: string; fields?: Record; handler?: (context: Context) => Event[] | Promise; timeSpan?: TimeSpan; bounceChance?: number; } ``` ## TimeSpan Delay configuration between actions or events (milliseconds). ```typescript interface TimeSpan { min: number; max: number; distribution?: 'uniform' | 'gaussian' | 'exponential'; } ``` ## SuppressionPeriod Cooldown after journey completion or bounce (milliseconds). ```typescript interface SuppressionPeriod { min: number; max: number; } ``` ## DatasetDefinition\ Configuration for `defineDataset()`. Fields can be static values or generator functions. ```typescript interface DatasetDefinition> { id: string; name: string; count: number; // 0 to 10,000,000 fields: { [K in keyof TFields]: | TFields[K] | ((ctx: Context, row: { index: number; data: DatasetRow }) => TFields[K]); }; } ``` ## Dataset\ A hydrated dataset with an id, name, and array of rows. ## DatasetRow Alias for `Record`. ## DatasetHandle\ Handle returned by `ctx.dataset()` and `ctx.typedDataset()`. ```typescript interface DatasetHandle { randomRow(): TRow; getRowById(id: string | number): TRow | undefined; getRowByIndex(index: number): TRow | undefined; getAllRows(): TRow[]; size(): number; } ``` ## InferDatasetRow\ Utility type that extracts the row type from a `DatasetDefinition`, unwrapping generator functions and promises. ```typescript const def = defineDataset({ id: 'x', name: 'X', count: 10, fields: { price: (ctx) => ctx.faker.number.float({ min: 1, max: 99 }) }, }); type Row = InferDatasetRow; // { price: number } ``` ## FieldGenerator\ A static value or `(context: Context, payload: Record) => T | Promise`. ## DatasetFieldGenerator\ Like `FieldGenerator` but receives `{ index, data }` instead of payload. ## EventSchemaConfig Configures Zod-based event validation in `RunOptions.eventSchema`. ```typescript interface EventSchemaConfig { schema: z.ZodType | Record; mode?: EventValidationMode; // default 'strict' } ``` ## EventValidationMode `'strict' | 'warn' | 'skip'` -- controls behavior on schema validation failure. --- # Builder Functions > Source: `packages/core/src/core/generators/builder.ts`, `packages/core/src/core/generators/fields.ts`, > `packages/core/src/core/generators/persona.ts`, `packages/core/src/core/generators/dataset.ts`, > `packages/core/src/core/monitoring/event-validation.ts` All builder functions are identity functions that exist for TypeScript type narrowing and editor IntelliSense. They return their input unchanged (except `defineAction`, which compiles `fields` into a handler when no custom handler is provided). ## defineJourney ```typescript function defineJourney(config: Journey): Journey; ``` Defines a journey. See [types.md](./types.md) for the `Journey` interface. ```typescript const journey = defineJourney({ id: 'onboarding', name: 'Onboarding', adventures: [welcomeAdventure], }); ``` ## defineAdventure ```typescript function defineAdventure(config: Adventure): Adventure; ``` Defines an adventure (session/interaction period within a journey). ```typescript const adventure = defineAdventure({ id: 'browse', name: 'Browse Pages', timeSpan: { min: 500, max: 3000 }, actions: [viewHome, viewProduct], }); ``` ## defineAction ```typescript function defineAction(config: ActionDefinition): Action; ``` Defines an action. Accepts either a `fields` map (compiled to a single-event handler) or a custom `handler` function that returns `Event[]`. ```typescript // Declarative (fields) const viewHome = defineAction({ id: 'view-home', name: 'page_view', fields: { page: '/home', referrer: (ctx) => ctx.faker.internet.url() }, }); // Imperative (handler) const addToCart = defineAction({ id: 'add-to-cart', name: 'add_to_cart', handler: (ctx) => { const product = ctx.typedDataset('products').randomRow(); return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'add_to_cart', timestamp: ctx.now(), payload: { productId: product.id, price: product.price }, }, ]; }, }); ``` ## definePersona ```typescript function definePersona(config: PersonaDefinition): PersonaDefinition; ``` Defines a persona with weighted attribute distributions. ```typescript const shoppers = definePersona({ id: 'shoppers', name: 'Online Shoppers', attributes: { locale: weighted({ en: 0.6, de: 0.2, fr: 0.2 }), tier: weighted({ free: 0.7, pro: 0.2, enterprise: 0.1 }), age: fake((f) => f.number.int({ min: 18, max: 65 })), device: oneOf(['mobile', 'desktop', 'tablet']), }, }); ``` The `PersonaDefinition` interface: ```typescript interface PersonaDefinition { id: string; name: string; attributes: Record; } ``` ## defineDataset ```typescript function defineDataset(config: DatasetDefinition): DatasetDefinition; ``` Defines a dataset. Use `InferDatasetRow` to extract the row type. ```typescript const productsDef = defineDataset({ id: 'products', name: 'Products', count: 200, fields: { id: (_ctx, row) => `prod-${row.index}`, name: (ctx) => ctx.faker.commerce.productName(), price: (ctx) => ctx.faker.number.float({ min: 10, max: 500 }), }, }); type Product = InferDatasetRow; ``` ## defineEventSchema ```typescript function defineEventSchema(shape: T): z.ZodObject; ``` Convenience wrapper around `z.object()` for defining event payload schemas. Used with `RunOptions.eventSchema` for runtime validation. ```typescript import { z } from 'zod'; import { defineEventSchema } from '@synode/core'; const pageViewSchema = defineEventSchema({ url: z.string().url(), referrer: z.string().optional(), }); ``` ## Field Helpers ### oneOf ```typescript function oneOf(options: T[]): FieldGenerator; ``` Returns one of the provided options at random with equal probability. ### weighted ```typescript function weighted(options: Record): FieldGenerator; ``` Returns a value based on weighted probabilities. Weights are normalized if they do not sum to 1. ### chance ```typescript function chance(probability: number): FieldGenerator; ``` Returns `true` with the given probability (0-1). ### fake ```typescript function fake(generator: (faker: Faker) => T): FieldGenerator; ``` Returns a value generated by the context's locale-aware Faker instance. --- # Execution > Source: `packages/core/src/core/execution/runner.ts`, `packages/core/src/core/execution/engine.ts`, > `packages/core/src/core/state/context.ts`, `packages/core/src/core/execution/pool.ts`, `packages/core/src/core/execution/worker.ts` ## generate ```typescript async function generate(journey: Journey | Journey[], options: RunOptions): Promise; ``` Main entry point for synthetic data generation. Accepts a single journey or an array. Orchestrates dataset hydration, user creation, and event output through the configured adapter. ### RunOptions ```typescript interface RunOptions { users: number; // Total users to simulate (0-10M) persona?: PersonaDefinition; // Persona for user attribute generation datasets?: DatasetDefinition[]; // Definitions to hydrate before execution preloadedDatasets?: Dataset[]; // Pre-populated datasets to inject lanes?: number; // Concurrent async lanes (default: 1) adapter?: OutputAdapter; // Output destination (default: ConsoleAdapter) debug?: boolean; // Enable telemetry (default: false) telemetryPath?: string; // Telemetry output path (default: './telemetry-report.json') startDate?: Date; // Start of date range (requires endDate) endDate?: Date; // End of date range (requires startDate) eventSchema?: EventSchemaConfig; // Zod schema validation for events workerModule?: string; // Module path for worker thread parallelism workers?: number; // Worker thread count (default: CPU cores) } ``` **Execution modes:** | Configuration | Mode | Details | | ------------------ | -------------- | ------------------------------------------------ | | Default | Sequential | Single-threaded, one user at a time | | `lanes > 1` | Parallel lanes | `Promise.all` concurrency in the main thread | | `workerModule` set | Worker threads | True multi-core parallelism via `worker_threads` | ```typescript import { generate, InMemoryAdapter } from '@synode/core'; const adapter = new InMemoryAdapter(); await generate(journey, { users: 1000, lanes: 4, persona: shoppers, datasets: [productsDef], adapter, }); ``` ## Engine Internal class that executes a single journey against a context, yielding events as an async generator. Documented for advanced use cases (custom runners, testing). ```typescript class Engine { constructor(journey: Journey); async *run(context?: SynodeContext): AsyncGenerator; } ``` The engine handles: - Prerequisite checks (`journey.requires`) - Journey-level and adventure-level bounce evaluation - Sequential adventure and action execution - Scope cleanup (`action`, `adventure`, `journey`) after each phase - Suppression period delays - Wrapping handler errors in `SynodeError` with code `HANDLER_ERROR` - Validating handler return type (must be `Event[]`) ```typescript import { Engine, SynodeContext } from '@synode/core'; const engine = new Engine(journey); const ctx = new SynodeContext(); for await (const event of engine.run(ctx)) { console.log(event.name, event.timestamp); } ``` ## SynodeContext Implementation of the `Context` interface. Manages per-user state, timing, datasets, and identity. ```typescript class SynodeContext implements Context { constructor(startTime?: Date, idGenerator?: IdGenerator, locale?: string); // Context interface methods get userId(): string; get sessionId(): string; get faker(): Faker; readonly locale: string; get(key: string): T | undefined; set(key: string, value: T, options?: ContextSetOptions): void; now(): Date; generateId(prefix?: string): string; hasCompletedJourney(journeyId: string): boolean; markJourneyComplete(journeyId: string): void; dataset(id: string): DatasetHandle; typedDataset(id: string): DatasetHandle; // Internal methods (used by Engine) registerDataset(dataset: Dataset): void; advanceTime(ms: number): void; rotateSession(): void; clearScope(scope: ContextScope): void; } ``` Key behaviors: - `set()` with a scope means the field is auto-cleared when that scope ends - `dataset()` / `typedDataset()` throw `SynodeError` (`DATASET_NOT_FOUND`) with "Did you mean..." suggestions - `now()` returns a copy of the internal clock (safe to mutate) - `rotateSession()` generates a new session ID (called per journey) ## WorkerPool Internal class that manages worker threads for true multi-core parallelism. Used automatically when `workerModule` is set in `RunOptions`. ```typescript class WorkerPool { constructor(options: WorkerPoolOptions); async run(): Promise; } ``` Worker modules must export `{ journeys: Journey[] }` and optionally `persona`, `datasets`, and `preloadedDatasets`. Datasets are serialized via structured clone and rehydrated in each worker. --- # Adapters > Source: `packages/core/src/io/adapters/`, `packages/adapter-file/`, `packages/adapter-http/`, > `packages/adapter-stream/`, `packages/adapter-composite/` Adapters receive events one at a time during generation and route them to their final destination. All adapters implement the `OutputAdapter` interface. ## OutputAdapter ```typescript interface OutputAdapter { write(event: Event): Promise | void; close?(): Promise | void; } ``` `write()` is called for every generated event. `close()` is called once after all events have been written -- use it for flushing buffers, closing handles, or finalizing connections. ## ConsoleAdapter Writes events to stdout as pretty-printed JSON. Default adapter when none is specified. ```typescript import { generate, ConsoleAdapter } from '@synode/core'; await generate(journey, { users: 5, adapter: new ConsoleAdapter() }); ``` ## InMemoryAdapter Stores events in an in-memory array. Useful for testing and dry runs. ```typescript import { InMemoryAdapter } from '@synode/core'; const adapter = new InMemoryAdapter(); await generate(journey, { users: 10, adapter }); console.log(adapter.events.length); adapter.clear(); // reset stored events ``` **Properties:** `events: Event[]` (readonly array of captured events). **Methods:** `clear()` -- empties the stored events array. ## FileAdapter Writes events to the local filesystem. Supports JSONL, JSON, and CSV formats with optional daily partitioning. ```typescript import { FileAdapter } from '@synode/adapter-file'; const adapter = new FileAdapter({ path: './out/events.jsonl', format: 'jsonl', }); await generate(journey, { users: 100, adapter }); ``` ### FileAdapterOptions ```typescript interface FileAdapterOptions { path: string; // Output file or directory path format: 'jsonl' | 'json' | 'csv'; // Serialization format partition?: 'daily' | 'none'; // Partitioning strategy (default: 'none') filePattern?: string; // Template for partitioned names (default: 'events-{date}.{ext}') } ``` Partition placeholders: `{date}` (YYYY-MM-DD from event timestamp), `{ext}` (format extension). ## HttpAdapter Sends events to an HTTP endpoint with batching, retry, and exponential backoff. ```typescript import { HttpAdapter } from '@synode/adapter-http'; const adapter = new HttpAdapter({ url: 'https://api.example.com/events', batchSize: 10, headers: { Authorization: 'Bearer token' }, }); await generate(journey, { users: 50, adapter }); await adapter.close(); ``` ### HttpAdapterOptions ```typescript interface HttpAdapterOptions { url: string; // Target endpoint method?: 'POST' | 'PUT'; // HTTP method (default: 'POST') headers?: Record; // Merged with Content-Type: application/json batchSize?: number; // Events per request (default: 1) flushInterval?: number; // Flush timer in ms (default: 5000) maxRetries?: number; // Retries on 5xx/429 (default: 3) transform?: (events: Event[]) => unknown; // Custom payload transform } ``` Default payload shape: `{ events: [...] }`. Override with `transform`. ## CallbackAdapter Forwards each event to a user-supplied callback function. ```typescript import { CallbackAdapter } from '@synode/core'; const events: Event[] = []; const adapter = new CallbackAdapter((event) => events.push(event)); await generate(journey, { users: 10, adapter }); ``` Constructor: `new CallbackAdapter(callback: (event: Event) => void | Promise)`. ## CompositeAdapter Fans out events to multiple child adapters in parallel. ```typescript import { CompositeAdapter } from '@synode/adapter-composite'; import { FileAdapter } from '@synode/adapter-file'; import { HttpAdapter } from '@synode/adapter-http'; const adapter = new CompositeAdapter([ new FileAdapter({ path: './out/events.jsonl', format: 'jsonl' }), new HttpAdapter({ url: 'https://webhook.example.com/ingest' }), ]); await generate(journey, { users: 100, adapter }); await adapter.close(); // closes all children ``` Constructor: `new CompositeAdapter(adapters: OutputAdapter[])`. ## StreamAdapter Writes events to a Node.js `Writable` stream. ```typescript import { createWriteStream } from 'node:fs'; import { StreamAdapter } from '@synode/adapter-stream'; const stream = createWriteStream('./events.jsonl'); const adapter = new StreamAdapter(stream); await generate(journey, { users: 10, adapter }); await adapter.close(); ``` ### StreamAdapterOptions ```typescript interface StreamAdapterOptions { format?: 'json' | 'jsonl'; // default: 'jsonl' } ``` - **jsonl**: writes each event immediately as a single JSON line - **json**: buffers all events, writes a pretty-printed JSON array on `close()` Constructor: `new StreamAdapter(stream: Writable, options?: StreamAdapterOptions)`. --- # Validation > Source: `packages/core/src/core/monitoring/validation.ts`, `packages/core/src/core/monitoring/event-validation.ts`, > `packages/core/src/core/errors.ts` ## validateConfig ```typescript function validateConfig(config: Journey, allJourneys?: Journey[]): void; ``` Validates a journey configuration. Performs Zod schema validation first, then structural checks for bounce chances, time spans, suppression periods, and duplicate IDs. When `allJourneys` is provided, also validates cross-journey references and detects circular dependencies. Throws `ZodError` for schema failures or `SynodeError` for structural issues. ```typescript import { validateConfig } from '@synode/core'; // Single journey validateConfig(journey); // Cross-journey validation validateConfig(purchaseJourney, [browseJourney, purchaseJourney]); ``` ## dryRun ```typescript async function dryRun(journey: Journey, userCount?: number): Promise; ``` Validates the journey, then generates events for `userCount` users (default: 1) and returns them in memory. Useful for quick smoke tests without configuring an adapter. ```typescript import { dryRun } from '@synode/core'; const events = await dryRun(journey, 3); console.log(`Generated ${events.length} events for 3 users`); ``` ## SynodeError Structured error class for all Synode validation and runtime errors. Extends `Error`. ```typescript class SynodeError extends Error { readonly code: ErrorCode; readonly path: string[]; readonly suggestion: string | undefined; readonly rawMessage: string; readonly expected: string | undefined; readonly received: string | undefined; constructor(options: SynodeErrorOptions); format(): string; } ``` ### format() Returns a structured multi-line representation: ``` [INVALID_BOUNCE_CHANCE] Bounce chance must be between 0 and 1 Path: Journey 'Purchase Flow' > Adventure 'Checkout' Expected: 0 <= bounceChance <= 1 Received: 1.5 Fix: Use a decimal like 0.3, not 30 ``` ### SynodeErrorOptions ```typescript interface SynodeErrorOptions { code: ErrorCode; message: string; path: string[]; suggestion?: string; expected?: string; received?: string; cause?: unknown; } ``` ## ErrorCode Union type of all 13 error codes: | Code | Thrown when | | ---------------------------- | ------------------------------------------------ | | `INVALID_BOUNCE_CHANCE` | Bounce chance is outside 0-1 range | | `INVALID_TIME_SPAN` | TimeSpan min exceeds max | | `INVALID_SUPPRESSION_PERIOD` | Suppression period min exceeds max | | `UNKNOWN_JOURNEY_REF` | `requires` references a non-existent journey | | `CIRCULAR_DEPENDENCY` | Journey prerequisites form a cycle | | `DUPLICATE_ID` | Same ID used twice in adventures or actions | | `DATASET_NOT_FOUND` | `ctx.dataset()` called with unregistered ID | | `DATASET_EMPTY` | Dataset has zero rows when a row is requested | | `HANDLER_ERROR` | Unhandled exception inside an action handler | | `ADAPTER_WRITE_ERROR` | `OutputAdapter.write()` threw during output | | `INVALID_HANDLER_RETURN` | Action handler returned non-array value | | `TYPO_DETECTED` | Fuzzy match found a likely typo in an identifier | | `INVALID_DATASET_COUNT` | Dataset count is negative, non-finite, or > 10M | ## SynodeValidationError Error thrown when an event fails schema validation in strict mode. Extends `Error`. ```typescript class SynodeValidationError extends Error { readonly event: Event; readonly issues: ValidationIssue[]; constructor(options: SynodeValidationErrorOptions); } interface ValidationIssue { path: (string | number)[]; message: string; code: string; } ``` ## ValidationSummary Aggregate summary of event validation results, accumulated during generation. ```typescript interface ValidationSummary { eventsValidated: number; eventsValid: number; eventsInvalid: number; validationErrors: { eventName: string; path: string; message: string }[]; } ``` Errors are capped at 50 entries. Created internally by `createValidationSummary()`. ## defineEventSchema ```typescript function defineEventSchema(shape: T): z.ZodObject; ``` Convenience wrapper around `z.object()` for defining event payload schemas. ```typescript import { z } from 'zod'; import { defineEventSchema } from '@synode/core'; const addToCartSchema = defineEventSchema({ productId: z.string(), quantity: z.number().int().positive(), price: z.number().positive(), }); ``` --- # Telemetry > Source: `packages/core/src/core/monitoring/telemetry.ts` Telemetry provides per-second performance snapshots during generation runs. Enabled via `RunOptions.debug: true`. ## TelemetryCollector Internal class that captures timing, throughput, and user progress data. Created automatically when `debug: true` is set in `RunOptions`. ```typescript class TelemetryCollector { constructor(lanes: number); start(): void; stop(): void; recordEvent(): void; recordUserStarted(): void; recordUserCompleted(): void; recordValidationSummary(summary: ValidationSummary): void; getReport(): TelemetryReport; async saveReport(filePath: string): Promise; } ``` - `start()` begins a 1-second interval timer for snapshot capture - `stop()` clears the timer and captures a final snapshot - `saveReport()` writes the report as pretty-printed JSON to disk ```typescript import { generate, InMemoryAdapter } from '@synode/core'; await generate(journey, { users: 5000, lanes: 4, adapter: new InMemoryAdapter(), debug: true, telemetryPath: './perf-report.json', }); // Telemetry saved automatically to ./perf-report.json ``` ## TelemetryReport Complete report for a generation run. ```typescript interface TelemetryReport { startTime: string; // ISO 8601 endTime: string; // ISO 8601 durationMs: number; totalUsers: number; totalEvents: number; lanes: number; averageEventsPerSecond: number; activeUsers: number; completedUsers: number; eventsValidated: number; eventsValid: number; eventsInvalid: number; validationErrors: TelemetryValidationError[]; snapshots: TelemetrySnapshot[]; } ``` ## TelemetrySnapshot Per-second data point captured during generation. ```typescript interface TelemetrySnapshot { timestamp: string; // ISO 8601 elapsedMs: number; eventsPerSecond: number; totalEvents: number; activeUsers: number; completedUsers: number; lanes: number; } ``` ## TelemetryValidationError A single validation error entry recorded in the telemetry report. ```typescript interface TelemetryValidationError { eventName: string; path: string; message: string; } ``` Validation errors are capped at 50 entries per report. Errors are merged from `ValidationSummary` via `recordValidationSummary()`. --- # Cookbook Practical recipes for common Synode use cases. Each recipe is self-contained and can be copied directly into your project. ## E-commerce purchase funnel A multi-journey flow: signup, browse products, add to cart, purchase. The purchase journey requires browse to have completed first. ```typescript import { defineJourney, defineAdventure, defineAction, defineDataset, definePersona, generate, InMemoryAdapter, weighted, fake, oneOf, InferDatasetRow, } from '@synode/core'; const persona = definePersona({ id: 'shoppers', name: 'Shoppers', attributes: { locale: weighted({ en: 0.6, de: 0.2, fr: 0.2 }), device: oneOf(['mobile', 'desktop', 'tablet']), }, }); const productsDef = defineDataset({ id: 'products', name: 'Products', count: 100, fields: { id: (_ctx, row) => `prod-${row.index}`, name: (ctx) => ctx.faker.commerce.productName(), price: (ctx) => ctx.faker.number.float({ min: 10, max: 200 }), }, }); type Product = InferDatasetRow; const browse = defineJourney({ id: 'browse', name: 'Browse Products', adventures: [ defineAdventure({ id: 'view-products', name: 'View Products', timeSpan: { min: 1000, max: 5000 }, actions: [ defineAction({ id: 'view-product', name: 'product_viewed', handler: (ctx) => { const product = ctx.typedDataset('products').randomRow(); ctx.set('lastProduct', product, { scope: 'journey' }); return [ { id: ctx.generateId('event'), userId: ctx.userId, sessionId: ctx.sessionId, name: 'product_viewed', timestamp: ctx.now(), payload: { productId: product.id, price: product.price }, }, ]; }, }), ], }), ], }); const purchase = defineJourney({ id: 'purchase', name: 'Purchase', requires: ['browse'], bounceChance: 0.3, adventures: [ defineAdventure({ id: 'checkout', name: 'Checkout', actions: [ defineAction({ id: 'purchase', name: 'purchase_completed', fields: { total: (ctx) => ctx.faker.number.float({ min: 20, max: 500 }) }, }), ], }), ], }); const adapter = new InMemoryAdapter(); await generate([browse, purchase], { users: 1000, lanes: 4, persona, datasets: [productsDef], adapter, }); console.log(`Generated ${adapter.events.length} events`); ``` ## Historical data generation Generate 3 months of backdated events by specifying `startDate` and `endDate`. User start times are randomized within the range. ```typescript import { generate } from '@synode/core'; import { FileAdapter } from '@synode/adapter-file'; await generate(journey, { users: 5000, startDate: new Date('2026-01-01'), endDate: new Date('2026-03-31'), adapter: new FileAdapter({ path: './out/events.jsonl', format: 'jsonl' }), }); ``` ## Multi-adapter output Write events to a local file and forward them to an HTTP webhook simultaneously using `CompositeAdapter`. ```typescript import { generate } from '@synode/core'; import { FileAdapter } from '@synode/adapter-file'; import { HttpAdapter } from '@synode/adapter-http'; import { CompositeAdapter } from '@synode/adapter-composite'; const adapter = new CompositeAdapter([ new FileAdapter({ path: './out/events.jsonl', format: 'jsonl' }), new HttpAdapter({ url: 'https://webhook.example.com/ingest', batchSize: 50, headers: { Authorization: 'Bearer my-token' }, }), ]); await generate(journey, { users: 500, adapter }); await adapter.close(); ``` ## Dataset-only generation Generate datasets without running any journeys. Useful for seeding product catalogs, location tables, or other reference data. ```typescript import { generate, InMemoryAdapter, defineJourney, defineAdventure, defineAction, } from '@synode/core'; // Minimal no-op journey (required by generate) const noop = defineJourney({ id: 'noop', name: 'No-op', adventures: [ defineAdventure({ id: 'noop', name: 'No-op', actions: [defineAction({ id: 'noop', name: 'noop', fields: {} })], }), ], bounceChance: 1, // always bounces, no events generated }); const adapter = new InMemoryAdapter(); await generate(noop, { users: 1, datasets: [productsDef, locationsDef], adapter }); // adapter.events is empty, but datasets are hydrated ``` ## Dry-run testing Validate configuration and generate a single user to verify the journey produces expected events. ```typescript import { validateConfig, dryRun } from '@synode/core'; // Step 1: validate structure validateConfig(journey); // Step 2: generate 1 user and inspect const events = await dryRun(journey, 1); console.log(`Events: ${events.map((e) => e.name).join(', ')}`); ``` ## Schema validation in CI Run generation in strict mode so the pipeline fails on any schema violation. ```typescript import { z } from 'zod'; import { generate, InMemoryAdapter, defineEventSchema } from '@synode/core'; const pageViewSchema = defineEventSchema({ url: z.string().url(), referrer: z.string().optional(), }); const addToCartSchema = defineEventSchema({ productId: z.string(), quantity: z.number().int().positive(), price: z.number().positive(), }); try { await generate(journey, { users: 100, adapter: new InMemoryAdapter(), eventSchema: { schema: { page_view: pageViewSchema, add_to_cart: addToCartSchema, }, mode: 'strict', // throws SynodeValidationError on first failure }, }); console.log('All events passed validation'); } catch (err) { console.error('Schema validation failed:', err); process.exit(1); } ``` Use `mode: 'warn'` to log failures without halting, or `mode: 'skip'` to silently drop invalid events. --- # Migration Guide Upgrading from pre-1.0 to Synode v1.0. ## SynodeError replaces plain Error throws All validation and runtime errors now throw `SynodeError` instead of plain `Error`. Update catch blocks to use the structured fields: ```typescript // Before try { await generate(journey, opts); } catch (err) { console.error(err.message); } // After import { SynodeError } from '@synode/core'; try { await generate(journey, opts); } catch (err) { if (err instanceof SynodeError) { console.error(err.format()); // structured multi-line output console.error(err.code); // e.g. 'HANDLER_ERROR' console.error(err.path); // e.g. ['purchase', 'checkout', 'submit'] console.error(err.suggestion); // e.g. 'Check adapter connection' } } ``` ## validateConfig accepts optional allJourneys `validateConfig` now takes an optional second argument for cross-journey validation (unknown references, circular dependencies). Existing single-argument calls still work. ```typescript // Still works validateConfig(journey); // New: cross-journey checks validateConfig(purchaseJourney, [browseJourney, purchaseJourney]); ``` ## Adapters moved to separate packages Adapters are now in their own `@synode/adapter-*` packages. Core adapters (Console, InMemory, Callback) remain in `@synode/core`. ```typescript // Core adapters (Console, InMemory, Callback) import { InMemoryAdapter, ConsoleAdapter, CallbackAdapter } from '@synode/core'; // File, HTTP, Stream, Composite adapters are separate packages import { FileAdapter } from '@synode/adapter-file'; import { HttpAdapter } from '@synode/adapter-http'; import { StreamAdapter } from '@synode/adapter-stream'; import { CompositeAdapter } from '@synode/adapter-composite'; ``` ## New exports The following are now exported from the package root: - **Error system**: `SynodeError`, `ErrorCode`, `SynodeErrorOptions` - **Event validation**: `defineEventSchema`, `SynodeValidationError`, `ValidationSummary`, `ValidationIssue` - **Adapters**: `CallbackAdapter`, `CompositeAdapter`, `StreamAdapter`, `StreamAdapterOptions` - **Worker types**: `WorkerInit`, `WorkerMessage`, `SerializedDataset` - **CLI config types**: `SynodeConfig`, `AdapterConfig` (type-only exports) ## New RunOptions fields - `eventSchema` -- Zod-based event payload validation (see [validation docs](./api/validation.md)) - `workerModule` -- enables worker thread parallelism (see [execution docs](./api/execution.md)) - `workers` -- number of worker threads (defaults to CPU core count) ## CLI Synode now ships a CLI binary (`synode`). If you previously used custom scripts to run generation, consider migrating to the CLI or a `synode.config.ts` file: ```bash synode generate ./synode.config.ts --users 1000 --lanes 4 synode validate ./synode.config.ts synode init # scaffold a starter config ```