Integrating causl-wasm-ts into a TypeScript / Node.js app Enterprise

This page kept its old URL on purpose. The package it describes was renamed from causl-client to causl-wasm-ts on 2026-07-27, but the directory stays integrating-causl-client/ so that existing links keep resolving. The prose is current; the path is legacy.

The Node-integration counterpart to the engine repo's producer-side causl-core-rs scripts/README.md. That file documents how a Python pipeline builds and places the wasm artefact; this file documents how a TS/Node app consumes the placed artefact through the @causl/causl-wasm-ts thin TypeScript client. The engine and client repositories are private, so I cite repositories, files and issues by name rather than by link; reaching them needs a Gitea account on git.opsite.ca.

This guide is the consumer-side concretion of SPEC §18A.4 (the thin-TS-client definition) and §18A.2 (the Node-target requirement). It covers, in order:

  1. What causl-wasm-ts is: the thin TS client over the Rust core, and what it is not.
  2. Installing and using it in a TS/Node app, including the preload plus synchronous construction boot (§18A.12).
  3. Where the wasm artefact comes from: the producer/consumer split and the causl-core-rs Python scripts.
  4. The node:fs loader and the host floor.
  5. The read()-identity migration: the one breaking change you must audit for before a real-Rust build.
  6. What the wasm path costs, and which of the older cost claims the measurements refute.

Every load-bearing claim cites a SPEC § anchor. Where the shipped code and the SPEC contract differ, the gap is called out as Shipped today versus Dated record, never blurred.


1. What causl-wasm-ts is

causl is a reactive dependency-graph engine with denotational, glitch-free, transactional semantics. Its public contract is the §12 surface: the seven-method spine

input · derived · commit · read · subscribe · snapshot · explain

plus the structural queries dependencies / dependents (and their transitive closures, the commit log, commit metadata, handle/disposal validation, stats: the §12.2 second-tier surface).

Per SPEC §18A.1, that contract ships in two conformant engines, held byte-identical at the §12 boundary by the cross-backend determinism gate (§18A.1.1). Since the 2026-07-27 rename the two engines live in two different repositories:

