# CNStra > CNStra is a zero-dependency orchestration library for TypeScript/JavaScript. It models your application as a Central Nervous System (CNS): a graph of typed neurons connected by collaterals (output channels) and dendrites (input bindings). Flows become explicit, deterministic, and compile-time safe. Works in React, Node.js, and any JS runtime. **Full documentation as a single file:** [llms-full.txt](https://cnstra.org/llms-full.txt) — every page listed below, concatenated with full content (~260 KB). Fetch this for complete text instead of following links one by one. ## Getting Started - [Introduction](https://cnstra.org/docs/concepts/intro): Core concepts — neurons, axons, collaterals, signals, stimulation. Biological analogy and key design properties. - [Quick Start](https://cnstra.org/docs/core/quick-start): Install CNStra and run your first neuron in 5 minutes. - [CNStra Overview](https://cnstra.org/docs/core/overview): Why MVC/Redux/Signals fall short for workflows and how CNStra fixes it with explicit, traceable orchestration. - [Basics](https://cnstra.org/docs/concepts/basics): Single-process stimulation, command pattern with signal-based dependency wiring, and separation of mutations from reads. ## Core API - [API Reference](https://cnstra.org/docs/core/api): Full reference for `collateral`, `neuron`, `dendrite`, `bind`, `CNS`, `stimulate`, `withCtx`, signals, and context. - [Stimulation Options](https://cnstra.org/docs/core/stimulation-options): Options for `cns.stimulate()` — abort signals, timeouts, and execution control. ## Frontend - [OIMDB — Optimistic In-Memory Database](https://cnstra.org/docs/frontend/oimdb): CNStra's recommended state layer for React. Zero-overhead reactive reads without a global store. - [React Patterns](https://cnstra.org/docs/frontend/react-patterns): How to wire neurons into React components, hooks, and lifecycle. - [Redux Migration](https://cnstra.org/docs/frontend/redux-migration): Step-by-step guide for moving from Redux Toolkit to CNStra. - [Benchmark](https://cnstra.org/docs/frontend/benchmark): Performance comparison — CNStra+OIMDB vs Zustand, Redux Toolkit, Effector (execution time and memory). ## Backend - [Backend Overview](https://cnstra.org/docs/backend/overview): Using CNStra for backend workflows, pipelines, and domain events. - [CQRS](https://cnstra.org/docs/backend/cqrs): Implementing Command/Query Responsibility Segregation with neurons and collaterals. ## Advanced - [Persistence & Resume](https://cnstra.org/docs/advanced/persistence): How to use CNSPersistOptionsRegistry to serialize in-flight stimulations, persist to DB, and resume after process restart. Patterns for message brokers, checkpointing, and context restore. - [Best Practices](https://cnstra.org/docs/advanced/best-practices): Signal ownership, neuron granularity, naming conventions, and architectural guidelines. - [Performance](https://cnstra.org/docs/advanced/performance): Tips for high-throughput stimulations and memory-efficient neuron graphs. - [Custom Context Store](https://cnstra.org/docs/advanced/custom-context-store): Replacing the default per-stimulation context with your own implementation. - [Common Issues](https://cnstra.org/docs/advanced/common-issues): Troubleshooting guide for the most frequent mistakes. ## Recipes - [Cancel](https://cnstra.org/docs/recipes/cancel): Cancelling in-flight stimulations with AbortSignal. - [Retry](https://cnstra.org/docs/recipes/retry): Retry logic inside dendrites. - [Retry Stimulation](https://cnstra.org/docs/recipes/retry-stimulation): Re-stimulating the whole flow on failure. - [Error Handling](https://cnstra.org/docs/recipes/error-handling): Catching and routing errors in neuron graphs. - [Saga](https://cnstra.org/docs/recipes/saga): Long-running multi-step sagas with compensation. - [Multiple Signals](https://cnstra.org/docs/recipes/multiple-signals): Emitting multiple signals from one dendrite response. - [Response Listeners](https://cnstra.org/docs/recipes/response-listeners): Observing stimulation results without coupling neurons. - [Flow Inheritance](https://cnstra.org/docs/recipes/flow-inheritance): Passing parent flow context to child stimulations. - [Self-Loop Cycles](https://cnstra.org/docs/recipes/self-loop-cycles): Neurons that re-stimulate themselves. - [Stimulation Gate](https://cnstra.org/docs/recipes/stimulation-gate): Preventing stimulation queue overflow. - [Exhaustive Binding](https://cnstra.org/docs/recipes/exhaustive-binding): Using `neuron.bind()` for compile-time coverage of all collaterals. - [Testing](https://cnstra.org/docs/recipes/testing): Unit and integration testing patterns for neuron graphs. ## Integrations & Ecosystem - [Message Brokers](https://cnstra.org/docs/integrations/message-brokers): Integrating CNStra with RabbitMQ, BullMQ, SQS, and other queues. - [pg-boss (Postgres)](https://cnstra.org/docs/integrations/pg-boss): Run stimulations as Postgres-backed jobs with `@cnstra/pg-boss`; retries/backoff/scheduling, plus per-hop progress for a durable admin panel. - [Swift SDK](https://cnstra.org/docs/ecosystem/swift-sdk): CNStra for iOS/macOS applications. ## DevTools - [DevTools Overview](https://cnstra.org/docs/devtools/overview): Visual debugger for stimulation graphs. - [DevTools Integration](https://cnstra.org/docs/devtools/integration): Wiring `@cnstra/devtools` into your project. - [MCP Server](https://cnstra.org/docs/devtools/mcp): Give Claude Code and Cursor live access to the neuron graph via MCP tools (`cns_get_graph`, `cns_get_neuron`, `cns_list_collaterals`). Zero prod overhead. - [AI Graph Inspection](https://cnstra.org/docs/devtools/ai-inspection): Export the neuron graph and runtime history as Markdown so AI tools (Claude, Cursor) instantly understand your architecture. `dumpCNSGraph` + `CNSHistoryLogger`. - [DevTools Advanced](https://cnstra.org/docs/devtools/advanced): Custom transport, filtering, and production-safe setup. - [Download DevTools](https://cnstra.org/docs/devtools/download): Install the DevTools desktop app. ## Workflow Engine Comparison - [CNStra vs Workflow Engines](https://cnstra.org/docs/concepts/workflow-engine-comparison): How CNStra compares to Temporal, XState, Redux-Saga, and similar tools. --- # Source: https://cnstra.org/docs/concepts/intro Central Nervous System (CNS) for apps. Think of your application as an organism. Instead of a traditional event bus, you have a central nervous system that runs deterministic, typed reactions across a graph of neurons with ownership guarantees. This makes flows explicit, testable, and fast. Analogy: biology ↔ application - CNS (central nervous system) ↔ `CNS` orchestrator instance running the graph - Neuron ↔ a unit of logic that reacts to one input and produces one continuation - Dendrite (input) ↔ neuron.dendrite bound to a specific collateral - Axon (outputs) ↔ neuron’s `axon` with named output channels - Collateral (channel) ↔ typed output channel that mints signals - Signal (impulse) ↔ typed payload traveling the graph - Stimulation (nerve firing) ↔ `cns.stimulate(signal, options)` run - Context (local chemistry) ↔ per‑run context store passed to dendrites - Queues (conduction control) ↔ ordered/batched/parallel execution with backpressure Key properties - Deterministic routing: signal routing over the registered graph is reproducible for a given input + context (execution is reproducible when your dendrite handlers are deterministic); hop‑bounded; no hidden listeners - SRP by construction: actors are visible; responsibilities are local and explicit - Ownership: a neuron emits only its axon’s collaterals; others bind via dendrites Short vs long flows - Short‑lived (one stimulation): complete a bounded flow in a single run (validate → fetch → render), cancel with `AbortSignal` - Long‑lived (many stimulations): continue on external events (queue/webhook/cron) by re‑stimulating with correlation data Where to go next - Concepts: [CNStra Overview](/docs/core/overview) - Frontend: [CNStra & OIMDB](/docs/frontend/oimdb) - Core: [Quick Start](/docs/core/quick-start), [API](/docs/core/api) # Source: https://cnstra.org/docs/core/overview # CNStra: A New Generation of Application Orchestration ![Brain in Grass](/img/brain_in_grass.png) **In one line:** CNStra is a single-process orchestration engine for your app's *logic*. You model behavior as an explicit, typed graph of small, isolated handlers ("neurons") wired by named signals ("collaterals"). A signal propagates through the graph **deterministically**; every step is **traceable** (and a run can be **persisted and resumed**); and because every connection is a typed edge, the compiler shows you the blast radius of any change. It also gives you the knobs to control *how* a flow runs — concurrency, queues, retries, timeouts, cancellation — and stays **decoupled from storage and I/O**, so the same model runs on the backend and the frontend. It is not a state manager, not an event bus, and not a distributed system. The rest of this page shows *why* it's shaped this way — starting from where the usual patterns break. ## 1. MVC — Clean on Paper, Chaotic in Reality Modern apps are no longer CRUD. They are **workflows**: multi‑step sequences that must remain consistent across UI, onboarding, background sync, imports, batch jobs, and automation. But classic patterns treat flows as second‑class citizens. For example, on paper, MVC looks clean: * Controllers call model methods, * Models update data, * Views rerender. But the moment you create your first multi-step operation, the problems jump out at you. Flow: **"Create a deck, then create a card inside it."** ### Controller ```js function onCreateCardClick(req) { const deck = Deck.create({ title: req.deckTitle }); const card = Card.create({ deckId: deck.id, title: req.cardTitle }); } ``` ### Models ```js class Deck { static create(data) { return db.insert("decks", data); } } class Card { static create(data) { return db.insert("cards", data); } } ``` That's it. Neither model has any idea: * **why** it was created, * **who** initiated the sequence, * **what other operations** the flow includes, * **which parent flow** it belongs to. There is **no flow identity**, no **graph**, no **traceability**. And when business later says: * "We need the same operation during onboarding" * "And during import" —you now have **multiple scattered controller copies** of the same flow. Nothing ensures they stay consistent. **MVC collapses the moment flows stop being trivial.** --- ## 2. REDUX — MVC Problem Solved... Or Is It? Faced with the same "create a deck with a card" task, most people arrive at roughly this: ### Redux Toolkit reducers ```js import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'; const deckAdapter = createEntityAdapter(); const cardAdapter = createEntityAdapter(); const deckSlice = createSlice({ name: 'deck', initialState: deckAdapter.getInitialState(), reducers: { cardWithDeckCreated: (state, action) => { deckAdapter.addOne(state, action.payload.deck); }, }, }); const cardSlice = createSlice({ name: 'card', initialState: cardAdapter.getInitialState(), reducers: { cardWithDeckCreated: (state, action) => { cardAdapter.addOne(state, action.payload.card); }, }, }); ``` ### Flow thunk ```js export const createCardWithDeck = (deckTitle, cardTitle) => (dispatch) => { const deck = { id: nanoid(), title: deckTitle }; const card = { id: nanoid(), deckId: deck.id, title: cardTitle }; dispatch({ type: 'cardWithDeckCreated', payload: { deck, card }, }); }; ``` In essence, you've recreated MVC: the models (reducers) know nothing about the flow, and the controller (thunk) just calls them in sequence. Redux handles *independent* state updates well — the case where there's no flow to coordinate. Add a flow, though, and you're back to a thunk driving reducers by hand. But flows? Still invisible. --- ## 3. MODERN STATE MANAGERS — Amazing Reactivity, Zero Orchestration Zustand, Jotai, Recoil, MobX, Signals, Effector, Valtio… All fantastic tools — but they solve **reactivity**, not **flows**. Architecturally, they are **Reactive MVC**: * atoms/stores/signals = **Model**, * components = **View**, * async helpers = **ad-hoc controllers**. And exactly like MVC: * state units have no idea what flows they participate in, * controllers become an unstructured soup of async logic, * multi-step operations are just naked functions. ```ts createDeckAndCard(); ``` Then onboarding calls it, import calls it, test suite calls it, background sync calls it. Nothing describes a flow. Nothing guarantees consistency. Nothing warns you that you forgot to wire a subflow to a new parent. **Reactive state ≠ orchestration.** --- ## 4. CNSTRA — Flows Become Explicit, Traceable, and Safe ![CNStra Art](/img/cnstra_art.png) CNStra introduces something missing everywhere else: ### **Flows become real, typed orchestration nodes — not just functions.** Let's revisit Deck + Card and see how the same flow can be triggered: * from a **UI button** (createCardWithDeckButtonClicked), and * from an **onboarding flow** (userEntersApp), while keeping everything explicit. > **New to the vocabulary?** In one breath: a **neuron** is an isolated handler, a **collateral** is a named typed signal, a neuron's **axon** is the collaterals it may emit, a **dendrite** binds a handler to an incoming collateral, and **`stimulate`** fires a signal into the graph. See the full nervous-system analogy in the [Introduction](/docs/concepts/intro). ```ts import { CNS, collateral, neuron, withCtx, TCNSSignal } from '../src/index'; const uiAxon = { userEntersApp: collateral<{ userId: string; deckTitle: string; cardTitle: string; }>(), createCardWithDeckButtonClicked: collateral<{ deckTitle: string; cardTitle: string; }>(), }; const deckAxon = { createdAtUserEntersApp: collateral<{ deckId: string; cardTitle: string; userId: string; }>(), createdAtCreateCardWithDeckButtonClicked: collateral<{ deckId: string; cardTitle: string; }>(), }; const deck = neuron(deckAxon) .dendrite({ collateral: uiAxon.userEntersApp, response: (payload, axon) => { const deckId = 'deck-' + Math.random().toString(36).slice(2); return axon.createdAtUserEntersApp.createSignal({ deckId, cardTitle: payload.cardTitle, userId: payload.userId, }); }, }) .dendrite({ collateral: uiAxon.createCardWithDeckButtonClicked, response: (payload, axon) => { const deckId = 'deck-' + Math.random().toString(36).slice(2); return axon.createdAtCreateCardWithDeckButtonClicked.createSignal({ deckId, cardTitle: payload.cardTitle, }); }, }); const card = neuron({}) .dendrite({ collateral: deckAxon.createdAtCreateCardWithDeckButtonClicked, response: payload => { console.log('card title', payload.cardTitle); // create a card }, }) .dendrite({ collateral: deckAxon.createdAtUserEntersApp, response: payload => { console.log('card title', payload.cardTitle); // create a card }, }); const cns = new CNS([deck, card]); cns.stimulate( uiAxon.userEntersApp.createSignal({ userId: 'user-123', deckTitle: 'Deck 1', cardTitle: 'Card 1', }) ); cns.stimulate( uiAxon.createCardWithDeckButtonClicked.createSignal({ deckTitle: 'Deck 1', cardTitle: 'Card 1', }) ); ``` - Each collateral has a **named, typed identity**. - A neuron is expected to emit only signals declared on **its own axon** — a typed convention the compiler and graph make visible, rather than a runtime lock. - This prevents accidental emissions and keeps wiring explicit. - Even if two flows share logic, they stay **structurally distinct**. - Both flows produce the **same deterministic orchestration**, but each carries its **own flow identity**. You can build the same compile-time guarantees on top of MVC/Redux/Signals using discriminated unions, exhaustive switches, and event schemas — but that essentially means reimplementing CNStra yourself. The value here is having this as one cohesive, ready-made model rather than a hand-assembled one. --- ## 5. Exhaustive Binding — The Guarantee Everyone Else Lacks Every other architecture suffers from the same silent failure mode: > **Flows evolve. Subscribers don't. No one warns you.** CNStra eliminates this entire class of bugs. ### `neuron.bind(axon, handlers)` enforces EXHAUSTIVENESS ```ts const order = { created: collateral<...>(), updated: collateral<...>(), cancelled: collateral<...>(), }; const orderMailer = neuron({}) .bind(order, { created: (p) => sendCreated(p), updated: (p) => sendUpdated(p), cancelled: (p) => sendCancelled(p), }); ``` Then the domain evolves: ```ts order.refunded = collateral<...>(); ``` ### What happens? * MVC / Redux / Zustand / Jotai / MobX / Signals → **no warnings, silent bug**. * CNStra → **TypeScript error: missing handler for `refunded`**. You literally **cannot ship** an incomplete flow. --- ## 6. Benchmarks **📊 [View Full Benchmark Report](/docs/frontend/benchmark)** | **🔗 [Interactive Results](https://abaikov.github.io/cnstra-oimdb-bench/)** | **📦 [Source Code](https://github.com/abaikov/cnstra-oimdb-bench)** What we measured on three planes (one machine, fixed library versions — directional, not universal; see the full report): - **Under React (production build), fine-grained stores tie.** Cnstra + OIMDB, both MobX variants, and atomic Effector all land at ~33–36 µs/update — React's commit cost dominates and the small spread between them is noise. - **The clearest difference we saw is fine-grained vs coarse.** Coarse stores that copy the whole record and re-run all selectors (Effector-ids, Zustand, Redux Toolkit) were **~35–160× slower** under React (1230 / 2372 / 5430 µs/update). - **On the pure data layer (no React), OIMDB/Cnstra measured fastest** — 0.25–0.48 µs/update, roughly 2–3× faster than MobX in our runs, while coarse stores sat at 95–302 µs. The Cnstra orchestration on top of OIMDB read as a small fixed cost (≈ noise), not a multiplier. - **On memory, Cnstra/OIMDB had the lightest footprint we measured** — 25.8–28.1 MB steady-state heap (in-place is leanest). Atomic Effector was ~3.5× heavier (89.7 MB): a store + event per entity buys update speed with memory. Takeaway: switching between fine-grained stores won't change React throughput (React dominates) — pick on architecture, data-layer cost, and memory. In our setup Cnstra + OIMDB sat in the top tier under React, measured fastest on the data layer, **and** had the lightest footprint — though that's a single-machine snapshot, so verify on your own workload. --- ## Final Summary Most architectures: * expose state updates, * but keep flows **implicit**. That's why orchestration rots. CNStra flips the model: * flows become explicit (a typed graph of collaterals), not implicit call order, * parent/child chains are tracked automatically, so runs stay traceable, * compile-time exhaustiveness eliminates missing handlers, * execution is controllable (concurrency, queues, retries, timeouts) and can be persisted and resumed, * state stays in a data layer of your choice (e.g. OIMDB) — CNStra orchestrates, it doesn't store. This is not Flux 3.0 or "better Redux". ### An explicit orchestration layer for application logic — decoupled from storage and I/O, on the backend and the frontend alike. ![Eye](/img/eye.png) Same primitives, same guarantees, wherever it runs: * workflows, * pipelines, * background jobs, * domain events, * durable, resumable runs. If you want a deep-dive into: * [Introduction](/docs/concepts/intro) * [Quick Start](/docs/core/quick-start) * [Basics](/docs/concepts/basics) * [Best Practices](/docs/advanced/best-practices) # Source: https://cnstra.org/docs/core/quick-start ## Installation Install CNStra using your preferred package manager: ```bash # Core npm i @cnstra/core # Optional: React bindings npm i @cnstra/react # Optional: Devtools (for local debugging) npm i -D @cnstra/devtools @cnstra/devtools-server @cnstra/devtools-transport-ws ``` **Requirements:** - Node 18+ - TypeScript 5+ (recommended) ## Minimal Example ```ts import { CNS, collateral, neuron } from '@cnstra/core'; const userCreated = collateral<{ id: string; name: string }>(); const userRegistered = collateral<{ userId: string; status: string }>(); const userService = neuron({ userRegistered }).dendrite({ collateral: userCreated, response: (payload, axon) => { console.log(`Processing user: ${payload.name}`); return axon.userRegistered.createSignal({ userId: payload.id, status: 'completed' }); } }); const cns = new CNS([userService]); const stimulation = cns.stimulate(userCreated.createSignal({ id: '123', name: 'John Doe' })); await stimulation.waitUntilComplete(); ``` ## Next Steps - Understand the architecture: [CNStra Overview](/docs/core/overview) - Explore API: [Core API](/docs/core/api) - Use in React: [React Patterns](/docs/frontend/react-patterns) - Debugging: [DevTools](/docs/devtools/overview) # Source: https://cnstra.org/docs/core/api ### `collateral()` Create a typed output channel. ```ts const userEvent = collateral<{ userId: string }>(); const simpleEvent = collateral(); ``` ### `collateral.createSignal(payload?)` Create a signal from a collateral. Payload is optional for collaterals without payload type. ```ts const signal = userEvent.createSignal({ userId: '123' }); const emptySignal = simpleEvent.createSignal(); // no payload ``` ### `neuron(axon: Axon)` Create a neuron with the given axon (neuron identity is the object itself). ```ts const myNeuron = neuron({ output: myCollateral }); ``` ### Signal ownership ::::warning Signal ownership Recommended: a neuron should emit only collaterals declared in its own axon. Cross-neuron orchestration is typically done by having a controller own request collaterals and letting each domain neuron emit its own responses. :::: Incorrect (emits someone else's collateral): ```ts // DON'T: myNeuron emits otherAxon.some return otherAxon.some.createSignal(result); ``` Correct (controller-owned request, domain emits its own): ```ts const controller = neuron({ requestA }); const serviceA = neuron({ doneA }) .dendrite({ collateral: requestA, response: (_, axon) => axon.doneA.createSignal(...) }); // controller emits requestA; serviceA emits doneA ``` ### `neuron.dendrite({...})` Add a dendrite bound to a collateral. ```ts myNeuron.dendrite({ collateral: inputCollateral, response: async (payload, axon, ctx) => { // ctx: per-neuron per-stimulation context (metadata storage) if (ctx.abortSignal?.aborted) return; // Business data flows through payloads, not context return axon.output.createSignal(result); } }); ``` ### `neuron.bind(axon, map)` Exhaustive bind to every collateral of another neuron's axon (compile-time safety). ```ts // Context is for per-neuron per-stimulation metadata, not business data withCtx<{ attempt: number }>().neuron({}) .bind(order, { created: (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata const attempt = (ctx.get()?.attempt || 0) + 1; ctx.set({ attempt }); // Business data flows through payloads }, updated: (payload) => { /* ... */ }, cancelled: (payload) => { /* ... */ }, }); ``` ### `neuron.setConcurrency(n: number | undefined)` Set per-neuron global concurrency limit (shared across all parallel stimulations). ```ts const worker = neuron({ out }) .setConcurrency(2) // max 2 parallel executions across all runs .dendrite({ collateral: task, response: async (p, axon) => { /* ... */ } }); ``` This limits how many concurrent executions of this neuron's dendrites can run at the same time, even across different `stimulate()` calls. Useful for rate-limiting external APIs or heavy I/O operations. ### `neuron.setMaxDuration(ms: number | undefined)` Set maximum execution duration for this neuron's dendrites. If exceeded, the dendrite execution will be aborted. ```ts const worker = neuron({ out }) .setMaxDuration(5000) // 5 seconds max .dendrite({ collateral: task, response: async (p, axon) => { /* ... */ } }); ``` ### `CNS` Main orchestrator. `new CNS(neurons, options?)` **Constructor options:** ```ts const cns = new CNS(neurons, { autoCleanupContexts: false // Auto-cleanup unused contexts (builds SCC analysis lazily on first use; adds per-hop runtime cost — see performance docs) }); ``` **Properties:** - `cns.network` — Access to network graph analysis (SCC, subscribers, etc.) ```ts const unsubscribe = cns.addResponseListener(r => { /* ... */ }); ``` ### `cns.stimulate(signal, options?)` Run a stimulation. Returns a `CNSStimulation` instance. Use `stimulation.waitUntilComplete()` to await completion. ```ts const stimulation = cns.stimulate(userCreated.createSignal({ id: '123', name: 'John' })); await stimulation.waitUntilComplete(); ``` ### `cns.activate(tasks, options?)` Start a stimulation with activation tasks directly. Useful for advanced scenarios where you need fine-grained control over task execution. ```ts const tasks: TCNSNeuronActivationTask[] = [ { neuron: worker, dendriteCollateral: task, input: signal } ]; const stimulation = cns.activate(tasks, { concurrency: 4 }); await stimulation.waitUntilComplete(); ``` #### Single entry point `stimulate(...)` and `activate(...)` are the entry points that begin execution. Nothing runs until you explicitly stimulate a signal or activate tasks. This is the "inverted" part of CNS: you start the run and each dendrite returns the explicit continuation. #### Stimulation options ```ts const stimulation = cns.stimulate(signal, { onResponse: (r) => { /* per-stimulation hook */ }, abortSignal, // Abort the whole run cooperatively concurrency: 4, // Per-stimulation parallelism maxNeuronHops: undefined, // Disabled by default; set to cap traversal length ctx, // Optional: reuse an existing context store (in‑process) modality, // Optional: modality selection for modalityDendrite afferentPath, // Optional: afferent path selection for modalityDendrite }); await stimulation.waitUntilComplete(); ``` #### Response shape (for listeners) Both `onResponse` and global listeners receive the same object: ```ts { inputSignal?: TCNSSignal; // when a signal is ingested outputSignal?: TCNSSignal; // when a dendrite returns a continuation contextValue: Map; // per-neuron per-stimulation metadata (not business data) queueLength: number; // all activations still owned = pending + active pendingActivations: number; // body not invoked yet activeActivations: number; // body invoked, awaiting an unsettled promise stimulation: CNSStimulation; // reference to the stimulation instance error?: Error; // when a dendrite throws hops?: number; // present if maxNeuronHops is set } ``` ##### The counters The unit is an **activation** — one `(neuron, dendrite, signal)` triple. A signal produces one activation per subscriber of its collateral. An activation is *pending* until its dendrite body is invoked, then *active* until the promise that body returned settles. A synchronous body passes through `active` within a single scheduler iteration and is decremented before its response is emitted, so only genuinely asynchronous work is ever observed there. Subscribers held behind an async `onResponse` count as **pending**, not active — the split is by lifecycle stage, not by "something is being awaited". `queueLength` counts active work deliberately, the way an AMQP queue counts unacknowledged messages in its total; `getAllActivationTasks()` follows the same model. So: ```ts r.queueLength === 0 // the stimulation is finished ``` This is the exact condition used internally to settle `waitUntilComplete()`, so it holds on the terminal response and nowhere else. :::caution `queueLength === 0` is not a batch boundary A response is emitted when an activation *finishes* — but a synchronous batch just as often ends when one *starts* and returns a promise, and no response is emitted at that moment. To flush or commit at batch boundaries use [`onDrain`](#batch-boundaries-ondrain). ::: ### Global response listeners (middleware‑style) Use `addResponseListener` to attach cross‑cutting concerns (logging, metrics, tracing) that run for every stimulation. ```ts const off = cns.addResponseListener((r) => { if (r.error) { metrics.count('error', 1); return; } if (r.outputSignal) { trace.log('out', r.outputSignal.collateral); } else if (r.inputSignal) { trace.log('in', r.inputSignal.collateral); } }); // later off(); ``` ### Batch boundaries: `onDrain` A CNStra run is a sequence of **synchronous turns**. A turn starts when something feeds the scheduler — the initial `stimulate()`, a settled neuron body, a settled async listener — and ends when nothing more can run without yielding to the event loop. `onDrain` fires once at the end of every turn. ```ts cns.stimulate(signal, { onDrain: () => store.flush(), // batch boundary onResponse: r => { if (r.queueLength === 0) done(); } // stimulation finished }); ``` Or once for the whole organism, which is how integrations install themselves: ```ts const off = cns.addDrainListener(() => store.flush()); ``` #### Why this cannot be a response A turn can end in six ways, and a response is emitted in only the first: 1. an activation finished and left the queue empty 2. the last activation **started** and returned a promise 3. the stimulation-level `concurrency` limit was reached 4. the stimulation was aborted 5. subscribers are parked behind an async `onResponse` 6. a per-neuron `setConcurrency` gate forced an activation onto a promise Case 2 is the common one. In `A → async B → C`, the turn ends the moment `B`'s body reaches its first `await` — anything `B` wrote synchronously before that point is already in the store, but no response has been emitted since `B` was scheduled. Without `onDrain` those writes stay uncommitted until `B` settles, which is exactly how optimistic updates and loading states get lost. #### Payload ```ts { stimulation: CNSStimulation; queueLength: number; // pending + active; zero means finished pendingActivations: number; // non-zero here means BLOCKED activeActivations: number; // non-zero means another drain is coming } ``` Same counters as on a response, but measured *after* the loop exhausted rather than before the work runs. On a response `pendingActivations` is a forecast; on a drain it is a fact — a non-zero value can only mean blocked by a concurrency limit or by an abort, never "about to run". #### Notes - Returns `void`; a returned promise is **not** awaited. There is nothing to gate on — the next turn is started by someone else's promise settling. For async work at a boundary use `onResponse`, which does support promises. - A listener that throws is logged and isolated; it cannot derail the stimulation or the other listeners. - Re-entrancy is safe. A nested `stimulate()` from inside the callback is a separate stimulation and announces its own boundary inline. Feeding work back into the *same* stimulation via `enqueueTasks()` runs it, but folds it into the turn already closing, so it yields no additional boundary. - Abort ends a turn without any further response or drain progress; pair it with `waitUntilComplete().catch(...)` if you need a final commit on that path. ### `CNSStimulation` methods #### `stimulation.waitUntilComplete()` Wait for the stimulation to complete. Returns a Promise that resolves when all tasks are done or rejects on error. ```ts await stimulation.waitUntilComplete(); ``` #### `stimulation.getContext()` Get the context store for this stimulation. Useful for saving/restoring stimulation state. ```ts const ctx = stimulation.getContext(); // Save for retry const savedCtx = ctx; ``` #### `stimulation.getFailedTasks()` Get all tasks that failed or were aborted. ```ts const failures = stimulation.getFailedTasks(); failures.forEach(f => console.error(f.error)); ``` ### `cns.network` — Network Graph Analysis Access network analysis utilities: ```ts // Get subscribers for a collateral const subscribers = cns.network.getSubscribers(userEvent); // Get all collaterals const collaterals = cns.network.getCollaterals(); // Get strongly connected components const sccs = cns.network.stronglyConnectedComponents; ``` Notes - Local `onResponse` (per stimulation) runs as well as global listeners; both can be `async`. - All listeners run in parallel per response; errors from any listener reject the `stimulation.waitUntilComplete()` Promise. - If all listeners are synchronous, no extra async deferrals are introduced. - Use `maxNeuronHops` to constrain traversal if needed. # Source: https://cnstra.org/docs/core/stimulation-options - `maxNeuronHops?: number` — limit traversal depth - `onResponse?: (response) => void | Promise` — tap into flow and completion (async supported) - `abortSignal?: AbortSignal` — graceful cancel - `concurrency?: number` — per-run concurrency limit - `ctx?: ICNSStimulationContextStore` — reuse context store (in‑process) - `modality?: TCNSModality` — optional modality routing for `modalityDendrite` - `afferentPath?: TCNSAfferentPath` — optional afferent path selection for `modalityDendrite` - `stimulationContext?: object` — optional user-defined bag for listeners/handlers - `onDrain?: (drain) => void` — called once at the end of every synchronous turn; the batch boundary for state managers (see [API reference](./api.md#batch-boundaries-ondrain)) ```ts const controller = new AbortController(); const stimulation = cns.stimulate(signal, { maxNeuronHops: 10, // optional, disabled by default abortSignal: controller.signal, onResponse: r => { // queueLength counts pending + in-flight activations, so zero means the // whole stimulation is finished - not merely that a batch ended. if (r.queueLength === 0) console.log('done'); } }); await stimulation.waitUntilComplete(); ``` ### Async listeners and failure semantics - Local `onResponse` and all global listeners (added via `addResponseListener`) can be synchronous or asynchronous. - They run in parallel for each response. If any throws or returns a rejected Promise, the `stimulation.waitUntilComplete()` Promise rejects. - A dendrite `response` that throws or returns a rejected Promise **also** fails the run (a *failed task*) and rejects `waitUntilComplete()` — independent of any listener. - If all listeners are synchronous, CNStra does not introduce extra async deferrals for that response. > ⚠️ `stimulate()` returns a `CNSStimulation`, **not** a Promise — the completion Promise is created lazily by `waitUntilComplete()`. If you neither await it nor register a listener, a failed run is swallowed silently (not even an unhandled rejection). See [Error Handling → Fire-and-forget](/docs/recipes/error-handling). ```ts // Async onResponse example (e.g., persist to DB/Redis) const stimulation = cns.stimulate(signal, { onResponse: async (r) => { if (r.outputSignal) { await repo.save(r.outputSignal); } } }); await stimulation.waitUntilComplete(); ``` # Source: https://cnstra.org/docs/concepts/basics ## Single-Process Stimulation Execution Stimulation lives in a single process to guarantee stable execution without unnecessary overhead. This design choice ensures: - **Deterministic execution**: All neurons within a stimulation run in the same execution context, eliminating cross-process synchronization complexity - **Performance**: No serialization/deserialization overhead for signal passing between neurons - **Reliability**: Single-process execution reduces failure modes and makes error handling straightforward ### Multi-Process Architecture For multi-process scenarios, CNStra doesn't prescribe a specific inter-process communication mechanism. Instead, you choose the approach that best fits your needs: - **Message queues**: Use RabbitMQ, AWS SQS, BullMQ, or any other message broker to coordinate between different CNS instances - **HTTP/gRPC**: Communicate between CNS instances via standard web protocols - **Event sourcing**: Share events through an event store that multiple CNS instances can consume - **Database**: Use database triggers or polling to coordinate between processes Each CNS instance handles its own stimulations independently, and you orchestrate communication between instances using your preferred mechanism. This flexibility allows you to scale horizontally while maintaining the simplicity and reliability of single-process stimulation execution. ## Command Pattern with Signal-Based Dependency Wiring CNStra implements a large-scale command pattern where each stimulation is a command that gets processed by many processors (neurons). This is not a DI container (there are no lifecycle scopes, constructor injection, or token resolution) — instead, the graph itself wires dependencies implicitly: - **Stimulations as commands**: When you call `cns.stimulate(signal)`, you're issuing a command that flows through the neuron graph - **Multiple processors**: Each neuron that binds to the signal's collateral becomes a processor for that command - **Automatic wiring**: The dependency graph is explicit and type-safe—neurons declare their dependencies (dendrites) and outputs (collaterals), and CNStra automatically routes signals between them - **No manual wiring**: You don't need to manually connect dependencies—the graph structure itself defines them This approach combines the benefits of the command pattern (encapsulation, decoupling, extensibility) with automatic signal routing through the explicit neuron graph structure. ```ts // Each stimulation is a command const signal = orderCreated.createSignal({ orderId: '123' }); // Multiple neurons process this command automatically // - inventory neuron: reserves items // - payment neuron: validates payment method // - notification neuron: sends confirmation email // All wired automatically through the graph structure await cns.stimulate(signal); ``` ## Separation of Concerns: Mutations vs Reads A common pain point in applications is tracking sources of data changes. While reading data in a unified format is relatively straightforward to set up, managing where and how data gets modified is much more challenging. ### Domain Neurons for Data Mutations CNStra recommends organizing **domain neurons** to handle model changes. These neurons: - Own the responsibility for mutating specific domain models - Provide a single source of truth for how data changes - Make data mutation flows explicit and traceable - Enable easy testing and validation of business rules By centralizing mutations in domain neurons, you eliminate the "where did this data come from?" problem. Every change flows through explicit, typed signals that you can trace through the graph. ### Reading Data Through Your Own System For reading models, CNStra suggests allowing reads everywhere through your own system: - **Flexible read access**: Components, services, and neurons can read data using whatever mechanism fits your architecture (direct database queries, read models, caches, etc.) - **No CNStra coupling**: Reading doesn't need to go through CNStra—use your existing data access patterns - **Optimization freedom**: Choose the most efficient read mechanism for each use case (indexed queries, materialized views, in-memory caches, etc.) This separation provides the best of both worlds: - **Mutations are controlled and explicit** through domain neurons, making data changes traceable and testable - **Reads are flexible and optimized** through your own data access layer, avoiding unnecessary overhead ### Example Pattern ```ts // Domain neuron: owns mutations for Order model const orderCreated = collateral<{ id: string; items: Item[] }>(); const orderUpdated = collateral<{ id: string; changes: Partial }>(); const orderCancelled = collateral<{ id: string; reason: string }>(); const orderDomain = neuron({ created: orderCreated, updated: orderUpdated, cancelled: orderCancelled, }) .dendrite({ collateral: createOrder, response: async (payload, axon) => { // Single source of truth for order creation const order = await db.orders.create(payload); return axon.created.createSignal({ id: order.id, items: order.items }); }, }); // Reading can happen anywhere, using your preferred method class OrderService { async getOrder(id: string) { // Direct read - no CNStra needed return await db.orders.findById(id); } async listOrders(filters: OrderFilters) { // Optimized query - your choice of implementation return await db.orders.findMany(filters); } } ``` This pattern ensures that: - All order mutations flow through the `orderDomain` neuron (traceable, testable) - Order reads use the most efficient method for each use case (flexible, optimized) - The system remains maintainable as it grows (clear separation of concerns) # Source: https://cnstra.org/docs/concepts/workflow-engine-comparison If you’re searching for a “workflow engine / orchestrator”, you’ve probably looked at **Temporal**, **Netflix Conductor**, or **Camunda Zeebe**. **CNStra is different by default**: it’s an **embeddable, in-memory workflow/orchestration engine** for TypeScript that runs deterministic flows over a typed graph (neurons + collaterals). There’s no separate cluster to operate — you embed it into your app/worker. ## Where CNStra fits best - **Backend jobs**: workers, fan-out/fan-in, concurrency gates - **Sync / integrations**: webhooks, external APIs, multi-step validation + side-effects - **ETL / pipelines**: step-by-step transforms with retries/backoff - **Sagas**: explicit compensation steps for partial failures ## CNStra vs “distributed workflow engines” Temporal / Conductor / Zeebe are typically **durable, distributed systems** with their own operational surface area (clusters, persistence, scaling model). With CNStra, you start from: - **Embeddability** (library, not a platform) - **Determinism & explicit routing** (no hidden listeners / no global event bus) - **Type safety** (flow boundaries are typed) And you *add durability* when you need it: - Trigger runs from a **queue** (e.g. BullMQ) or message broker - Persist correlation/state externally between runs (database/object storage) - Re-stimulate on retries/timeouts/webhooks with correlation data ## Quick comparison (high level) | Capability | CNStra | Temporal / Conductor / Zeebe | |---|---|---| | Default model | **Embeddable library** | **Distributed platform** | | Durability out of the box | In-memory (bring your own persistence) | Built-in persistence | | Operations overhead | Low | Medium–high | | TypeScript-first | **Yes** | Varies (SDKs / DSLs) | | Great for | Jobs/sync/ETL/sagas inside your services | Cross-service durable workflows | ## A practical mental model - If you need **durable, distributed workflows spanning many services** with “always-on” history and replay semantics: start with Temporal/Zeebe/Conductor. - If you need **clean orchestration inside a Node.js service/worker** (and you want it **explicit, deterministic, type-safe**) and you’re happy to compose with queues/persistence: CNStra is a strong fit. Next: - Backend overview: [/docs/backend/overview](/docs/backend/overview) - Recipes: retries, saga: [/docs/recipes/retry](/docs/recipes/retry), [/docs/recipes/saga](/docs/recipes/saga) # Source: https://cnstra.org/docs/frontend/oimdb # OIMDB: Reactive In-Memory Database for JavaScript OIMDB (Object In-Memory Database) is a reactive in-memory database library that provides normalized entity storage, intelligent indexing, and automatic change notifications. Unlike traditional state managers that copy entire state trees, OIMDB uses O(1) Map-based lookups and efficient event coalescing to deliver high-performance state management. ## Why OIMDB? Traditional state management approaches like Redux or MobX have limitations: - **Tree copying overhead**: Immutable updates require copying large state trees, causing GC pressure - **No built-in indexing**: Querying related data requires manual filtering or memoization - **Fragile batching**: UI updates are batched inconsistently across frameworks - **Complex coordination**: Multiple reducers need ad-hoc messaging to coordinate updates OIMDB solves these problems with: - **Normalized storage**: Entities stored by primary key in Maps (O(1) lookups) - **Reactive indexes**: Set- and array-based indexes for efficient queries (e.g., "all posts by author") - **Event coalescing**: Multiple rapid updates to the same entity trigger only one notification - **Configurable scheduling**: Choose when events fire (microtask, animationFrame, timeout, immediate) ## Installation ```bash npm install @oimdb/core ``` ## Core Concepts ### Collections: Normalized Entity Storage The canonical entry point is `createOIMCollectionKit`. It wires a collection to an event queue and returns a kit with everything you need — `collection` (reads/writes/subscriptions), `indexFactory` (build indexes), and `select` (selectors). Entities are stored by primary key, providing O(1) lookups: ```typescript import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit, } from '@oimdb/core'; interface User { id: string; name: string; email: string; } // Create event queue with microtask scheduler (most common) const queue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); // Create a collection kit const users = createOIMCollectionKit(queue, { selectPk: (user) => user.id }); // CRUD operations go through users.collection users.collection.upsertOne({ id: 'user1', name: 'John Doe', email: 'john@example.com' }); users.collection.upsertMany([ { id: 'user2', name: 'Jane Smith', email: 'jane@example.com' }, { id: 'user3', name: 'Bob Wilson', email: 'bob@example.com' } ]); // O(1) lookups const user = users.collection.getOneByPk('user1'); const multipleUsers = users.collection.getManyByPks(['user1', 'user2']); ``` ### Reactive Updates: Key-Specific Subscriptions Subscribe directly on the collection to changes for specific entities. Handlers receive no payload — read the current value from the collection when they fire: ```typescript // Subscribe to changes for a specific user users.collection.subscribeOnKey('user1', () => { console.log('User1 changed!', users.collection.getOneByPk('user1')); }); // Subscribe to changes for multiple users users.collection.subscribeOnKeys(['user1', 'user2'], () => { console.log('Users changed!'); }); // Updates trigger notifications users.collection.upsertOne({ id: 'user1', name: 'John Updated' }); // Notification fires in next microtask ``` ### Indexes: Efficient Queries Indexes are created through the kit's `indexFactory`. OIMDB provides two manual index types optimized for different use cases, plus **derived** indexes that maintain themselves from an entity field. #### SetBased Indexes: For Incremental Updates Use when you frequently add/remove individual items: ```typescript // Create a manual Set-based index for user roles const userRoleIndex = users.indexFactory.setBasedIndex(); // Build the index userRoleIndex.setPks('admin', ['user1']); userRoleIndex.setPks('user', ['user2', 'user3']); // Efficient incremental updates userRoleIndex.addPks('admin', ['user2']); // O(1) userRoleIndex.removePks('admin', ['user1']); // O(1) // Query returns Set const adminUsers = userRoleIndex.getPksByKey('admin'); // Set(['user2']) // Subscribe to a specific index key userRoleIndex.subscribeOnKey('admin', () => { console.log('Admins changed:', userRoleIndex.getPksByKey('admin')); }); ``` #### ArrayBased Indexes: For Full Replacements Use when you typically replace entire arrays (e.g., ordered lists): ```typescript // Create a manual Array-based index for deck cards const cardsByDeckIndex = cards.indexFactory.arrayBasedIndex(); // Set full array (O(1) - direct assignment, no diff computation) cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card3']); // Query returns TPk[] const deckCards = cardsByDeckIndex.getPksByKey('deck1'); // ['card1', 'card2', 'card3'] // For ArrayBased, prefer setPks for updates cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card4']); // Recommended // addPks/removePks work but are O(n) - less efficient than SetBased ``` **When to use which:** - **SetBased**: Frequent add/remove operations, order doesn't matter - **ArrayBased**: Full array replacements, need to preserve order/sorting #### Derived Indexes: Maintained Automatically When the index key is a field of the entity, use a derived index — it updates itself on every upsert/remove, so you never call `setPks` manually: ```typescript // Automatically groups users by their `teamId` field const usersByTeam = users.indexFactory.derivedSetIndex((user) => user.teamId); // Query returns Set, always in sync with the collection const engineering = usersByTeam.getPksByKey('engineering'); ``` ### Event Coalescing: Performance Optimization Multiple rapid updates to the same entity are automatically coalesced into a single notification: ```typescript // These three updates... users.collection.upsertOne({ id: 'user1', name: 'John' }); users.collection.upsertOne({ id: 'user1', email: 'john@test.com' }); users.collection.upsertOne({ id: 'user1', name: 'John Doe' }); // ...result in only one notification with the final state // This prevents unnecessary re-renders and improves performance ``` ### Event Queue and Schedulers Control when events fire with different schedulers: ```typescript // Microtask (most common) - executes before next browser render const microtaskQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); // AnimationFrame - syncs with browser rendering (60fps) const animationFrameQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createAnimationFrame() }); // Timeout - configurable delay for custom batching const timeoutQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createTimeout(100) }); // Immediate - fastest execution const immediateQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createImmediate() }); // Manual queue (no scheduler) const manualQueue = new OIMEventQueue(); manualQueue.enqueue(() => console.log('Task 1')); manualQueue.flush(); // Execute when ready ``` ## Advanced Patterns ### Collections with Indexes A kit already exposes `indexFactory` — create as many indexes as you need and keep references to them: ```typescript import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit, } from '@oimdb/core'; interface User { id: string; name: string; teamId: string; role: 'admin' | 'user'; } const queue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); // Create the collection kit const users = createOIMCollectionKit(queue, { selectPk: (user) => user.id, }); // Derived index: auto-maintained from the `teamId` field const usersByTeam = users.indexFactory.derivedSetIndex((user) => user.teamId); // Manual index: you control membership explicitly const usersByRole = users.indexFactory.setBasedIndex(); // Subscribe to index changes usersByTeam.subscribeOnKey('engineering', () => { console.log('Engineering team changed:', usersByTeam.getPksByKey('engineering')); }); // Writing entities keeps the derived index in sync automatically users.collection.upsertMany([ { id: 'u1', name: 'Ann', teamId: 'engineering', role: 'admin' }, { id: 'u2', name: 'Bob', teamId: 'engineering', role: 'user' }, ]); // Manual index is updated by hand usersByRole.setPks('admin', ['u1']); ``` ### Custom Entity Updaters By default an upsert replaces the stored entity. To merge instead, pass an `updateEntity` updater. OIMDB ships factories for the common cases, and you can supply your own `(draft, prevEntity) => entity`: ```typescript import { createOIMCollectionKit, createMergeEntityUpdater } from '@oimdb/core'; // Built-in shallow merge: partial upserts merge into the existing entity const users = createOIMCollectionKit(queue, { selectPk: (user) => user.id, updateEntity: createMergeEntityUpdater(), }); // Updates merge with existing users.collection.upsertOne({ id: 'user1', name: 'John' }); users.collection.upsertOneByPk('user1', { email: 'john@example.com' }); // merges // Or a custom updater, e.g. a deep merge const deepMerge: (draft: Partial, prev: User) => User = (draft, prev) => { const result = { ...prev }; for (const [key, value] of Object.entries(draft)) { if (value !== undefined) { (result as any)[key] = value; } } return result; }; const usersDeep = createOIMCollectionKit(queue, { selectPk: (user) => user.id, updateEntity: deepMerge, }); ``` ## Integration with CNStra CNStra provides orchestration for OIMDB, replacing reducers, slices, thunks, and sagas with a typed neuron graph. Together, they deliver deterministic state management with high performance. ### Why CNStra + OIMDB? **The Problem with Flux:** - Multiple reducers need to coordinate ordering and cross-updates - Immutable tree copies cause extra allocations and GC pressure - No built-in "after everything settles" phase for batching **Our Approach:** - A controlling neuron orchestrates the sequence of updates across models - OIMDB stores normalized data with reactive indexes (no tree copies) - After all model updates in a run, we flush the OIMDB event queue once, so the UI updates efficiently in batches ### Minimal Setup ```ts import { CNS, neuron, collateral } from '@cnstra/core'; import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit } from '@oimdb/core'; const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const users = createOIMCollectionKit<{ id: string; name: string }, string>(dbEventQueue, { selectPk: (u) => u.id, }); // Define UI/update collateral const userUpdated = collateral<{ id: string; name: string }>(); // Controlling neuron updates models and returns nothing (end of branch) export const usersNeuron = neuron({}).dendrite({ collateral: userUpdated, response: (payload) => { users.collection.upsertOne({ id: payload.id, name: payload.name }); // OIMDB event queue will flush after the run completes return undefined; }, }); const cns = new CNS([usersNeuron]); ``` ### React Usage React hooks take the **reactive collection** (`users.collection`), not the kit: ```tsx import { useSelectEntityByPk } from '@oimdb/react'; function UserName({ id }: { id: string }) { const user = useSelectEntityByPk(users.collection, id) || null; return {user?.name ?? ''}; } ``` ### Updating Multiple Collections Best practice: each model is updated by its own domain neuron. The controller emits one controller-owned signal with both payloads; each domain neuron listens and updates its model. ```ts import { collateral, neuron } from '@cnstra/core'; import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit } from '@oimdb/core'; type UserEntity = { id: string; name: string }; type PostEntity = { id: string; title: string; authorId: string }; const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const users = createOIMCollectionKit(dbEventQueue, { selectPk: (u) => u.id, }); export const posts = createOIMCollectionKit(dbEventQueue, { selectPk: (p) => p.id, }); // Derived index: posts grouped by author, kept in sync automatically export const postsByAuthor = posts.indexFactory.derivedSetIndex((p) => p.authorId); // Single incoming signal with both payloads const userAndPostUpdated = collateral<{ user: UserEntity; post: PostEntity; }>(); // Controller-owned single update signal const controllerUpdated = collateral<{ user: UserEntity; post: PostEntity; }>(); // Controller receives inbound and emits one outbound export const controller = neuron({ controllerUpdated }) .dendrite({ collateral: userAndPostUpdated, response: (payload, axon) => axon.controllerUpdated.createSignal(payload), }); // Domain neurons update their own collections export const userModel = neuron({}).dendrite({ collateral: controllerUpdated, response: (p) => { users.collection.upsertOne(p.user); return undefined; }, }); export const postModel = neuron({}).dendrite({ collateral: controllerUpdated, response: (p) => { posts.collection.upsertOne(p.post); return undefined; }, }); ``` React selectors will observe a single batched change after the run completes, not N re-renders during the sequence. Index hooks take the **index object** (not a string name): ```tsx import { useSelectEntityByPk, useSelectEntitiesByIndexKeySetBased } from '@oimdb/react'; function AuthorWithPosts({ authorId }: { authorId: string }) { const user = useSelectEntityByPk(users.collection, authorId) || null; const postsByAuthorList = useSelectEntitiesByIndexKeySetBased(posts.collection, postsByAuthor, authorId) ?? []; return (

