# 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. ## 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 built-in DI, 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. - [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: same input + same context → same path; 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) ## 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? Anyone who has dealt with a similar task of creating a deck with a card has probably come to roughly the following solution: ### 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: models (reducers) have no idea about the flow context, and the controller (thunk) just calls them sequentially. Redux solves the problem of independent model updates well (not like in our example, of course, but when you don't need a flow). 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. ```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 can only emit signals declared on **its own axon**. - This prevents accidental emissions and guarantees explicit wiring. - Even if two flows share logic, they stay **structurally distinct**. - Both flows produce the **same deterministic orchestration**, but each carries its **own flow identity**. This is impossible to achieve in MVC/Redux/Signals with the same compile-time guarantees without building your own CNStra on top. --- ## 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)** ### Execution Time CNStra + OIMDB leads in all categories: | Scenario | CNStra + OIMDB | Zustand | Redux Toolkit | Effector | |----------|----------------|---------|---------------|----------| | Background Churn | **69.4ms** | 83.0ms | 100.6ms | 127.3ms | | Inline Editing | **70.8ms** | 152.9ms | 250.5ms | 400.4ms | | Bulk Update | **50.7ms** | 81.2ms | 156.8ms | 103.7ms | ### Memory Usage Top results in two categories, competitive in the third: | Scenario | CNStra + OIMDB | Zustand | Redux Toolkit | Effector | |----------|----------------|---------|---------------|----------| | Background Churn | **5.5 MB** | 5.8 MB | 6.0 MB | 3.4 MB | | Inline Editing | **1.1 MB** | 3.5 MB | 3.1 MB | 6.5 MB | | Bulk Update | **1.7 MB** | 3.6 MB | 5.1 MB | 4.4 MB | ### Code Complexity Second simplest codebase (394 LOC) while being the fastest — no trade-offs between performance and structure. --- ## Final Summary Most architectures: * expose state updates, * but keep flows **implicit**. That's why orchestration rots. CNStra flips the model: * state becomes explicit (via OIMDB), * flows become explicit (via collaterals), * parent/child chains are tracked automatically, * compile-time safety eliminates missing handlers, * performance rivals handcrafted atomic stores. This is not Flux 3.0 or "better Redux". ### This is the first explicit orchestration layer designed specifically for the realities of the modern web. ![Eye](/img/eye.png) Frontend or backend — same primitives, same guarantees: * workflows, * pipelines, * background jobs, * domain events, * distributed flows. 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 (performance warning: O(V²) cost) }); ``` **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; // current work queue size stimulation: CNSStimulation; // reference to the stimulation instance error?: Error; // when a dendrite throws hops?: number; // present if maxNeuronHops is set } ``` ### 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(); ``` ### `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 ```ts const controller = new AbortController(); const stimulation = cns.stimulate(signal, { maxNeuronHops: 10, // optional, disabled by default abortSignal: controller.signal, onResponse: r => { 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. - If all listeners are synchronous, CNStra does not introduce extra async deferrals for that response. ```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 Built-in Dependency Injection CNStra implements a large-scale command pattern where each stimulation is a command that gets processed by many processors (neurons). This architecture provides dependency injection out of the box: - **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 DI setup**: You don't need to configure dependency injection containers or manually wire dependencies—the graph structure itself defines the dependencies This approach combines the benefits of the command pattern (encapsulation, decoupling, extensibility) with automatic dependency resolution 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**: Manual 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 Collections store entities by primary key, providing O(1) lookups: ```typescript import { OIMReactiveCollection, OIMEventQueue, OIMEventQueueSchedulerFactory } 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 reactive collection const users = new OIMReactiveCollection(queue, { selectPk: (user) => user.id }); // CRUD operations users.upsertOne({ id: 'user1', name: 'John Doe', email: 'john@example.com' }); users.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.getOneByPk('user1'); const multipleUsers = users.getManyByPks(['user1', 'user2']); ``` ### Reactive Updates: Key-Specific Subscriptions Subscribe to changes for specific entities: ```typescript // Subscribe to changes for a specific user users.updateEventEmitter.subscribeOnKey('user1', () => { console.log('User1 changed!'); }); // Subscribe to changes for multiple users users.updateEventEmitter.subscribeOnKeys(['user1', 'user2'], () => { console.log('Users changed!'); }); // Updates trigger notifications users.upsertOne({ id: 'user1', name: 'John Updated' }); // Notification fires in next microtask ``` ### Indexes: Efficient Queries OIMDB provides two index types optimized for different use cases: #### SetBased Indexes: For Incremental Updates Use when you frequently add/remove individual items: ```typescript import { OIMReactiveIndexManualSetBased } from '@oimdb/core'; // Create Set-based index for user roles const userRoleIndex = new OIMReactiveIndexManualSetBased(queue); // 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.index.getPksByKey('admin'); // Set(['user1', 'user2']) ``` #### ArrayBased Indexes: For Full Replacements Use when you typically replace entire arrays (e.g., ordered lists): ```typescript import { OIMReactiveIndexManualArrayBased } from '@oimdb/core'; // Create Array-based index for deck cards const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased(queue); // Set full array (O(1) - direct assignment, no diff computation) cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card3']); // Query returns TPk[] const deckCards = cardsByDeckIndex.index.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 ### Event Coalescing: Performance Optimization Multiple rapid updates to the same entity are automatically coalesced into a single notification: ```typescript // These three updates... users.upsertOne({ id: 'user1', name: 'John' }); users.upsertOne({ id: 'user1', email: 'john@test.com' }); users.upsertOne({ id: 'user1', role: 'admin' }); // ...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 Use `OIMRICollection` to combine collections with indexes: ```typescript import { OIMRICollection, OIMReactiveIndexManualSetBased, OIMReactiveIndexManualArrayBased } from '@oimdb/core'; interface User { id: string; name: string; teamId: string; role: 'admin' | 'user'; } // Create indexes const teamIndex = new OIMReactiveIndexManualSetBased(queue); const roleIndex = new OIMReactiveIndexManualArrayBased(queue); // Create collection with indexes const users = new OIMRICollection(queue, { collectionOpts: { selectPk: (user: User) => user.id }, indexes: { byTeam: teamIndex, byRole: roleIndex } }); // Subscribe to index changes users.indexes.byTeam.updateEventEmitter.subscribeOnKey('engineering', (pks) => { console.log('Engineering team changed:', pks); }); // Update indexes manually users.indexes.byTeam.setPks('engineering', ['u1', 'u2']); ``` ### Custom Entity Updaters Customize how entities are merged on update: ```typescript import { TOIMEntityUpdater } from '@oimdb/core'; // Deep merge updater const deepMergeUpdater: TOIMEntityUpdater = (newEntity, oldEntity) => { const result = { ...oldEntity }; for (const [key, value] of Object.entries(newEntity)) { if (value !== undefined) { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { result[key] = deepMergeUpdater(value, result[key] || {}); } else { result[key] = value; } } } return result; }; // Use custom updater const users = new OIMReactiveCollection(queue, { selectPk: (user) => user.id, updateEntity: deepMergeUpdater }); // Updates merge with existing users.upsertOne({ id: 'user1', name: 'John' }); users.upsertOne({ id: 'user1', email: 'john@example.com' }); // Merges with existing ``` ## 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, OIMRICollection, OIMReactiveIndexManualSetBased } from '@oimdb/core'; const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const users = new OIMRICollection(dbEventQueue, { indexes: { byId: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (u: { id: string }) => 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 ```tsx import { useSelectEntityByPk } from '@oimdb/react'; function UserName({ id }: { id: string }) { const user = useSelectEntityByPk(users, 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, OIMRICollection, OIMReactiveIndexManualSetBased } from '@oimdb/core'; const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const users = new OIMRICollection(dbEventQueue, { indexes: { byId: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (u: { id: string }) => u.id }, }); export const posts = new OIMRICollection(dbEventQueue, { indexes: { byAuthor: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (p: { id: string }) => p.id }, }); // Single incoming signal with both payloads const userAndPostUpdated = collateral<{ user: { id: string; name: string }; post: { id: string; title: string; authorId: string }; }>(); // Controller-owned single update signal const controllerUpdated = collateral<{ user: { id: string; name: string }; post: { id: string; title: string; authorId: string }; }>(); // 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. ```tsx import { useSelectEntityByPk, useSelectEntitiesByIndexKey } from '@oimdb/react'; function AuthorWithPosts({ authorId }: { authorId: string }) { const user = useSelectEntityByPk(users, authorId) || null; const postsByAuthor = useSelectEntitiesByIndexKey(posts, 'byAuthor', authorId) || []; return (