EngineWhat it isWhere it lives
The TypeScript engine The reference engine, the value-of-record running natively on the JS event loop. The unconditional floor (§13.8). causl-core-ts keeps it as the dual-engine default (js-ssot) and the differential oracle; causl-ts is its open-source distribution. It is not in this package at all.
The Rust engine The Rust core (engine-rs-core + engine-rs-bridge) compiled to WebAssembly, reached over FFI. The unconditional production engine in causl-wasm-ts (rust-ssot is the default); the §18A.3 FFI lift landed (causl-core-rs#170) so every adopter operation resolves from Rust. Lives in causl-core-rs.

causl-wasm-ts is the thin TypeScript client over the causl-core-rs core (§18A.4). Stated plainly so the boundary stays honest, it is not:

It is a thin adapter that (1) implements the public Graph interface by delegating each method to the wasm core over FFI; (2) marshals parameters across the JS↔WASM boundary (the cost is measured, not hidden, §6 below); and (3) binds to the placed .wasm artefact. It ships no Rust source, no build tooling, and no TypeScript engine. The produce-and-place tooling is the engine repo's (causl-core-rs, §18A.11); the consume-and-bind surface is this package's.

Repository topology (§18A.10)

causl-core-rs Rust engine, FFI externs, Python build and package tooling. The source of truth for the Rust to WebAssembly engine. Formerly causljs/causl-wasm. causl-wasm-ts THIS PACKAGE. The thin TS client, the Node loader, the adoption docs. Consumes the placed .wasm; ships no Rust and no TypeScript engine. Formerly causljs/causl-client. causl-core-ts The TypeScript engine (the floor), the differential oracle, and the cross-backend bench and conformance harness. Stays dual-engine. Formerly causljs/causl-ts-wasm-engine.

All three now live under git.opsite.ca/causl; the old GitHub names are 301 redirects. Named first-party integration consumers: iasbuilt/xldatagrid and iasbuilt/webapp (Node.js TS web apps).

Current state of the wasm path (SPEC §18A.3 / §18A.5). The engine this package loads is a real Rust engine (engine-rs-core), not a TS wrapper: the §18A.3 FFI structural lift has landed (causl-core-rs#170), so every adopter operation resolves from Rust. Engine orchestration (commit pipeline, dependency tracking, cutoff, history) runs in Rust, while the user's derived() compute lambdas run in JS over the bridge callback by design. The FFI seam, the bridge picker and the cross-backend byte-identity gate are stable and enforced.

Single-engine, and the TypeScript fallback is withdrawn (§18A.13; §18A.13.1 is now a dated record). By a recorded governance decision, causl-wasm-ts ships the wasm engine as its sole engine. The driver is complexity-elimination, one engine and one codepath, with perf explicitly accepted as immaterial here; it deliberately bypasses the §18A.7 GO/NO-GO perf gate and says so plainly.

§18A.13.1 (2026-06-23) briefly reversed part of that: it retained createCauslTs internally and wired it as the implicit createCausl() path's capability fallback. That section was withdrawn at 0.5.0 and its own heading now tells you to read it "as a dated record, not as a live obligation." Two findings killed it. The fallback engine and the primary disagreed on in-place mutation of a committed value (causl-wasm-ts#272), which §18A.1.1 requires to be identical; and the implicit path was the accident path, the one you reached by calling the ordinary factory. 0.4.0 warned on it and 0.5.0 throws.

So today createCausl() is construct-or-throw, and createCauslTs is deleted from source: a git grep createCauslTs over packages/*/src/** returns zero hits. Nothing in this package degrades to TypeScript at runtime. Scope: this is causl-wasm-ts only. causl-core-ts hosts the §18A.1.1 differential oracle and the benchmark suite, deliberately keeps the dual-engine TypeScript floor as its default (DEFAULT_WASM_ENGINE_MODE = 'js-ssot'), and adds a real-Rust differential leg as the byte-identity oracle.


2. Installing and using it

2.1 Install

The @causl scope is served by the private Gitea package registry, and every read needs the token issued during Enterprise onboarding. Put both lines in .npmrc before the first install:

@causl:registry=https://git.opsite.ca/api/packages/causl/npm/ //git.opsite.ca/api/packages/causl/npm/:_authToken=${CAUSL_NPM_TOKEN} pnpm add @causl/causl-wasm-ts # npm install @causl/causl-wasm-ts # yarn add @causl/causl-wasm-ts

The @causl/core hazard: get this wrong and the install still succeeds. @causl/core is not this package and has zero versions on the Gitea registry. It does resolve on the public npm registry, at 0.3.0 through 0.3.3, to a different package (the retired TypeScript engine). So an .npmrc missing the @causl:registry= line, or a lockfile still pinning @causl/core, installs cleanly and gives you the wrong engine instead of an error. If you are migrating from an older integration, grep your lockfile for @causl/core and for the dead Verdaccio host iasbuilt-npm.opsite.ca, and replace both.

Node 22 or newer is required (engines.node: ">=22"). The wasm engine lives on the /wasm subpath of the package, but do not read that as optional: the engine is unconditional in this client, and a build that never imports the subpath throws CAUSL_WASM_ENGINE_UNAVAILABLE at construction. Import it from your boot module, as below.

2.2 The smallest worked example

Identical to SPEC §10. Two inputs, one derived, one diamond, one subscriber, two commits, three observed propagations:

import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm' import { createCausl } from '@causl/causl-wasm-ts' // Once, at app init. On the Node target the glue is already synchronous and // this is a no-op; on the browser bundler target it is required. See §2.3. await preloadCauslWasm() const graph = createCausl() // the wasm engine, or a throw. No third outcome. const a = graph.input('a', 1) const b = graph.input('b', 2) const sum = graph.derived('sum', (get) => get(a) + get(b)) const sumPlusOne = graph.derived('sumPlusOne', (get) => get(sum) + 1) graph.subscribe(sumPlusOne, (v) => console.log(v)) // 4 graph.commit('bump-a', (tx) => tx.set(a, 10)) // 13 graph.commit('bump-both', (tx) => { tx.set(a, 100) tx.set(b, 200) }) // 301, exactly one notification rather than two (glitch-free)

All mutation happens inside commit; outside, the graph is read-only (§12, commitment 2). The single notification on the second commit is glitch-freedom as a theorem, not a scheduler trick (§3 Theorem 2).

2.3 Booting the engine: preload once, construct synchronously (§18A.12)

There is exactly one adopter-facing boot pattern, and it has one await in it. Compiling the WebAssembly.Module is unavoidably async, and the §12 commit / read spine is and stays synchronous (§18A.6). A synchronous consumer (a React hook or render body, an SSR pass, xldatagrid's imperative grid API) cannot put an await at the construction site. SPEC §18A.12 splits the compile out of construction so the construction site stays synchronous. This is shipped.

import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm' import { createCausl, createCauslWasmSync } from '@causl/causl-wasm-ts' // ONCE, at app init: the only async seam. Compiles and caches the // WebAssembly.Module (plus the _bg.js sidecar and the compute-imports // snippet), keyed by bridge. Idempotent: concurrent calls share one // compile; a transient failure drops the cache entry so a retry recompiles. await preloadCauslWasm() // Thereafter, at every render / hook / SSR site: FULLY synchronous. // `new WebAssembly.Instance` from the cached Module, zero await. const graph = createCausl() // the default factory const another = createCauslWasmSync() // the explicit one, same engine

Node versus browser (cross-ref §18A.2). On Node and SSR the --target nodejs glue is already synchronous end to end (readFileSync + new WebAssembly.Module + new WebAssembly.Instance at require or import time), so construction works without a prior preload and the server render stays synchronous on its own. It is the browser bundler target that needs the one-time await preloadCauslWasm() to complete before the first render that constructs a graph. That ordering is what keeps SSR↔CSR hydration parity: no engine constructed mid-render in an unresolved-promise state. An app that skips the preload does not silently block. It throws.

2.4 When construction fails: two codes, and no fallback

Construction has two outcomes: a wasm Graph, or a throw. There is no third outcome and no silent degrade, because the TypeScript engine is not in this package to degrade to (§1 above). Branch on error.code and never on instanceof: the code is the contract, the class is not.

CodeMeansWhat to do
CAUSL_WASM_ENGINE_UNAVAILABLE The wasm subpath was never imported, or the host cannot instantiate the engine. Check that your boot module imports the /wasm subpath and that the host clears the floor. If the host genuinely cannot run the engine, that host cannot run this package; use the open-source TypeScript engine there instead.
CAUSL_WASM_NOT_PRELOADED The subpath was imported, but preloadCauslWasm() never resolved before construction. An ordering bug in your boot, not a capability problem. await the preload before the first render that constructs a graph.

There is no backend: 'auto' here. Older versions of this guide described a createCausl({ backend: 'auto' }) heuristic that started a graph on the TypeScript engine and migrated the live graph onto the wasm backend once graph size, derivation depth or subscriber count tripped a threshold. That heuristic, and the backend: 'js' | 'auto' string option generally, exist only on the dual-engine causl-core-ts distribution. In causl-wasm-ts there is nothing to migrate from and nothing to migrate to, so there is no heuristic, no live-graph migration, and no per-call backend string.

2.5 Engine choice lives at the seam, never in user code

The whole point of the byte-identity gate is that the two engines are interchangeable at the public-contract boundary. Your model code (input / derived / commit / read / subscribe) is identical regardless of which engine is behind it. Where a choice exists at all (that is, in causl-core-ts) it lives at the BackendEngine seam (packages/core/src/backend.ts) and nowhere else. If a piece of application logic has to know which engine it is running on, that is a bug. The only legitimate exception is the read()-identity migration (§5), which you fix once, defensively, so it is correct under both engines.


3. The wasm artefact placement (the producer/consumer split)

causl-wasm-ts consumes a placed .wasm artefact. It does not build one. Per §18A.11, the build-and-place tooling is two stdlib-only Python scripts in causl-core-rs, the producer side, and a webapp's CI/CD pipeline drives them. The split:

SideRepoArtefactTool
Producer causl-core-rs builds and places the .wasm scripts/build_wasm.py, scripts/package_wasm.py
Consumer causl-wasm-ts (you) binds the placed .wasm over FFI the @causl/causl-wasm-ts/wasm loader

3.1 Producer side: the Python scripts (run by your pipeline)

These need a Rust toolchain (cargo + wasm-pack + wasm-opt); your consuming pipeline does not, it only needs CPython stdlib to run package_wasm.py. The full contract is in causl-core-rs's scripts/README.md.

# Build and place in one call, the common pipeline entry point. # --dest states WHERE the artefact goes; the consumer picks the path. python3 scripts/build_wasm.py --bridge gc-classic # to build/wasm-nodejs python3 scripts/package_wasm.py --build --dest /path/to/app/src/engine/wasm

build_wasm.py produces a size-optimised, node-target (--target nodejs) .wasm plus the node glue (causl_engine_bridge.js) and .d.ts typings. package_wasm.py copies that artefact set into --dest and writes a deterministic manifest recording the engine version, bridge, target, wasm-opt flags, and a per-file sha256. It is stdlib-only, makes no network calls, and fails loud (non-zero exit, clear stderr) if --src is incomplete, a file cannot be hashed, or --dest is unwritable.

The same scripts also place the compute-imports snippet next to the .wasm / _bg.js and assert fail-loud at packaging time on the §18A.12 invariants, so a wasm-pack or wasm-bindgen upgrade that breaks the synchronous-construction seam (§2.3) never ships silently:

3.2 Consumer side: vendor and verify the manifest

In your app's CI/CD, after package_wasm.py has placed the artefact, verify it against the manifest before the build proceeds. This closes the "producer ships, consumer places" loop honestly:

# verify-wasm-manifest.py: a loud post-place gate for your pipeline. import hashlib, json, pathlib, sys dest = pathlib.Path("src/engine/wasm") manifest = json.loads((dest / "causl-wasm.manifest.json").read_text()) for f in manifest["files"]: digest = hashlib.sha256((dest / f["name"]).read_bytes()).hexdigest() if digest != f["sha256"]: sys.exit(f"checksum mismatch for {f['name']}") print(f"wasm artefact verified: {manifest['engineVersion']} ({manifest['bridge']})")

Git-track the manifest so rebuilds are deterministic and a drifted artefact fails CI loudly rather than shipping silently. Vendoring the artefact this way is also the answer to a CDN or strict-CSP layout: the engine resolves from your own deploy, offline, with no fetch at runtime.


4. The loader and the host floor

4.1 The host floor and the one bridge that ships

Two exported functions are called detectBridge() and they do opposite things, so check which one you imported. The version on the @causl/causl-wasm-ts/wasm subpath is a real probe: #426 wired it to a 56-byte module built from the artefact's measured minimum, and it throws WasmEngineUnavailableError naming the failing feature. The version re-exported from the main barrel is still the placeholder the older caveat described: every marshalling primitive on it throws "pending #692", and it reports { gc: false, jsStringBuiltins: false } with abiVersion: 0 even on hosts where the engine instantiates without complaint.

So do not call detectBridge() from the default import to ask whether this host supports the engine. It will tell you no on a host that works. Either import it from the /wasm subpath, or do the thing you actually want, which is to call preloadCauslWasm() and branch on error.code if it throws.

There is currently one bridge. gc-classic is it. The second variant, gc-builtins, was deleted (causl-core-rs#355). The ids are gc-classic and gc-builtins, never wasmgc-classic or wasmgc-builtins; if you find the wasmgc- spelling in a config, it is stale. With one bridge shipping there is nothing to pin, so let detectBridge() do its job.

The host floor (packages/core/src/wasm-registry.ts):

HostMinimum
Safari18.2
Chromium119
Firefox120
Node22

The governing requirement is typed function references, established by feature-bisecting the shipped artefact in causl-wasm-ts#426. It is not WasmGC heap types: the artefact declares zero WasmGC struct, array or rec-group types, so any prose that gates the floor on "WasmGC support" is describing a requirement the artefact does not have. The floor this site used to publish (Safari 18, paired with macOS 15, and a Node ceiling of 20) was wrong on three separate counts.

Treat these versions as engine release-note claims, not as our measurement. Nobody has run the shipped artefact on a real boundary host. If you deploy to a fleet sitting near the floor, test it yourself before you commit to it.

4.2 The loader resolution shape

Bundler target. The loader resolves the artefact via new URL('./pkg/<segment>/causl_engine_bridge_bg.wasm', import.meta.url), the lowest common denominator across webpack 5 (experiments.asyncWebAssembly), Vite 5 (vite-plugin-wasm), esbuild 0.20+ (--loader:.wasm=file), and Node 22+ ESM.

Node target (§18A.2). The --target nodejs glue instantiates the wasm synchronously at require or import time, so the Node loader hook resolves the placed artefact via node:fs with no async load shape, and works identically in CJS and ESM. This is the loader that causl-core-rs's scripts/README.md "Consuming the packaged artefact in Node" section pairs with, and it satisfies the §18A.2 requirement. For a CDN or strict-CSP layout, prefer vendoring the artefact into your own deploy with package_wasm.py (§3.2) over rewriting a base URL at runtime: the §18A.11 contract is explicitly offline-resolvable.

4.3 The FFI single-tick invariant: never await the commit entry (§18A.6)

The FFI commit entry point (apply_commands / the Rust commit_batch extern) is a synchronous #[wasm_bindgen] call. The async surface you await is loading the engine (await preloadCauslWasm()), not committing through it. A commit is one atomic tick (§3 Theorem 2 / §5 Phase A to H, "no intermediate time"). Do not await a commit: interleaving an await mid-commit would break single-tick atomicity silently. graph.commit(intent, tx => …) is and stays synchronous on both engines.

Atomicity is untouched by the preload (cross-ref §4.3 / §18A.6 / §3 Theorem 2). The compile await is hoisted to bootstrap, outside any commit envelope, and createCauslWasmSync performs no await between the §5 Phase E and Phase G boundaries. Moving the one async to app init does not put an await anywhere near a commit.


5. The read()-identity migration (§18A.5 / §15.1 #1124)

This is the one breaking change the wasm path introduces, and the one thing you must audit for when consuming the Rust engine.

The contract. graph.read(node) is not contractually required to return the same JavaScript reference across calls. Value identity at a fixed GraphTime is guaranteed; reference identity across commits is not (§15.1, ratified by the #1124 amendment).

Why it bites, and why it is worse than it sounds. The obvious mental model, "the wasm engine deserialises across the boundary so every read() is a fresh object", is wrong, and the truth is harder to test for. The engine is not uniformly fresh: a ValueHandleCache retains the original adopter reference, so identity is stable on a cache hit and fresh only on the decode paths. The break is therefore intermittent. A test that reads the same node twice in a row will very likely pass. Any adopter who keys memoisation on the read() return reference gets a bug that fires on some commits and not others, with no error.

The fix: key on commit.time or a per-node version, not on read() identity.

// WRONG: keys the memo on a reference the engine does not promise to keep. // It invalidates on some commits and not others, which is why it survives // a naive test. import { useMemo } from 'react' function UserCard({ user }: { user: User /* a read() return */ }) { const transformed = useMemo(() => transform(user), [user]) // reference key // ... } // RIGHT: keys on commit.time (the GraphTime on the published Commit), // which advances by exactly one per commit and is byte-identical under // both engines. import { useMemo } from 'react' function UserCard({ user }: { user: User }) { const commit = useCauslCommit() // commit.time: GraphTime const transformed = useMemo(() => transform(user), [commit.time, user]) // ... }

For workloads where commit.time is too coarse (it advances on every commit, even ones that do not touch your node), key on the per-node version counter instead: the read_derived_version extern surfaced through EngineTelemetry:

const telemetry = useEngineTelemetry() const version = telemetry.nodeVersion(node) // bumps only when `node` changes const transformed = useMemo(() => expensiveTransform(value), [version])

Pre-migration checklist (required reading before a real-engine upgrade, §18A.5). Audit, across your codebase:

Migrate each to commit.time (GraphTime on the Commit) or the per-node version counter. A dev-only hazard warning is available behind createCausl({ enableH1HazardWarning: true }): it records each long-held read() return as a WeakRef and emits one console.warn per survivor whose read-time GraphTime predates the post-commit clock (opt-in, off by default, dead-code-eliminated in production builds).


6. What the wasm path costs

Honesty about cost is a contract, not a footnote. State it in front of every adoption decision.

The §18A.7 Criterion-3 ceiling, and why it is the wrong thing to watch. Criterion 3 named a marshal-overhead bar: single commit ≤ 250 µs p95, batch or large mutation ≤ 5 ms p95. That criterion is GO, and it always would have been, because marshalling is not the binding constraint. On the benchmark host a raw boundary crossing measures 1.6 ns, and value marshalling costs about 77 ns per read, roughly 4× the TypeScript engine's read. Real, but third-order.

Two claims this page used to make are refuted. The "78× wire tax" and the "~85× per-commit execution cost" attributed to WASM runtime immaturity do not survive measurement. The per-commit wire cost the older text implied is several times larger than the entire measured commit, and it was derived against a serde bridge artefact that no longer ships. What actually dominates is engine-side and client-side work: a residual per-commit clone that scales with the number of live derived nodes, a per-recompute constant, and a per-commit constant. I do not restate that analysis here. Wasm performance carries the numbers, the confidence caveats, and the open issues.

What this means for you, concretely:

Promotion was governance, not a CI flip, and it happened. The wasm engine becomes the WASM-path default only when all five §18A.7 GO/NO-GO criteria pass: byte-identity (1), the full FFI surface (2), the marshal ceiling above (3), the Node target plus a real-Rust production adopter (4), and the TS-engine floor maintained (5). That last one is the same criterion the two-engine page lists, and it is satisfied today by causl-core-ts rather than by anything inside this package. The dated amendment landed on 2026-06-21 (causl-core-rs#169): all five GO, rust-ssot unconditional, and the per-flush byte-compare oracle plus sticky-downgrade fail-safe removed (§18A.8).


See also