{user?.name}

    {postsByAuthorList.map(p => p &&
  • {p.title}
  • )}
); } ``` ### Example: Create Deck then Card Goal: on UI click, create a deck first (to obtain `deckId`), then create a card that needs that `deckId`. We orchestrate this with a controlling neuron; OIMDB persists models; the event queue flushes once after the run. ```ts import { CNS, collateral, neuron } from '@cnstra/core'; import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit, } from '@oimdb/core'; type DeckEntity = { id: string; title: string }; type CardEntity = { id: string; deckId: string; title: string }; // OIMDB setup const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const decks = createOIMCollectionKit(dbEventQueue, { selectPk: (d) => d.id, }); export const cards = createOIMCollectionKit(dbEventQueue, { selectPk: (c) => c.id, }); // Cards grouped by deck, maintained automatically export const cardsByDeck = cards.indexFactory.derivedSetIndex((c) => c.deckId); // Collaterals const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>(); const controllerCreateDeckForCard = collateral<{ title: string; cardTitle: string }>(); const controllerCreateCard = collateral<{ deckId: string; title: string }>(); const deckCreatedForCard = collateral<{ deckId: string; title: string; cardTitle: string }>(); // Services (mocked) const generateDeckId = () => 'deck-' + Math.random().toString(36).slice(2); const generateCardId = () => 'card-' + Math.random().toString(36).slice(2); // Deck neuron: listens controller:deck:createForCard, emits deck:createdForCard, upserts OIMDB export const deckNeuron = neuron({ deckCreatedForCard }).dendrite({ collateral: controllerCreateDeckForCard, response: async (payload, axon) => { const deckId = generateDeckId(); decks.collection.upsertOne({ id: deckId, title: payload.title }); return axon.deckCreatedForCard.createSignal({ deckId, title: payload.title, cardTitle: payload.cardTitle }); }, }); // Card neuron: listens controller:card:create, upserts OIMDB export const cardNeuron = neuron({}).dendrite({ collateral: controllerCreateCard, response: async (payload) => { const cardId = generateCardId(); cards.collection.upsertOne({ id: cardId, deckId: payload.deckId, title: payload.title }); return undefined; }, }); // Controller neuron: emits only its own collaterals (controller:*) // Pass cardTitle through signal payloads, not context export const controller = neuron({ controllerCreateDeckForCard, controllerCreateCard }) .dendrite({ collateral: uiCreateCardClick, response: (payload, axon) => { // Pass cardTitle along with deck creation through payload return axon.controllerCreateDeckForCard.createSignal({ title: payload.deckTitle, cardTitle: payload.cardTitle }); }, }) .dendrite({ collateral: deckCreatedForCard, response: (payload, axon) => { return axon.controllerCreateCard.createSignal({ deckId: payload.deckId, title: payload.cardTitle }); }, }); // CNS const cns = new CNS([controller, deckNeuron, cardNeuron]); // UI click starts the run; OIMDB event queue flushes once after both upserts await cns.stimulate(uiCreateCardClick.createSignal({ deckTitle: 'Inbox', cardTitle: 'First task' })); ``` ### Simplified Example: Direct Neuron Communication (No Controller) For simpler flows, you can skip the controller and have neurons communicate directly: ```ts import { CNS, collateral, neuron } from '@cnstra/core'; import { OIMEventQueue, OIMEventQueueSchedulerFactory, createOIMCollectionKit, } from '@oimdb/core'; type DeckEntity = { id: string; title: string }; type CardEntity = { id: string; deckId: string; title: string }; // OIMDB setup const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const decks = createOIMCollectionKit(dbEventQueue, { selectPk: (d) => d.id, }); export const cards = createOIMCollectionKit(dbEventQueue, { selectPk: (c) => c.id, }); // Collaterals const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>(); const deckCreatedForCard = collateral<{ deckId: string; cardTitle: string }>(); // Services (mocked) const generateDeckId = () => 'deck-' + Math.random().toString(36).slice(2); const generateCardId = () => 'card-' + Math.random().toString(36).slice(2); // Deck neuron: listens to UI click, creates deck, emits deckCreatedForCard export const deckNeuron = neuron({ deckCreatedForCard }).dendrite({ collateral: uiCreateCardClick, response: async (payload, axon) => { const deckId = generateDeckId(); decks.collection.upsertOne({ id: deckId, title: payload.deckTitle }); return axon.deckCreatedForCard.createSignal({ deckId, cardTitle: payload.cardTitle }); }, }); // Card neuron: listens to deckCreatedForCard, creates card export const cardNeuron = neuron({}).dendrite({ collateral: deckCreatedForCard, response: async (payload) => { const cardId = generateCardId(); cards.collection.upsertOne({ id: cardId, deckId: payload.deckId, title: payload.cardTitle }); return undefined; }, }); // CNS (no controller needed) const cns = new CNS([deckNeuron, cardNeuron]); // UI click starts the run; OIMDB event queue flushes once after both upserts await cns.stimulate(uiCreateCardClick.createSignal({ deckTitle: 'Inbox', cardTitle: 'First task' })); ``` **Rule of ownership:** - A neuron emits only collaterals from its own axon - Other neurons subscribe to those collaterals via dendrites - In this example, controller emits `controller:*` requests; deck emits `deck:createdForCard`; card writes data on `controller:card:create` ## Performance Characteristics - **Collections**: O(1) primary key lookups using Map-based storage - **Reactive Collections**: O(1) lookups + efficient event coalescing - **Indices**: O(1) index lookups with lazy evaluation - **Event System**: Smart coalescing prevents redundant notifications - **Memory**: Efficient key-based subscriptions, no global listeners - **Schedulers**: Configurable timing for optimal batching: - **Microtask**: ~1-5ms delay, ideal for UI updates - **Immediate**: <1ms, fastest execution - **Timeout**: Custom delay for batching strategies - **AnimationFrame**: 16ms, synced with 60fps rendering ### Index Performance **SetBased Indexes** (`indexFactory.setBasedIndex()` / `derivedSetIndex()`): - Returns `Set` for efficient membership checks - O(1) add/remove operations - Best for frequent incremental updates **ArrayBased Indexes** (`indexFactory.arrayBasedIndex()` / `derivedArrayIndex()`): - Returns `TPk[]` for direct array access - O(1) `setPks` operation (direct assignment, no diff computation) - Best for full array replacements - Note: `addPks`/`removePks` are O(n) - prefer `setPks` for better performance ## Integration with Other Libraries ### React (@oimdb/react) The core library integrates seamlessly with React through dedicated hooks. All hooks take the **reactive collection** (`kit.collection`) and, for index queries, the **index object**: ```typescript import { useSelectEntityByPk, useSelectEntitiesByPks } from '@oimdb/react'; // Single entity by primary key const user = useSelectEntityByPk(users.collection, 'user1'); // Many entities by primary keys const teamUsers = useSelectEntitiesByPks(users.collection, userIds); ``` ### Redux (@oimdb/redux-adapter) > **⚠️ Experimental**: The Redux adapter is experimental. Functionality is not guaranteed and the API may change. Migrate from Redux to OIMDB gradually or use both systems side-by-side with automatic two-way synchronization. See [Redux Migration Guide](/docs/frontend/redux-migration) for details. ## API Reference ### Collection Kit #### `createOIMCollectionKit(queue, opts?)` Creates a reactive collection and returns a kit: ```typescript const kit = createOIMCollectionKit(queue: OIMEventQueue, opts?: { selectPk?: (entity: TEntity) => TPk; updateEntity?: (draft: Partial, prevEntity: TEntity) => TEntity; }); // kit = { queue, collection, indexFactory, select } ``` #### `kit.collection` — `OIMReactiveCollection` Reactive collection with automatic change notifications and event coalescing. **Key Methods:** - `upsertOne(entity): TOIMEntitySlot` - Insert or update a single entity - `upsertOneByPk(pk, partial): TOIMEntitySlot` - Insert or update by primary key - `upsertMany(entities): TOIMEntitySlot[]` - Insert or update multiple entities - `removeOneByPk(pk): void` / `removeManyByPks(pks): void` - Remove entities - `getOneByPk(pk): TEntity | undefined` - Get entity by primary key - `getManyByPks(pks): TEntity[]` - Get multiple entities - `getAll(): TEntity[]` - Get all entities - `subscribeOnKey(pk, handler): () => void` - Subscribe to a single key (handler takes no args) - `subscribeOnKeys(pks, handler): () => void` - Subscribe to multiple keys #### `kit.indexFactory` — `OIMCollectionIndexFactory` Builds indexes bound to the collection: - `setBasedIndex(): OIMReactiveCollectionIndexManualSetBased` - Manual Set-based index - `arrayBasedIndex(): OIMReactiveCollectionIndexManualArrayBased` - Manual Array-based index - `derivedSetIndex(selectKey): OIMDerivedCollectionIndexSetBased` - Auto-maintained Set index - `derivedArrayIndex(selectKey): OIMDerivedCollectionIndexArrayBased` - Auto-maintained Array index **Index methods** (on the returned index): - `getPksByKey(key): Set | TPk[]` - Query the index - `subscribeOnKey(key, handler): () => void` - Subscribe to a key - Manual indexes only: `setPks(key, pks)`, `addPks(key, pks)`, `removePks(key, pks)`, `clear(key?)` #### `kit.select` — `OIMCollectionSelectors` Composable selectors (each has a `.watch(cb)` method): - `byPk(pk)` / `byPks(pks)` - `entitiesBySetIndexKey(index, key)` / `entitiesByArrayIndexKey(index, key)` ### `OIMEventQueue` Event processing queue with configurable scheduling. **Constructor:** ```typescript new OIMEventQueue(options?: TOIMEventQueueOptions) ``` **Methods:** - `enqueue(fn: () => void): void` - Add function to queue - `flush(): void` - Execute all queued functions - `clear(): void` - Clear queue without executing ### Schedulers #### `OIMEventQueueSchedulerFactory` Factory for creating different scheduler types: ```typescript OIMEventQueueSchedulerFactory.createMicrotask() OIMEventQueueSchedulerFactory.createAnimationFrame() OIMEventQueueSchedulerFactory.createTimeout(delay?: number) OIMEventQueueSchedulerFactory.createImmediate() ``` ## License MIT License # Source: https://cnstra.org/docs/frontend/react-patterns Common patterns to use CNStra from React. ## Stimulate on mount or on deps change ```tsx const [result, stimulate] = useNeuron(myNeuron); useEffect(() => { stimulate({ userId }); }, [userId]); ``` ## Keep UI responsive with abort Use the `AbortSignal` supported by CNStra to cancel in-flight runs. ```tsx const controller = useRef(); const [data, stimulate] = useNeuron(searchNeuron); function onSearch(q: string) { controller.current?.abort(); controller.current = new AbortController(); stimulate(q, { abortSignal: controller.current.signal }); } ``` ## Derive UI lists via OIMDB Use OIMDB for queryable derived state (dashboards, analytics) and subscribe via hooks. Build an index once, then read it reactively with the matching hook (index hooks take the **index object**, not a string name). ```tsx import { useSelectEntitiesByIndexKeySetBased } from '@oimdb/react'; // logs = createOIMCollectionKit(queue, { selectPk: l => l.id }); // Derived set-based index keyed by the entry's `type` field: const logsByType = logs.indexFactory.derivedSetIndex(log => log.type); function WarningList() { const items = useSelectEntitiesByIndexKeySetBased(logs.collection, logsByType, 'warning') ?? []; return
    {items.map(l => l &&
  • {l.message}
  • )}