{user?.name}

    {postsByAuthor.map(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, OIMRICollection, OIMReactiveIndexManualSetBased, } from '@oimdb/core'; // OIMDB setup const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const decks = new OIMRICollection(dbEventQueue, { indexes: { byId: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (d: { id: string }) => d.id }, }); export const cards = new OIMRICollection(dbEventQueue, { indexes: { byDeck: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (c: { id: string }) => c.id }, }); // Collaterals const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>(); const controllerCreateDeckForCard = collateral<{ title: string; cardTitle: string }>(); const controllerCreateCard = collateral<{ deckId: string; cardId: string; title: string }>(); const deckCreatedForCard = collateral<{ deckId: string; title: string; cardTitle: string }>(); // Services (mocked) const generateDeckId = (title: string) => '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 { card }; }, }); // 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, OIMRICollection, OIMReactiveIndexManualSetBased, } from '@oimdb/core'; // OIMDB setup const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() }); export const decks = new OIMRICollection(dbEventQueue, { indexes: { byId: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (d: { id: string }) => d.id }, }); export const cards = new OIMRICollection(dbEventQueue, { indexes: { byDeck: new OIMReactiveIndexManualSetBased(dbEventQueue) }, collectionOpts: { selectPk: (c: { id: string }) => c.id }, }); // Collaterals const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>(); const deckCreatedForCard = collateral<{ deckId: string; cardTitle: string }>(); // Services (mocked) const generateDeckId = (title: string) => '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(payload.deckTitle); 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** (`OIMReactiveIndexManualSetBased`): - Returns `Set` for efficient membership checks - O(1) add/remove operations - Best for frequent incremental updates **ArrayBased Indexes** (`OIMReactiveIndexManualArrayBased`): - 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: ```typescript import { useSelectEntitiesByPks, selectEntityByPk } from '@oimdb/react'; // React hooks automatically subscribe to reactive collections const user = selectEntityByPk(users, 'user1'); const teamUsers = useSelectEntitiesByPks(users, 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 ### Core Classes #### `OIMReactiveCollection` Reactive collection with automatic change notifications and event coalescing. **Constructor:** ```typescript new OIMReactiveCollection(queue: OIMEventQueue, opts?: TOIMCollectionOptions) ``` **Key Methods:** - `upsertOne(entity: TEntity): void` - Insert or update single entity - `upsertMany(entities: TEntity[]): void` - Insert or update multiple entities - `getOneByPk(pk: TPk): TEntity | undefined` - Get entity by primary key - `getManyByPks(pks: readonly TPk[]): Map` - Get multiple entities **Properties:** - `updateEventEmitter: OIMUpdateEventEmitter` - Key-specific subscriptions - `coalescer: OIMUpdateEventCoalescerCollection` - Event coalescing #### `OIMRICollection` Reactive collection with integrated indexing capabilities. **Constructor:** ```typescript new OIMRICollection(queue: OIMEventQueue, opts: { collectionOpts?: TOIMCollectionOptions; indexes: TReactiveIndexMap; }) ``` **Properties:** - `indexes: TReactiveIndexMap` - Named reactive indexes #### `OIMReactiveIndexManualSetBased` Reactive Set-based index. Returns `Set` for efficient membership checks. **Methods:** - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys (replaces entire Set) - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys (O(1)) - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys (O(1)) - `index.getPksByKey(key: TKey): Set` - Query index #### `OIMReactiveIndexManualArrayBased` Reactive Array-based index. Returns `TPk[]` for direct array access. **Methods:** - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys (O(1), recommended) - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys (O(n)) - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys (O(n)) - `index.getPksByKey(key: TKey): TPk[]` - Query index #### `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 // Available types: 'immediate' | 'microtask' | 'timeout' | 'animationFrame' 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. ```tsx const items = useSelectEntitiesByIndexKey(db.tables.logs, 'byType', 'warning'); ``` ## 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. Production-ready Redux adapter for OIMDB that enables seamless 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 - **📦 Production Ready**: Battle-tested, optimized 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 conducted a comprehensive performance benchmark comparing **Cnstra + OIMDB** against leading React state management libraries: **Redux Toolkit**, **Zustand**, and **Effector**. The benchmark evaluates execution time, memory usage, and code complexity across three critical scenarios. **🔗 [View Interactive Benchmark Results](https://abaikov.github.io/cnstra-oimdb-bench/)** | **📦 [Benchmark Source Code](https://github.com/abaikov/cnstra-oimdb-bench)** ## Tested Libraries 1. **Cnstra + OIMDB (ids-based)** - Reactive collections with CNS (Central Nervous System) combining Cnstra core with OIMDB reactive indexing 2. **Effector (ids-based)** - Reactive state management library with fine-grained reactivity using stores and events 3. **Redux Toolkit (ids-based)** - Official Redux toolkit with RTK Query using createSlice, createEntityAdapter, and optimized selectors 4. **Zustand (ids-based)** - Lightweight state management with minimal boilerplate and simple API ## Test Scenarios ### 1. Background Churn Tests batch update performance with frequent bulk updates, simulating real-world scenarios with high-frequency state mutations. ### 2. Inline Editing Tests reactivity during rapid user input (typing responsiveness), measuring how well each library handles fine-grained updates during user interaction. ### 3. Bulk Update Tests batch operations on multiple entities, evaluating performance when updating large numbers of records simultaneously. ## Metrics Explained - **Execution Time** (ms) - Total time to complete operations (lower is better) - **Memory Usage** (MB) - Memory consumed by operations (lower is better) - **Lines of Code (LOC)** - Implementation complexity (lower indicates simpler code) ## Detailed Results ### Execution Time (ms) - Lower is Better | Library | Background Churn | Inline Editing | Bulk Update | |---------|-----------------|----------------|-------------| | **Cnstra + OIMDB** | 69.4 | 70.8 | 50.7 | | **Zustand** | 83.0 | 152.9 | 81.2 | | **Redux Toolkit** | 100.6 | 250.5 | 156.8 | | **Effector** | 127.3 | 400.4 | 103.7 | ### Memory Usage (MB) - Lower is Better | Library | Background Churn | Inline Editing | Bulk Update | |---------|-----------------|----------------|-------------| | **Cnstra + OIMDB** | 5.5 | 1.1 | 1.7 | | **Zustand** | 5.8 | 3.5 | 3.6 | | **Redux Toolkit** | 6.0 | 3.1 | 5.1 | | **Effector** | 3.4 | 6.5 | 4.4 | ### Code Complexity (Lines of Code) - Lower is Better | Library | LOC | |--------|-----| | **Zustand** | 380 | | **Cnstra + OIMDB** | 394 | | **Redux Toolkit** | 531 | | **Effector** | 560 | ## Key Findings ### Performance Winners by Scenario - **Background Churn**: Cnstra + OIMDB (best execution time: 69.4ms) - **Inline Editing**: Cnstra + OIMDB (best execution time: 70.8ms) - **Bulk Update**: Cnstra + OIMDB (best execution time: 50.7ms) ### Overall Best Performer **Cnstra + OIMDB** demonstrates superior performance across all scenarios: - Fastest execution times in all scenarios (50.7-70.8ms) - Low memory usage (1.1-5.5 MB) - Second simplest codebase (394 LOC) ### Code Complexity - **Simplest**: Zustand (380 LOC) - **Most Complex**: Effector (560 LOC) - **Cnstra + OIMDB**: 394 LOC (second simplest) ### Notable Observations 1. **Cnstra + OIMDB** demonstrates superior performance across all metrics while maintaining relatively simple code (394 LOC). 2. **Zustand** offers the simplest implementation (380 LOC) with good performance, making it a solid choice for projects prioritizing code simplicity. 3. **Redux Toolkit** shows consistent performance but with higher memory usage and slower execution times in some scenarios. 4. **Effector** has the highest code complexity (560 LOC) and shows slower execution times, particularly in inline editing scenarios (400ms). ## Architectural Overview ### Cross-Library Indexing Strategy (Id-based) For all adapters we implemented id-based indexing: - O(1) entity lookup by primary key (id/PK) with reference preservation wherever possible. - List views access precomputed id collections (e.g., `deck.cardIds`, `card.commentIds`, `card.tagIds`) or index key → PK sets. - Hooks/selectors subscribe at the id level and return stable references/arrays to avoid unnecessary React updates. ### Cnstra + OIMDB Architecture - **Core model**: Normalized collections keyed by id with reactive secondary indexes (OIMDB). - **Data structures**: Primary storage is Map-like PK→entity; indexes are `Map>` for O(1) membership and fast fanout; PK APIs expose Set semantics (e.g., getPksByKey). - **Subscriptions**: Components subscribe to item-level data and index-driven queries; dependency tracking keeps subscriptions precise. - **Updates**: Batched via an event queue; writes upsert/remove PKs and incrementally update Map/Set indexes; `flush()` applies diffs atomically. - **Rendering**: Fine-grained invalidation means only affected rows/items update; index lookups avoid array scans on lists and tags. ### Other Libraries **Zustand**: - Core model: Single store with setter functions; normalized entities as Records (`Record`), plus derived arrays on entities. - Data structures: Plain JS objects for maps; arrays for per-entity indexes. - Subscriptions: Per-selector subscriptions with shallow comparison; developers hand-roll normalization and memoization. **Redux Toolkit**: - Core model: Single immutable store; slices use Immer to draft and produce next state; entities normalized via `createEntityAdapter`. - Data structures: Adapter maintains `{ ids: ID[], entities: Record }`; per-entity arrays stored directly on entities. - Subscriptions: Selectors (often via Reselect) memoize and preserve references. **Effector**: - Core model: Event/store graph; base stores hold normalized Records, derived stores recompute indexes via `combine`. - Data structures: Entity maps are plain objects; grouping helpers produce `Map` for intermediate rebuilds. - Subscriptions: Fine-grained at store level; derived graph fanout depends on dependency breadth. ## Why the Results Differ ### Background Churn (frequent bulk writes) - **Cnstra + OIMDB**: Incremental index maintenance and batched transactions minimize per-write work; precise subscriptions keep updates focused → lowest execution time. - **Zustand**: Low framework overhead keeps it competitive, but without automatic indexing some list/selector recomputation remains → mid-pack time. - **Redux Toolkit**: Copy-on-write via Immer plus action/reducer plumbing adds overhead under sustained churn → slower times. - **Effector**: Event/store graph propagation touches many derived stores; scheduling overhead accumulates with many small updates → slowest times. ### Inline Editing (rapid keystrokes) All libraries constrain subscriptions at the field/row level, but compute paths differ: - **Cnstra + OIMDB**: Fine-grained dependency tracking updates only affected item/index entries → best time (70.8ms). - **Zustand**: Simple updates and selector subscriptions perform well; some selector churn keeps it behind OIMDB. - **Redux Toolkit**: Selector memoization helps, yet Immer and selector invalidation on each keystroke increase costs → slower times. - **Effector**: Graph fan-out on every keystroke causes more propagation work → slowest time and higher memory in this scenario. ### Bulk Update (many entities at once) - **Cnstra + OIMDB**: Index-driven queries plus transactional batching keep updates focused → best time (50.7ms). - **Redux Toolkit**: `createEntityAdapter` and memoized selectors reduce churn effectively → respectable time. - **Zustand**: Without a built-in entity adapter, more list items change identity → higher time. - **Effector**: Many store updates propagate through derived graphs → slower time. ## Boilerplate and Developer Ergonomics | Library | Boilerplate Level | Typical Sources | |---------|-------------------|-----------------| | **Redux Toolkit** | High | Slices, action creators, thunks/RTK Query setup, selectors, entity adapter wiring | | **Effector** | High | Unit definitions (events/stores), derived chains (`map/combine/sample`), clocking, FX/error wiring | | **Cnstra + OIMDB** | Low–Medium | Collection/index definitions, typed queries/selectors, transactional helpers | | **Zustand** | Low | Store shape, setters, optional custom selectors/memoization | These levels align with the measured LOC: Effector (560) and Redux (531) require more scaffolding; Cnstra + OIMDB (394) is the second simplest; Zustand (380) is the simplest. In practice, higher boilerplate buys structure (Redux) or expressive reactive graphs (Effector) but can slow iteration and increase maintenance. Lower boilerplate (Zustand, Cnstra+OIMDB) improves ergonomics; Cnstra's reactive indexes also translate to performance wins in data-heavy UIs. ## Test Methodology - Each scenario was run 10 times - Results include warmup runs and outlier removal - Metrics calculated using median/mean aggregation with IQR outlier detection - All tests run on the same environment for consistency **Note**: This is a browser-based benchmark. While specific hardware configurations may vary, the observed performance differences (often measured in multiples) are substantial enough to draw meaningful conclusions. The relative performance characteristics remain consistent regardless of the testing environment. --- ## Why Cnstra + OIMDB Wins: The Performance Advantage The benchmark results clearly demonstrate that **Cnstra + OIMDB** is not just competitive—it's the clear winner across virtually every metric. Here's why this combination delivers such exceptional performance: ### Architectural Superiority **Incremental Index Maintenance**: Unlike libraries that rebuild entire state trees or recompute derived data on every change, OIMDB maintains indexes incrementally. When you toggle a tag or update an entity, only the affected index entries are modified. This O(1) update complexity means that as your data grows, performance doesn't degrade linearly. **Batched Transactional Updates**: The CNS (Central Nervous System) orchestrates updates across multiple collections, and OIMDB's event queue batches all changes. Everything is flushed atomically at the end of a run, resulting in smoother UI and better performance. **Fine-Grained Dependency Tracking**: Components subscribe at the entity or index level, not at the store level. When a single card's title changes, only components that actually depend on that specific card update. This precision eliminates unnecessary work that other libraries perform. ### Real-World Performance Benefits **Background Churn Excellence**: In scenarios with frequent bulk updates (common in real-time applications, dashboards, or collaborative editing), Cnstra + OIMDB's 69.4ms execution time is 20% faster than the next best option. The incremental index updates and batched flushing mean your app stays responsive even under heavy load. **Inline Editing Perfection**: Cnstra + OIMDB delivers the best typing responsiveness. The fine-grained dependency tracking ensures that rapid keystrokes only update what's necessary. **Bulk Update Dominance**: When updating many entities at once, Cnstra + OIMDB completes in just 50.7ms—nearly 3x faster than Redux Toolkit and 2x faster than Effector. The index-driven queries and transactional batching keep updates focused and efficient. ### Code Quality Meets Performance What makes these results even more impressive is that **Cnstra + OIMDB achieves this with only 394 lines of code**—the second simplest implementation. You get enterprise-grade performance without enterprise-grade complexity. The reactive indexes and normalized collections provide powerful abstractions that eliminate boilerplate while delivering speed. ### Memory Efficiency With memory usage ranging from 1.1MB to 5.5MB across scenarios, Cnstra + OIMDB demonstrates excellent memory efficiency. The Map/Set-based data structures and incremental updates mean minimal allocations and reduced GC pressure compared to libraries that copy entire state trees. ### The Complete Package **Cnstra + OIMDB** doesn't just win on one metric—it wins across the board: - ✅ Fastest execution times (all scenarios) - ✅ Low memory usage - ✅ Simple, maintainable code ### Conclusion The benchmark results speak for themselves: **Cnstra + OIMDB is the optimal choice for React applications that demand both high performance and developer productivity**. Whether you're building a real-time dashboard, a collaborative editor, or a data-intensive application, this combination delivers the speed, efficiency, and maintainability you need. The architectural advantages—incremental indexing, batched transactions, and fine-grained reactivity—translate directly to measurable performance gains. And with code complexity that's second only to Zustand, you get these benefits without sacrificing developer experience. **Ready to experience the performance difference?** [View the interactive benchmark](https://abaikov.github.io/cnstra-oimdb-bench/) and see for yourself why Cnstra + OIMDB is the future of React state management. [Explore the benchmark source code](https://github.com/abaikov/cnstra-oimdb-bench) to see how each library was implemented. # 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 has a fixed overhead for internal data structures: ### Base overhead (minimal context, ~2-3 keys) - **CNSStimulation object**: ~1.2-1.5 KB - Context store (Map): ~200 bytes - Task queue (array + Set): ~500 bytes - Pending/failed tasks tracking: ~200 bytes - Promise and metadata: ~300 bytes - **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: ```ts onResponse: (r) => { if (r.queueLength > 1000) { // Queue is growing - consider: // - Reducing concurrency // - Adding backpressure // - Investigating slow processing console.warn(`Queue length: ${r.queueLength}`); } } ``` 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'); } }); await stimulation.waitUntilComplete(); ``` ### Avoid `autoCleanupContexts` in production The CNS `autoCleanupContexts` option adds significant overhead: - **O(V²) initialization cost**: building SCC (Strongly Connected Components) structures - **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(); ``` 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/core'; 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' }); }); ``` **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/core'; 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, `createPersistRegistry` is a convenient shorthand: ```ts import { createPersistRegistry } from '@cnstra/core'; export const registry = createPersistRegistry({ 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 { ICNSStimulationContextStore } from '@cnstra/core'; 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 an error and you don't handle it, the stimulation might appear stuck. **Solution**: Always handle errors in `onResponse`: ```ts const stimulation = cns.stimulate(signal, { onResponse: (r) => { if (r.error) { console.error('Error:', r.error); // Handle or recover } } }); ``` ## 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 also cause `stimulation.waitUntilComplete()` to reject if any response listener (local or global) throws or rejects: ```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(); ``` If you do not await `stimulation.waitUntilComplete()`, the run still proceeds, but rejections from listeners won't be observed by the caller. ## 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. - 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`, and `queueLength`. ## 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`: current internal work queue length (can be used for backpressure metrics) ## Tips - Keep listeners lightweight; heavy work should be offloaded (e.g., buffer and batch). - 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 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/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 { createPersistRegistry } from '@cnstra/core'; import { deckNeuron, cardNeuron } from './neurons'; const registry = createPersistRegistry({ 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 `createPersistRegistry` from '@cnstra/core' — 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/core'; 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 { createPersistRegistry } from '@cnstra/core'; import { deckNeuron, cardNeuron, uiNeuron } from './index'; export const registry = createPersistRegistry({ 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 `createPersistRegistry` — 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 { createPersistRegistry } from '@cnstra/core'; const registry = createPersistRegistry({ 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 { createPersistRegistry } from '@cnstra/core'; import { CNSHistoryLogger } from '@cnstra/devtools'; import { cns } from './cns'; import { deckNeuron, cardNeuron } from './neurons'; const registry = createPersistRegistry({ 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).