; } ``` ## Error and loading states ```tsx const [user, stimulate, status] = useNeuron(fetchUserNeuron); // status: { loading: boolean, error?: unknown } ``` ## Testing - Mock `useNeuron` outputs for components - Prefer unit tests for neurons and a smaller set of integration tests for flows # Source: https://cnstra.org/docs/frontend/redux-migration # @oimdb/redux-adapter > **⚠️ Experimental**: This package is experimental. Functionality is not guaranteed and the API may change in future versions. Use at your own risk. Experimental Redux adapter for OIMDB that enables integration between OIMDB's reactive in-memory database and Redux state management. This package allows you to gradually migrate from Redux to OIMDB or use both systems side-by-side with automatic two-way synchronization. ## 🚀 Installation ```bash npm install @oimdb/redux-adapter @oimdb/core redux ``` ## ✨ Key Features - **🔄 Two-Way Synchronization**: Automatic sync between OIMDB and Redux in both directions - **📦 Optimized**: Designed for large datasets with efficient change detection - **🔄 Gradual Migration**: Integrate OIMDB into existing Redux projects without breaking changes - **🎯 Flexible State Mapping**: Custom mappers for any Redux state structure - **⚡ Performance Optimized**: Efficient diffing algorithms and batched updates - **🔌 Redux Compatible**: Works seamlessly with existing Redux middleware and tools ## 🎯 Use Cases ### 1. **Replace Redux Entirely** Use OIMDB as your primary state management with Redux as a compatibility layer for existing code. ### 2. **Gradual Migration** Migrate from Redux to OIMDB incrementally, one collection at a time, without disrupting your application. ### 3. **Hybrid Approach** Use OIMDB for complex relational data and Redux for simple UI state, with automatic synchronization. ## 📦 What's Included - **OIMDBReduxAdapter**: Main adapter class for creating Redux reducers and middleware from OIMDB collections - **Automatic Middleware**: Built-in middleware for automatic event queue flushing after Redux actions - **Default Mappers**: RTK Entity Adapter-style mappers for collections and indexes - **Utility Functions**: `findUpdatedInRecord` and `findUpdatedInArray` for efficient change detection - **Type-Safe**: Full TypeScript support with comprehensive type definitions ## 🔧 Basic Usage ### Simple One-Way Sync (OIMDB → Redux) ```typescript import { OIMDBReduxAdapter } from '@oimdb/redux-adapter'; import { OIMReactiveCollection, OIMEventQueue } from '@oimdb/core'; import { createStore, combineReducers, applyMiddleware } from 'redux'; interface User { id: string; name: string; email: string; } // Create OIMDB collection const queue = new OIMEventQueue(); const users = new OIMReactiveCollection(queue, { selectPk: (user) => user.id }); // Create Redux adapter const adapter = new OIMDBReduxAdapter(queue); // Create Redux reducer from OIMDB collection const usersReducer = adapter.createCollectionReducer(users); // Create middleware for automatic flushing const middleware = adapter.createMiddleware(); // Create Redux store with middleware const store = createStore( combineReducers({ users: usersReducer, }), applyMiddleware(middleware) ); // Set store in adapter (can be done later) adapter.setStore(store); // OIMDB changes automatically sync to Redux users.upsertOne({ id: '1', name: 'John', email: 'john@example.com' }); queue.flush(); // Triggers Redux update // Redux state is automatically updated const state = store.getState(); console.log(state.users.entities['1']); // { id: '1', name: 'John', email: 'john@example.com' } ``` ### Two-Way Sync (OIMDB ↔ Redux) Enable bidirectional synchronization by providing a child reducer. The middleware automatically flushes the queue after each action, so manual `queue.flush()` is not needed: ```typescript import { OIMDBReduxAdapter, TOIMDBReduxDefaultCollectionState, TOIMDBReduxCollectionReducerChildOptions } from '@oimdb/redux-adapter'; import { Action } from 'redux'; import { createStore, applyMiddleware } from 'redux'; // Child reducer handles custom Redux actions const childReducer = ( state: TOIMDBReduxDefaultCollectionState | undefined, action: Action ): TOIMDBReduxDefaultCollectionState => { if (state === undefined) { return { entities: {}, ids: [] }; } if (action.type === 'UPDATE_USER_NAME') { const { id, name } = action.payload; return { ...state, entities: { ...state.entities, [id]: { ...state.entities[id], name } } }; } return state; }; const childOptions: TOIMDBReduxCollectionReducerChildOptions> = { reducer: childReducer, getPk: (user) => user.id, // extractEntities is optional - default implementation handles TOIMDBReduxDefaultCollectionState // linkedIndexes is optional - automatically updates indexes when entity fields change }; // Create reducer with child const usersReducer = adapter.createCollectionReducer(users, childOptions); // Create store with middleware const store = createStore( usersReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); // Redux actions automatically sync back to OIMDB // Middleware automatically flushes queue after dispatch store.dispatch({ type: 'UPDATE_USER_NAME', payload: { id: '1', name: 'John Updated' } }); // No manual queue.flush() needed - middleware handles it! // OIMDB collection is automatically updated const user = users.getOneByPk('1'); console.log(user?.name); // 'John Updated' ``` ### Linked Indexes Automatically update indexes when entity array fields change in **both directions**: - **Redux → OIMDB**: When Redux state changes via child reducer, linked indexes are updated - **OIMDB → Redux**: When OIMDB collection changes directly, linked indexes are updated automatically The entity's PK (obtained via `getPk`) becomes the index key, and the array field values become the index values. Index updates are triggered when the array field changes **by reference** (`===` comparison). No need to create separate index reducers: ```typescript import { OIMDBReduxAdapter, TOIMDBReduxDefaultCollectionState, TOIMDBReduxCollectionReducerChildOptions } from '@oimdb/redux-adapter'; import { OIMReactiveIndexManualArrayBased } from '@oimdb/core'; interface Deck { id: string; cardIds: string[]; // Array of card IDs name: string; } const decksCollection = new OIMReactiveCollection(queue, { selectPk: (deck) => deck.id }); // Use ArrayBased index for full array replacements (recommended for redux-adapter) // The adapter optimizes ArrayBased indexes by skipping diff computation const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased(queue); const childReducer = ( state: TOIMDBReduxDefaultCollectionState | undefined, action: Action ): TOIMDBReduxDefaultCollectionState => { if (state === undefined) { return { entities: {}, ids: [] }; } if (action.type === 'UPDATE_DECK_CARDS') { const { deckId, cardIds } = action.payload; const deck = state.entities[deckId]; if (deck) { return { ...state, entities: { ...state.entities, [deckId]: { ...deck, cardIds } // Update cardIds array } }; } } return state; }; const childOptions: TOIMDBReduxCollectionReducerChildOptions< Deck, string, TOIMDBReduxDefaultCollectionState > = { reducer: childReducer, getPk: (deck) => deck.id, linkedIndexes: [ { index: cardsByDeckIndex, fieldName: 'cardIds', // Array field containing PKs }, ], }; const decksReducer = adapter.createCollectionReducer( decksCollection, childOptions ); // When deck.cardIds changes (by reference), the index is automatically updated: // - index[deck.id] = deck.cardIds // - Old values removed, new values added automatically // - Works in both directions: Redux → OIMDB and OIMDB → Redux // No need to create a separate index reducer! // Example: Update via Redux store.dispatch({ type: 'UPDATE_DECK_CARDS', payload: { deckId: 'deck1', cardIds: ['card1', 'card2', 'card3'] } }); // Index automatically updated: cardsByDeckIndex['deck1'] = ['card1', 'card2', 'card3'] // Example: Update via OIMDB decksCollection.upsertOne({ id: 'deck1', cardIds: ['card4', 'card5'], // New array reference name: 'Deck 1' }); queue.flush(); // Triggers OIMDB_UPDATE action // Index automatically updated: cardsByDeckIndex['deck1'] = ['card4', 'card5'] ``` ### Custom State Structure Use custom mappers for any Redux state structure: ```typescript // Array-based state type ArrayBasedState = { users: User[]; }; const arrayMapper = (collection: OIMReactiveCollection) => { return { users: collection.getAll() }; }; const arrayReducer = adapter.createCollectionReducer(users, undefined, arrayMapper); // Custom extractor for array-based state const childOptions: TOIMDBReduxCollectionReducerChildOptions = { reducer: (state, action) => { // Your custom reducer logic return state; }, extractEntities: (prevState, nextState, collection, getPk) => { const prevIds = (prevState?.users ?? []).map(u => getPk(u)); const nextIds = nextState.users.map(u => getPk(u)); // Use utility function for efficient diffing const { findUpdatedInArray } = require('@oimdb/redux-adapter'); const diff = findUpdatedInArray(prevIds, nextIds); // Sync changes to OIMDB if (diff.added.length > 0 || diff.updated.length > 0) { const toUpsert = nextState.users.filter(u => diff.added.includes(getPk(u)) || diff.updated.includes(getPk(u)) ); collection.upsertMany(toUpsert); } if (diff.removed.length > 0) { collection.removeManyByPks(diff.removed); } }, getPk: (user) => user.id }; ``` ## 🔄 Migration Strategy ### Phase 1: Add OIMDB Alongside Redux Start by adding OIMDB for new features while keeping existing Redux code unchanged: ```typescript const adapter = new OIMDBReduxAdapter(queue); const middleware = adapter.createMiddleware(); const store = createStore( combineReducers({ // Existing Redux reducers ui: uiReducer, auth: authReducer, // New OIMDB-backed reducers users: adapter.createCollectionReducer(usersCollection), posts: adapter.createCollectionReducer(postsCollection), }), applyMiddleware(middleware) ); adapter.setStore(store); ``` ### Phase 2: Migrate Existing Redux Reducers Gradually replace Redux reducers with OIMDB collections, using child reducers to maintain compatibility: ```typescript // Old Redux reducer const oldUsersReducer = (state, action) => { // ... existing logic }; // New OIMDB-backed reducer with compatibility layer const adapter = new OIMDBReduxAdapter(queue); const newUsersReducer = adapter.createCollectionReducer( usersCollection, { reducer: oldUsersReducer, // Reuse existing reducer logic getPk: (user) => user.id } ); const store = createStore( newUsersReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); ``` ### Phase 3: Full OIMDB Migration Once all collections are migrated, you can remove Redux entirely and use OIMDB directly with React hooks or other reactive patterns. ## 🛠️ Advanced Usage ### Custom Mappers ```typescript const customMapper: TOIMDBReduxCollectionMapper = ( collection, updatedKeys, currentState ) => { // Your custom mapping logic // Only process entities in updatedKeys for performance const entities: Record = {}; const ids: string[] = []; if (currentState) { // Reuse existing state Object.assign(entities, currentState.entities); ids.push(...currentState.ids); } // Update only changed entities for (const id of updatedKeys) { const entity = collection.getOneByPk(id); if (entity) { entities[id] = entity; if (!ids.includes(id)) { ids.push(id); } } else { delete entities[id]; const index = ids.indexOf(id); if (index > -1) { ids.splice(index, 1); } } } return { entities, ids }; }; ``` ### Index Reducers #### Simple One-Way Sync (OIMDB → Redux) ```typescript import { OIMReactiveIndexManualArrayBased } from '@oimdb/core'; // Create index (ArrayBased recommended for redux-adapter - optimized performance) const userRolesIndex = new OIMReactiveIndexManualArrayBased(queue); // Create reducer for index const adapter = new OIMDBReduxAdapter(queue); const rolesReducer = adapter.createIndexReducer(userRolesIndex); // Use in Redux store with middleware const store = createStore( combineReducers({ users: usersReducer, userRoles: rolesReducer, }), applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); ``` #### Two-Way Sync (OIMDB ↔ Redux) for Indexes Enable bidirectional synchronization for indexes by providing a child reducer: ```typescript import { OIMDBReduxAdapter, TOIMDBReduxDefaultIndexState, TOIMDBReduxIndexReducerChildOptions } from '@oimdb/redux-adapter'; import { Action } from 'redux'; import { createStore, applyMiddleware } from 'redux'; // Child reducer handles custom Redux actions const childReducer = ( state: TOIMDBReduxDefaultIndexState | undefined, action: Action ): TOIMDBReduxDefaultIndexState => { if (state === undefined) { return { entities: {} }; } if (action.type === 'UPDATE_INDEX_KEY') { const { key, ids } = action.payload; return { ...state, entities: { ...state.entities, [key]: { id: key, ids } } }; } return state; }; const childOptions: TOIMDBReduxIndexReducerChildOptions< string, string, TOIMDBReduxDefaultIndexState > = { reducer: childReducer, // extractIndexState is optional - default implementation handles TOIMDBReduxDefaultIndexState }; // Create reducer with child const indexReducer = adapter.createIndexReducer(userRolesIndex, childOptions); // Create store with middleware const store = createStore( indexReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); // Redux actions automatically sync back to OIMDB // Middleware automatically flushes queue after dispatch store.dispatch({ type: 'UPDATE_INDEX_KEY', payload: { key: 'role1', ids: ['user1', 'user2', 'user3'] } }); // No manual queue.flush() needed - middleware handles it! // OIMDB index is automatically updated const pks = userRolesIndex.index.getPksByKey('role1'); // Returns TPk[] for ArrayBased console.log(pks); // ['user1', 'user2', 'user3'] ``` ## 📊 Performance The adapter is optimized for large datasets: - **Efficient Diffing**: Uses optimized algorithms to detect changes - **Batched Updates**: Changes are coalesced and applied in batches - **Selective Updates**: Only changed entities are processed - **Memory Efficient**: Reuses state objects when possible ### Index Performance Optimization The adapter intelligently handles different index types for optimal performance: - **ArrayBased Indexes** (using `setPks`): When linked indexes use ArrayBased indexes, the adapter directly sets the new array without computing diffs. This eliminates unnecessary array comparisons and `getPksByKey` calls, providing significant performance improvements, especially with many linked indexes. - **SetBased Indexes** (using `addPks`/`removePks`): For SetBased indexes, the adapter computes diffs to apply incremental updates, which is more efficient than full replacements for Set-based data structures. **Recommendation**: For redux-adapter, prefer **ArrayBased indexes** for linked indexes, as they provide the best performance when replacing entire arrays (which is the common pattern when syncing from Redux state). **Example:** ```typescript // ArrayBased index - direct assignment (fast, recommended for redux-adapter) const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased(queue); // When deck.cardIds changes, adapter simply does: // index.setPks(deckId, newCardIds) - no diff computation needed! // SetBased index - incremental updates (efficient for Sets) const tagsByUserIndex = new OIMReactiveIndexManualSetBased(queue); // When user.tags changes, adapter computes diff and uses: // index.addPks(userId, toAdd) and index.removePks(userId, toRemove) ``` ## 🔍 Utility Functions ### `findUpdatedInRecord` Efficiently find differences between two entity records (dictionaries): ```typescript import { findUpdatedInRecord } from '@oimdb/redux-adapter'; const oldEntities = { '1': user1, '2': user2 }; const newEntities = { '1': user1Updated, '3': user3 }; const diff = findUpdatedInRecord(oldEntities, newEntities); // diff.added = Set(['3']) // diff.updated = Set(['1']) // diff.removed = Set(['2']) // diff.all = Set(['1', '2', '3']) ``` ### `findUpdatedInArray` Efficiently find differences between two arrays of primary keys: ```typescript import { findUpdatedInArray } from '@oimdb/redux-adapter'; const oldIds = ['1', '2', '3']; const newIds = ['1', '3', '4']; const diff = findUpdatedInArray(oldIds, newIds); // diff.added = ['4'] // diff.updated = ['1', '3'] // diff.removed = ['2'] // diff.all = ['1', '2', '3', '4'] ``` ## 🎨 TypeScript Support Full type safety with comprehensive TypeScript definitions: ```typescript import type { TOIMDBReduxCollectionMapper, TOIMDBReduxIndexMapper, TOIMDBReduxDefaultCollectionState, TOIMDBReduxDefaultIndexState, TOIMDBReduxCollectionReducerChildOptions, TOIMDBReduxLinkedIndex, TOIMDBReduxIndexReducerChildOptions, TOIMDBReduxUpdatedEntitiesResult, TOIMDBReduxUpdatedArrayResult, } from '@oimdb/redux-adapter'; ``` ## 📚 API Reference ### `OIMDBReduxAdapter` Main adapter class for integrating OIMDB with Redux. Creates Redux reducers from OIMDB collections and provides middleware for automatic event queue flushing. #### Methods - `createCollectionReducer(collection, child?, mapper?)`: Create reducer for a collection - `createIndexReducer(index, child?, mapper?)`: Create reducer for an index (supports both SetBased and ArrayBased indexes) - `createMiddleware()`: Create Redux middleware that automatically flushes the event queue after each action - `setStore(store)`: Set Redux store (can be called later) - `flushSilently()`: Flush the event queue without triggering OIMDB_UPDATE dispatch (used internally by middleware) #### Constructor Options - `defaultCollectionMapper`: Default mapper for all collections - `defaultIndexMapper`: Default mapper for all indexes #### Automatic Flushing The middleware created by `createMiddleware()` automatically calls `flushSilently()` after every Redux action. This ensures that: - Events triggered by child reducers are processed synchronously - No manual `queue.flush()` is needed when updating OIMDB from Redux - OIMDB_UPDATE dispatch is not triggered unnecessarily (preventing loops) # Source: https://cnstra.org/docs/frontend/benchmark ## Overview We compared **Cnstra + OIMDB** against **MobX**, **Effector**, **Zustand**, and **Redux Toolkit**. These are our results on one machine with the library versions we tested; read them as directional, not as universal rankings. The summary up front: - **Among fine-grained (per-key) stores, React's commit cost dominates and they effectively tie** at ~33–36 µs/update. The 1–3 µs spread between them did **not reproduce** across our runs — treat it as a coin flip. - **The clearest difference we saw is fine-grained vs coarse.** Coarse stores (copy the whole record + re-run all selectors per update) landed at 1230–5430 µs/update — **~35–160× slower** in our runs — and React does *not* hide this. - **On the pure data layer (no React), OIMDB/Cnstra measured fastest** (0.25–0.48 µs/update), roughly 2–3× faster than MobX in our runs, while coarse stores were 95–302 µs. - **On memory, Cnstra/OIMDB had the lightest footprint we measured** (25.8–28.1 MB steady-state). Atomic Effector was ~3.5× heavier (89.7 MB) — the price of a store + event per entity. **🔗 [Interactive Benchmark Results](https://abaikov.github.io/cnstra-oimdb-bench/)** | **📦 [Benchmark Source Code](https://github.com/abaikov/cnstra-oimdb-bench)** ## Three measurement planes (and why) The three planes answer different questions, so we report all of them: - **React throughput (production build)** — the cost of one update in a real React app (`flushSync`, no frame floor). **Production build only** (`vite build` + `preview`): a dev build of React adds ~2× overhead (`jsxDEV`/validation) and distorts everything. - **Data-layer micro (no React)** — the pure cost of a store update: 1 subscriber per entity, `notify` confirms delivery. - **Steady-state memory** — heap after GC in the production app, with an identical DOM (50,162 nodes for every adapter), so the difference is the store layer, not the view. ## Table 1 — React throughput, production (µs/update, lower is better) 1500 components, best-of-N. | Adapter | µs/update | upd/s | re-renders/update | |---|---:|---:|---:| | MobX (deep/in-place) | 33.0 | 30,300 | 1 | | MobX (ids-based) | 33.3 | 30,060 | 1 | | Oimdb (no cnstra) | 33.4 | 29,940 | 1 | | Cnstra + Oimdb (in-place) | 33.9 | 29,470 | 1 | | Cnstra + Oimdb (ids-based) | 34.2 | 29,210 | 1 | | Effector (atomic stores) | 36.1 | 27,700 | 1 | | *— tier boundary —* | | | | | Effector (ids-based) | 1,230 | 813 | 1 | | Zustand (ids-based) | 2,372 | 420 | 1 | | Redux Toolkit (ids-based) | 5,430 | 184 | 1 | The top six (all **fine-grained**) are within noise of each other. The bottom three (all **coarse**) are ~35–160× slower — a gap large enough that it held consistently across our runs. ## Table 2 — Data layer, no React (µs/update) 1 subscriber per entity; `notify = 200000` for all. | Layer | µs/update | |---|---:| | oimdb in-place upsert+flush | 0.25 | | oimdb merge upsert+flush | 0.34 | | cnstra → oimdb (full stimulate) | 0.48 | | mobx deep in-place + reaction | 0.67 | | mobx map.set + reaction | 0.74 | | effector atomic + watch | 0.89 | | zustand setState + N selectors | 95 | | effector record + useStoreMap | 248 | | redux dispatch + N selectors | 302 | This is the one plane where fine-grained stores separate at all — and OIMDB/Cnstra came out ahead in our runs. Note that adding the full Cnstra orchestration on top of OIMDB (0.34 → 0.48 µs) read as a small, fixed cost, not a multiplier. ## Table 3 — Steady-state heap, production, after GC (MB, lower is better) Identical DOM across all adapters (50,162 nodes), so this isolates the store layer. | Adapter | heap MB | |---|---:| | Cnstra + Oimdb (in-place) | 25.8 | | Cnstra + Oimdb (ids-based) | 28.1 | | Oimdb (no cnstra) | 28.1 | | Zustand (ids-based) | 30.2 | | MobX (ids-based) | 31.5 | | MobX (deep/in-place) | 37.4 | | Redux Toolkit (ids-based) | 37.7 | | Effector (ids-based) | 42.0 | | Effector (atomic stores) | 89.7 | Memory is where the speed trade-offs become visible: - **Cnstra/OIMDB were the lightest we measured** (25.8–28.1 MB). The in-place updater also avoids per-update allocations, so it was the leanest in this run. - **Atomic Effector — 89.7 MB, ~3.5× heavier than anyone else.** That's the cost of a store + event *per entity*: thousands of Effector units. Atomic Effector buys its fast-tier update speed (Table 1/2) **with memory** — name this trade-off explicitly before reaching for it. - **MobX deep (37.4 MB) is heavier than MobX ids (31.5 MB)** — deep observables wrap every field (proxies/atoms), so the "native" idiom costs more memory than the normalized one. - **Redux / Effector-ids sit at ~38–42 MB.** ## What the variants are (not duplicates) Several variants per framework are **different data-access idioms**, not repeats: - **Cnstra + Oimdb (ids-based)** — the default: a merge updater (each upsert creates a new entity object) + `useSyncExternalStore` hooks. "Like everyone else." - **Cnstra + Oimdb (in-place)** — `createInPlaceEntityUpdater` (mutate the object in place, no allocation) + `*Signal` hooks (re-render by key subscription, read the mutated object). Fastest on the data layer. - **Oimdb (no cnstra)** — pure OIMDB, writing directly to the collection without CNS orchestration. Included to isolate Cnstra's cost (34.2 vs 33.4 → orchestration ≈ noise). - **MobX (ids-based)** — `observable.map`, replace the whole entity, `useObserver` per hook (normalized store, "like everyone else"). - **MobX (deep/in-place)** — idiomatic MobX: deep observables, mutate a field in place, components in `observer()` (read observables directly in JSX). MobX's best case. - **Effector (ids-based)** — idiomatic: `Record` stores + `useStoreMap`, incremental indexes. Coarse (copies the `Record` per update). - **Effector (atomic stores)** — a store + event per entity (maximally granular). Fast tier. - **Zustand** — single store, manual normalization, per-component shallow selectors. Coarse. - **Redux Toolkit** — `createEntityAdapter` + memoized selectors (Immer). Coarse. ## Findings ### 1. React noise drowns out good frameworks In production React, every **fine-grained** (per-key) store — OIMDB, Cnstra, both MobX variants, atomic Effector — collapses into 33–36 µs. The 1–3 µs spread does not reproduce: paired runs gave a coin flip (OIMDB faster in 4/10, MobX in 6/10, median +1 µs). **The cost of the React commit itself dominates and zeroes out the differences between store layers** — their React numbers carry no signal. ### 2. Only two things are distinguishable - **Fine vs coarse.** Coarse stores (Effector-ids, Zustand, Redux) copy the entire record and re-run all N selectors per update → 1230 / 2372 / 5430 µs, **35–160× slower**. React does **not** hide this. - **The data layer (no React).** There the store cost shows: OIMDB/Cnstra measured fastest (0.25–0.48 µs), ~2–3× faster than MobX (0.67–0.74) in our runs, and coarse stores were 95–302 µs. This is the one plane where fine-grained frameworks separate — and OIMDB came out ahead here. ### 3. Re-renders/update = 1 for everyone All frameworks are equally precise about React invalidation. The difference is purely the **CPU cost of the update**, not the volume of rendering. ## What this means in practice - If you're already using a **fine-grained** store (OIMDB/Cnstra, MobX, atomic Effector), don't expect a React-visible speedup from switching between them — React dominates. Choose on ergonomics, architecture, and the data-layer cost (which matters off the render path: workers, sync engines, large in-memory derivations). - The decision that **does** move React throughput by orders of magnitude is **fine-grained vs coarse**. Coarse normalization (copy-the-record + re-run-all-selectors) is what costs 35–160×. - In our setup Cnstra + OIMDB sat in the **top tier under React**, measured **fastest on the data layer**, and had the **lightest memory footprint** — with the orchestration overhead showing up as noise. (One machine, fixed versions — your mileage may vary.) - **Watch the memory trade-off.** Granularity isn't free: atomic Effector wins a fast update tier but pays ~3.5× the heap (a unit per entity), and MobX's deep/native idiom costs more memory than its normalized one. Speed, memory, and ergonomics pull in different directions — pick deliberately. ## Why coarse stores are slow Coarse stores (Zustand/Redux/Effector-ids as benchmarked) update a single normalized record (`Record` or equivalent) and then re-run every subscribing selector to find what changed. Cost scales with the number of subscribers/selectors, not with the number of changed entities. OIMDB instead maintains `Map>` indexes incrementally and notifies only the affected keys, so an update touches O(changed) work regardless of how many components are mounted. ## Methodology - **React plane**: production build (`vite build` + `preview`), `flushSync` per update (no frame floor / no batching across updates), 1500 mounted components, best-of-N with warmup. - **Data-layer plane**: no React; 1 subscriber per entity; `notify = 200000` across all adapters to confirm delivery. - **Memory plane**: steady-state heap measured after GC in the production build, with an identical DOM (50,162 nodes) across all adapters so the delta reflects the store layer, not the view. - All adapters ran on the **same single machine and environment**, with the library versions available at the time. We did **not** test across different hardware, OS, browsers, or library versions, so treat the absolute numbers as a snapshot rather than a general result. The large gaps (fine-vs-coarse, the memory spread) are big enough that we'd expect them to hold directionally elsewhere, but we haven't verified that. Sub-3 µs differences inside the fine-grained tier are within noise and should not be read as rankings. # Source: https://cnstra.org/docs/backend/overview CNStra fits backend workloads where you need deterministic, typed orchestration. Examples: - Worker pipelines and ETL steps - Integrations reacting to queue messages or webhooks - Sagas and long-running processes with cancellation Why CNStra on the backend: - Deterministic routing across neurons (no global pub/sub ambiguity) - Backpressure via queue concurrency and per-neuron gates - Type-safe boundaries and testable units Pair CNStra with a message queue (BullMQ, RabbitMQ, SQS) to trigger runs and fan-out work. See [Integrations](/docs/integrations/message-brokers). # Source: https://cnstra.org/docs/backend/cqrs CNStra's architecture naturally supports Command Query Responsibility Segregation (CQRS) patterns. The separation between commands (signals that trigger neurons) and queries (reading from external stores) aligns well with CQRS principles. **Note**: Context stores per-neuron per-stimulation metadata, not business aggregates. ### CQRS with CNStra ```typescript import { CNS, neuron, collateral } from '@cnstra/core'; // Command side: Write operations // Note: In real CQRS, aggregates would be stored in a database, not context // Context here is only for per-neuron per-stimulation metadata const createUserCommand = collateral<{ name: string; email: string }>(); const userCreated = collateral<{ id: string; name: string; email: string; createdAt: number }>(); // Command handlers (write side) const userCommandHandler = neuron({ userCreated }).dendrite({ collateral: createUserCommand, response: async (payload, axon) => { // Execute command and create aggregate // In production, this would persist to a database const userId = generateId(); const newAggregate = { id: userId, name: payload.name, email: payload.email, createdAt: Date.now(), }; // Emit event (could trigger query side updates) return axon.userCreated.createSignal(newAggregate); }, }); // Query side: Read operations (would typically read from a separate read model/store) const getUserQuery = collateral<{ id: string }>(); const userQueryResult = collateral<{ id: string; name: string; email: string; createdAt: number }>(); const userQueryHandler = neuron({ userQueryResult }).dendrite({ collateral: getUserQuery, response: async (payload, axon) => { // In real CQRS, this would read from a read model/database // Context is not used for business aggregates const aggregate = await readModel.getUser(payload.id); if (!aggregate) { throw new Error('User not found'); } return axon.userQueryResult.createSignal(aggregate); }, }); const cns = new CNS([userCommandHandler, userQueryHandler]); // Command: Write operation await cns.stimulate(createUserCommand.createSignal({ name: 'John Doe', email: 'john@example.com', })); // Query: Read operation (separate from write path) const queryStimulation = cns.stimulate(getUserQuery.createSignal({ id: 'user-123' })); await queryStimulation.waitUntilComplete(); ``` ### Read Model Projection In canonical CQRS, read models are updated asynchronously by separate projection neurons that listen to events. This ensures true separation between write and read stores: ```typescript // Command side: Write operations const createUserCommand = collateral<{ name: string; email: string }>(); const userCreated = collateral<{ id: string; name: string; email: string; createdAt: number }>(); const userCommandHandler = neuron({ userCreated }).dendrite({ collateral: createUserCommand, response: async (payload, axon) => { // Write to command/write database (source of truth) const userId = generateId(); await writeDb.users.create({ id: userId, name: payload.name, email: payload.email, createdAt: Date.now(), }); // Emit event (triggers read model projection asynchronously) return axon.userCreated.createSignal({ id: userId, name: payload.name, email: payload.email, createdAt: Date.now() }); }, }); // Read model projection: Updates read model from events const readModelUpdated = collateral<{ userId: string }>(); const readModelProjection = neuron({ readModelUpdated }).dendrite({ collateral: userCreated, response: async (payload, axon) => { // Update read model (separate database/store) from event // Read model might have denormalized data, different structure, etc. await readModel.users.upsert({ id: payload.id, name: payload.name, email: payload.email, createdAt: payload.createdAt, // Additional denormalized fields for read optimization displayName: `${payload.name} (${payload.email})`, }); // Emit signal after read model update is complete return axon.readModelUpdated.createSignal({ userId: payload.id }); }, }); const cns = new CNS([userCommandHandler, readModelProjection]); // Command writes to write database const stimulation = cns.stimulate(createUserCommand.createSignal({ name: 'John Doe', email: 'john@example.com', }), { onResponse: async (response) => { // After read model update is complete, read from read model if (response.outputSignal?.collateral === readModelUpdated) { const userId = response.outputSignal.payload.userId; // Read from read model (different database/store) // Now we know read model is updated, safe to read const userFromReadModel = await readModel.getUser(userId); if (userFromReadModel) { // Use read model data for side effects (notifications, webhooks, etc.) await sendWelcomeEmail(userFromReadModel.email, userFromReadModel.displayName); await logUserCreated(userFromReadModel); } } } }); await stimulation.waitUntilComplete(); // Read model is updated asynchronously by projection neuron // Queries read from read model independently ``` **Key points:** - **Command writes to write database** - `writeDb` stores the source of truth - **Event triggers projection** - `userCreated` event triggers read model update - **Separate projection neuron** - Updates read model asynchronously, independent from command - **Different stores** - Write and read operations use completely separate databases/stores - **Eventual consistency** - Read model is updated asynchronously, queries may see slightly stale data **Key points:** - **Command writes to write database** - `writeDb` stores the source of truth - **Event triggers projection** - `userCreated` event triggers read model update - **Projection emits completion signal** - After upsert, `readModelUpdated` signal is emitted - **Read from read model in `onResponse`** - Once `readModelUpdated` is received, read model is guaranteed to be updated - **Different stores** - Write and read operations use completely separate databases/stores - **Side effects** - Use `onResponse` for notifications, webhooks, logging with read model data ### CQRS Benefits with CNStra - **Clear separation**: Commands and queries are naturally separated through different collaterals and neurons - **Event sourcing potential**: Each command can emit events that update read models - **Scalability**: Command and query sides can be scaled independently - **Type safety**: Full type safety for both commands and queries ### Advanced CQRS Patterns CNStra can support more advanced CQRS patterns: - **Event sourcing**: Store all commands as events and rebuild aggregates from events - **Read model projections**: Use neurons to project events into read models - **Saga orchestration**: Coordinate multiple aggregates through CNStra workflows - **Eventual consistency**: Handle eventual consistency between command and query sides ## See Also - [Message Brokers Integration](/docs/integrations/message-brokers) - Retry patterns and context preservation with message brokers - [Backend Overview](/docs/backend/overview) - General backend orchestration patterns # Source: https://cnstra.org/docs/advanced/best-practices This guide covers best practices for building robust, maintainable CNStra applications. ## Context Store Usage ### Store Only Per-Neuron Per-Stimulation Metadata **Don't store business data in context store.** Context is designed for per-neuron per-stimulation metadata (retry attempts, debounce state, processing stats), not for passing business data between neurons. **Why?** Signals can arrive unexpectedly and out of order. If you store business data in context, you risk: - Data inconsistency when signals arrive in unexpected order - Race conditions when multiple signals process the same context - Memory leaks if context grows large with business data - Difficult debugging when context contains mixed metadata and business data **✅ Good**: Store only metadata in context ```ts const processor = withCtx<{ attempt: number; startTime: number }>() .neuron({ output }) .dendrite({ collateral: input, response: async (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata const attempt = (ctx.get()?.attempt || 0) + 1; ctx.set({ attempt, startTime: ctx.get()?.startTime || Date.now() }); // Business data flows through payloads return axon.output.createSignal({ userId: payload.userId, result: `Processed (attempt ${attempt})` }); } }); ``` **❌ Bad**: Storing business data in context ```ts const processor = withCtx<{ users: User[]; orders: Order[] }>() .neuron({ output }) .dendrite({ collateral: input, response: async (payload, axon, ctx) => { // ❌ Don't store business data in context const users = ctx.get()?.users || []; users.push(payload.user); ctx.set({ users, orders: ctx.get()?.orders || [] }); return axon.output.createSignal({ done: true }); } }); ``` **Use context for:** - Retry attempt counters - Debounce timers and state - Processing statistics (counters, timestamps) - Temporary flags and state **Pass business data through:** - Signal payloads (recommended) - External storage (database, cache) if persistence is needed ## One Collateral Per Response Type **Create separate collaterals for each distinct response type.** This improves type safety, makes the graph more readable, and prevents confusion about what each signal represents. **✅ Good**: Separate collaterals for different outcomes ```ts const userCreated = collateral<{ userId: string }>(); const userUpdated = collateral<{ userId: string; changes: Record }>('user:updated'); const userDeleted = collateral<{ userId: string }>(); const userError = collateral<{ userId: string; error: string }>(); const userHandler = neuron({ userCreated, userUpdated, userDeleted, userError }) .dendrite({ collateral: createUser, response: async (payload, axon) => { try { const user = await createUser(payload); return axon.userCreated.createSignal({ userId: user.id }); } catch (error) { return axon.userError.createSignal({ userId: payload.id, error: String(error) }); } }, }); ``` **❌ Bad**: Reusing collaterals for different purposes ```ts const userEvent = collateral<{ type: 'created' | 'updated' | 'deleted'; userId: string }>(); const userHandler = neuron({ userEvent }) .dendrite({ collateral: createUser, response: async (payload, axon) => { // ❌ Using same collateral for different event types return axon.userEvent.createSignal({ type: 'created', userId: user.id }); }, }); ``` **Benefits of separate collaterals:** - **Type safety**: Each collateral has a specific payload type - **Graph clarity**: Easy to see what signals a neuron can emit - **Subscriber clarity**: Subscribers know exactly what they're listening to - **Better error handling**: Errors can have their own collateral type ## Idempotency for Retries **Ensure neurons are idempotent when using retries.** Idempotency means that calling the same operation multiple times produces the same result as calling it once. **Why?** When retries occur (via BullMQ, SQS, or manual retries), the same signal may be processed multiple times. Without idempotency, you risk: - Duplicate operations (e.g., charging a user twice) - Inconsistent state (e.g., creating duplicate records) - Data corruption **✅ Good**: Idempotent operations ```ts const processPayment = neuron({ paymentProcessed }) .dendrite({ collateral: paymentRequest, response: async (payload, axon) => { // Check if payment already processed (idempotency check) const existingPayment = await db.payments.findOne({ idempotencyKey: payload.idempotencyKey }); if (existingPayment) { // Already processed - return existing result return axon.paymentProcessed.createSignal({ paymentId: existingPayment.id, status: existingPayment.status }); } // Process payment const payment = await chargeCard(payload); await db.payments.create({ id: payment.id, idempotencyKey: payload.idempotencyKey, status: payment.status }); return axon.paymentProcessed.createSignal({ paymentId: payment.id, status: payment.status }); }, }); ``` **❌ Bad**: Non-idempotent operations ```ts const processPayment = neuron({ paymentProcessed }) .dendrite({ collateral: paymentRequest, response: async (payload, axon) => { // ❌ No idempotency check - will charge multiple times on retry const payment = await chargeCard(payload); return axon.paymentProcessed.createSignal({ paymentId: payment.id }); }, }); ``` **Idempotency strategies:** - **Idempotency keys**: Include unique keys in payloads, check before processing - **Database constraints**: Use unique constraints to prevent duplicates - **Status checks**: Check current state before modifying - **Compare-and-swap**: Use atomic operations when updating state ## Non-Serializable Payloads in Separate Stimulations **When working with message brokers (BullMQ, SQS, etc.), signals must be serializable (JSON).** For non-serializable data (blobs, file handles, database connections), use separate stimulations with shared context store. **Key insight**: You can pass the entire context store (not just values) to a new stimulation. The new stimulation will update the same context store, making it behave as if it's part of the original stimulation. This is especially useful for retries with persistence. **✅ Good**: Separate stimulation with shared context store ```ts import { Queue, Worker, Job } from 'bullmq'; import { CNS, neuron, withCtx, collateral } from '@cnstra/core'; // Serializable data for the queue const processRequest = collateral<{ userId: string; blobId: string }>(); const processResult = collateral<{ userId: string; success: boolean }>(); // Non-serializable data (only within the process) const blobData = collateral<{ userId: string; blob: Blob }>(); const blobProcessed = collateral<{ userId: string; success: boolean }>(); // Transaction neuron: creates inner stimulation with blob const ctxBuilder = withCtx<{ innerStimulation?: Promise }>(); const transactionNeuron = ctxBuilder.neuron({ processResult }).dendrite({ collateral: processRequest, response: async (payload, axon, ctx) => { // Get blob from storage (non-serializable) const blob = await blobStorage.get(payload.blobId); // Create inner stimulation with blob // Pass the entire context store (not just values) // This allows the inner stimulation to update the same context store const innerStimulation = ctx.cns.stimulate( blobData.createSignal({ userId: payload.userId, blob }), { // Pass the entire context store - inner stimulation updates the same store ctx: ctx, // This is the context store instance, not ctx.get() } ); // Store stimulation promise in context ctx.set({ innerStimulation: innerStimulation.waitUntilComplete() }); // Wait for inner stimulation to complete await innerStimulation.waitUntilComplete(); // Return serializable result return axon.processResult.createSignal({ userId: payload.userId, success: true }); }, }); // Neuron for processing blob (runs only within the process) const blobProcessor = neuron({ blobProcessed }).dendrite({ collateral: blobData, response: async (payload, axon, ctx) => { // Process blob (non-serializable object) await processBlob(payload.blob); // Update shared context store (same instance as parent stimulation) const metadata = ctx.get() || { processedCount: 0 }; ctx.set({ processedCount: metadata.processedCount + 1 }); return axon.blobProcessed.createSignal({ userId: payload.userId, success: true }); }, }); const cns = new CNS([transactionNeuron, blobProcessor]); // BullMQ worker const worker = new Worker('jobs', async (job: Job<{ signal: any }>) => { const { signal } = job.data; // Stimulation runs in this process // If an error occurs, blob won't be in results const stimulation = cns.stimulate(signal); await stimulation.waitUntilComplete(); // Return only serializable results return { success: true }; }); // Enqueue job (only serializable data) await queue.add('process', { signal: processRequest.createSignal({ userId: '42', blobId: 'blob-123' // only ID, not the blob itself }) }); ``` **Key points:** - **Pass entire context store**: Use `ctx: ctx` (the store instance) - **Shared context**: Inner stimulation updates the same context store as parent - **Behaves as one stimulation**: For retries with persistence, both stimulations share the same context - **Non-serializable data stays in process**: Blobs and handles never leave the process memory - **Only serializable data in queue**: Queue only contains IDs and metadata **When to use:** - Working with message brokers that require JSON serialization - Processing large files or blobs - Using database connections or other non-serializable resources - Need to maintain context state across inner stimulations for retries ## Unique Signals for User Interactions **Each user interaction should be a unique signal with unique responses.** This makes the system more transparent - you can see exactly what the system is reacting to, which simplifies system design and debugging. **Why?** When every user interaction has its own unique signal chain, you get: - **Traceability**: Easy to trace which user action triggered which system responses - **System visibility**: Clear understanding of what the system reacts to - **Easier debugging**: When something goes wrong, you know exactly which interaction caused it - **Better design**: Forces you to think about the flow explicitly **Trade-off**: This approach increases boilerplate code, but the benefits in maintainability and clarity outweigh the cost. **✅ Good**: Unique signals for user interactions with hierarchical naming ```ts // User interaction signal const createDeckWithCardButtonClick = collateral<{ userId: string; deckName: string }>(); // Deck neuron response const createDeckWithCardButtonClickDeckCreated = collateral<{ deckId: string; userId: string }>(); // Card neuron response const createDeckWithCardButtonClickCardCreated = collateral<{ cardId: string; deckId: string }>(); const deckNeuron = neuron({ createDeckWithCardButtonClickDeckCreated }) .dendrite({ collateral: createDeckWithCardButtonClick, response: async (payload, axon) => { const deck = await createDeck(payload); return axon.createDeckWithCardButtonClickDeckCreated.createSignal({ deckId: deck.id, userId: payload.userId, }); }, }); const cardNeuron = neuron({ createDeckWithCardButtonClickCardCreated }) .dendrite({ collateral: createDeckWithCardButtonClickDeckCreated, response: async (payload, axon) => { const card = await createCard({ deckId: payload.deckId }); return axon.createDeckWithCardButtonClickCardCreated.createSignal({ cardId: card.id, deckId: payload.deckId, }); }, }); ``` **Naming convention**: Use hierarchical naming to show the flow - Base signal: `create-deck-with-card-button-click` (user interaction) - First response: `create-deck-with-card-button-click:deck:deck-created` (component:action) - Second response: `create-deck-with-card-button-click:deck:card-created` (component:action) **❌ Bad**: Reusing generic signals for different user interactions ```ts // ❌ Generic signal reused for multiple interactions const buttonClick = collateral<{ action: string; userId: string }>(); const entityCreated = collateral<{ type: string; id: string }>(); // Hard to trace which button click caused which creation const handler = neuron({ entityCreated }) .dendrite({ collateral: buttonClick, response: async (payload, axon) => { // Which button? Which user interaction? Unclear! return axon.entityCreated.createSignal({ type: 'deck', id: '123' }); }, }); ``` **Benefits:** - **Clear traceability**: Every signal chain traces back to a specific user action - **System transparency**: Easy to see what triggers what - **Better debugging**: Know exactly which interaction caused an issue - **Explicit design**: Forces explicit thinking about user interaction flows ## Domain Neuron Ownership ### One Model, One Neuron **Each domain model should have exactly one neuron responsible for all its mutations.** Other neurons must not write to a model they don't own — they can read it, but mutations belong to the owning neuron. **Why?** When mutations are scattered across multiple neurons, it becomes impossible to reason about what state a model can be in, and impossible to find all the places that change it. The owning neuron is the single source of truth for how that model evolves. **✅ Good**: one `deckNeuron` owns all deck mutations ```ts const deckAxon = { createdAtOnboarding: collateral<{ deckId: string; userId: string }>(), createdAtButtonClick: collateral<{ deckId: string }>(), renamed: collateral<{ deckId: string; title: string }>(), archived: collateral<{ deckId: string }>(), }; const deckNeuron = neuron(deckAxon) .dendrite({ collateral: uiAxon.createDeckButtonClicked, response: async (payload, axon) => { const deckId = await db.decks.create({ title: payload.title }); return axon.createdAtButtonClick.createSignal({ deckId }); }, }) .dendrite({ collateral: onboardingAxon.userOnboarded, response: async (payload, axon) => { const deckId = await db.decks.create({ title: 'My first deck' }); return axon.createdAtOnboarding.createSignal({ deckId, userId: payload.userId }); }, }); ``` **❌ Bad**: deck mutations spread across neurons ```ts // ❌ onboardingNeuron mutates decks — that's deckNeuron's responsibility const onboardingNeuron = neuron({ userOnboarded }).dendrite({ collateral: userRegistered, response: async (payload, axon) => { await db.decks.create({ title: 'My first deck', userId: payload.userId }); // ❌ await db.users.update(payload.userId, { onboarded: true }); return axon.userOnboarded.createSignal({ userId: payload.userId }); }, }); ``` ### Collaterals Represent Past Events, Not Commands **Name collaterals in the past tense — they describe something that already happened**, not an instruction to do something. A collateral is an observable fact emitted by a neuron, not a request sent to one. ```ts // ✅ Past events const deckCreated = collateral<{ deckId: string }>(); const paymentFailed = collateral<{ orderId: string; reason: string }>(); const userOnboarded = collateral<{ userId: string }>(); // ❌ Commands / instructions const createDeck = collateral<{ title: string }>(); // sounds like a request const failPayment = collateral<{ orderId: string }>(); // sounds like an order ``` This matters for readability and for correctly understanding the graph: a subscriber reacts to what *happened*, not to what it's being asked to do. ### Intent Collaterals — Use with Caution Sometimes the same logical operation is triggered from many places (button click, onboarding, import, API). It's tempting to create a single "command" collateral to avoid duplicating downstream wiring: ```ts // Intent collateral — groups all "create deck" triggers into one const createDeckIntentActivated = collateral<{ title: string; source: string }>(); ``` This works, but comes with a significant trade-off: **you lose afferent path traceability**. When `createDeckIntentActivated` fires, there is no way to see in the graph *where* it came from and *why* — all sources collapse into one anonymous signal. **Prefer keeping afferent paths separate:** ```ts // ✅ Each source has its own collateral — origin is always visible const deckAxon = { createdAtButtonClick: collateral<{ deckId: string }>(), createdAtOnboarding: collateral<{ deckId: string; userId: string }>(), createdAtImport: collateral<{ deckId: string; importId: string }>(), }; ``` If you do use intent collaterals, keep them at the boundary of your system (e.g., as a public API for external callers) and document the sources explicitly. Never use them as a shortcut to avoid thinking about the graph. ### Keep Neuron Code Focused on Mutations **A domain neuron's dendrite responses should primarily contain mutations — creating, updating, or deleting the model it owns.** Everything else is noise that makes the neuron harder to read and reason about. Move non-mutation logic out: **Mappings and transformations → utility functions or a mapper layer** ```ts // ✅ Mapping lives outside the neuron import { toDeckEntity } from './deck.mapper'; const deckNeuron = neuron(deckAxon).dendrite({ collateral: importAxon.deckRowParsed, response: async (payload, axon) => { const entity = toDeckEntity(payload); // mapping extracted const deckId = await db.decks.create(entity); return axon.createdAtImport.createSignal({ deckId, importId: payload.importId }); }, }); ``` **Concurrent I/O, retries, external requests → auxiliary neurons** When a domain neuron needs to make multiple external calls (fetch user, fetch plan, call API), extract that work into a dedicated auxiliary neuron that runs the I/O and emits a ready signal. The domain neuron then receives clean data and only runs its mutation. ```ts // Auxiliary neuron: fetches everything needed, emits one ready signal const deckCreateDataFetched = collateral<{ userId: string; plan: Plan; defaultTitle: string; }>(); const deckCreateFetcherNeuron = neuron({ deckCreateDataFetched }).dendrite({ collateral: uiAxon.createDeckButtonClicked, response: async (payload, axon) => { const [user, plan] = await Promise.all([ api.getUser(payload.userId), api.getPlan(payload.userId), ]); return axon.deckCreateDataFetched.createSignal({ userId: payload.userId, plan, defaultTitle: user.preferredDeckTitle ?? 'New Deck', }); }, }); // Domain neuron: only mutates const deckNeuron = neuron(deckAxon).dendrite({ collateral: deckCreateDataFetched, response: async (payload, axon) => { const deckId = await db.decks.create({ userId: payload.userId, title: payload.defaultTitle, planId: payload.plan.id, }); return axon.createdAtButtonClick.createSignal({ deckId }); }, }); ``` This separation keeps domain neurons readable, testable, and focused — and makes auxiliary logic easy to reuse or replace independently. ## Summary 1. **Context store**: Store only per-neuron per-stimulation metadata, not business data 2. **Collaterals**: Create separate collaterals for each response type 3. **Idempotency**: Ensure neurons are idempotent when using retries 4. **Non-serializable data**: Use separate stimulations with shared context store for non-serializable payloads 5. **User interactions**: Each user interaction should be a unique signal with unique responses for better traceability and system visibility 6. **Domain ownership**: Each domain model has one owning neuron — all mutations live there, nowhere else 7. **Event naming**: Collaterals describe past events, not commands; keep afferent paths separate instead of merging them into intent collaterals 8. **Neuron focus**: Keep dendrite responses focused on mutations; extract mappings to utility functions and I/O to auxiliary neurons Following these practices will help you build robust, maintainable CNStra applications that handle edge cases gracefully. # Source: https://cnstra.org/docs/advanced/performance CNStra is designed to be memory-efficient and fast for reactive orchestration. ## Memory-efficient design - **Zero dependencies**: No third-party packages, minimal bundle size. - **No error storage**: Errors are delivered via callbacks, not accumulated in memory. - **Streaming responses**: Signal traces are delivered via `onResponse` callbacks, not buffered. - **Context on-demand**: Context stores are created only when needed via `withCtx()`. - **No global state**: Each stimulation starts with a clean slate; no ambient listeners. ## Memory overhead per stimulation Each active `CNSStimulation` instance carries some internal overhead, but most of it is **allocated lazily** — a minimal synchronous fire-and-forget run (no listener, no context, never awaited) allocates only the object itself plus a small ring-buffer array. ### Base overhead (minimal context, ~2-3 keys) - **CNSStimulation object**: ~0.6-1.5 KB depending on what's actually used - Activation queue: a small ring-buffer array (lazily allocated on first enqueue); the active-tasks `Set` is only created while an async task is in flight - Context store (`Map`): lazily created — only when a neuron reads/writes `ctx` - Pending/failed task tracking: lazily created — only on async `onResponse` or an actual failure - Completion `Promise`: created only if you call `waitUntilComplete()` - **Active tasks in queue**: ~100-150 bytes per task (metadata only) - **⚠️ Important**: Each task stores the full input signal with payload - Task structure: `{ neuron, dendriteCollateral, input: { collateral, payload } }` - Typical stimulation has 5-10 tasks simultaneously: ~500-1500 bytes (metadata) + **payload size** - **Context data**: ~100-200 bytes (for minimal context with 2-3 simple values) **Total per stimulation (minimal context, small payloads)**: ~1.8-3.2 KB ### Queue size and payload impact **Critical**: The activation queue stores complete signal payloads in memory. Queue size directly multiplies payload memory usage. **Per-task memory breakdown:** - Task metadata (object references): ~100-150 bytes - **Signal payload**: variable, can be **any size** (from 0 bytes to MBs) **Queue memory = (task metadata × queue length) + (payload size × queue length)** **Examples:** | Queue Length | Small Payloads (100 bytes) | Medium Payloads (1 KB) | Large Payloads (10 KB) | Very Large Payloads (100 KB) | |-------------|----------------------------|------------------------|------------------------|------------------------------| | **10 tasks** | ~2.5 KB | ~11.5 KB | ~101.5 KB | ~1 MB | | **100 tasks** | ~25 KB | ~115 KB | ~1 MB | ~10 MB | | **1,000 tasks** | ~250 KB | ~1.15 MB | ~10 MB | ~100 MB | | **10,000 tasks** | ~2.5 MB | ~11.5 MB | ~100 MB | ~1 GB | **At scale (1,000 concurrent stimulations):** | Queue Length per Stimulation | Small Payloads | Medium Payloads | Large Payloads | Very Large Payloads | |------------------------------|----------------|-----------------|----------------|---------------------| | **10 tasks** | ~2.5 MB | ~11.5 MB | ~100 MB | ~1 GB | | **100 tasks** | ~25 MB | ~115 MB | ~1 GB | ~10 GB | | **1,000 tasks** | ~250 MB | ~1.15 GB | ~10 GB | ~100 GB | **⚠️ Memory warning**: If your payloads are large (e.g., full documents, images, large JSON objects) and queues grow (e.g., due to slow processing or high concurrency), memory usage can explode quickly. ### How much can you queue before hitting 1 GB? CNStra's queue is **purely in-memory** — a ring buffer that holds every pending task until a dendrite processes it. There is no spilling to disk, no backpressure from the queue itself. All pending work lives in RAM. Each queued task occupies roughly: ``` ~200 bytes (task metadata + ring buffer slot overhead) + payload size ``` From that, the maximum number of tasks you can hold in 1 GB of RAM: | Payload size | Max tasks in 1 GB | Typical scenario | |---|---|---| | ~0 bytes (IDs only) | ~5,000,000 | Event IDs, simple triggers | | ~100 bytes (small record) | ~3,300,000 | Minimal user events | | ~1 KB (typical entity) | ~830,000 | Order, session, compact DTO | | ~10 KB (medium document) | ~97,000 | Product page, large form submission | | ~100 KB (large document) | ~9,800 | Full report, paginated result set | | ~1 MB (binary / large JSON) | ~1,000 | Image metadata, export chunk | **These numbers are per-process totals across all concurrent stimulations.** If you have 1,000 stimulations running in parallel and each queues 100 tasks with 10 KB payloads, that's already 100,000 tasks × 10 KB ≈ **1 GB consumed**. ### When not to use CNStra's queue for work items The in-memory queue is designed for **short fan-outs within a single business flow** — a stimulation that spawns a handful of parallel tasks, each completing quickly. It is **not** a job queue. **Stop and reach for an external queue (BullMQ, SQS, RabbitMQ) when:** - **You don't control queue depth** — work arrives faster than dendrites can drain it (e.g., webhook bursts, large imports) - **The total item count is unbounded** — processing "all users", "all orders", "all rows in a CSV" fans out into a queue that grows linearly with data size - **Payloads are large** — passing full documents or large objects through signals; at 100 KB per payload you hit 1 GB at fewer than 10,000 queued tasks - **The process might restart** — everything in the ring buffer is lost on crash; if durability matters, use an external queue - **You need visibility and retries** — external queues give you dead-letter queues, retry policies, and dashboards out of the box **The correct pattern** for large workloads: let the external queue control depth, and have each worker create exactly one CNStra stimulation per job: ```ts // ✅ External queue controls how many items are in-flight at once new Worker('jobs', async (job) => { // One stimulation per job — CNStra queue depth stays tiny const stimulation = cns.stimulate(myCollateral.createSignal(job.data)); await stimulation.waitUntilComplete(); }); ``` This way CNStra's queue never holds more than the tasks for a single job, regardless of how many jobs are waiting. ### Memory usage at scale | Concurrent Stimulations | Minimal Context (2-3 keys) | Growing Context (10 keys, objects) | |------------------------|----------------------------|-----------------------------------| | **1,000** | ~1.8-3.2 MB | ~2.2-5 MB | | **10,000** | ~18-32 MB | ~22-50 MB | | **1,000,000** | ~1.8-3.2 GB | ~2.2-5 GB | ### Context size impact Context growth significantly impacts memory usage: - **Minimal context** (2-3 keys, primitives): +100-200 bytes per stimulation - **Small context** (5-10 keys, primitives): +300-500 bytes per stimulation - **Medium context** (10-20 keys, small objects): +500-1500 bytes per stimulation - **Large context** (20+ keys, complex objects): +1.5-5 KB per stimulation **Example**: If you store full user objects (5-10 KB each) in context instead of just IDs: - 1,000 stimulations: +5-10 MB → **~7-15 MB total** - 10,000 stimulations: +50-100 MB → **~70-130 MB total** - 1,000,000 stimulations: +5-10 GB → **~7-15 GB total** ### Best practices for memory efficiency 1. **Keep payloads small** - this is the most critical factor: ```ts // ✅ Good: small payload (~50 bytes) return axon.output.createSignal({ userId: '123', action: 'created' }); // ❌ Bad: large payload (~50 KB+) return axon.output.createSignal({ user: fullUserObject, history: largeArray, metadata: hugeObject }); ``` 2. **Use references instead of full data** in signals: ```ts // ✅ Good: pass only ID, fetch data when needed return axon.process.createSignal({ documentId: 'doc-123' }); // ❌ Bad: pass entire document return axon.process.createSignal({ document: fullDocumentObject }); ``` 3. **Context stores per-neuron per-stimulation metadata only** (retry attempts, debounce state), not business data: ```ts // ✅ Good: Context stores metadata (~50 bytes) ctx.set({ attempt: 2, startTime: Date.now() }); // ❌ Bad: Don't store business data in context // Business data should flow through signal payloads ctx.set({ user: fullUserObject, history: lotsOfData }); ``` 4. **Monitor and limit queue size** to prevent memory bloat. `queueLength` counts every activation the stimulation still owns — both waiting and in flight — so it is the right backpressure signal; split it when you need to know *why* it is high: ```ts onResponse: (r) => { if (r.queueLength > 1000) { // Queue is growing - consider: // - Reducing concurrency // - Adding backpressure // - Investigating slow processing console.warn( `Queue ${r.queueLength} (${r.pendingActivations} waiting, ` + `${r.activeActivations} in flight)` ); } } ``` A high `pendingActivations` with a low `activeActivations` means the graph is producing work faster than the concurrency limit lets it start. The reverse means slow neuron bodies. 5. **Set reasonable concurrency limits** to prevent queue buildup: ```ts // Limit concurrent operations to match your processing capacity const stimulation = cns.stimulate(signal, { concurrency: 10 // Prevents queue from growing unbounded }); ``` 6. **Avoid storing large arrays or nested objects** in context. Use external storage (DB, cache) and reference by ID. 7. **Clean up completed stimulations** promptly if you're tracking them externally. The stimulation object is garbage-collected when no longer referenced. 8. **For large payloads, consider streaming or chunking**: ```ts // Instead of one large signal, split into smaller chunks const chunks = splitIntoChunks(largeData, 1000); return chunks.map(chunk => axon.process.createSignal({ chunk, index })); ``` 9. **Use external queue systems to control memory load**: **⚠️ Critical**: For production systems processing high volumes, use external queue systems (BullMQ, RabbitMQ, AWS SQS) to control memory usage instead of creating thousands of stimulations in memory. ```ts // ✅ Good: Use external queue to control load import { Queue, Worker } from 'bullmq'; const queue = new Queue('jobs', { limiter: { max: 100, duration: 1000 } // Rate limit }); new Worker('jobs', async (job) => { // Process one job at a time, controlling memory const stimulation = cns.stimulate( myCollateral.createSignal(job.data), { concurrency: 10 } ); await stimulation.waitUntilComplete(); }); // Enqueue work externally - doesn't consume memory until processed await queue.add('process', { userId: '123' }); ``` **Benefits:** - Work is persisted externally, not in memory - Rate limiting and backpressure handled by queue system - Survives process restarts - Better observability and retry mechanisms See [Integrations](/docs/integrations/message-brokers) for examples. 10. **Context is per-neuron per-stimulation and automatically cleaned up**: Each neuron in each stimulation has its own context instance. Contexts hold memory for the entire duration of a stimulation and are automatically cleaned up when the stimulation completes. **Use context for metadata only**, not business data: ```ts // Context stores per-neuron per-stimulation metadata (processing stats) const processor = withCtx<{ processedCount: number; startTime: number }>() .neuron({ next }) .dendrite({ collateral: input, response: async (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata const metadata = ctx.get() ?? { processedCount: 0, startTime: Date.now() }; ctx.set({ ...metadata, processedCount: metadata.processedCount + 1 }); // Business data flows through payloads const batch = await fetchBatch(payload.batchId); const results = await processBatch(batch); return axon.next.createSignal({ batchId: payload.nextBatchId, results // Business data in payload }); } }); ``` **Best practice**: Context is automatically cleaned up when stimulation completes. Store only metadata in context, pass business data through signal payloads. 11. **Use batch processing with recursive self-calls** (instead of fan-out): **❌ Bad**: Creating 10,000 signals with large payloads floods memory: ```ts // This creates 10,000 tasks in queue, each with full payload const items = await db.fetchAll(10000); return items.map(item => axon.process.createSignal({ fullItem: item }) // 10KB each = 100MB in queue! ); ``` **✅ Good**: Process in batches with recursive self-calls. **Pass offset through payload**, not context: ```ts const BATCH_SIZE = 20; const batchProcessor = neuron({ processBatch, nextBatch }) .dendrite({ collateral: processBatch, response: async (payload, axon) => { // Offset comes from payload, not context const offset = payload.offset ?? 0; // Fetch only one batch from DB const batch = await db.fetchBatch(offset, BATCH_SIZE); // Process this batch (small memory footprint) await processItems(batch); // If more items exist, recursively call self with next offset in payload if (batch.length === BATCH_SIZE) { // Recursive self-call - pass offset through payload return axon.nextBatch.createSignal({ offset: offset + BATCH_SIZE }); } // Done ctx.delete('offset'); return undefined; } }); // Start processing cns.stimulate(processBatch.createSignal({ offset: 0 })); ``` **Benefits:** - Only one batch (20 items) in memory at a time - Queue length stays at 1-2 tasks instead of 10,000 - Memory usage: ~200KB instead of ~100MB - Natural backpressure: next batch only starts after current completes - Works perfectly with per-neuron concurrency limits **Pattern**: Fetch → Process → Recurse (if more) → Cleanup ## Performance characteristics - **Sync-first**: Synchronous neuron chains execute in a single tick without extra Promise overhead. - **Minimal async overhead**: Async responses only schedule a microtask; not inherently slower. Promises are created only when a neuron returns an async result. - **Stack-safe**: Deep chains are handled via an internal queue, avoiding stack overflow. - **Bounded execution**: `maxNeuronHops` prevents runaway processing in cyclic graphs. ## Best practices ### Keep context data minimal Store only essential data (IDs, counters, flags) in context. Avoid large objects or full entities. ```ts // ✅ Good: minimal context ctx.set({ userId: '123', attempt: 2 }); // ❌ Bad: bloated context ctx.set({ user: fullUserObject, history: lotsOfData }); ``` ### Use synchronous responses when possible If a neuron doesn't perform I/O, return the next signal synchronously: ```ts // ✅ Sync response (fast) .dendrite({ collateral: input, response: (p, axon) => axon.output.createSignal({ value: p.value * 2 }) }); // ⚠️ Async response (schedules a microtask; use when doing I/O) .dendrite({ collateral: input, response: async (p, axon) => { const result = await fetch('/api'); return axon.output.createSignal(result); } }); ``` ### Set reasonable `maxNeuronHops` Default: undefined (disabled). If you need a safety cap for cyclic graphs, set a lower limit: ```ts const stimulation = cns.stimulate(signal, { maxNeuronHops: 10 // stop after 10 hops (optional, disabled by default) }); await stimulation.waitUntilComplete(); ``` ### Implement proper error handling Use `onResponse` to log errors without blocking the flow: ```ts const stimulation = cns.stimulate(signal, { onResponse: (r) => { if (r.error) logger.error(r.error); if (r.queueLength === 0) logger.info('done'); // terminal response } }); await stimulation.waitUntilComplete(); ``` ### Avoid `autoCleanupContexts` in production The CNS `autoCleanupContexts` option adds overhead: - **Initialization cost**: the SCC (Strongly Connected Components) analysis is built lazily on first use — so it is only paid when this option is enabled (or you read the SCC APIs), not on every `new CNS()`. Building it is `O(V + E)` for the graph/SCC pass plus the SCC-ancestor transitive closure (up to `O(V²)` for densely connected graphs). - **O(1 + A) runtime cost** per cleanup check (where A = number of SCC ancestors) - **Memory overhead** for storing SCC graphs and ancestor relationships **Use only when:** - Memory leaks are a critical issue - You have a small to medium-sized neuron graph (< 1000 neurons) - Performance is less critical than memory management **For production systems**, prefer manual context cleanup or custom cleanup strategies. ## Measuring performance Use `onResponse` to track signal flow timing: ```ts const start = Date.now(); const stimulation = cns.stimulate(signal, { onResponse: (r) => { if (r.queueLength === 0) { console.log(`Completed in ${Date.now() - start}ms, ${r.hops} hops`); } } }); await stimulation.waitUntilComplete(); ``` To measure the synchronous batches *inside* a run rather than the run as a whole, use `onDrain` — it fires once per synchronous turn, so counting drains tells you how many times the stimulation had to yield to the event loop: ```ts let turns = 0; const stimulation = cns.stimulate(signal, { onDrain: (d) => { turns++; if (d.queueLength === 0) { console.log(`Completed in ${Date.now() - start}ms across ${turns} turns`); } } }); ``` Or integrate with your APM/tracing tool (e.g., OpenTelemetry): ```ts const stimulation = cns.stimulate(signal, { onResponse: (r) => { span.addEvent('neuron', { collateral: r.outputSignal?.collateral }); if (r.error) span.recordException(r.error); } }); await stimulation.waitUntilComplete(); ``` # Source: https://cnstra.org/docs/advanced/persistence When you run large queues or long-lived flows in production, processes can restart — deploys, crashes, OOM kills. `CNSPersistOptionsRegistry` gives you the tools to serialize in-flight stimulations so they can be resumed after a restart. ## The problem CNStra stimulations are in-memory. When a process dies, all pending signals are lost. For short-lived flows this is fine. For long-running flows — multi-step order processing, batch jobs, import pipelines — you need a way to know what was running and pick up where you left off. ## How CNSPersistOptionsRegistry helps The registry maps **object references** ↔ **stable string names**: ``` deckNeuron (object ref) ←→ "deckNeuron" (string, safe to store in DB) deckAxon.created (object ref) ←→ "deckCreated" (string, safe to store in DB) ``` This lets you serialize "which signal was in flight" as a plain JSON record, then deserialize it back into a real signal on restart. ## Setup Create one registry instance and register neurons and collaterals from each file: ```ts // src/neurons/registry.ts — create once import { CNSPersistOptionsRegistry } from '@cnstra/persist'; export const registry = new CNSPersistOptionsRegistry(); ``` ```ts // src/neurons/deck.ts — register in your neuron's own file import { collateral, withCtx } from '@cnstra/core'; import { registry } from './registry'; export const deckCreated = collateral<{ deckId: string }>(); export const deckArchived = collateral<{ deckId: string }>(); export const importStarted = collateral<{ importId: string }>(); // external entry point export const deckNeuron = withCtx() .neuron({ deckCreated, deckArchived }) .bind({ importStarted }, { importStarted: ({ importId }, axon) => axon.deckCreated.createSignal({ deckId: importId }), }); registry .register('deckNeuron', deckNeuron) // registers neuron + its axon collaterals .registerCollateral('importStarted', importStarted); // standalone entry-point collateral ``` Register **only the collaterals that are entry points** — the ones you actually stimulate from outside (from a job queue, webhook, cron). You don't need to register intermediate collaterals that are emitted between neurons internally. ## Pattern 1: Message broker with named signals The most common pattern. Each job stores a collateral name + payload. On restart, the broker re-delivers unprocessed jobs and the worker reconstructs the signal by name. ```ts import Queue from 'bullmq'; // Enqueue — store collateral name as a string await queue.add('process', { collateralName: 'deckCreated', payload: { deckId: 'deck-123', userId: 'user-456' }, }); // Worker — reconstruct signal from name on any process (after restart too) new Worker('process', async (job) => { const collateral = registry.getCollateral(job.data.collateralName); if (!collateral) throw new Error(`Unknown collateral: ${job.data.collateralName}`); const stimulation = cns.stimulate( collateral.createSignal(job.data.payload) ); await stimulation.waitUntilComplete(); }); ``` Because the name `"deckCreated"` is a stable string in your DB, the worker can reconstruct the exact signal on any restart. ## Pattern 2: Checkpoint in-flight stimulations For very long flows you may want to checkpoint progress — save which stimulation is running so you can detect stale ones after a crash. ```ts const stimulationId = `stim-${crypto.randomUUID()}`; // Save to DB before starting await db.stimulations.create({ id: stimulationId, collateralName: 'importStarted', payload: { importId: 'import-123' }, status: 'running', startedAt: new Date(), }); // Register the in-flight stimulation const stimulation = cns.stimulate( importAxon.started.createSignal({ importId: 'import-123' }) ); registry.addStimulation(stimulation, { stimulationId }); // Clean up on complete stimulation.waitUntilComplete().then(() => { registry.removeStimulation(stimulationId); db.stimulations.update(stimulationId, { status: 'done' }); }).catch(() => { registry.removeStimulation(stimulationId); db.stimulations.update(stimulationId, { status: 'failed' }); }); ``` > **Per-hop checkpoints (finer granularity).** The pattern above records progress at start/finish. If you need to durably record progress at *each* hop, return a promise from `onResponse` — it acts as a **barrier**, so the next hops do not run until your write lands. This gives a transactional boundary per hop: a crash between the checkpoint and the next hop resumes exactly the not-yet-done work. See [Response Listeners → Async listeners are a barrier](../recipes/response-listeners.md#async-listeners-are-a-barrier-this-is-how-checkpointing-works). **On restart** — find stale `running` records in the DB and re-stimulate: ```ts const stale = await db.stimulations.findAll({ status: 'running' }); for (const record of stale) { const collateral = registry.getCollateral(record.collateralName); if (!collateral) continue; const stimulation = cns.stimulate( collateral.createSignal(record.payload) ); registry.addStimulation(stimulation, { stimulationId: record.id }); // update status back to 'running' } ``` ## Pattern 3: Full serialization with context When you need to resume with the same context (retry state, attempt counters), pass a shared context store: ```ts import { CNSStimulationContextStore } from '@cnstra/core'; // Save context to DB alongside stimulation record const ctx = new CNSStimulationContextStore(); const stimulation = cns.stimulate(signal, { ctx }); // Serialize context periodically stimulation.waitUntilComplete().catch(async () => { const serialized = ctx.serialize(); // if your store supports it await db.stimulations.update(id, { status: 'failed', context: serialized, }); }); // Resume with restored context const restoredCtx = new CNSStimulationContextStore(); restoredCtx.restore(savedContext); cns.stimulate(signal, { ctx: restoredCtx }); ``` See [Custom Context Store](/docs/advanced/custom-context-store) for implementing serializable context. ## What to register | Register | Why | |---|---| | Entry-point collaterals (stimulated from outside) | So workers/jobs can reconstruct signals by name | | Neurons that own persisted domain models | So you can look them up by name for diagnostics | | Active stimulations (optional) | So you can detect and recover stale ones after a crash | **Don't register** internal collaterals emitted between neurons — those are intermediate steps, not resumption points. ## Registry in devtools vs production The same registry instance works for both persistence and devtools/AI inspection. The per-file `register()` approach is recommended for production since it co-locates registration with the neuron definition: ```ts // src/neurons/registry.ts import { CNSPersistOptionsRegistry } from '@cnstra/persist'; export const registry = new CNSPersistOptionsRegistry(); // src/neurons/deck.ts import { registry } from './registry'; registry .register('deckNeuron', deckNeuron) .registerCollateral('importStarted', importStarted); ``` For simple projects where all neurons are in one place, `CNSPersistOptionsRegistryFactory` is a convenient shorthand: ```ts import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist'; export const registry = CNSPersistOptionsRegistryFactory.create({ deckNeuron, cardNeuron, uiNeuron }); // equivalent to calling register() for each neuron ``` Both produce the same result and can be passed to devtools, MCP, and BullMQ workers. # Source: https://cnstra.org/docs/advanced/custom-context-store Implement a custom context store for persistence, distributed systems, or specialized storage backends. ## Interface Context stores implement `ICNSStimulationContextStore`: ```ts interface ICNSStimulationContextStore { get(): T | undefined; set(value: T): void; } ``` The default implementation is an in-memory store. For long-lived sagas or distributed systems, you can provide a persistent or shared store. ## Redis-backed context store ```ts import type { ICNSStimulationContextStore } from '@cnstra/types'; import { RedisClient } from 'redis'; class RedisContextStore implements ICNSStimulationContextStore { constructor( private client: RedisClient, private sessionId: string ) {} get(): T | undefined { const raw = this.client.getSync(`cnstra:ctx:${this.sessionId}`); return raw ? JSON.parse(raw) : undefined; } set(value: T): void { this.client.setSync( `cnstra:ctx:${this.sessionId}`, JSON.stringify(value), 'EX', 3600 // 1 hour TTL ); } } // Use in stimulation await cns.stimulate(signal, { createContextStore: () => new RedisContextStore(redisClient, 'session-123') }); ``` ## Database-backed context store ```ts class DBContextStore implements ICNSStimulationContextStore { constructor( private db: DatabaseClient, private runId: string ) {} get(): T | undefined { const row = this.db.query( 'SELECT data FROM context_store WHERE run_id = ?', [this.runId] ); return row ? JSON.parse(row.data) : undefined; } set(value: T): void { this.db.execute( 'INSERT INTO context_store (run_id, data) VALUES (?, ?) ON CONFLICT(run_id) DO UPDATE SET data = ?', [this.runId, JSON.stringify(value), JSON.stringify(value)] ); } } await cns.stimulate(signal, { createContextStore: () => new DBContextStore(db, 'run-456') }); ``` ## OIMDB-backed context store For reactive frontend state: ```ts import { db } from './oimdb-instance'; class OIMDBContextStore implements ICNSStimulationContextStore { constructor(private runId: string) {} get(): T | undefined { const record = db.context.selectByPrimaryKey({ runId: this.runId }); return record?.data as T | undefined; } set(value: T): void { db.context.upsertOne({ runId: this.runId, data: value }); } } await cns.stimulate(signal, { createContextStore: () => new OIMDBContextStore('run-789') }); ``` ## Reusing context for recovery When an error occurs, save the context store and retry: ```ts let savedContext: ICNSStimulationContextStore | undefined; await cns.stimulate(signal, { onResponse: (r) => { if (r.error) { savedContext = r.contextStore; // capture for retry } } }); // Retry with same context if (savedContext) { await cns.stimulate(retrySignal, { ctx: savedContext }); } ``` ## Tips - **Serialization**: Store only JSON-serializable data (no functions, Dates, etc.) unless using a custom serializer. - **TTL**: For distributed stores (Redis, DB), set TTL or cleanup policies to avoid unbounded growth. - **Performance**: Persistent stores add I/O overhead; use in-memory stores for high-throughput short-lived flows. - **Consistency**: In distributed systems, ensure your store supports read-your-own-writes semantics if neurons might run on different nodes. # Source: https://cnstra.org/docs/advanced/common-issues This guide covers common issues you might encounter when using CNStra and how to resolve them. ## Concurrency Scope: Per-CNS Instance **Important**: Concurrency limits in CNStra work at the level of **one CNS instance in one process**. Each CNS instance maintains its own independent concurrency gates. ### Understanding Instance-Level Concurrency When you set a concurrency limit on a neuron using `neuron.setConcurrency(n)`, that limit applies **only within a single CNS instance**. If you create multiple CNS instances, each will have its own separate concurrency control. ```ts // First CNS instance const cns1 = new CNS([workerNeuron.setConcurrency(2)]); // This instance allows max 2 concurrent executions of workerNeuron // Second CNS instance (separate process or module) const cns2 = new CNS([workerNeuron.setConcurrency(2)]); // This instance ALSO allows max 2 concurrent executions // But these limits are INDEPENDENT - total could be 4 concurrent executions ``` ### Multiple Instances in NestJS If you create multiple CNS instances in a NestJS application (e.g., in different modules), each instance will have its own independent concurrency gates: ```ts // Module A @Module({}) export class ModuleA { private cnsA = new CNS([ apiWorker.setConcurrency(5) ]); } // Module B @Module({}) export class ModuleB { private cnsB = new CNS([ apiWorker.setConcurrency(5) // Same neuron, but different instance ]); } ``` **Result**: You could have up to **10 concurrent executions** of `apiWorker` (5 per instance), not 5 total. ### Why This Matters This behavior is important when: 1. **Rate limiting external APIs**: If you set `concurrency: 5` to limit API calls, but create 3 CNS instances, you could end up with 15 concurrent calls instead of 5. 2. **Resource management**: Database connection pools, file handles, or other limited resources might be exhausted if multiple instances each allow their own concurrency. 3. **Shared resource contention**: If multiple instances access the same external service, the effective concurrency is the sum of all instance limits. ### Solutions #### Option 1: Single CNS Instance (Recommended) Create one shared CNS instance and inject it where needed: ```ts // cns.module.ts @Module({ providers: [ { provide: 'CNS', useFactory: () => new CNS([ workerNeuron.setConcurrency(5) ]) } ], exports: ['CNS'] }) export class CNSModule {} // other.module.ts @Module({ imports: [CNSModule] }) export class OtherModule { constructor(@Inject('CNS') private cns: CNS) {} } ``` #### Option 2: Coordinate Concurrency Across Instances If you must use multiple instances, coordinate concurrency limits externally: ```ts // Use a shared semaphore or rate limiter import { RateLimiter } from 'limiter'; const sharedLimiter = new RateLimiter({ tokensPerInterval: 5, interval: 'second' }); const workerNeuron = neuron({}) .dendrite({ collateral: task, response: async (payload, axon) => { await sharedLimiter.removeTokens(1); // Now process with shared limit return axon.output.createSignal(await processTask(payload)); } }); ``` #### Option 3: Use External Queue Systems For production systems, use external queue systems (BullMQ, RabbitMQ, AWS SQS) that provide process-wide or distributed concurrency control: ```ts import { Queue, Worker } from 'bullmq'; // Single queue with process-wide concurrency control const queue = new Queue('jobs'); const worker = new Worker('jobs', async (job) => { const stimulation = cns.stimulate(task.createSignal(job.data)); await stimulation.waitUntilComplete(); }, { concurrency: 5 // This applies across all workers in the process }); ``` See [Integrations](/docs/integrations/message-brokers) for more details. For best practices on context usage, see [Best Practices](/docs/advanced/best-practices). ## Memory Issues ### Large Payloads in Queue If your signal payloads are large and queues grow, memory usage can spike. See [Performance](/docs/advanced/performance) for detailed guidance on managing memory. **Quick fix**: Keep payloads small, use references (IDs) instead of full objects. ### Context Not Cleaning Up Context data persists for the entire duration of a stimulation. If stimulations run for a long time or context grows large, memory usage increases. **Solution**: Context is automatically cleaned up when stimulation completes. **Store only metadata in context**, not business data. Business data should flow through signal payloads: ```ts .dendrite({ collateral: process, response: async (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata (not business data) const metadata = ctx.get(); // Use metadata... // Business data flows through payloads return axon.next.createSignal({ result: payload.data }); } }); ``` ## Stimulation Not Completing ### Infinite Loops If your neuron graph has cycles and no termination condition, stimulations can run indefinitely. **Solution**: Set `maxNeuronHops` to prevent runaway processing: ```ts const stimulation = cns.stimulate(signal, { maxNeuronHops: 100 // Stop after 100 hops }); ``` ### Errors Not Being Handled If a dendrite throws and **nothing observes the run**, the failure is *silent*: `stimulate()` returns a `CNSStimulation` (not a Promise), the completion Promise is created lazily by `waitUntilComplete()`, and with no response listener the error is caught internally onto `response.error` but never logged, thrown, or even raised as an unhandled rejection. The symptom is usually "no output / nothing happened" (sometimes a run that looks stuck) — not an error in the console. **Solution**: observe every run with a global listener, **or** await completion (a dendrite throw rejects `waitUntilComplete()` even with no listener). See [Error Handling](/docs/recipes/error-handling) for the full picture. ```ts // A) Global listener — surfaces failures from every stimulation (fire-and-forget safe) cns.addResponseListener((r) => { if (r.error) console.error('Error:', r.error); }); // B) Or await the run and catch its rejection await cns.stimulate(signal).waitUntilComplete(); ``` ## Type Safety Issues ### Missing Collateral Bindings If you forget to bind a collateral, TypeScript will catch it at compile time (with exhaustive binding). **Solution**: Use exhaustive binding to ensure all collaterals are handled: ```ts neuron.bind(otherNeuron, { signal1: (p) => { /* ... */ }, signal2: (p) => { /* ... */ }, // TypeScript will error if signal3 is missing }); ``` ## Performance Issues ### Too Many Concurrent Stimulations Creating thousands of stimulations simultaneously can overwhelm the system. **Solution**: Use external queue systems or batch processing. See [Performance](/docs/advanced/performance) for optimization strategies. ### Slow External APIs If external APIs are slow and you have high concurrency, you might exhaust connection pools or hit rate limits. **Solution**: Set appropriate per-neuron concurrency limits: ```ts const apiNeuron = neuron({}) .setConcurrency(10) // Limit concurrent API calls .dendrite({ collateral: request, response: async (payload, axon) => { const result = await callExternalAPI(payload); return axon.output.createSignal(result); } }); ``` ## Getting Help If you encounter issues not covered here: 1. Check the [API documentation](/docs/core/api) for correct usage 2. Review [Performance guide](/docs/advanced/performance) for optimization tips 3. See [Recipes](/docs/recipes/cancel) for common patterns 4. Open an issue on [GitHub](https://github.com/abaikov/cnstra/issues) # Source: https://cnstra.org/docs/recipes/cancel Use `AbortSignal` to cancel in-flight stimulation runs. ```ts const controller = new AbortController(); const stimulation = cns.stimulate(collateral.createSignal(input), { abortSignal: controller.signal, }); // Later controller.abort(); await stimulation.waitUntilComplete(); ``` Notes: - Dendrites receive `abortSignal` in their context; long operations should check it. - The same `abortSignal` is visible to every neuron participating in the current run; each can react early. - Compose with UI events (route change, user typing) to avoid stale work. Example: cooperative cancel inside a neuron ```ts const work = neuron({}).dendrite({ collateral: someInput, response: async (payload, axon, ctx) => { if (ctx.abortSignal?.aborted) return; // bail before starting const res = await heavyTask(payload, ctx.abortSignal); // pass signal to IO when possible if (ctx.abortSignal?.aborted) return; // bail before emit return undefined; }, }); ``` Cancelling a running stimulation ```ts const ac = new AbortController(); // start the run const stimulation = cns.stimulate(start.createSignal(payload), { abortSignal: ac.signal }); // cancel at any time (e.g., on route change) ac.abort(); await stimulation.waitUntilComplete(); ``` ## Graceful shutdown There are two ways to finish active runs: - Natural drain (recommended): - Stop starting new `stimulate(...)` calls and simply `await` all in‑flight stimulations via `stimulation.waitUntilComplete()`. - The internal queue drains and the run completes naturally. - Cooperative termination via `AbortSignal`: - Pass an `abortSignal` to `stimulate(...)` and call `abort()` when you want to stop. - Behavior: - New queued items do not start (cancel gate). - Once active tasks finish, the run resolves even if there are queued items that never started. - Implications: - Not‑started items are skipped; if you need them later, persist them (e.g., in your context store or an external queue) and restart explicitly. Example: graceful shutdown on process signals ```ts const ac = new AbortController(); process.on('SIGTERM', () => ac.abort()); process.on('SIGINT', () => ac.abort()); // elsewhere const stimulation = cns.stimulate(start.createSignal(payload), { abortSignal: ac.signal }); await stimulation.waitUntilComplete(); ``` # Source: https://cnstra.org/docs/recipes/retry Implement retries by looping within the same neuron. **Use context for attempt tracking** (per-neuron per-stimulation metadata), while business data flows through payloads. ```ts import { withCtx, collateral } from '@cnstra/core'; // Collaterals owned by the retry neuron const tryFetch = collateral<{ url: string }>(); const completed = collateral<{ ok: true; data: unknown }>(); const failed = collateral<{ ok: false; error: unknown }>(); const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); export const fetchWithRetry = withCtx<{ attempt?: number }>() .neuron({ tryFetch, completed, failed }) .dendrite({ collateral: tryFetch, response: async (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata (retry attempts) const attempt = (ctx.get()?.attempt ?? 0) + 1; ctx.set({ attempt }); try { const res = await fetch(payload.url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return axon.completed.createSignal({ ok: true, data }); } catch (error) { if (attempt < 3) { await sleep(2 ** (attempt - 1) * 250); // backoff: 250ms, 500ms, ... // self-loop: re-emit our own input collateral // Business data (url) flows through payload return axon.tryFetch.createSignal({ url: payload.url }); } return axon.failed.createSignal({ ok: false, error }); } }, }); ``` Notes - Self-loop uses the neuron's own input collateral (`retry:tryFetch`), complying with ownership. - **Context stores per-neuron per-stimulation metadata** (attempt count) - each neuron in each stimulation has its own context - **Business data (url) flows through payloads** - not stored in context - Prefer queue-native retries (e.g., BullMQ, SQS) in production for visibility; use local retries for transient client/network work. # Source: https://cnstra.org/docs/recipes/retry-stimulation When a stimulation fails or is interrupted, you can wait for active tasks to complete and then retry from the same point using `cns.activate()` with failed tasks. This pattern ensures that: - Active tasks finish executing before saving state - Only failed tasks are retried - State can be passed through signal payloads if needed ## Basic Pattern ```ts import { CNS, collateral, neuron } from '@cnstra/core'; const input = collateral<{ id: number }>(); const step1Out = collateral<{ id: number }>(); const step2Out = collateral<{ id: number }>(); const output = collateral<{ result: string }>(); const step1 = neuron({ step1Out }).dendrite({ collateral: input, response: async (payload, axon) => { await processStep1(payload); return axon.step1Out.createSignal({ id: payload.id }); }, }); const step2 = neuron({ step2Out }).dendrite({ collateral: step1Out, response: async (payload, axon) => { await processStep2(payload); return axon.step2Out.createSignal({ id: payload.id }); }, }); const step3 = neuron({ output }).dendrite({ collateral: step2Out, response: async (payload, axon) => { return axon.output.createSignal({ result: `Final: ${payload.id}` }); }, }); const cns = new CNS([step1, step2, step3]); // First attempt let savedFailedTasks: Array<{ neuron: object; dendriteCollateral: object; }> | undefined; const stimulation = cns.stimulate(input.createSignal({ id: 100 })); try { // waitUntilComplete() waits for all active tasks to finish // even if there's an error, active tasks will complete first await stimulation.waitUntilComplete(); } catch (error) { // After active tasks complete, save failed tasks for retry savedFailedTasks = stimulation.getFailedTasks().map(ft => ft.task); } // Retry attempt: resume from failed tasks if (savedFailedTasks) { const retryStimulation = cns.activate(savedFailedTasks); await retryStimulation.waitUntilComplete(); } ``` ## Passing State Through Payloads If you need to preserve state across retries, pass it through signal payloads: ```ts // Include state in payload const input = collateral<{ id: number; executed?: string[] }>(); const step1 = neuron({ step1Out }).dendrite({ collateral: input, response: async (payload, axon) => { const executed = payload.executed || []; await processStep1(payload); // Pass state forward in signal return axon.step1Out.createSignal({ id: payload.id, executed: [...executed, 'step1'] }); }, }); ``` ## Key Points - `stimulation.waitUntilComplete()` waits for all active tasks to finish before resolving or rejecting - `stimulation.getFailedTasks()` returns tasks that failed or were aborted - `cns.activate()` resumes execution from specific tasks - State can be passed through signal payloads if needed ## Notes - This pattern works with any retry mechanism: queue systems (BullMQ, SQS, RabbitMQ), custom retry endpoints, scheduled jobs, or manual retry triggers - You can persist the failed tasks to a database, Redis, or any storage system, then restore them when retrying - If you need to preserve state, include it in signal payloads and pass it through the chain - Active tasks are allowed to complete before saving state, ensuring consistency # Source: https://cnstra.org/docs/recipes/error-handling Handle errors gracefully using `onResponse` callbacks (sync or async) and context-based retry. ## Error delivery Errors are delivered immediately via `onResponse`, and they also **fail the run**: `stimulation.waitUntilComplete()` rejects if **any dendrite `response` throws or returns a rejected Promise** (a *failed task*) — or if any response listener (local or global) throws or rejects. A dendrite throw fails the run on its own; you do **not** need a listener for `waitUntilComplete()` to reject. ```ts const stimulation = cns.stimulate(signal, { onResponse: async (response) => { if (response.error) { await errorsRepo.store({ signal: response.outputSignal?.collateral || response.inputSignal?.collateral, error: String(response.error), }); if (response.error instanceof ValidationError) { handleValidationError(response.error); } } } }); await stimulation.waitUntilComplete(); ``` ## Fire-and-forget & the lazy completion promise `cns.stimulate(...)` returns a **`CNSStimulation` object, not a Promise**. The completion Promise is materialized **lazily** — only when you call `stimulation.waitUntilComplete()`. This is a deliberate optimization (allocating a Promise per stimulation is the single most expensive setup cost), but it has a sharp edge: > ⚠️ **If you fire-and-forget a stimulation (never call `waitUntilComplete()`) and register no response listener, a thrown dendrite error is completely swallowed.** It is caught internally and recorded on `response.error`, but with nothing observing it there is no log, no throw, and **not even an unhandled promise rejection** — the rejecting Promise was never created. (With no listener, the run also skips building the response object entirely, so the error has nowhere to surface.) ```ts // ❌ Silent on failure: no listener AND not awaited. cns.stimulate(signal); // a throwing dendrite here vanishes without a trace ``` Make failures visible with **either** of these — pick based on the call site: ```ts // A) Global listener — cheapest for fire-and-forget producers (WS streams, queues, // background ingestion). One listener surfaces errors from every stimulation, // so you never have to await individual runs. cns.addResponseListener(r => { if (r.error) console.error('[cns] neuron error:', r.error); }); cns.stimulate(signal); // a failure is now logged // B) Await completion — when the caller owns the run and wants the rejection. // Works even with NO onResponse listener: a dendrite throw becomes a failed // task, and waitUntilComplete() rejects with // "Stimulation completed with N failed task(s)". await cns.stimulate(signal).waitUntilComplete(); ``` Rule of thumb: **await `waitUntilComplete()`** when a caller drives a single run and cares about its outcome; register a **global `addResponseListener`** for long-lived, fire-and-forget producers where awaiting each run is impractical. Do at least one — otherwise dendrite failures are invisible. ## Error recovery with context Save context for retry on failure: ```ts let savedContext: ICNSStimulationContextStore | undefined; const stimulation = cns.stimulate(signal, { onResponse: (response) => { if (response.error) { savedContext = response.stimulation.getContext(); // save for retry } } }); await stimulation.waitUntilComplete(); // Retry with preserved context if (savedContext) { const retryStimulation = cns.stimulate(retrySignal, { ctx: savedContext }); await retryStimulation.waitUntilComplete(); } ``` ## Retry with backoff (self-loop) Use a self-looping neuron with context to track **per-neuron per-stimulation retry attempts** (metadata), while business data flows through payloads: ```ts import { withCtx, collateral } from '@cnstra/core'; const tryTask = collateral<{ taskId: string }>(); const completed = collateral<{ taskId: string }>(); const failed = collateral<{ taskId: string; reason: string }>(); const taskRunner = withCtx<{ attempt: number }>() .neuron({ tryTask, completed, failed }) .dendrite({ collateral: tryTask, response: async (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata (retry attempts) const prev = ctx.get() ?? { attempt: 0 }; const attempt = prev.attempt + 1; ctx.set({ attempt }); try { // Business data (taskId) comes from payload await performTask(payload.taskId); return axon.completed.createSignal({ taskId: payload.taskId }); } catch (err) { if (attempt < 5) { const backoff = Math.pow(2, attempt) * 100; // exponential backoff await new Promise(resolve => setTimeout(resolve, backoff)); // Business data flows through payload, not context return axon.tryTask.createSignal(payload); // self-loop retry } return axon.failed.createSignal({ taskId: payload.taskId, reason: String(err) }); } }, }); ``` **Key point**: Context stores **per-neuron per-stimulation metadata** (attempt count), while **business data** (taskId) flows through signal payloads. ## Tips - Use `onResponse` for real-time error logging/monitoring; make it `async` if you need to persist. - Store minimal retry state in context (attempt count, correlation IDs). - For long-lived sagas, persist context to a DB/OIMDB and re-stimulate on external triggers. - Always set a max retry limit to avoid infinite loops. ### Global listeners Global listeners registered via `addResponseListener` run for every stimulation alongside the local `onResponse`. They also can be async; failures in any listener reject the `stimulation.waitUntilComplete()` Promise. ## Best practices - Timeouts: wrap external I/O in timeouts inside dendrites and async `onResponse` to avoid hanging runs. - Idempotency: design `onResponse` persistence to be idempotent (e.g., upserts, unique keys) so retries are safe. - Retry policy: prefer bounded retries with exponential backoff; use context to track attempts; avoid hot loops. - Partial failure: emit explicit failure signals from dendrites when business errors occur; reserve thrown errors for exceptional cases. - Observability: tag your own run/correlation id (if you have one) and collaterals in logs/metrics; capture `queueLength` to identify bottlenecks (and split it into `pendingActivations` / `activeActivations` to tell "producing too fast" from "slow bodies" apart). - Isolation: keep `onResponse` lightweight; move heavy processing to dedicated neurons/signals when possible. - Concurrency: if persisting from `onResponse`, consider batching or a queue to smooth spikes in traffic. - Ordering: if ordering matters, include sequence numbers in payloads or serialize writes per run/correlation id you provide. - Durability: when persisting context for retries, write before emitting downstream effects; verify on restart. # Source: https://cnstra.org/docs/recipes/saga Model long-running, multi-step reactions with explicit branches and cancel hooks. Short‑lived saga (single stimulation) - Entire flow completes within one `stimulate(...)` run. - Each neuron emits only its own axon collaterals (signal ownership). ```ts import { CNS, collateral, neuron } from '@cnstra/core'; // Domain collaterals const order = { created: collateral<{ id: string }>(), reserved: collateral<{ id: string }>(), charged: collateral<{ id: string }>(), confirmed: collateral<{ id: string }>(), failed: collateral<{ id: string; reason?: string }>(), compensated: collateral<{ id: string }>(), }; // Reserve inventory → emits reserved/failed (owned by reservation) export const reservation = neuron({ reserved: order.reserved, failed: order.failed, }).dendrite({ collateral: order.created, response: async (p, axon) => { const ok = await inventory.reserve(p.id); return ok ? axon.reserved.createSignal({ id: p.id }) : axon.failed.createSignal({ id: p.id, reason: 'no_stock' }); }, }); // Charge payment → emits charged/failed; also compensates on failure (releases stock) export const payment = neuron({ charged: order.charged, failed: order.failed, compensated: order.compensated, }) .dendrite({ collateral: order.reserved, response: async (p, axon) => { const ok = await payments.charge(p.id); return ok ? axon.charged.createSignal({ id: p.id }) : axon.failed.createSignal({ id: p.id, reason: 'card_declined' }); }, }) .dendrite({ collateral: order.failed, response: async (p, axon) => { await inventory.release(p.id); return axon.compensated.createSignal({ id: p.id }); }, }); // Confirm order → emits confirmed export const confirmation = neuron({ confirmed: order.confirmed, }).dendrite({ collateral: order.charged, response: (p, axon) => axon.confirmed.createSignal({ id: p.id }), }); // Wire and run const cns = new CNS([reservation, payment, confirmation]); await cns.stimulate(order.created.createSignal({ id: 'o1' })); ``` Long‑lived saga (multiple stimulations over time) - Re‑stimulate when an external event arrives (queue/webhook/cron/socket). - Use a small bridge neuron to map external events into domain collaterals (ownership preserved). ```ts const paymentReceivedExternal = collateral<{ id: string }>(); // Bridge: converts external event into domain "charged" (owned by this bridge) export const paymentBridge = neuron({ charged: order.charged }) .dendrite({ collateral: paymentReceivedExternal, response: (p, axon) => axon.charged.createSignal({ id: p.id }), }); const cns = new CNS([reservation, payment, confirmation, paymentBridge]); // First run await cns.stimulate(order.created.createSignal({ id: 'o1' })); // Later, on external event queue.on('payment_received', async (m) => { await cns.stimulate(paymentReceivedExternal.createSignal({ id: m.orderId })); }); ``` Notes - No function-passing neurons: use `neuron(name, axon).dendrite({ collateral, response })`. - Ownership: a neuron may emit only its axon collaterals; other neurons listen via dendrites. - Compensation is modeled as explicit branches; cancellation via `AbortSignal` if needed. # Source: https://cnstra.org/docs/recipes/multiple-signals Sometimes a neuron needs to emit multiple signals in response to a single input. CNStra supports returning arrays of signals from neuron responses. ## Basic Array Return A neuron can return an array of signals instead of a single signal: ```ts import { CNS, collateral, neuron } from '@cnstra/core'; const input = collateral<{ value: number }>(); const output1 = collateral<{ result: string }>(); const output2 = collateral<{ result: string }>(); const splitter = neuron({ output1, output2 }).dendrite({ collateral: input, response: (payload, axon) => { // Return an array of signals return [ axon.output1.createSignal({ result: `First: ${payload.value}` }), axon.output2.createSignal({ result: `Second: ${payload.value}` }), ]; }, }); ``` ## Use Cases ### Fan-out Pattern Split a single input into multiple parallel processing paths: ```ts const processPayment = collateral<{ orderId: string }>(); const updateInventory = collateral<{ orderId: string }>(); const notifyUser = collateral<{ orderId: string }>(); const logTransaction = collateral<{ orderId: string }>(); const orderProcessor = neuron({ updateInventory, notifyUser, logTransaction, }).dendrite({ collateral: processPayment, response: (payload, axon) => { // Process payment and trigger multiple downstream actions return [ axon.updateInventory.createSignal({ orderId: payload.orderId }), axon.notifyUser.createSignal({ orderId: payload.orderId }), axon.logTransaction.createSignal({ orderId: payload.orderId }), ]; }, }); ``` ### Dynamic Signal Generation Generate a variable number of signals based on input: ```ts const batchInput = collateral<{ items: string[] }>(); const itemOutput = collateral<{ item: string; index: number }>(); const batchProcessor = neuron({ itemOutput }).dendrite({ collateral: batchInput, response: (payload, axon) => { // Create a signal for each item return payload.items.map((item, index) => axon.itemOutput.createSignal({ item, index }) ); }, }); ``` ### Conditional Multiple Signals Return different numbers of signals based on conditions: ```ts const validation = collateral<{ data: any }>(); const success = collateral<{ validated: any }>(); const error = collateral<{ error: string }>(); const audit = collateral<{ action: string }>(); const validator = neuron({ success, error, audit }).dendrite({ collateral: validation, response: (payload, axon) => { const signals = []; if (isValid(payload.data)) { signals.push(axon.success.createSignal({ validated: payload.data })); } else { signals.push(axon.error.createSignal({ error: 'Validation failed' })); } // Always log to audit signals.push(axon.audit.createSignal({ action: 'validation_attempt' })); return signals; }, }); ``` ## Async Array Return Arrays of signals work seamlessly with async functions: ```ts const dataFetch = collateral<{ ids: string[] }>(); const dataReady = collateral<{ id: string; data: any }>(); const fetcher = neuron({ dataReady }).dendrite({ collateral: dataFetch, response: async (payload, axon) => { // Fetch all data asynchronously const results = await Promise.all( payload.ids.map(id => fetchData(id)) ); // Return array of signals return results.map((data, i) => axon.dataReady.createSignal({ id: payload.ids[i], data }) ); }, }); ``` ## Empty Arrays Returning an empty array is valid and will not propagate any signals: ```ts const filter = neuron({ output }).dendrite({ collateral: input, response: (payload, axon) => { if (!shouldProcess(payload)) { return []; // No signals emitted } return [axon.output.createSignal(payload)]; }, }); ``` ## Multiple Initial Signals You can also start a stimulation with multiple signals: ```ts const cns = new CNS([/* neurons */]); // Stimulate with an array of signals cns.stimulate([ input.createSignal({ value: 1 }), input.createSignal({ value: 2 }), input.createSignal({ value: 3 }), ]); ``` ## Best Practices 1. **Use for genuine fan-out**: Return arrays when you truly need parallel processing, not just for convenience 2. **Consider ordering**: Signals in the array are processed in order, but may execute concurrently based on queue settings 3. **Empty arrays are fine**: Don't hesitate to return an empty array when appropriate 4. **Mix with single signals**: You can mix neurons that return single signals with those that return arrays 5. **Type safety**: TypeScript will enforce correct signal types in arrays ## Performance Considerations - Each signal in the array triggers its own set of subscribers - If you have many signals, consider using concurrency limits - Arrays are processed immediately; signals are not batched # Source: https://cnstra.org/docs/recipes/response-listeners CNStra lets you observe traversal without polluting domain neurons. There are two hook points: - Per‑run: `onResponse` option of `cns.stimulate(...)` - Global: `cns.addResponseListener(...)` Both receive the same response object with `inputSignal`, `outputSignal`, `error`, `ctx`, `queueLength`, `pendingActivations` and `activeActivations`. ## Per‑run `onResponse` Use for ad‑hoc debugging or request‑scoped tracing. ```ts await cns.stimulate(start.createSignal({ id: '123' }), { onResponse: (r) => { if (r.error) { console.error('[run]', r.error.message); return; } if (r.inputSignal) { console.log('IN', r.inputSignal.collateral); } if (r.outputSignal) { console.log('OUT', r.outputSignal.collateral); } }, }); ``` ## Global `addResponseListener` Use for cross‑cutting concerns: metrics, logging, or OpenTelemetry spans. ```ts const off = cns.addResponseListener((r) => { if (r.error) { metrics.increment('cnstra.error'); return; } if (r.outputSignal) { tracer.add('emit', r.outputSignal.collateral); } }); // later, to remove the listener off(); ``` ## What events are delivered? - `inputSignal`: when a signal enters the run (including the initial one) - `outputSignal`: when a dendrite returns a continuation - `error`: when a dendrite throws - `queueLength`: every activation the stimulation still owns — `pendingActivations + activeActivations`. Good for backpressure metrics. `queueLength === 0` marks the terminal response, i.e. the stimulation is finished. - `pendingActivations`: activations whose dendrite body has not been invoked yet (queued, held, or reserved by an in-progress fan-out). Subscribers parked behind an async listener count here. - `activeActivations`: activations whose body was invoked and returned a promise that has not settled. :::note Looking for a batch boundary? `queueLength === 0` tells you the run is over, not that a synchronous batch ended. Responses are emitted when an activation *finishes*, but a batch just as often ends when one *starts* and goes async. Use [`onDrain`](../core/api.md#batch-boundaries-ondrain) to commit or flush at batch boundaries. ::: ## Async listeners are a barrier (this is how checkpointing works) A listener may be synchronous or `async`. The return value changes the semantics: - **Return nothing (or a non-promise)** → purely observational. Traversal continues immediately; the listener does not affect flow. - **Return a promise (or use `async`/`await`)** → the listener becomes a **barrier**: the subscribers of the current response (the *next* hops) are **not enqueued until your promise resolves**. This blocking is intentional and is what makes **per-hop checkpointing** correct: in `onResponse` you can durably persist progress *before* the next hop runs, so a crash between the checkpoint and the next hop resumes exactly the not-yet-done work — no double execution, no lost work. Any neuron's response can act as a checkpoint point this way. ```ts await cns.stimulate(start.createSignal(input), { onResponse: async (r) => { // Subscribers of r.outputSignal wait until this write lands. await db.checkpoints.save(serialize(r.stimulation)); }, }); ``` > ⚠️ **Footgun**: because the same hook is used for observation *and* gating, a "passive" listener that happens to `await` something (a slow logger, a metrics flush) will **throttle the entire graph** — every hop now waits on it. For pure observation, do not return/await anything; buffer and flush out of band instead. ## Tips - Keep listeners lightweight; heavy work should be offloaded (e.g., buffer and batch) — unless you *intend* to gate (see above). - Exceptions thrown in listeners will reject `stimulation.waitUntilComplete()` once all active tasks finish. - Combine with `maxNeuronHops` in `stimulate` options to constrain traversal during debugging. `maxNeuronHops` is disabled by default. # Source: https://cnstra.org/docs/recipes/flow-inheritance Modalities and afferent paths are not just for debugging: they are a **flow inheritance mechanism**. You select a modality + afferent path **once** when starting a stimulation, and that context is available to every neuron response in the flow via `response.stimulation.options` / `ctx.stimulation.options`. This lets you reuse the same flow but override **one step** (or a few steps) for a specific scenario (onboarding, automation, retry, admin mode, etc.) without copying the whole pipeline. ![Two Brains - Neural network pattern for flow inheritance in CNStra](/img/two_brains.png) ## Core Concepts **Modality** — a group of related afferent paths that describe the hierarchy of signal processing. **Afferent Path** — the path a signal takes through the system. Paths can have parent paths, creating a hierarchy. **modalityDendrite** — a factory helper that automatically routes signals to different handlers based on the modality and afferent path specified during stimulation. This eliminates the need for manual path checking in response handlers. ## Biological Foundation These abstractions mirror two ideas from neuroscience: - **Modalities**: families of related signal sources (vision, hearing, touch…) - **Afferent paths**: hierarchical “routes” a signal takes through processing layers In neuroscience, a **sensory modality** refers to a type of sensory information, such as visual, auditory, somatosensory, olfactory, and gustatory. **Afferent pathways** (ascending pathways) carry sensory information from the periphery toward the central nervous system. These pathways are hierarchical (relay levels) and can do parallel processing, convergence, and divergence. Very rough visual example: ``` Retina → LGN → V1 → V2 → (streams) ``` ### Why This Matters for CNStra In CNStra, modalities and afferent paths solve a common problem in software architecture: **multiple different triggers leading to similar reactions**. #### The Problem: Duplicate Flows Consider a scenario where two different external triggers can lead to nearly identical processing: - A **user clicks a button** to create a deck with a card - An **onboarding flow starts** and needs to create the same deck with a card Without modalities and afferent paths, you'd need to duplicate the entire flow: ```ts // ❌ Without modalities - duplicated flows const deckFromClick = neuron(deckAxon) .dendrite({ collateral: uiAxon.createCardWithDeckButtonClicked, response: (payload, axon) => { // Create deck logic... }, }); const deckFromOnboarding = neuron(deckAxon) .dendrite({ collateral: onboardingAxon.started, response: (payload, axon) => { // Same create deck logic duplicated... }, }); ``` This leads to code duplication, maintenance burden, and makes it hard to see all the ways your system can be triggered. #### The Solution: Path Families With modalities and afferent paths, you create **path families** that allow: 1. **Reusing reactions** — the same neuron can handle multiple triggers 2. **Unique responses per path** — you can still customize behavior based on the afferent path 3. **Clear visibility** — you can see all external triggers a neuron responds to Minimal shape: ```ts const click = afferentPath(); const clickDeck = afferentPath(click); const userInteractionModality = modality({ click, clickDeck }); neuron(deckAxon).modalityDendrite({ collateral: uiAxon.createCardWithDeckButtonClicked, modality: userInteractionModality, afferentPaths: new Map([[clickDeck, handler]]), output: (result, axon) => axon.createdAtCreateCardWithDeckButtonClicked.createSignal(result), }); ``` ```ts // ✅ With modalities - shared flow with path awareness using modalityDendrite import { collateral, neuron, afferentPath, modality } from '@cnstra/core'; // Define collaterals const uiAxon = { createCardWithDeckButtonClicked: collateral<{ deckTitle: string; cardTitle: string; }>(), }; const onboardingAxon = { started: collateral<{ deckTitle: string; cardTitle: string; }>(), }; const deckAxon = { createdAtCreateCardWithDeckButtonClicked: collateral<{ deckId: string; cardTitle: string; }>(), }; // Create afferent paths as objects (no names - identity-based) const click = afferentPath(); const onboarding = afferentPath(); const clickDeck = afferentPath(click); const onboardingDeck = afferentPath(onboarding); const userInteractionModality = modality({ click, onboarding, clickDeck, onboardingDeck, }); // Shared deck creation logic function createDeck(payload: { deckTitle: string; cardTitle: string }) { return 'deck-' + Math.random().toString(36).slice(2); } function trackOnboardingProgress(event: string) { console.log(`Onboarding: ${event}`); } const deck = neuron(deckAxon) .modalityDendrite({ collateral: uiAxon.createCardWithDeckButtonClicked, modality: userInteractionModality, afferentPaths: new Map([ [clickDeck, (payload, axon) => { const deckId = createDeck(payload); return { deckId, cardTitle: payload.cardTitle, }; }], [onboardingDeck, (payload, axon) => { const deckId = createDeck(payload); // Special handling for onboarding path trackOnboardingProgress('deck-created'); return { deckId, cardTitle: payload.cardTitle, }; }], ]), default: (payload, axon) => { // Fallback for other paths const deckId = createDeck(payload); return { deckId, cardTitle: payload.cardTitle, }; }, output: (result, axon) => { return axon.createdAtCreateCardWithDeckButtonClicked.createSignal(result); }, }) .modalityDendrite({ collateral: onboardingAxon.started, modality: userInteractionModality, afferentPaths: new Map([ [onboardingDeck, (payload, axon) => { // Convert onboarding payload to deck creation format const deckId = createDeck({ deckTitle: payload.deckTitle, cardTitle: payload.cardTitle, }); trackOnboardingProgress('deck-created'); return { deckId, cardTitle: payload.cardTitle, }; }], ]), output: (result, axon) => { return axon.createdAtCreateCardWithDeckButtonClicked.createSignal(result); }, }); ``` #### Benefits - **No code duplication** — shared reactions across different triggers using `modalityDendrite` - **True flow inheritance** — one stimulation context propagates through the whole flow; each step can switch behavior based on the same selected path - **Scenario overrides** — override one step for “onboarding” (or any scenario) without rewriting the entire flow - **Clear architecture** — path families document the supported scenarios/entrypoints - **Type-safe routing** — handlers are matched by object reference, ensuring correct path selection ![Eye Wired - Visual system hierarchy example for afferent paths](/img/eye_wired.png) This biological inspiration makes CNStra's abstractions not just intuitive, but practically powerful for modeling real-world software systems where multiple entry points lead to shared processing flows. ## Flow Inheritance: Override One Step (Without Duplicating the Flow) The key idea: **every response sees the same selected afferent path** (from the stimulation options). So you can implement a “default flow”, and override only the step that changes for a specific scenario. Example: the flow is `createDeck → createCard → trackAnalytics`. For onboarding we only want to change analytics, everything else stays identical. ```ts import { CNS, collateral, neuron, afferentPath, modality } from '@cnstra/core'; const uiAxon = { createCardWithDeck: collateral<{ deckTitle: string; cardTitle: string }>(), }; const axon = { deckCreated: collateral<{ deckId: string; cardTitle: string }>(), cardCreated: collateral<{ cardId: string }>(), }; // Paths = scenarios (identity-based) const normal = afferentPath(); const onboarding = afferentPath(); const scenarios = modality({ normal, onboarding }); const createDeck = neuron(axon).modalityDendrite({ collateral: uiAxon.createCardWithDeck, modality: scenarios, // Same step shape, potentially different behavior per scenario afferentPaths: new Map([ [normal, payload => ({ deckId: `deck-${payload.deckTitle}`, cardTitle: payload.cardTitle })], [onboarding, payload => ({ deckId: `deck-${payload.deckTitle}`, cardTitle: payload.cardTitle })], ]), output: (result, axon) => axon.deckCreated.createSignal(result), }); const createCard = neuron(axon).modalityDendrite({ collateral: axon.deckCreated, modality: scenarios, afferentPaths: new Map([ [normal, payload => ({ cardId: `card-${payload.cardTitle}` })], [onboarding, payload => ({ cardId: `card-${payload.cardTitle}` })], ]), output: (result, axon) => axon.cardCreated.createSignal(result), }); // ✅ Only this step differs per scenario (the “override”) const trackAnalytics = neuron({}).modalityDendrite({ collateral: axon.cardCreated, modality: scenarios, afferentPaths: new Map([ [normal, payload => { track('card_created', { cardId: payload.cardId }); }], [onboarding, payload => { track('onboarding_card_created', { cardId: payload.cardId }); }], ]), output: () => undefined, }); const cns = new CNS([createDeck, createCard, trackAnalytics]); // Start the same flow with different inherited context: await cns .stimulate(uiAxon.createCardWithDeck.createSignal({ deckTitle: 'A', cardTitle: 'B' }), { modality: scenarios, afferentPath: scenarios.afferentPaths.onboarding, }) .waitUntilComplete(); function track(event: string, props: Record) { console.log(event, props); } ``` ## Creating Modalities and Afferent Paths CNStra provides factory functions to create modalities and afferent paths. These are identity-based objects (not named strings), which means they are compared by object reference, not by name. ### Creating Afferent Paths Use the `afferentPath()` function to create an afferent path. You can optionally specify a parent path to create a hierarchy: ```ts import { afferentPath } from '@cnstra/core'; // Create a root path (no parent) const root = afferentPath(); // Create a child path const child = afferentPath(root); // Create a grandchild path const grandchild = afferentPath(child); ``` This creates a hierarchy: ``` root └── child └── grandchild ``` ### Creating Modalities Use the `modality()` function to group related afferent paths together. Pass an object where keys are meaningful names (for your own reference) and values are the afferent path objects: ```ts import { afferentPath, modality } from '@cnstra/core'; // Create afferent paths const ui = afferentPath(); const deck = afferentPath(ui); const card = afferentPath(deck); // Create a modality grouping these paths const userInteractionModality = modality({ ui, deck, card, }); ``` The keys in the modality object (`ui`, `deck`, `card`) are for your convenience when debugging or logging. At runtime, paths are compared by object reference, not by these keys. ```ts import { afferentPath, modality } from '@cnstra/core'; // Step 1: Create afferent paths with hierarchy const click = afferentPath(); const onboarding = afferentPath(); const clickDeck = afferentPath(click); const onboardingDeck = afferentPath(onboarding); const clickCard = afferentPath(clickDeck); const onboardingCard = afferentPath(onboardingDeck); // Step 2: Group them into a modality const userInteractionModality = modality({ click, onboarding, clickDeck, onboardingDeck, clickCard, onboardingCard, }); // The modality now contains all paths, accessible by key: console.log(userInteractionModality.afferentPaths.click === click); // true console.log(userInteractionModality.afferentPaths.clickDeck === clickDeck); // true ``` ### Important Notes 1. **Identity-based**: Afferent paths are compared by object reference (`path1 === path2`), not by name 2. **Parent relationships**: When creating a path with a parent, the parent-child relationship is stored in `path.parentAfferentPath` 3. **Modality keys**: The keys in the modality object are for your convenience only; they don't affect runtime behavior 4. **No names at runtime**: Paths don't have string names at runtime - use the modality's keys for debugging/logging ## Using Modalities in Stimulation When starting a stimulation, you can specify a modality and initial afferent path in the options. These values are available in each response through `onResponse`: ```ts await cns.stimulate(signal, { modality: userInteractionModality, afferentPath: userInteractionModality.afferentPaths.clickDeck, onResponse: response => { const { modality, afferentPath } = response.stimulation.options ?? {}; console.log('modality match:', modality === userInteractionModality); console.log('afferentPath:', afferentPath); }, }).waitUntilComplete(); ``` ## Using modalityDendrite Helper The `modalityDendrite` helper is the recommended way to handle modality-based routing. It automatically selects the correct handler based on the modality and afferent path specified during stimulation. ### Basic Usage ```ts const click = afferentPath(); const onboarding = afferentPath(); const userModality = modality({ click, onboarding, }); const createNeuron = neuron({ output: collateral<{ id: string }>() }) .modalityDendrite({ collateral: input, modality: userModality, afferentPaths: new Map([ [click, (payload, axon) => { return { id: `click-${payload.source}` }; }], [onboarding, (payload, axon) => { return { id: `onboarding-${payload.source}` }; }], ]), output: (result, axon) => { return axon.output.createSignal(result); }, }); ``` ### Using Default Handlers You can provide default handlers at different levels: ```ts const click = afferentPath(); const onboarding = afferentPath(); const unknown = afferentPath(); const userModality = modality({ click, onboarding, unknown, }); const createNeuron = neuron({ output: collateral<{ id: string }>() }) .modalityDendrite({ collateral: input, modality: userModality, afferentPaths: new Map([ [click, (payload, axon) => { return { id: `click-${payload.source}` }; }], // onboarding path not specified - will use modality default ]), default: (payload, axon) => { // Handler for paths in this modality that don't have specific handlers return { id: `default-${payload.source}` }; }, output: (result, axon) => { return axon.output.createSignal(result); }, }); // When stimulating with unknown path, default handler is used await cns.stimulate(input.createSignal({ source: 'test' }), { modality: userModality, afferentPath: unknown, // Uses default handler }); ``` ### Multiple Modalities A single `modalityDendrite` can handle multiple modalities: ```ts const uiModality = modality({ interaction: afferentPath() }); const apiModality = modality({ request: afferentPath() }); neuron({ output }) .modalityDendrite({ collateral: input, modalities: [ { modality: uiModality, afferentPaths: new Map([[uiModality.afferentPaths.interaction, onUI]]) }, { modality: apiModality, afferentPaths: new Map([[apiModality.afferentPaths.request, onAPI]]) }, ], default: onDefault, output: (result, axon) => axon.output.createSignal(result), }); ``` ## Best Practices 1. **Use meaningful variable names**: Variable names for afferent paths should reflect their purpose (e.g., `const uiPath = afferentPath()`) 2. **Create hierarchies logically**: Parent paths should represent a higher level of abstraction 3. **Group related paths**: Combine related paths into a single modality 4. **Use for scenario overrides**: Treat afferent paths as “scenario selectors” that let you override a step (or a subtree) without duplicating the whole flow 5. **Don't overcomplicate**: Don't create overly deep hierarchies without need 6. **Use object references**: Always compare afferent paths by object reference (`path === card`), not by name ![Skulls Connected - Network connections representing flow inheritance patterns](/img/skulls_connected.png) ## Conclusion Modalities and afferent paths provide a powerful **flow inheritance** mechanism: one selected context (modality + afferent path) is shared across the entire stimulation, so every step can react consistently to the same “scenario”. - **Override one step** (or a few steps) for onboarding/retry/admin/etc. without rewriting the whole pipeline - **Reuse the same flow** across different entrypoints/triggers while keeping behavior explicit and deterministic - **Route deterministically** with `modalityDendrite` (object-reference matching, no stringly-typed switches) - **Improve observability** as a side effect (clearer tracing, analytics, and debugging) Use them when you want “one flow, many scenarios” — shared logic by default, with targeted overrides where needed. # Source: https://cnstra.org/docs/recipes/self-loop-cycles Use self-loops to model cycles/iterations inside a single neuron. **Pass data through signal payloads**, not context. Context is for per-neuron per-stimulation metadata only. Why self-loops - Deterministic: one place owns the loop logic, no hidden cross-neuron chatter - Data flows through payloads: each iteration passes state in the signal - Ownership: the neuron emits only its own input collateral to continue Counter example (iterate until max) ```ts import { collateral, neuron } from '@cnstra/core'; const step = collateral<{ amount: number; total?: number; attempt?: number }>(); const done = collateral<{ total: number }>(); export const counter = neuron({ step, done }) .dendrite({ collateral: step, response: (payload, axon) => { const total = (payload.total || 0) + payload.amount; const attempt = (payload.attempt || 0) + 1; if (attempt < 5) { // self-loop: pass state through payload return axon.step.createSignal({ amount: payload.amount, total, attempt }); } return axon.done.createSignal({ total }); }, }); ``` Pagination example (loop until no next page) ```ts import { collateral, neuron } from '@cnstra/core'; const tryPage = collateral<{ cursor?: string; items?: unknown[] }>(); const finished = collateral<{ items: unknown[] }>(); async function fetchPage(cursor?: string): Promise<{ items: unknown[]; next?: string }> { // replace with real API call return { items: [{ id: cursor ?? '0' }], next: cursor ? undefined : '1' }; } export const pager = neuron({ tryPage, finished }) .dendrite({ collateral: tryPage, response: async (payload, axon, ctx) => { if (ctx.abortSignal?.aborted) return; // cooperative cancel // Use payload cursor, not context const { items, next } = await fetchPage(payload.cursor); const accumulatedItems = [...(payload.items || []), ...items]; if (next) { // self-loop: pass accumulated data through payload return axon.tryPage.createSignal({ cursor: next, items: accumulatedItems }); } return axon.finished.createSignal({ items: accumulatedItems }); }, }); ``` Tips - **Pass data through signal payloads**, not context - Context is for per-neuron per-stimulation metadata (retry attempts, debounce state) - Check `ctx.abortSignal` between iterations - For retries, prefer a separate self-loop neuron with backoff per attempt # Source: https://cnstra.org/docs/recipes/stimulation-gate `CNSStimulationGate` is a small utility for a common backend pattern: - a cron, webhook, or manual trigger may fire many times; - only one processing run should be active at a time; - the run should process work in small batches instead of loading everything at once. Create one guard per workflow/source and call `drain()` freely. If a run is already active, `drain()` returns the same promise and does not start a second stimulation. When the current run reaches idle, the next `drain()` call can start a new stimulation.
A stylized brain drain illustration for CNSStimulationGate
One stimulation gate keeps repeated triggers flowing through a single active processing run.
## Basic Shape ```ts import { CNSStimulationGate } from '@cnstra/core'; const guard = new CNSStimulationGate({ cns, signal: jobsAxon.processPendingUsers.createSignal(), options: { concurrency: 4, }, }); // Safe to call from cron, webhook, or admin action. await guard.drain(); ``` `CNSStimulationGate` is usually a singleton per workflow. Do not create it inside the cron handler, because then each tick would get its own guard and could overlap with other ticks. ## NestJS Cron Example This example processes pending users from a database in batches of 100. The cron can tick every few seconds, but a new run will not start while the previous run is still draining. ```ts import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { CNS, CNSStimulationGate, collateral, neuron, } from '@cnstra/core'; type PendingUser = { id: string; email: string; }; const jobsAxon = { processPendingUsers: collateral(), }; @Injectable() export class PendingUsersWorkflow { private readonly logger = new Logger(PendingUsersWorkflow.name); private readonly processPendingUsers = neuron(jobsAxon).dendrite({ collateral: jobsAxon.processPendingUsers, response: async (_payload, axon, ctx) => { const users = await this.claimPendingUsers(100); if (users.length === 0) { return undefined; } await Promise.all( users.map(user => this.processUser(user, ctx.abortSignal)) ); // Continue the same stimulation with the next batch. return axon.processPendingUsers.createSignal(); }, }); private readonly cns = new CNS([this.processPendingUsers]); private readonly pendingUsersDrain = new CNSStimulationGate({ cns: this.cns, signal: jobsAxon.processPendingUsers.createSignal(), options: { concurrency: 1, maxNeuronHops: 10_000, onResponse: response => { if (response.error) { this.logger.error(response.error); } }, }, }); @Cron(CronExpression.EVERY_10_SECONDS) async drainPendingUsers(): Promise { if (this.pendingUsersDrain.isDraining()) { return; } try { await this.pendingUsersDrain.drain(); } catch (error) { this.logger.error(error); } } async onModuleDestroy(): Promise { if (!this.pendingUsersDrain.isDraining()) { return; } this.pendingUsersDrain.abort(); try { await this.pendingUsersDrain.drain(); } catch { // The current run may reject because it was aborted. } } private async claimPendingUsers(limit: number): Promise { // Use your ORM here. In production, claim rows atomically so another worker // cannot process the same records. return []; } private async processUser( user: PendingUser, abortSignal?: AbortSignal ): Promise { if (abortSignal?.aborted) return; // Do the actual work: call APIs, update rows, publish events, etc. void user; } } ``` ## Why The Neuron Returns Its Own Signal The first signal starts the workflow. Each successful batch returns the same signal again: ```ts return axon.processPendingUsers.createSignal(); ``` That keeps the stimulation alive while there may be more rows to process. Once the database returns an empty batch, the neuron returns `undefined`, no new tasks are enqueued, and `drain()` resolves. ## Abort Behavior If you do not pass `options.abortSignal`, `CNSStimulationGate` creates an internal `AbortController`. Calling `guard.abort()` aborts the current stimulation and returns `true` when it actually sent an abort. If you pass your own `abortSignal`, `guard.abort()` returns `false`; in that case, abort from the owner of that signal. ## When To Use It Use `CNSStimulationGate` when: - cron or external triggers can arrive while previous work is still active; - work should be pulled in batches; - processing can be represented as a CNS flow; - overlapping runs would duplicate work or waste resources. If each trigger should always create an independent run, use `cns.stimulate(...)` directly. # Source: https://cnstra.org/docs/recipes/exhaustive-binding Use `neuron.bind(axon, map)` to subscribe to every collateral of another neuron's axon with compile-time exhaustiveness checking. This ensures that if a developer adds a new collateral to the axon you're binding to, TypeScript will immediately flag missing handlers, preventing you from forgetting to handle new cases. ## Why Exhaustive Binding? When a neuron's axon evolves (new collaterals are added), any neurons that react to those collaterals must be updated. Without exhaustive binding, it's easy to miss new cases, leading to silent failures or incomplete behavior. With `bind()`, the compiler enforces completeness. ## Basic Example ```ts import { withCtx, collateral, neuron } from '@cnstra/core'; // Order domain model (axon) const order = { created: collateral<{ id: string; amount: number }>(), updated: collateral<{ id: string; changes: Record }>('order:updated'), cancelled: collateral<{ id: string; reason?: string }>(), }; // Mailer neuron must react to ALL order events const orderMailer = withCtx() .neuron({}) .bind(order, { created: (payload) => { sendEmail(`Order created #${payload.id} for $${payload.amount}`); }, updated: (payload) => { sendEmail(`Order updated #${payload.id} (changes: ${Object.keys(payload.changes).join(', ')})`); }, cancelled: (payload) => { sendEmail(`Order cancelled #${payload.id}${payload.reason ? `: ${payload.reason}` : ''}`); }, }); ``` ## Compile-Time Safety If someone later adds a new collateral to the `order` axon: ```ts // New collateral added to order axon const order = { created: collateral<{ id: string; amount: number }>(), updated: collateral<{ id: string; changes: Record }>('order:updated'), cancelled: collateral<{ id: string; reason?: string }>(), refunded: collateral<{ id: string; amount: number }>(), // NEW! }; ``` TypeScript will immediately error on the `orderMailer` bind: ```ts // ❌ TypeScript Error: Property 'refunded' is missing in type '{ created: ...; updated: ...; cancelled: ...; }' const orderMailer = withCtx() .neuron({}) .bind(order, { created: (payload) => { /* ... */ }, updated: (payload) => { /* ... */ }, cancelled: (payload) => { /* ... */ }, // Missing 'refunded' handler! }); ``` You must add the handler: ```ts // ✅ Fixed: All collaterals handled const orderMailer = withCtx() .neuron({}) .bind(order, { created: (payload) => { /* ... */ }, updated: (payload) => { /* ... */ }, cancelled: (payload) => { /* ... */ }, refunded: (payload) => { sendEmail(`Order refunded #${payload.id} for $${payload.amount}`); }, }); ``` ## Shorthand vs Full Dendrite Objects You can pass either a response function (shorthand) or a full dendrite object per key: ### Shorthand (response function only) ```ts const notifier = neuron({}) .bind(order, { created: (payload) => { // Just the response function notifyUser(payload.id); }, updated: (payload) => { notifyUser(payload.id); }, }); ``` ### Full dendrite objects ```ts const notifier = neuron({}) .bind(order, { created: { collateral: order.created, // Explicit (though redundant) response: async (payload, axon, ctx) => { if (ctx.abortSignal?.aborted) return; await notifyUser(payload.id); // Can emit signals from this neuron's axon return axon.someOutput?.createSignal({ ... }); }, }, updated: { response: (payload) => { notifyUser(payload.id); }, }, }); ``` ## Type Inference Payload types are automatically inferred from the axon you're binding to: ```ts const order = { created: collateral<{ id: string; amount: number }>(), updated: collateral<{ id: string; changes: Record }>('order:updated'), }; // payload types are inferred - no need to annotate! const handler = neuron({}) .bind(order, { created: (payload) => { // payload is { id: string; amount: number } console.log(payload.id, payload.amount); }, updated: (payload) => { // payload is { id: string; changes: Record } console.log(payload.changes); }, }); ``` ## Real-World Use Cases ### Domain Event Notifiers Ensure notifications are sent for every domain event: ```ts const user = { registered: collateral<{ userId: string; email: string }>(), verified: collateral<{ userId: string }>(), suspended: collateral<{ userId: string; reason: string }>(), deleted: collateral<{ userId: string }>(), }; const userNotifier = neuron({}) .bind(user, { registered: (payload) => sendWelcomeEmail(payload.email), verified: (payload) => sendVerificationConfirmation(payload.userId), suspended: (payload) => sendSuspensionNotice(payload.userId, payload.reason), deleted: (payload) => sendDeletionConfirmation(payload.userId), }); ``` ### Audit Logging Ensure all state changes are logged: ```ts const product = { created: collateral<{ id: string; name: string }>(), updated: collateral<{ id: string; changes: Record }>('product:updated'), priceChanged: collateral<{ id: string; oldPrice: number; newPrice: number }>(), deleted: collateral<{ id: string }>(), }; const auditLogger = neuron({}) .bind(product, { created: (payload) => logAudit('product_created', payload), updated: (payload) => logAudit('product_updated', payload), priceChanged: (payload) => logAudit('product_price_changed', payload), deleted: (payload) => logAudit('product_deleted', payload), }); ``` ### Metrics Collection Track metrics for all events: ```ts const payment = { initiated: collateral<{ id: string; amount: number }>(), processed: collateral<{ id: string; amount: number }>(), failed: collateral<{ id: string; reason: string }>(), refunded: collateral<{ id: string; amount: number }>(), }; const metricsCollector = neuron({}) .bind(payment, { initiated: (payload) => metrics.increment('payment.initiated', { amount: payload.amount }), processed: (payload) => metrics.increment('payment.processed', { amount: payload.amount }), failed: (payload) => metrics.increment('payment.failed', { reason: payload.reason }), refunded: (payload) => metrics.increment('payment.refunded', { amount: payload.amount }), }); ``` ## Best Practices 1. **Use for cross-cutting concerns**: Exhaustive binding is ideal for neurons that must react to every event in a domain (notifiers, loggers, metrics, analytics). 2. **Keep handlers focused**: Each handler should have a single responsibility. If you need complex logic, extract it to a separate function. 3. **Return signals when needed**: If your neuron needs to emit follow-up signals, return them from the handler. Otherwise, return `undefined` or nothing. 4. **Leverage type inference**: Don't manually annotate payload types; let TypeScript infer them from the axon. 5. **Context for per-neuron per-stimulation metadata**: Use `withCtx()` if you need to store per-neuron per-stimulation metadata (retry attempts, debounce state) across multiple bind handlers. **Business data should flow through signal payloads**, not context. ![Eye Wired](/img/skull_wired.png) ## Comparison with Manual Dendrites Without exhaustive binding, you'd need to manually add each dendrite: ```ts // ❌ Manual approach - easy to miss new collaterals const orderMailer = neuron({}) .dendrite({ collateral: order.created, response: (p) => { /* ... */ } }) .dendrite({ collateral: order.updated, response: (p) => { /* ... */ } }) .dendrite({ collateral: order.cancelled, response: (p) => { /* ... */ } }); // If order.refunded is added, this won't error - you might miss it! ``` With exhaustive binding: ```ts // ✅ Exhaustive approach - compiler enforces completeness const orderMailer = neuron({}) .bind(order, { created: (p) => { /* ... */ }, updated: (p) => { /* ... */ }, cancelled: (p) => { /* ... */ }, // If order.refunded is added, TypeScript will error here! }); ``` ## Summary `neuron.bind()` provides compile-time exhaustiveness checking, ensuring you never miss handling new collaterals when a neuron's axon evolves. This is especially valuable for domain-oriented neurons that must react to every way a record can be created, updated, or changed. The compiler becomes your safety net, preventing incomplete implementations before they reach production. # Source: https://cnstra.org/docs/recipes/testing CNStra architecture makes testing simple thanks to the explicit neuron graph structure and typed signals. The key idea: **neurons can be easily replaced with mocks**, especially if data access (DB, external APIs) is also implemented through neurons. ## Why This Works In CNStra, each neuron: - Accepts one input signal through a dendrite - Returns one or more output signals through an axon - Has explicit typed contracts (collaterals) This means: - ✅ Neurons are easily replaceable with mocks that have the same axons - ✅ Tests are isolated: you can test one neuron by replacing its dependencies - ✅ Types guarantee mock compatibility - ✅ Signals can be created directly without real neurons ## Basic Pattern: Replacing a Neuron with a Mock ### Example: Testing Business Logic with a DB Mock ```ts import { CNS, collateral, neuron } from '@cnstra/core'; // Shared collaterals (contracts) const user = { fetchRequest: collateral<{ userId: string }>(), fetched: collateral<{ userId: string; name: string }>(), }; // Real DB neuron (in production) export const dbUserNeuron = neuron({ fetched: user.fetched }) .dendrite({ collateral: user.fetchRequest, response: async (payload) => { const userData = await db.users.findById(payload.userId); return user.fetched.createSignal({ userId: userData.id, name: userData.name, }); }, }); // Mock neuron for tests export const mockDbUserNeuron = neuron({ fetched: user.fetched }) .dendrite({ collateral: user.fetchRequest, response: async (payload) => { // Return test data without real DB return user.fetched.createSignal({ userId: payload.userId, name: `Mock User ${payload.userId}`, }); }, }); // Business logic we're testing export const userProcessor = neuron({ processed: collateral<{ userId: string; greeting: string }>(), }) .dendrite({ collateral: user.fetched, response: (payload, axon) => { return axon.processed.createSignal({ userId: payload.userId, greeting: `Hello, ${payload.name}!`, }); }, }); // Test: use mock instead of real DB neuron describe('User Processor', () => { it('should process user data correctly', async () => { // Create CNS with mock neuron instead of real one const cns = new CNS([mockDbUserNeuron, userProcessor]); const responses: unknown[] = []; const stimulation = cns.stimulate( user.fetchRequest.createSignal({ userId: '123' }), { onResponse: (response) => { responses.push(response); }, } ); await stimulation.waitUntilComplete(); // Verify processor received data and processed it const processed = responses.find(r => r.outputSignal?.collateral === user.processed); expect(processed?.outputSignal?.payload).toEqual({ userId: '123', greeting: 'Hello, Mock User 123!', }); }); }); ``` ## Advanced Pattern: Mocks with Configurable Behavior Mocks can be made flexible to test different scenarios: ```ts // Factory for mock neurons with configurable behavior function createMockDbUserNeuron( behavior: 'success' | 'not-found' | 'error' ) { return neuron({ fetched: user.fetched, notFound: collateral<{ userId: string }>(), error: collateral<{ userId: string; error: string }>(), }) .dendrite({ collateral: user.fetchRequest, response: async (payload, axon) => { if (behavior === 'not-found') { return axon.notFound.createSignal({ userId: payload.userId }); } if (behavior === 'error') { return axon.error.createSignal({ userId: payload.userId, error: 'Database connection failed', }); } // success return axon.fetched.createSignal({ userId: payload.userId, name: `Test User ${payload.userId}`, }); }, }); } // Tests for different scenarios describe('User Processor with different DB behaviors', () => { it('handles successful fetch', async () => { const cns = new CNS([ createMockDbUserNeuron('success'), userProcessor, ]); // ... test success scenario }); it('handles user not found', async () => { const cns = new CNS([ createMockDbUserNeuron('not-found'), userProcessor, // Need neuron to handle not-found handleNotFoundNeuron, ]); // ... test not-found scenario }); }); ``` ## Testing with Real Signals but Mock Neurons You can create signals directly, bypassing real neurons: ```ts describe('Testing downstream neurons in isolation', () => { it('should process signal without upstream neuron', async () => { // Test only userProcessor by creating signal directly const cns = new CNS([userProcessor]); // Create signal directly, as if sent by dbUserNeuron const fetchedSignal = user.fetched.createSignal({ userId: '123', name: 'Test User', }); const responses: unknown[] = []; await cns.stimulate(fetchedSignal, { onResponse: (r) => responses.push(r), }); // Verify processing result const processed = responses.find(r => r.outputSignal?.collateral === user.processed); expect(processed?.outputSignal?.payload.greeting).toBe('Hello, Test User!'); }); }); ``` ## Testing Entire Graphs with Partial Replacement You can replace only some neurons, leaving others real: ```ts // Real business logic const orderProcessor = neuron({ orderCreated: collateral<{ orderId: string }>(), }) .dendrite({ collateral: user.fetched, response: (payload, axon) => { // Creates order based on user data return axon.orderCreated.createSignal({ orderId: `order-${payload.userId}`, }); }, }); describe('Order flow with mocked DB', () => { it('should create order using mocked user data', async () => { // Replace only DB neuron, others are real const cns = new CNS([ mockDbUserNeuron, // mock userProcessor, // real orderProcessor, // real ]); await cns.stimulate( user.fetchRequest.createSignal({ userId: '123' }) ); // Verify entire graph worked correctly // with mock data from DB }); }); ``` ## Testing with Mocks Through Signals (Recommended Approach) **Best practice**: if data access is implemented through neuron-signals, testing becomes trivial: ```ts // In production: neuron reads from DB export const dbReadNeuron = neuron({ dataFetched: collateral<{ id: string; data: unknown }>(), }) .dendrite({ collateral: collateral<{ id: string }>(), response: async (payload, axon) => { const data = await database.findById(payload.id); return axon.dataFetched.createSignal({ id: payload.id, data }); }, }); // In tests: mock neuron returns test data export const mockDbReadNeuron = neuron({ dataFetched: collateral<{ id: string; data: unknown }>(), }) .dendrite({ collateral: collateral<{ id: string }>(), response: async (payload, axon) => { // Return predefined test data const testData = { '123': { name: 'Test Item 1' }, '456': { name: 'Test Item 2' }, }; return axon.dataFetched.createSignal({ id: payload.id, data: testData[payload.id] || null, }); }, }); // Business logic works the same with real and mock neurons const businessLogic = neuron({ result: collateral<{ processed: unknown }>(), }) .dendrite({ collateral: collateral<{ id: string; data: unknown }>(), response: (payload, axon) => { // Processes data regardless of source return axon.result.createSignal({ processed: transformData(payload.data), }); }, }); // Test: simply replace dbReadNeuron with mockDbReadNeuron const testCns = new CNS([mockDbReadNeuron, businessLogic]); const prodCns = new CNS([dbReadNeuron, businessLogic]); // Both work the same way! ``` ## Testing with Context If neurons use context, it can also be mocked: ```ts import { withCtx } from '@cnstra/core'; const ctxNeuron = withCtx<{ sessionId: string }>() .neuron({ output: collateral<{ result: string }>(), }) .dendrite({ collateral: collateral<{ action: string }>(), response: (payload, axon, ctx) => { // Context stores per-neuron per-stimulation metadata (session tracking) const sessionId = ctx.get()?.sessionId || 'default'; // Business data flows through payloads return axon.output.createSignal({ result: `${payload.action} (session: ${sessionId})`, }); }, }); describe('Context-aware neuron', () => { it('should use provided context', async () => { const cns = new CNS([ctxNeuron]); const responses: unknown[] = []; await cns.stimulate( collateral<{ action: string }>().createSignal({ action: 'test' }), { ctx: { get: () => ({ sessionId: 'test-session-123' }), set: () => {}, delete: () => {}, }, onResponse: (r) => responses.push(r), } ); expect(responses[0]?.outputSignal?.payload.result).toBe( 'test (session: test-session-123)' ); }); }); ``` ## Integration Tests with Real Neurons For integration tests, you can use real neurons but with a test DB: ```ts // Real neuron, but with test DB const testDb = createTestDatabase(); // in-memory or test DB const testDbNeuron = neuron({ fetched: user.fetched, }) .dendrite({ collateral: user.fetchRequest, response: async (payload) => { // Use test DB instead of production const userData = await testDb.users.findById(payload.userId); return user.fetched.createSignal({ userId: userData.id, name: userData.name, }); }, }); // Integration test with real logic but test DB describe('Integration test', () => { beforeEach(async () => { await testDb.users.insert({ id: '123', name: 'Test User' }); }); it('should work end-to-end with test DB', async () => { const cns = new CNS([testDbNeuron, userProcessor]); // ... full integration test }); }); ``` ## Benefits of This Approach 1. **Isolation**: Each neuron is tested independently 2. **Speed**: Mocks are faster than real DBs/APIs 3. **Determinism**: Tests always return predictable results 4. **Type Safety**: TypeScript guarantees mock compatibility 5. **Flexibility**: Easy to test edge cases and errors 6. **Readability**: Explicit graph structure makes tests clear ## Recommendations - ✅ Use shared collaterals for contracts between neurons - ✅ Create mock neuron factories for reuse - ✅ Test neurons in isolation by creating signals directly - ✅ For integration tests, use real neurons with test DB - ✅ If data comes through neuron-signals, mocking is trivial - ✅ Use types to guarantee mock compatibility ## Example: Complete Test Suite ```ts import { CNS, collateral, neuron } from '@cnstra/core'; // Contracts const user = { fetchRequest: collateral<{ userId: string }>(), fetched: collateral<{ userId: string; name: string }>(), }; // Mock DB neuron const createMockDb = (users: Record) => neuron({ fetched: user.fetched }) .dendrite({ collateral: user.fetchRequest, response: async (payload, axon) => { const userData = users[payload.userId]; if (!userData) { throw new Error(`User ${payload.userId} not found`); } return axon.fetched.createSignal({ userId: payload.userId, name: userData.name, }); }, }); // Business logic const processor = neuron({ processed: collateral<{ greeting: string }>(), }) .dendrite({ collateral: user.fetched, response: (payload, axon) => axon.processed.createSignal({ greeting: `Hello, ${payload.name}!`, }), }); describe('Full test suite', () => { it('processes user correctly', async () => { const mockDb = createMockDb({ '123': { name: 'Alice' }, }); const cns = new CNS([mockDb, processor]); const results: unknown[] = []; await cns.stimulate( user.fetchRequest.createSignal({ userId: '123' }), { onResponse: (r) => results.push(r) } ); const processed = results.find(r => r.outputSignal?.collateral === processorAxon.processed); expect(processed?.outputSignal?.payload.greeting).toBe('Hello, Alice!'); }); it('handles missing user', async () => { const mockDb = createMockDb({}); const cns = new CNS([mockDb, processor]); await expect( cns.stimulate(user.fetchRequest.createSignal({ userId: '999' })) ).rejects.toThrow('User 999 not found'); }); }); ``` This approach makes testing CNStra applications simple and effective, especially when data access is implemented through neuron-signals. # Source: https://cnstra.org/docs/integrations/message-brokers CNStra can be integrated with message brokers (MQ systems) to schedule work and feed signals into the system. Message brokers provide process-wide concurrency control, rate limiting, and retry mechanisms that complement CNStra's instance-level concurrency. This guide demonstrates integration patterns using BullMQ as an example, but the same principles apply to other message brokers (RabbitMQ, AWS SQS, etc.). For more information on why external queue systems are recommended for production, see [Performance: External Queue Systems](/docs/advanced/performance). ## Basic Integration ```ts import { Queue, Worker } from 'bullmq'; import { CNS } from '@cnstra/core'; const queue = new Queue('jobs'); const cns = new CNS(); new Worker('jobs', async job => { // Convert job data into a CNStra signal const stimulation = cns.stimulate(myCollateral.createSignal(job.data)); await stimulation.waitUntilComplete(); }); // Enqueue work somewhere else await queue.add('importUser', { userId: '42' }); ``` ## Retry with Context Preservation For long-running workflows that may be interrupted (e.g., process crashes, timeouts), you can save stimulation progress to BullMQ job progress and resume from failed tasks on retry using `cns.activate()`. This pattern ensures that: - Completed steps are not re-executed on retry - Context is preserved across retries - Only failed tasks are retried ```ts import { Queue, Worker, Job, QueueEvents } from 'bullmq'; import { Redis } from 'ioredis'; import { CNS } from '@cnstra/core'; import { withCtx, collateral } from '@cnstra/core'; // Context stores per-neuron per-stimulation metadata (execution tracking), not business data const ctxBuilder = withCtx<{ executed: string[] }>(); const input = collateral<{ id: number }>(); const step1Out = collateral<{ id: number }>(); const step2Out = collateral<{ id: number }>(); const output = collateral<{ result: string }>(); const step1 = ctxBuilder.neuron({ step1Out }).dendrite({ collateral: input, response: async (payload, axon, ctx) => { // Context stores stimulation metadata (execution tracking) ctx.set({ executed: [...(ctx.get()?.executed || []), `step1-${payload.id}`], }); // Business data (id) flows through payloads await processStep1(payload); return axon.step1Out.createSignal({ id: payload.id }); }, }); const step2 = ctxBuilder.neuron({ step2Out }).dendrite({ collateral: step1Out, response: async (payload, axon, ctx) => { // Context stores stimulation metadata ctx.set({ executed: [...(ctx.get()?.executed || []), `step2-${payload.id}`], }); await processStep2(payload); return axon.step2Out.createSignal({ id: payload.id }); }, }); const step3 = ctxBuilder.neuron({ output }).dendrite({ collateral: step2Out, response: async (payload, axon, ctx) => { // Context stores stimulation metadata ctx.set({ executed: [...(ctx.get()?.executed || []), `step3-${payload.id}`], }); return axon.output.createSignal({ result: `Final: ${payload.id}` }); }, }); const cns = new CNS([step1, step2, step3]); // BullMQ setup const queue = new Queue('workflows', { connection: redis }); const queueEvents = new QueueEvents('workflows', { connection: redis }); type SavedProgress = { // NOTE: CNStra runtime uses object-identity for neurons/collaterals and stores // context as Map. For cross-process retries, persist your own // stable ids/names and reconstruct tasks+context via your persistence layer. failedTasks: Array<{ task: { neuron: object; dendriteCollateral: object; }; error: { message: string; name: string; stack?: string; }; aborted: boolean; }>; }; const worker = new Worker( 'workflows', async (job: Job<{ signal: any }, { results: string[] }>) => { const { signal } = job.data; const results: string[] = []; const savedProgress = job.progress as SavedProgress | number | undefined; // Retry attempt: resume from saved progress if ( savedProgress && typeof savedProgress === 'object' && job.attemptsMade > 0 ) { const failedTasksToRetry = savedProgress.failedTasks.map(ft => ft.task); // Resume from failed tasks with preserved context const stimulation = cns.activate(failedTasksToRetry, { ctx: savedProgress.ctx, onResponse: r => { if (r.outputSignal?.collateral === output) { results.push( (r.outputSignal.payload as { result: string }).result ); } }, }); await stimulation.waitUntilComplete(); return { results }; } // First attempt: start fresh stimulation const abortController = new AbortController(); const stimulation = cns.stimulate(signal, { abortSignal: abortController.signal, }); // Simulate interruption (e.g., timeout, crash) await new Promise(resolve => setTimeout(resolve, 10)); abortController.abort(); try { await stimulation.waitUntilComplete(); return { results }; } catch (error: any) { // Save progress for retry const progress: SavedProgress = { failedTasks: stimulation.getFailedTasks().map(ft => ({ task: ft.task, error: { message: ft.error.message, name: ft.error.name, stack: ft.error.stack, }, aborted: ft.aborted, })), }; // Save to BullMQ job progress (stored in Redis) await job.updateProgress(progress); // Throw to trigger BullMQ retry throw error; } }, { connection: redis, concurrency: 1 } ); // Enqueue job with retry configuration const job = await queue.add( 'workflow-job', { signal: input.createSignal({ id: 100 }) }, { attempts: 2, removeOnComplete: true, removeOnFail: true, } ); // Wait for completion const result = await job.waitUntilFinished(queueEvents); ``` **Key points:** - `stimulation.getContext().getAll()` captures all context values - `stimulation.getFailedTasks()` returns tasks that failed or were aborted - `cns.activate()` resumes execution from specific tasks with restored context - Progress is saved to Redis via `job.updateProgress()`, so it persists across retries ## Handling Non-Serializable Data in Signals {#non-serializable-data} When working with message brokers, signals must be serializable (JSON), but sometimes you need to work with non-serializable data (e.g., blobs, file handles, database connections). Solution: use a controller/transaction neuron that creates an inner stimulation and stores it in context. The inner stimulation runs in the same process and can work with non-serializable data. On error, either the entire transaction completes or it doesn't, but non-serializable data won't be included in results sent back to the queue. ```ts import { Queue, Worker, Job } from 'bullmq'; import { CNS, neuron, withCtx, collateral } from '@cnstra/core'; // Serializable data for the queue const processRequest = collateral<{ userId: string; blobId: string }>(); const processResult = collateral<{ userId: string; success: boolean }>(); // Non-serializable data (only within the process) const blobData = collateral<{ userId: string; blob: Blob }>(); const blobProcessed = collateral<{ userId: string; success: boolean }>(); // Transaction neuron: creates inner stimulation with blob const ctxBuilder = withCtx<{ innerStimulation?: Promise }>(); const transactionNeuron = ctxBuilder.neuron({ processResult }).dendrite({ collateral: processRequest, response: async (payload, axon, ctx) => { // Get blob from storage (non-serializable) const blob = await blobStorage.get(payload.blobId); // Create inner stimulation with blob // Use ctx.cns to access CNS from context const innerStimulation = ctx.cns.stimulate( blobData.createSignal({ userId: payload.userId, blob }) ); // Store stimulation promise in context ctx.set({ innerStimulation: innerStimulation.waitUntilComplete() }); try { // Wait for inner stimulation to complete await innerStimulation.waitUntilComplete(); // If inner stimulation succeeds, return serializable result return axon.processResult.createSignal({ userId: payload.userId, success: true }); } catch (error) { // On error, return failure result // Blob won't be in results since it's non-serializable return axon.processResult.createSignal({ userId: payload.userId, success: false }); } }, }); // Neuron for processing blob (runs only within the process) const blobProcessor = neuron({ blobProcessed }).dendrite({ collateral: blobData, response: async (payload, axon) => { // Process blob (non-serializable object) await processBlob(payload.blob); return axon.blobProcessed.createSignal({ userId: payload.userId, success: true }); }, }); const cns = new CNS([transactionNeuron, blobProcessor]); // BullMQ worker const worker = new Worker('jobs', async (job: Job<{ signal: any }>) => { const { signal } = job.data; // Stimulation runs in this process // If an error occurs, blob won't be in results const stimulation = cns.stimulate(signal); await stimulation.waitUntilComplete(); // Return only serializable results return { success: true }; }); // Enqueue job (only serializable data) await queue.add('process', { signal: processRequest.createSignal({ userId: '42', blobId: 'blob-123' // only ID, not the blob itself }) }); ``` **Key points:** - Controller/transaction neuron creates inner stimulation with non-serializable data - Inner stimulation runs in the same process as the worker - On error, either the entire transaction completes or it doesn't, but non-serializable data won't be in results - Only serializable data (IDs, metadata) is sent to the queue - Blobs and other non-serializable objects remain in process memory and are not serialized ## Tips - Use BullMQ rate limits and concurrency to protect resources - Store intermediate or aggregated state in OIMDB or a real DB - For retries, prefer BullMQ retry/backoff plus idempotent neurons - Save progress only when needed (errors, checkpoints), not on every response - For non-serializable data (blobs, file handles), use transaction neurons with inner stimulations - Use `cns.activate()` to resume from specific failed tasks with preserved context # Source: https://cnstra.org/docs/integrations/pg-boss > **⚠️ Experimental**: The `@cnstra/pg-boss` package is experimental. The API may change. [pg-boss](https://github.com/timgit/pg-boss) is a job queue built on PostgreSQL. If you're already on Postgres and want durable jobs, retries, backoff, scheduling and singleton guarantees **without adding Redis**, it pairs well with CNStra. This guide mirrors the [Message Brokers guide](/docs/integrations/message-brokers): the core model is **one job = one stimulation**. The one thing you must get right for a queue is that CNStra's runtime uses **object identity** for neurons/collaterals and stores context as a `Map` — those references are **not portable across processes**. So jobs carry **names** (resolved through a `CNSPersistOptionsRegistry`), never serialized tasks or context. The `@cnstra/pg-boss` package packages the pattern below; you can also wire it by hand. ```bash npm install @cnstra/pg-boss @cnstra/core @cnstra/factory pg-boss ``` ## Basic integration ```ts import PgBoss from 'pg-boss'; import { CNSFactory } from '@cnstra/factory'; import { createCNSWorker, enqueueStimulation, stimulationJob } from '@cnstra/pg-boss'; // Build the CNS and a registry from one map so names can't drift const { cns, registry } = CNSFactory.create({ importUser: importUserNeuron, // …other neurons }); const boss = new PgBoss(process.env.DATABASE_URL!); await boss.start(); await boss.createQueue('cns'); // Worker: one job → one stimulation await createCNSWorker({ boss, cns, registry, queue: 'cns' }); // Producer: enqueue by (registered) collateral name const job = stimulationJob(registry, importUser, { userId: '42' }); await enqueueStimulation(boss, 'cns', job); ``` Under the hood the worker does exactly what the message-brokers guide shows, just resolved by name: ```ts await boss.work('cns', async ([job]) => { const { collateralName, payload } = job.data; const collateral = registry.getCollateral(collateralName); if (!collateral) throw new Error(`Unknown collateral: ${collateralName}`); const stimulation = cns.stimulate(collateral.createSignal(payload)); await stimulation.waitUntilComplete(); // rejects on failure/abort → pg-boss retries }); ``` Because `waitUntilComplete()` **rejects** when a stimulation has failed tasks or was aborted, letting the error propagate is exactly what you want: pg-boss then applies its retry/backoff. ## pg-boss features worth wiring | pg-boss option | CNStra use | | --- | --- | | `retryLimit` / `retryBackoff` / `retryDelay` | Retry the whole (idempotent) stimulation | | `singletonKey` | One in-flight stimulation per entity (e.g. per `userId`) | | `boss.schedule(...)` (cron) / `startAfter` | Scheduled / deferred stimulations | | `expireInSeconds` | Wire to an `AbortController` → `stimulate(signal, { abortSignal })` | | dead-letter queue | Park terminally failed runs; store `getFailedTasks()` (by name) | ```ts await enqueueStimulation(boss, 'cns', job, { retryLimit: 5, retryBackoff: true, singletonKey: 'user:42', }); ``` ## Retries & resume There are two levels, and only one is safe across processes: - **Whole-flow retry (recommended).** A retried job re-derives the entry signal by name and re-runs the flow. Make neurons **idempotent**, or deduplicate work with a per-hop unique key (see `unique (stimulation_id, index)` below). - **In-process partial resume.** `cns.activate(cns_stimulation.getFailedTasks(), …)` resumes only the failed branch, but the failed tasks hold **live object references** — this works **only in the same process**. Don't try to ship tasks/context through the queue. For resumable context across processes you need a name-keyed serializable context store (see [Custom Context Store](/docs/advanced/custom-context-store)). ## Non-serializable data Queue payloads must be JSON (pg-boss stores `jsonb`). Keep blobs/handles in-process: a "controller" neuron holds them and launches an **inner stimulation** via `ctx.cns.stimulate(...)`, while only serializable IDs go on the queue. This is the same pattern as the [Message Brokers guide](/docs/integrations/message-brokers#non-serializable-data). ## Per-step progress & an admin panel CNStra already emits everything an admin needs, per step, via `onResponse` (or the global `cns.addResponseListener`). Each response exposes the input/output signal, any error, the current `queueLength`, and (when `maxNeuronHops` is set) the hop count. This is the same data the DevTools stream models as `stimulation.started` / `stimulation.hop` / `stimulation.completed`. An admin panel is essentially **durable DevTools**: persist those events to Postgres. > **Shortcut — durable DevTools out of the box.** If you already run the CNStra DevTools > server, you don't have to hand-roll the store below. Swap its in-memory repository for > [`@cnstra/devtools-server-repository-postgres`](https://www.npmjs.com/package/@cnstra/devtools-server-repository-postgres) > or [`@cnstra/devtools-server-repository-redis`](https://www.npmjs.com/package/@cnstra/devtools-server-repository-redis) > — same `ICNSDevToolsServerRepository` contract, but stimulations and hops survive restarts, > and the existing DevTools panel becomes a persistent admin view: > ```ts > import { CNSDevToolsServer } from '@cnstra/devtools-server'; > import { CNSDevToolsServerRepositoryPostgres } from '@cnstra/devtools-server-repository-postgres'; > const repo = new CNSDevToolsServerRepositoryPostgres(pgPool); > await repo.init(); > const server = new CNSDevToolsServer(repo); > ``` > The schema below is for when you want your **own** progress tables (independent of DevTools). `@cnstra/pg-boss` exposes a `progress` sink for this. The key detail: **if `onHop` returns a Promise, CNStra waits for it before enqueuing the next hop** — a per-hop checkpoint barrier. So each step is durably committed *before* the flow advances, and after a crash the DB reflects exactly how far the run got. ```ts await createCNSWorker({ boss, cns, registry, queue: 'cns', progress: { onStarted: (s) => db.insertStimulation(s), // { jobId, collateralName, payload, startedAt } onHop: (h) => db.insertHop(h), // one row per step (see schema) onCompleted: (s) => db.finishStimulation(s), // status: completed | failed, hopCount }, }); ``` Suggested schema (your own tables — pg-boss has no per-step progress of its own): ```sql create table cns_stimulation ( id uuid primary key, job_id text, collateral_name text not null, payload jsonb, status text not null, -- running | completed | failed | aborted started_at timestamptz, completed_at timestamptz, hop_count int default 0, error text ); create table cns_hop ( id bigserial primary key, stimulation_id uuid references cns_stimulation(id), index int not null, neuron_name text, input_collateral text, output_collateral text, input_payload jsonb, output_payload jsonb, started_at timestamptz, duration_ms int, -- core does not time hops; stamp it yourself if needed error text, unique (stimulation_id, index) -- makes retries/replay idempotent ); ``` Names come from the registry (`registry.getNeuronName`, `registry.getCollateralName`), exactly like DevTools derives its ids — so the same registry powers execution, resume, and the admin. Three natural admin screens fall out of this: 1. **Live runs** — `status = 'running'`, with `hop_count`, last neuron, and `queueLength`. 2. **Run timeline** — the ordered `cns_hop` rows for one stimulation: a neuron-by-neuron waterfall of payloads in/out and errors. The single most useful artifact for debugging a flow. 3. **Failures & retries** — `status = 'failed'`, the failed tasks (by name), the pg-boss attempt count, and a "replay" action that simply re-enqueues the job (idempotent). ## pg-boss vs. your own Postgres utility? Separate two concerns: - **Execution / delivery** (who runs the stimulation, retries, backoff, cron, singleton, concurrency): pg-boss solves this natively on Postgres. Reinventing `SELECT … FOR UPDATE SKIP LOCKED` + backoff + scheduling + archival is a lot of subtle work — **use pg-boss**. - **Progress / history** (durable per-step records, admin): pg-boss has no per-step model, so this is **always your own tables** regardless. The `progress` sink above is that layer. Roll your own queue only if you specifically need the job and its trace to be the same row/transaction, or you have visibility semantics that clash with pg-boss. Otherwise: **pg-boss for execution + a thin progress store for history/admin.** ## Tips - Build `cns` + `registry` together with `CNSFactory.create(...)` so names never drift. - Use pg-boss concurrency/rate limits for backpressure; keep neurons idempotent. - Save progress on every hop only if you need a live admin; otherwise checkpoint on errors and milestones to reduce write load. - Wire `expireInSeconds`/cancellation to an `AbortController` passed as `abortSignal`. # Source: https://cnstra.org/docs/ecosystem/swift-sdk A Swift implementation of CNStra is available as a separate package. See the repository for details, examples, and API docs. - Repository: [abaikov/cnstra-swift](https://github.com/abaikov/cnstra-swift) Highlights: - Graph-routed, type-safe orchestration for iOS, macOS, tvOS, watchOS, and server-side Swift - Deterministic traversal from collateral → dendrite → returned signal - Concurrency gates and async scheduling options Use it to share architectural patterns across web and Apple platforms. # Source: https://cnstra.org/docs/devtools/overview # CNStra DevTools Powerful debugging and monitoring tools for CNStra applications. Visualize your neural network topology, inspect signal flows, and monitor performance in real-time. ## Features ### 🧠 Neural Network Visualization - **Interactive Graph**: Explore your application's neural network as an interactive graph - **Real-time Updates**: See neurons, collaterals, and connections update live - **Topology Analysis**: Understand the structure and dependencies of your neural system ### 📊 Signal Flow Monitoring - **Live Stimulation Tracking**: Watch signals propagate through your neural network - **Response Inspection**: Examine payloads, context, and timing for each response - **Queue Visualization**: Monitor signal queues and processing order ### 🔍 Advanced Debugging - **Signal Filtering**: Filter by input collateral name with partial matching - **Signal Injection**: Manually inject signals for testing via Signal Debugger - **Neuron Inspection**: Click neurons to see detailed metrics and recent activity - **Context Inspection**: Deep dive into stimulation context and state - **Performance Metrics**: Measure response times and identify bottlenecks - **Error Tracking**: Catch and analyze errors in your neural network - **Trace View**: Step-by-step signal flow visualization per stimulation ### 🧪 Snapshots & Replay - **Record Sessions**: Capture full stimulation sessions with inputs, responses, and context deltas - **Deterministic Replay**: Re-run captured sessions to reproduce issues exactly - **Shareable Artifacts**: Export/import snapshots for team debugging and CI reproduction ### 🚀 Multi-Instance Support - **Server Manager**: Start/stop DevTools servers on custom ports - **Multiple Apps**: Connect to multiple CNStra applications simultaneously - **Cross-Platform**: Available for macOS, Windows, and Linux ## Quick Start 1. **Download** the DevTools application from the [Download page](/docs/devtools/download) 2. **Install** the DevTools package in your project: ```bash npm i -D @cnstra/devtools @cnstra/devtools-server @cnstra/devtools-transport-ws ``` 3. **Configure** your application to connect to DevTools: ```ts import { CNSDevTools } from '@cnstra/devtools'; import { CNSDevToolsTransportWs } from '@cnstra/devtools-transport-ws'; const transport = new CNSDevToolsTransportWs({ url: 'ws://localhost:8080' }); const devtools = new CNSDevTools('my-app', transport, { devToolsInstanceName: 'My App DevTools', }); devtools.registerCNS(cns); ``` 4. **Launch** DevTools and start debugging! ## Use Cases - **Development**: Debug complex neural networks during development - **Testing**: Verify signal flows and responses in test scenarios - **Performance**: Identify bottlenecks and optimize neural network performance - **Production Monitoring**: Monitor live applications (with appropriate security measures) ## Next Steps - [Download DevTools](/docs/devtools/download) - Get the desktop application - [Integration Guide](/docs/devtools/integration) - Learn how to integrate DevTools - [Advanced Features](/docs/devtools/advanced) - Explore advanced debugging capabilities # Source: https://cnstra.org/docs/devtools/integration # DevTools Integration Two ways to run the DevTools UI — pick the one that fits your setup. ## Option A: Embedded (one function call) Best for: Next.js, Vite, any Node.js dev server where you control the process. ```bash npm i -D @cnstra/devtools-server ``` ```ts // dev.ts ← excluded from prod entry point import { startDevTools } from '@cnstra/devtools-server'; import { cns } from './cns'; import { registry } from './src/neurons/registry'; // your CNSPersistOptionsRegistry await startDevTools(cns, registry); // → 🧠 CNStra DevTools: http://localhost:3141 ``` If you don't have a shared registry yet: ```ts import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist'; import { deckNeuron, cardNeuron } from './neurons'; const registry = CNSPersistOptionsRegistryFactory.create({ deckNeuron, cardNeuron }); ``` Opens the browser automatically. The UI, WebSocket server, and your CNS instance are all wired up in one call. ### Options ```ts await startDevTools(cns, registry, { port: 3141, // default open: true, // auto-open browser (default: true in TTY) appId: 'app', // label shown in DevTools consoleLogEnabled: false, }); ``` ### With Next.js ```ts // next.config.ts if (process.env.NODE_ENV === 'development') { const { startDevTools } = await import('@cnstra/devtools-server'); const { cns } = await import('./src/cns'); const { registry } = await import('./src/neurons/registry'); await startDevTools(cns, registry); } ``` ### With Vite ```ts // vite.config.ts export default defineConfig({ plugins: [{ name: 'cnstra-devtools', async buildStart() { if (process.env.NODE_ENV === 'development') { const { startDevTools } = await import('@cnstra/devtools-server'); const { cns } = await import('./src/cns'); const { registry } = await import('./src/neurons/registry'); await startDevTools(cns, registry); } } }] }); ``` --- ## Option B: Standalone server Best for: when your app and DevTools server run as separate processes (e.g. browser app, React Native, separate backend). ```bash # Terminal 1 — start DevTools server npx @cnstra/devtools-server # → 🧠 CNStra DevTools: http://localhost:3141 # Or with custom port: PORT=4000 npx @cnstra/devtools-server ``` Then connect your app: ```bash npm i -D @cnstra/devtools @cnstra/devtools-transport-ws ``` ```ts // dev entry point import { CNSDevTools } from '@cnstra/devtools'; import { CNSDevToolsTransportWs } from '@cnstra/devtools-transport-ws'; const transport = new CNSDevToolsTransportWs({ url: 'ws://localhost:3141' }); const devtools = new CNSDevTools('my-app', transport, { devToolsInstanceName: 'My App', }); devtools.registerCNS(cns, 'main'); ``` Open `http://localhost:3141` in your browser. --- ## DevTools Features Once connected: - **Graph view** — interactive neuron graph with live signal flow - **Stimulations** — list of all stimulations with full hop traces - **Signal Debugger** — inject signals manually to test flows - **Performance** — response times, queue lengths, error rates - **Context Inspector** — per-stimulation context at each hop - **Snapshot & Replay** — record a session and replay it deterministically ## Production DevTools are dev-only. In production: - Don't import `@cnstra/devtools-server` or call `startDevTools` - Don't import `@cnstra/devtools` or `@cnstra/devtools-transport-ws` - The `CNSPersistOptionsRegistry` (used for naming) is also dev-only All devtools packages should be in `devDependencies` only. # Source: https://cnstra.org/docs/devtools/mcp `@cnstra/mcp` is an MCP server that gives AI tools (Claude Code, Cursor, VS Code, Windsurf) live access to your neuron graph. It uses the same registry you create with `CNSPersistOptionsRegistryFactory` from `@cnstra/persist` — one file, one line. Tools exposed: - **`cns_get_context`** — START HERE: CNStra concept guide + neuron list - **`cns_get_graph`** — full architecture with all connections - **`cns_get_neuron`** — deep dive into one neuron - **`cns_list_neurons`** — quick overview of all neurons - **`cns_list_collaterals`** — all collaterals with owners and subscribers ## Setup ### 1. Install ```bash npm i -D @cnstra/mcp ``` ### 2. Create cns-mcp.ts First, create a registry. Two options: **Option A — per-file (recommended for larger projects):** ```ts // src/neurons/registry.ts import { CNSPersistOptionsRegistry } from '@cnstra/persist'; export const registry = new CNSPersistOptionsRegistry(); // src/neurons/deck.ts — each neuron registers itself import { registry } from './registry'; registry .register('deckNeuron', deckNeuron) .register('cardNeuron', cardNeuron, { deckCreated: 'deck-created' }) // explicit collateral names .registerCollateral('userLogin', userLogin); // standalone collateral (no neuron owner) ``` **Option B — all at once (simpler for small projects):** ```ts // src/neurons/registry.ts import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist'; import { deckNeuron, cardNeuron, uiNeuron } from './index'; export const registry = CNSPersistOptionsRegistryFactory.create({ deckNeuron, cardNeuron, uiNeuron }); ``` Then the MCP entry point: ```ts // cns-mcp.ts ← dev-only, not imported from prod entry point import { startCNSMCPServer } from '@cnstra/mcp'; import { cns } from './src/cns'; import { registry } from './src/neurons/registry'; await startCNSMCPServer(cns, registry); ``` ### 3. Run init to register with all AI tools ```bash npx @cnstra/mcp init ``` Writes MCP config for every tool found in the project: | Tool | Config file | |------|-------------| | Claude Code | `.claude/settings.json` → `mcpServers` | | Cursor | `.cursor/mcp.json` → `mcpServers` | | VS Code | `.vscode/mcp.json` → `servers` | | Windsurf | `.windsurf/mcp.json` → `mcpServers` | After `init`, open the project in any of these tools — the MCP server starts automatically. ## What the AI gets The server exposes three MCP mechanisms — tools that support any subset will benefit: **Resources** (auto-loaded on connect in tools that support them): - `cnstra://context` — concepts, rules, neuron list - `cnstra://graph` — full neuron graph **Prompts** (slash-commands / @ references): - `understand_project` — context + full graph in one shot **Tools** (called on demand): - `cns_get_context`, `cns_get_graph`, `cns_get_neuron`, `cns_list_neurons`, `cns_list_collaterals` ### Example: cns_get_graph output ```markdown # CNS Graph ## deckNeuron **Emits:** - `createdAtButtonClick` → cardNeuron - `createdAtOnboarding` → cardNeuron - `renamed` - `archived` **Reacts to:** - `createDeckButtonClicked` from **uiNeuron** - `userOnboarded` from **onboardingNeuron** ``` ## Zero prod overhead `@cnstra/mcp` is a dev dependency. `cns-mcp.ts` is never imported in production. The MCP server process only runs when an AI tool starts it. # Source: https://cnstra.org/docs/devtools/ai-inspection AI tools like Claude and Cursor understand your CNStra application much better when they can read the neuron graph and runtime history as plain text — instead of parsing dozens of source files. `@cnstra/devtools` exports two utilities for this: `dumpCNSGraph` and `CNSHistoryLogger`. Both take a registry created with `CNSPersistOptionsRegistryFactory` — one line, no boilerplate. ## Static graph dump `dumpCNSGraph` produces a Markdown description of your neuron graph: which collaterals each neuron emits, where those collaterals go, and what each neuron reacts to. ```ts // cns.debug.ts ← excluded from your prod entry point import { writeFileSync } from 'fs'; import { dumpCNSGraph } from '@cnstra/devtools'; import { cns } from './cns'; import { registry } from './src/neurons/registry'; // your CNSPersistOptionsRegistry writeFileSync('CNS_GRAPH.md', dumpCNSGraph(cns, registry)); ``` If you don't have a shared registry yet, create one inline: ```ts import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist'; const registry = CNSPersistOptionsRegistryFactory.create({ deckNeuron, cardNeuron, uiNeuron }); writeFileSync('CNS_GRAPH.md', dumpCNSGraph(cns, registry)); ``` Add a script to `package.json`: ```json { "scripts": { "graph": "tsx cns.debug.ts" } } ``` Run `npm run graph` whenever your neuron structure changes. Commit `CNS_GRAPH.md` — AI tools will pick it up automatically. ### Example output ```markdown # CNS Graph ## deckNeuron **Emits:** - `createdAtButtonClick` → cardNeuron - `createdAtOnboarding` → cardNeuron - `renamed` - `archived` **Reacts to:** - `createDeckButtonClicked` from **uiNeuron** - `userOnboarded` from **onboardingNeuron** ``` ## Runtime history `CNSHistoryLogger` subscribes to a CNS instance and records the stimulation history. ```ts import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist'; import { CNSHistoryLogger } from '@cnstra/devtools'; import { cns } from './cns'; import { deckNeuron, cardNeuron } from './neurons'; const registry = CNSPersistOptionsRegistryFactory.create({ deckNeuron, cardNeuron }); const logger = new CNSHistoryLogger(cns, registry); // ... run your app, trigger some flows ... console.log(logger.dump()); // or: writeFileSync('CNS_HISTORY.md', logger.dump()); logger.stop(); ``` ### Options ```ts new CNSHistoryLogger(cns, registry, { maxRecords: 50, // keep last N stimulations (default: 100) }); ``` ### Example output ```markdown # CNS History ### #3 · 12:00:05.234 triggered by: `userOnboarded` - `userOnboarded` → `createdAtOnboarding` - `createdAtOnboarding` → `createdAtOnboarding` ### #2 · 12:00:02.100 triggered by: `createDeckButtonClicked` - `createDeckButtonClicked` → `createdAtButtonClick` ``` ## Zero prod overhead Both utilities are in `@cnstra/devtools` (dev dependency). As long as `cns.debug.ts` is not in your production entry point, they are fully tree-shaken. For live AI access to the graph without generating files, see the [MCP Server](/docs/devtools/mcp). # Source: https://cnstra.org/docs/devtools/advanced # Advanced Features Explore advanced capabilities of CNStra DevTools. ## Stimulations Page The Stimulations page provides a unified view of all stimulations and responses in your application. ### Features - **Unified Feed**: Combined view showing both stimulations (initial signals) and responses (processing results) - **Filtering**: Search by input collateral name with partial matching - **Replay**: Click the ▶️ Replay button on any stimulation to re-execute it - **Response Details**: Inspect full payloads: - Input payload: Original signal data - Output payload: Result from processing - Response payload: Final response data - **Trace View**: See step-by-step signal flow per stimulation ID - **Replay Indicator**: Responses from replay are marked with 🔁 Replay badge - **Timestamps**: Formatted timestamps showing when each item occurred - **Error Display**: Visual indicators for errors with error details ### Using Filters Type in the search box to filter by input collateral name. The filter performs partial matching, so typing "user" will match "userCreated", "userUpdated", etc. ### Replay Feature 1. Click the **▶️ Replay** button on any stimulation 2. Wait for "Replay accepted" confirmation 3. New responses will appear with 🔁 Replay indicator 4. Use replay to debug issues by reproducing exact signal flows ## Signal Debugger Manually inject signals into your neural network for testing and debugging. ### Signal Injection - **Select Collateral**: Choose the collateral to inject into - **Payload Editor**: JSON editor for signal payload - **Context Editor**: JSON editor for stimulation context (optional) - **Options Editor**: JSON editor for stimulation options (optional) - **History**: Track all injected signals with success/failure status ### Use Cases - Test edge cases - Debug specific signal flows - Verify neuron behavior - Reproduce bugs ## Neuron Details Panel Click any neuron in the graph to open the details panel. ### Information Shown - **Activity Metrics**: - Stimulation count - Response count - Error count - Average duration - **Recent Stimulations**: Last 10 stimulations for this neuron - **Signal Analysis**: Types and intensity averages - **Real-time Updates**: Panel updates automatically as new stimulations occur ### Use Cases - Debug specific neurons - Monitor neuron activity - Identify bottlenecks - Analyze error patterns ## Performance Monitor Real-time performance metrics and monitoring. ### Metrics - **Stimulations/sec**: Signal processing rate - **Active Neurons**: Currently processing neurons - **Response Times**: Average response durations - **Memory Usage**: JavaScript heap size - **Error Rate**: Percentage of failed stimulations - **Queue Utilization**: Signal queue status - **Hop Count**: Signal traversal depth ### Thresholds - **Response Time**: - Green: < 100ms - Yellow: < 500ms - Red: ≥ 500ms - **Error Rate**: - Green: < 5% - Yellow: < 15% - Red: ≥ 15% - **Hop Count**: - Green: ≤ 3 - Yellow: ≤ 7 - Red: > 7 ## Analytics Dashboard Comprehensive analytics for your neural network. ### Features - **Neuron Activity**: Activity metrics per neuron - **Collateral Usage**: Most and least used collaterals - **Error Analysis**: Error rates and patterns - **Performance Trends**: Historical performance data ## Context Store Monitor Monitor and inspect context stores in real-time. ### Features - **Context Inspection**: View all active context stores - **Context Values**: Real-time context value tracking - **Context Deltas**: Track changes in context over time ## Export & Share Export data for offline analysis or sharing. ### Export Options - **Topology Export**: Export neural network structure - **Stimulations Export**: Export stimulations and responses - Time range filtering - Error-only filtering - Limit and offset support - **Snapshot Export**: Export full application snapshots ### Import Import previously exported data for analysis or replay. ## Snapshots & Replay Capture and replay complete stimulation sessions. ### Snapshots - Capture snapshots of signals, responses, and context transitions - Export snapshots as JSON files - Import snapshots for analysis - Share snapshots with your team ### Replay - Deterministic replay engine reproduces sessions step-by-step - Replay responses are marked with 🔁 Replay indicator - Useful for: - Bug reproduction - CI regression tests - Performance analysis - Team debugging ## Keyboard Shortcuts - **Cmd/Ctrl+K**: Command palette (if available) - **Cmd/Ctrl+F**: Focus search (if available) # Source: https://cnstra.org/docs/devtools/download # Download CNStra DevTools Desktop application for debugging and monitoring CNStra applications. ## Features - **Server Manager**: Start/stop DevTools servers on custom ports - **Live Monitoring**: Real-time view of stimulations, responses, and neural network topology - **Multi-Instance**: Connect to multiple applications simultaneously - **Cross-Platform**: Available for macOS, Windows, and Linux ## Installation import BrowserOnly from '@docusaurus/BrowserOnly'; {() => { const React = require('react'); const [state, setState] = React.useState({ loading: true, url: '', error: '' }); React.useEffect(() => { async function pick() { try { const platform = (window.navigator.platform || '').toLowerCase(); const ua = (window.navigator.userAgent || '').toLowerCase(); const isMac = platform.includes('mac') || ua.includes('mac'); const isWindows = platform.includes('win') || ua.includes('win'); const isLinux = (platform.includes('linux') || ua.includes('linux')) && !ua.includes('android'); const isArm = ua.includes('arm') || ua.includes('aarch64') || ua.includes('apple'); const res = await fetch('https://api.github.com/repos/abaikov/cnstra/releases/latest', { headers: { 'Accept': 'application/vnd.github+json' } }); if (!res.ok) throw new Error('Failed to fetch latest release'); const json = await res.json(); const assets = json.assets || []; function findAsset(regex) { return assets.find(a => regex.test(a.name)); } let asset; if (isMac) { // Prefer DMG; choose arch by UA asset = isArm ? findAsset(/DevTools-.*-arm64\.dmg$/) : findAsset(/DevTools-.*-x64\.dmg$/) || findAsset(/DevTools-.*-intel\.dmg$/); } else if (isWindows) { asset = findAsset(/Setup .*\.exe$/) || findAsset(/DevTools-.*\.exe$/); } else if (isLinux) { asset = findAsset(/DevTools-.*\.AppImage$/); } if (!asset) throw new Error('No matching asset for your platform'); setState({ loading: false, url: asset.browser_download_url, error: '' }); } catch (e) { setState({ loading: false, url: '', error: (e && e.message) || 'Unknown error' }); } } pick(); }, []); return (
{state.loading ? (

Detecting your platform and preparing the latest download…

) : state.url ? ( ) : (

Could not determine a suitable installer automatically.

Open latest releases page and pick your platform manually.

{state.error &&
{state.error}
}
)}
); }}
## Usage 1. Launch CNStra DevTools 2. Click "Start Server" to spawn a DevTools server (default port: 8080) 3. In your application, configure DevTools transport: ```typescript import { CNSDevToolsTransportWs } from '@cnstra/devtools-transport-ws'; const transport = new CNSDevToolsTransportWs({ url: 'ws://localhost:8080' }); ``` 4. Click "Open Panel" in the manager to view your application's neural network ## All Releases View all versions and release notes on [GitHub Releases](https://github.com/abaikov/cnstra/releases).