Module @causl/causl-wasm-ts

@causl/causl-wasm-ts

Build & distribution: this is the causl-client build of @causl/causl-wasm-ts: the thin TypeScript API over the Rust→WASM causl-wasm engine (wasm is the only engine it ships: as of 0.5.0 there is no TypeScript fallback and no degradation path). It is distributed privately via the Gitea npm registry at https://git.opsite.ca/api/packages/causl/npm/ (org causl; a read:package token is required), and ONLY there: @causl/causl-wasm-ts has no versions on public npmjs (an unscoped install fails with E404). The name it renamed from, the retired @causl/core, is still live on npmjs at 0.3.x as the deleted reference TypeScript engine, so a stale pin on the old name installs successfully and silently delivers the wrong engine.

The semantic core of Causl: two primitives, one commit, the canonical seven-method API plus the second-tier extensions justified individually in SPEC §12.2.

Point the @causl scope at the Gitea registry first: it is the one registry that serves this package. Create a token with the read:package scope (git.opsite.ca, Settings, Applications) and add to your ~/.npmrc (or a repo-root .npmrc):

@causl:registry=https://git.opsite.ca/api/packages/causl/npm/
//git.opsite.ca/api/packages/causl/npm/:_authToken=${GITEA_PACKAGES_TOKEN}

Then:

pnpm add @causl/causl-wasm-ts

Skipping the .npmrc step makes the command resolve the default registry, public npmjs, where this name has no versions: the install fails with E404 rather than delivering this package.

Before any cell, any formula, any resource, the engine must support this shape. The four invariants: atomic commit, dependency tracking, dynamic-dep cleanup, glitch-free diamond: all fall out of getting it right; everything downstream depends on it.

createCausl() is construct-or-throw as of 0.5.0 (causl/causl-wasm-ts#280): the Rust→WASM engine is the only engine this package ships, so the one-time await preloadCauslWasm() is required, not an optimisation. Skip it and the first line that builds a graph throws: see Errors.

import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm'
import { createCausl } from '@causl/causl-wasm-ts'

await preloadCauslWasm() // ONCE, at app init — the only await in the API

const graph = createCausl()
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 fire

A constructed Graph exposes twenty-two members. Six of them: input, derived, commit, read, subscribe, explain: are, with the createCausl factory, SPEC §12.1's canonical seven: the load-bearing surface the engine cannot exist without one of. The other sixteen are SPEC §12.2 second-tier extensions, each justified individually and reviewed quarterly. That is twenty-three callable entry points in total; the size of that surface is itself a row in the engine's eight-commitment table, so a twenty-third Graph member demands the same justification as a twenty-second.

Method Purpose
createCausl(options?) Construct a graph. Options: name (validated at construction; a malformed name throws InvalidGraphNameError), commitHistoryCap, snapshotRetentionCap, onObserverError. Passing an option this release removed; engine, backend, fallbackToTs, fallbackToJs; throws RemovedEngineOptionError.
graph.input<T>(id, initial) Register a writable Behavior. Throws DuplicateNodeError on a clashing id.
graph.derived<T>(id, compute) Register a derived Behavior; deps captured by get() calls in compute, so a derivation that switches branches on an if rewires its dependency set on the next evaluation.
graph.commit(intent, tx => …) Discrete event: advances GraphTime by exactly 1. The only mutation entry; nested commits throw CommitInProgressError.
graph.read(node) Read at the current committed time. Per the SPEC §15.1 amendment shipped via PR #1129, the JavaScript reference returned across calls is not contractually guaranteed; the contractual identity surface is value identity at a fixed GraphTime. Because the reference is not contractual, code that memoises on it (e.g. React.memo, useMemo([value])) should key on commit.time or graph.stats().nodeVersion(node) instead of on the returned object. What the shipping engine actually hands back is described in Reads are live and unfrozen: read that before you treat a read return as a private copy.
graph.subscribe(node, observer) Observe value changes; one notification per commit per affected subscriber. Returns Unsubscribe.
graph.explain(node) Returns a DerivedNode<Explanation> of the lineage. Itself subscribable, so devtools render on top of the engine's own primitives instead of a parallel system.
Surface Purpose
graph.subscribeCommits(observer) Observe every commit. Narrow per-fire notification capability: one Commit per fire, no log read; for devtools, persistence, and SSR-hydrate listeners that want "wake me on any change" without access to the full log.
graph.commitLog (getter) The transaction log surfaced as a DerivedNode<readonly Commit[]>: readable, subscribeable, and visible in explain(node) lineage views. Bounded by commitHistoryCap, which defaults to 0, so the log is empty until you opt in with createCausl({ commitHistoryCap: 1000 }); failed commits never append. There is no separate "log API" because the log is just another node.
graph.exportModel(options?) CauslModel IR for causl-check, the bounded model checker. The IR is a public contract; this method is its only producer.
graph.snapshot() Capture the current input set + GraphTime as a serialisable GraphSnapshot (SSR transfer, persistence, time-travel). Derived nodes are intentionally omitted: they are pure functions of inputs and recompute on first read.
graph.hydrate(snap) Bulk-apply a GraphSnapshot: writes the snapshotted inputs, advances now to snap.time, fires per-node subscribers whose values changed, and emits a single Commit with intent: 'hydrate'. Throws HydrationSchemaError on a schemaHash mismatch; the structural defence against the hydration-mismatch race class.
graph.readAt<T>(node, t) Read node at a past committed time t ≤ now. Returns a RetentionResult<T> discriminated Retained | Evicted per SPEC §9; bounded by snapshotRetentionCap, which defaults to 0, as does the commitHistoryCap gating it, so a graph built with no retention options answers Evicted at every t. Retention is opt-in: pass both caps, createCausl({ commitHistoryCap: 1000, snapshotRetentionCap: 1000 }). The Evicted arm is the engine's honesty about bounded retention: a tag check at the call site rather than undefined or a throw.
graph.snapshotAt(t) Project a whole-graph GraphSnapshot at a historical GraphTime t, sourced from the bounded retention buffer. Returns a RetentionResult<GraphSnapshot>. The bridge consumes this on JUMP / IMPORT_STATE / ROLLBACK so time travel is observed as a read on a Behavior, not as a mutation: preserving the contract that the only way time advances is through commit.
graph.simulate(intent, run) The §5 dry-run API: predict what commit(intent, run) would do without committing. Returns a discriminated SimulateResult carrying the would-be Commit and the staged-input / derived-recompute diffs on the 'clean' arm, or the typed engine error on the 'failed' arm. After the call returns, engine state is byte-identical to the pre-call moment; now unchanged, no commit-log append, no subscriber fires. The only throw is CommitInProgressError on re-entry.
graph.dependencies<T>(node) Realises the §11 third-bullet liveness primitive: direct (depth-1) dependency ids of node at the current committed time, projected as a frozen readonly NodeId[] and lex-sorted for stable iteration. Inputs return []; derivations return the dep-set captured by the most recent compute. Same getEntry gate as read/subscribe/explain; throws UnknownNodeError for fabricated ids and NodeDisposedError for released ones. Ships as a one-shot snapshot rather than a DerivedNode-valued handle: the commit pipeline cannot host derived nodes whose value is metadata about the commit pipeline itself without breaking the §5 single-tick invariant (#383).
graph.dependents<T>(node) Sister primitive: direct (depth-1) reverse-dep ids of node at the current committed time, sourced from the same adjacency map the commit pipeline already maintains for invalidation. Same snapshot semantics, same error surface. The spreadsheet question, what depends on this cell?; is answerable through engine primitives instead of by walking every other node's explain.
graph.stats() The EngineTelemetry snapshot: commit counters, retained-commit count, and nodeVersion(node), a per-node integer that advances by exactly 1 on each commit in which that node's value changed under the §15.1 !Object.is cutoff and is unchanged on every other commit. It is the supported memoisation key for adopters who must not depend on read-reference identity. Engine authority, so it is deliberately absent from the narrowed ReadOnlyGraph a @causl/react selector receives; call it on the Graph itself.
graph.subscribeMany(nodes, observer, options?) Register one observer across a group of nodes in a single call. Fires once per commit no matter how many nodes in the group changed (a per-commit dedupe marker), and is never visited at all by commits that touch none of them. Cheaper than N separate subscribe calls because the per-node index is built once at registration. Returns one Unsubscribe that drops the whole group.
graph.subscribeReads(observer, projection) Subscribe to a projection rather than to a node: the read-set is captured by running projection under the engine's tracking accessor, and re-captured on every fire, so a conditional read follows the live branch with no hand-managed dep array. This is "subscribe to a derived view without registering a derived node"; the dispatch shape per-node adapter hooks build on instead of routing through subscribeCommits's every-commit fan-in.
graph.dispose() Release the wasm engine behind this Graph: drops the per-engineId handler registrations and frees the Rust slot (#111). Idempotent, and also published as graph[Symbol.dispose], so using graph = createCausl() frees it at scope exit. A FinalizationRegistry is the backstop, not the contract; long-lived processes that build graphs per request should call it. Non-enumerable, so it does not show up in Object.keys(graph).
graph.commitMetadataDerived<T>(id, compute) Register a derived Behavior whose compute reads commit metadata: graph.commitLog, the just-completed commit's stamp, or values produced by other commit-metadata-tagged deriveds; and whose value reflects the just-completed commit, not the previous one. Internally a DerivedNode like any other; it participates in read / subscribe / explain / readAt uniformly with derived(...). The seam this factory adds is scheduling: tagged nodes recompute in Phase F.5 (post-commitLog refresh, pre-Phase-G subscriber dispatch), so subscribers see the post-commit value on the same commit that produced it. Ordinary derived(...) is unaffected; its Phase D atomicity is the §3 invariant. Closes #452 and unblocks #383's whyUpdated / whyNotUpdated / commitLog derived rewrites.
graph.now (getter) Current GraphTime. A getter, not a method: the §3 vocabulary needs an external observer to ask "what time is it?" without firing a commit.

graph.readAt and graph.snapshotAt both return a RetentionResult<T>:

type RetentionResult<T> =
| { readonly status: 'retained'; readonly value: T; readonly time: GraphTime }
| { readonly status: 'evicted'; readonly oldestRetainedTime: GraphTime }

The evicted arm carries the oldest still-retained time so callers can clamp future requests into the bounded window. The tag check is the SPEC §9 "make impossible states impossible" pattern: a read for a time outside the retention window cannot silently return undefined.

The clamp terminates only when retention is on. Both caps default to 0, and at cap 0 there is no window, so oldestRetainedTime is not its lower edge: it reports graph.now as a placeholder, and reading at that time evicts as well. Measured on a default-cap graph after two commits, readAt(a, now) answers evicted { oldestRetainedTime: 2 }, and clamping to 2 and re-reading answers evicted { oldestRetainedTime: 2 } again, without end. So a "retry until retained" loop does not terminate on a graph that opted into nothing. Treat evicted as terminal, or retry at most once and only when you know the graph was built with { commitHistoryCap: > 0, snapshotRetentionCap: > 0 }.

The contract is the one stated on graph.read above: reference identity across calls is not guaranteed, so do not build on it. What rust-ssot (the engine this package ships) does today is the other thing, and it is the hazard worth writing down:

read(node) hands back the very object you put in, not a copy.

await preloadCauslWasm()
const graph = createCausl()

const passed = { a: 1 }
const n = graph.input('obj', passed)

graph.read(n) === graph.read(n) // true — same reference, same tick
graph.read(n) === passed // true — your own object, back again
Object.isFrozen(graph.read(n)) // false — nothing defends it

graph.read(n).a = 999 // no throw, no commit, no fire…
graph.read(n).a // 999 — committed state just changed
passed.a // 999 — and so did your object

That write went around commit. now did not advance, no Commit was appended, no subscriber fired, and explain shows nothing: but every later read observes it. It is the one way to move committed state without a commit, and the engine cannot see it happen.

Treat every value you hand to input / tx.set, and every value read gives you, as read-only. If you need a mutable working copy, clone it (structuredClone, a spread, an immutable library) and commit the clone. Deep-freezing values before you commit them turns the silent corruption into a TypeError at the mutation site, which is usually what you want in development.

None of this is contractual in the other direction either: do not start relying on read returning a stable reference. Key memoisation on commit.time or graph.stats().nodeVersion(node), both of which are value-typed and survive any future change to how values cross the FFI boundary.

  • Atomicity: graph.commit is the only mutation entry, and it produces exactly one new GraphTime. Outside a commit the graph is read-only; there is no fractional time and no "concurrent mutation" question because there is no concurrent mutation API. Observers wake at most once per commit per observed value change.
  • Glitch-freedom: a derived value at time t is a pure function of its inputs at the same time t: derived(t) = f(b₁(t), …, bₙ(t)). There is no intermediate "B updated but C did not" state because there is no intermediate time. Diamond observations never see interleaved deps regardless of what the scheduler does.
  • Determinism: derived(t) = f(b₁(t), …, bₙ(t)) is a function, so two implementations either agree or one of them is wrong. A recorded commit sequence replayed on a fresh graph produces a byte-identical state.
  • O(|affected|) recomputation: a commit producing N derived recomputations runs in O(N) time, not O(graph size). Dirty marking and dependency walking are bounded by the affected subgraph. This is a correctness criterion phrased as performance, not a benchmark.

Every public throw is a typed subclass of CauslError, and every one carries a literal kind discriminant, so a switch (e.kind) over the catalogue narrows exhaustively rather than parsing a message string. A single e instanceof CauslError captures any engine-emitted failure.

The catalogue is twenty classes plus the CauslError root. This table is the whole of it: the class column and the kind column are both held to packages/core/src/errors.ts by error-catalogue-inventory-511.test.ts in the shipped suite, so a class added to the engine and not added here fails the build. Nothing gated this section before #511 and it had drifted to thirteen of the twenty, which left an adopter branching on the documented set with a third of the taxonomy unhandled.

Class kind Fires when
CommitInProgressError CommitInProgress commit, simulate or hydrate is re-entered while a commit is running on the same graph. Commits do not nest.
CycleError Cycle A commit closes a derivation cycle. Carries the ordered path of participating ids.
DerivedComputeError DerivedCompute A user compute(get) body itself throws. Carries the original thrown value as cause, so e.cause instanceof MyValidationError branches on your own error modes.
DerivedRegistrationStackOverflowError DerivedRegistrationStackOverflow A registration walker exhausts the call stack. Defence in depth: the registration path is iterative, so this is a residual guard rather than the steady-state outcome.
DisposalDuringCommitError DisposalDuringCommit Adapter code disposes a node while a commit is in flight. Disposal waits for the engine to return to Idle.
DuplicateNodeError DuplicateNode An id is registered twice on one graph.
HydrationSchemaError HydrationSchema hydrate receives an unsupported schema version, or a schemaHash that diverges from the live node id-set.
InvalidGraphNameError InvalidGraphName createCausl({ name }) receives a name outside /^[A-Za-z0-9_.:-]{1,256}$/. Thrown at construction, so there is no half-built graph.
InvalidNodeHandleError InvalidNodeHandle A compute calls get with something that is not a live node handle (undefined, null, a primitive, an object with no string id). The usual cause is a self-referential registration reading undefined from its own closure.
InvariantViolationError InvariantViolation A value staged by tx.set fails the invariant registered on that input. The whole commit rolls back: now does not advance and no subscriber fires. Carries the invariant's throw as cause.
NodeDisposedError NodeDisposed read, subscribe or tx.set targets a node released through the adapter disposal hook. Distinct from UnknownNodeError so "registered then released" is tellable from "never registered".
NodeHasDependentsError NodeHasDependents Adapter code disposes a node that still has live dependents. Names every offending dependent.
NonDeterministicComputeError NonDeterministicCompute The opt-in assertDeterministicCompute gate catches a compute returning a different value on a second call against the same dependency snapshot. Off by default; the gate doubles compute() work.
NotAnInputNodeError NotAnInputNode tx.set targets a derived node. A derived value is a pure function of its dependencies, so writing one would tear that equation.
RemovedEngineOptionError RemovedEngineOption An options bag still carries engine, backend, fallbackToTs or fallbackToJs, all removed in 0.5.0. Branch on code === 'CAUSL_REMOVED_ENGINE_OPTION'; the fix is always to delete the key.
RetainedValueUnavailableError RetainedValueUnavailable readAt / snapshotAt asks for a historical CONTAINER input whose value the Rust engine kept only as a content-hash and whose live reference has since changed. The honest "not retained" arm rather than a corrupt value.
StaleTxError StaleTx A Tx captured from a commit callback is used after that callback returned.
UndeclaredDependencyError UndeclaredDependency A compute dynamically reads a NodeId that was never registered. Rewiring a dependency set across recomputes is legal (SPEC §10.3); reading an id the graph has never seen is not.
UnknownNodeError UnknownNode Any read-side primitive names an unregistered id.
WasmInstancePoisonedError WasmInstancePoisoned A Rust trap aborted mid-mutation. wasm32 has no unwinding, so engine state is undefined and this is NOT an atomic rollback: the shared instance is marked dead and every engine on it fails loud. Rebuild the graph on a fresh instance.

InvalidNodeHandleError (kind: 'InvalidNodeHandle') is new in 0.6.0. It fires when a derived's compute(get) hands get a value that is not a live node handle. The commonest producer is a self-referential registration: the handle for the node being registered does not exist until derived() returns, so a compute closing over its own binding reads undefined. Before 0.6.0 that escaped as a raw TypeError wrapped in DerivedComputeError, and other non-node arguments were misreported as UnknownNodeError; see the 0.6.0 changelog entry for the migration note.

RemovedEngineOptionError (code: 'CAUSL_REMOVED_ENGINE_OPTION') is new in 0.5.0 and was the only throw that release added. It fires from createCausl(): and from loadAuthoritativeWasm() / loadWasmBackend() — when the options bag still carries one of the four keys the release removed: engine, backend, fallbackToTs, fallbackToJs. The message names every offending key. Deleting the key is always the fix; see Construction failure.

Observer exceptions are reported via options.onObserverError (default: console.error). They never tear down the engine.

createCausl() is synchronous and throws during construction, before any Graph is returned: there is no partially-built graph and no async window. Four cases:

condition class code
@causl/causl-wasm-ts/wasm never imported in this process WasmEngineUnavailableError (main-bundle twin) CAUSL_WASM_ENGINE_UNAVAILABLE
subpath imported, preloadCauslWasm() never resolved CauslWasmNotPreloadedError CAUSL_WASM_NOT_PRELOADED
subpath imported and preloaded, WasmGC cannot instantiate WasmEngineUnavailableError (/wasm subpath) CAUSL_WASM_ENGINE_UNAVAILABLE
the options bag still carries engine, backend, fallbackToTs or fallbackToJs RemovedEngineOptionError CAUSL_REMOVED_ENGINE_OPTION

Branch on error.code, never on instanceof. Rows 1 and 3 deliberately share a code, and row 1 is thrown by a leak-free twin class in the main bundle that cannot extend the /wasm class (the bundle-no-wasm-leak gate), so err instanceof WasmEngineUnavailableError imported from /wasm is false for row 1. One switch (err.code) covers all four; the remedy difference is carried in the message. isCauslEngineUnavailable(err): a type guard exported from both the main barrel and /wasm: narrows rows 1–3 without a cast under useUnknownInCatchVariables.

Row 4 is the one an upgrade meets first, and it is not a host problem: it means a 0.4.x options bag survived the upgrade. It fires on the key, not the value, so { engine: undefined }: what spreading an old config produces: throws too. Deleting the key is the whole fix:

await preloadCauslWasm()

createCausl() // OK
createCausl({ fallbackToTs: true }) // throws RemovedEngineOptionError

Note the asymmetry, because it is the opposite of "the option is inert": without the key the construction succeeds. Leaving it in place is the only thing that fails.

pnpm test:run    # full suite (incl. ≥1000 fast-check trials)
CAUSL_FUZZ_SEED=42 pnpm test:run # reproduce a failure

The 1000-trial floor is the SPEC §15.2 default tier. Routing through the tier resolver shipped by PR #1097 (issue #1073) lets PR and nightly CI widen the budget without code changes:

CAUSL_FUZZ_TIER=pr      pnpm test:run   #   5 000 trials
CAUSL_FUZZ_TIER=nightly pnpm test:run # 100 000 trials
CAUSL_FUZZ_TRIALS=20000 pnpm test:run # numeric override

Property suites that opt into the tier system go through tieredPropertyTrials (published from @causl/causl-wasm-ts/testing) or tieredPropertyOptions (internal to packages/core/test/properties/); the post-#1153 sweep routed every callsite through one of those wrappers so the env vars actually fire end-to-end.

@causl/causl-wasm-ts is one §12 Graph surface (SPEC §18A.1) over exactly one engine. As of 0.5.0 (causl/causl-wasm-ts#280) there is no second engine and no degradation path: the §18A.13.1 capability fallback is withdrawn, and the two options that used to pin the pure-TypeScript floor (engine: 'js-ssot' and backend: 'js') no longer exist. App code programs against the surface, never against an engine.

Engine What it is Reached via Role
causl-wasm The Rust → WebAssembly engine core (engine-rs-core), bound through causl-client's surface. Every adopter op resolves from Rust (§18A.3 lift landed, causl-wasm#170); the user's derived() compute lambdas run in JS over the bridge callback by design. createCausl() (after preloadCauslWasm()) / @causl/causl-wasm-ts/wasm The only engine (rust-ssot)

There is no second engine in the source tree either. The pure-TypeScript closure used to stay declared in packages/core/src/graph.ts as the structural closure every graph this package built sat inside, rust-ssot included, while being absent from the barrel and from all four declared subpaths (., ./internal, ./testing, ./wasm). It is gone, deleted by EPIC causl/causl-wasm-ts#275. CHANGELOG.md's 0.5.0 Breaking changes section names every removed export and the replacement for each; the dated architectural record is SPEC §18A.13.1 and the §19 amendment trail. The two-engine topology: a TypeScript value-of-record floor, the differential byte-identity oracle and the benchmarks: lives only in causl/causl-core-ts.

The bare @causl/causl-wasm-ts import never pulls the wasm chunk into the main bundle (sideEffects:false); the wasm factories live behind the explicit @causl/causl-wasm-ts/wasm subpath. Since 0.5.0 importing that subpath is also required: a createCausl() that runs after preloadCauslWasm() has primed the default bridge routes synchronously to the real WASM engine, and a createCausl() with no preload throws during construction.

Factory Engine Shape Reached via
createCausl(options?) The wasm engine, or a throw sync @causl/causl-wasm-ts (the default)
createCauslWasm(options?) / createCauslWasmSync(handle?, create?) The wasm engine async / sync @causl/causl-wasm-ts/wasm

createCausl() is the default public factory: once @causl/causl-wasm-ts/wasm has been preloaded for the default bridge (via preloadCauslWasm()) it routes to the real wasm engine, synchronously, forever after. With no preload it is construct-or-throw: there is no partially-built graph and no async window; see Errors for the three cases and their code values. The pure-TypeScript engine stopped being a public choice in causl-client at epic #31 / #34 and no longer exists in this package at all. The §18A.3 FFI lift has landed (causl/causl-core-rs#170), so every adopter op resolves from Rust. causl/causl-core-ts: the repo formerly called causl-ts-wasm-engine, a name no longer in use: keeps that engine's factory public as its dual-engine floor.

From a consumer's perspective the wasm engine is now synchronous. The one unavoidable async (the WebAssembly compile) is split out of construction so the single await lives at app init, not at every call site. This lets a sync consumer (a React hook or render pass, xldatagrid) build a wasm-backed Graph with no await where the graph is needed:

import { preloadCauslWasm, createCauslWasmSync } from '@causl/causl-wasm-ts/wasm'

await preloadCauslWasm() // once, at app/init — the only await
// …later, on any render / hook / event, with zero await:
const graph = createCauslWasmSync()
  • await preloadCauslWasm(opts?): async, called once. Compiles and caches the WebAssembly.Module (plus its _bg.js glue sidecar and the compute-imports snippet), keyed by bridge. Idempotent: concurrent calls share one compile, and a transient failure drops the cache so the next call retries. Companions are the sync peeks isCauslWasmPreloaded(bridge?) and getPreloadedCauslWasm(bridge?) (both read the resolved slot, never await), and the opaque CauslWasmModule handle it resolves.
  • createCauslWasmSync(handle?, create?): Graph: fully synchronous (a fresh new WebAssembly.Instance from the cached Module, zero await). Not preloaded → throws CauslWasmNotPreloadedError (code: 'CAUSL_WASM_NOT_PRELOADED'). The { fallbackToTs: true } / { fallbackToJs: true } opt-out of that throw was removed in 0.5.0: catch the error and decide what your application should do on a host that cannot run the engine.
  • createCauslWasm(opts?): Promise<Graph>: retained, now re-expressed as preload ∘ createCauslWasmSync over a single construct codepath (provably equal, zero drift). It pays the one-time preload then constructs. It is the only factory with two options bags, a loader bag at the top level and create: CreateCauslOptions inside it, and the options both bags declare (commitHistoryCap, snapshotRetentionCap, batchedFlush) are read from either, with create winning per field. Passing a retention cap in create alone used to build a graph with no retention and no error (causl/causl-wasm-ts#411).

Node's --target nodejs glue is already synchronous end-to-end, so the server needs no preload; only the browser bundler target needs the one-time preloadCauslWasm(). These factories construct the real wasm engine: preloadCauslWasm() compiles the WebAssembly.Module and createCauslWasmSync() does a new WebAssembly.Instance from the cached module (zero await), constructing a wasm-authoritative Graph whose every adopter op resolves from Rust (see the "The engine is real Rust" note below). The authoritative loader source is mirrored in causl/causl-core-ts.

import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm'
import { createCausl } from '@causl/causl-wasm-ts'

await preloadCauslWasm() // ONCE, at app init

try {
const graph = createCausl() // synchronous forever after
console.log(graph.read(graph.input('ready', true)))
} catch (err) {
switch ((err as { code?: string }).code) {
case 'CAUSL_WASM_NOT_PRELOADED': // fix the boot ordering
case 'CAUSL_WASM_ENGINE_UNAVAILABLE': // import the subpath, or the host cannot run it
console.error(err)
break
default:
throw err
}
}

preloadCauslWasm(options?) is the entry point of @causl/causl-wasm-ts/wasm an application actually calls: it is the one async seam, and everything downstream of it is synchronous. Honoured options are wasmBaseUrl (CDN/CSP override), computeImportsUrl, fetch, graphName and bridge. The engine option was removed in 0.5.0: every construct is rust-ssot.

bridge has one value: 'wasmgc-classic'. The BridgeId union still declares 'wasmgc-builtins' so the id can be named and refused, and refused is what it is: preloadCauslWasm({ bridge: 'wasmgc-builtins' }) throws WasmEngineUnavailableError on the source checkout and the packed tarball alike, before it resolves anything. Vendoring the tree yourself does not help. causl/causl-core-rs#210 measured that artefact's wasm:js-string imports as i32-typed where the W3C builtins are externref-typed, so WebAssembly.compile(bytes, { builtins: ['js-string'] }) refuses it on every host; causl/causl-core-rs#355 deleted the tree and the build row. causl/causl-core-rs#358 is the ABI retype that would bring the tier back, and nothing in this package can do it. Leave bridge unset.

batchedFlush is accepted and inert at the preload level: a graph built after preloadCauslWasm({ batchedFlush: 1 }) and one built after batchedFlush: 1000 produce identical subscriber-fire sequences. Pass it per-graph (createCauslWasm({ create: { batchedFlush } })) if you want it honoured.

loadWasmBackend(options?) is also exported, but it is a determinism-gate / test shim, not an adopter entry point: the backend option that used to consume its return value was removed in 0.5.0, so nothing an adopter writes should call it. The WasmBackend it returns is backed by the wasm engine. It wrapped a plain TypeScript graph from Phase 1 until causl/causl-wasm-ts#279 compiled and instantiated the bridge here and deleted the wrapped graph, so any older note describing this loader as a TS wrap is stale.

The engine is real Rust. The Graph that preloadCauslWasm() + createCausl() build is engine-rs-core compiled to WebAssembly: the production engine: and every adopter op resolves from Rust: commit / read / subscribe / derived plus the second-tier dependencies / dependents / stats / commitLog / explain (incl. per-node timestamps via node_meta) / exportModel / readAt / snapshotAt / subscribeCommits (the §18A.3 FFI structural lift landed, causl-wasm#170). The substrate that landed this seam is EPIC #680. Orchestration runs in Rust; the user's derived() compute lambdas run in JS over the bridge callback by design: the only JS in the hot path. Per-commit wall-time stays inside the §14 RAIL responsiveness budget; perf is not the driver (causl-client made wasm its default engine for complexity-elimination). read() has no contractual reference identity (SPEC §18A.5 / §15.1), so memoise on commit.time or stats().nodeVersion(node), never on the read reference. What the engine returns today is a live, unfrozen reference to your own value: mutating it edits committed state behind the commit pipeline's back; see Reads are live and unfrozen.

Enterprise framing. causl-wasm via @causl/causl-wasm-ts/wasm is the Enterprise-tier path: and the production engine. An Enterprise CI/CD pipeline builds and vendors a pinned, checksummed .wasm with causl-wasm's stdlib-only Python tooling (CPython stdlib + a checksum to vendor: never a Rust toolchain in the consuming app), and the app reaches it through the seam.

The causl-wasm producer scripts: build_wasm.py / package_wasm.py — place the compute-imports snippet next to the .wasm/_bg.js and fail loud if any of three invariants break: the §18A.12 synchronous seam (the node glue must instantiate via new WebAssembly.Instance, no Promise), the presence of every snippet the glue references, and node-loadability. The gc-classic bridge loads as --target nodejs. The gc-builtins bridge emitted require("wasm:js-string"), which stock Node cannot resolve, so it was bundler-target only and was rejected as a node-target artefact at packaging time rather than detonating on the consumer's require(); causl/causl-core-rs#355 has since retired it everywhere.

Wasm-only core (SPEC §18A.13 + §18A.13.1's withdrawal: SHIPPED at 0.5.0). causl-client ships a wasm-only core: createCausl routes to the wasm engine or throws, and the pure-TS engine is not on the public surface. Executed wire-before-cut (epic #31: #32 WIRE → #33 FLIP → #34 CUT). SPEC §18A.13.1 (2026-06-23) then briefly retained that engine as the implicit createCausl() path's WasmGC-unavailable capability fallback: loud, never silent. That reversal is withdrawn at 0.5.0 (causl/causl-wasm-ts#280): the fallback engine and the primary disagree on a §18A.1.1 MUST-be-identical surface (causl/causl-wasm-ts#272, in-place mutation of a committed value), and a fallback that answers differently from the engine it stands in for is worse than no fallback. onCauslCapabilityFallback and the 'js-ssot' / 'wasm-fallback' union members stay exported, @deprecated and never firing until 0.6.0. The §18A.3 FFI structural lift has landed (causl/causl-core-rs#170): every adopter op resolves from Rust under rust-ssot, and the user's derived() compute lambdas run in JS over the bridge callback by design. The literal zero-TS core has landed: EPIC causl/causl-wasm-ts#275, sub-task causl/causl-wasm-ts#279, deleted the engine declaration outright, along with the shared commit pipeline, the structural facade and the injectedBackend seam. The driver is complexity-elimination (shedding the dual-engine maintenance and conformance surface); perf is explicitly accepted as immaterial, not a gate. The direction is scoped to causl-client only: causl/causl-core-ts keeps the dual-engine TS floor: it is the differential-test oracle and the benchmark repo, and is now the organisation's only owner of that floor: so do not read this direction as removing causl-ts from the org.

The accepted cost. Hosts below the declared engine floor now hard-fail at createCausl(), with no flag that restores the old behaviour. The floor is declared once, on WASM_HOST_FLOOR in packages/core/src/wasm-registry.ts (causl/causl-wasm-ts#426), together with its measured basis (the shipped artefact needs typed function references, which engines enabled in the same release as WasmGC); the Host requirements table in the wasm entry-point reference mirrors it under test, so this paragraph deliberately repeats no version numbers. The ## [0.5.0] section of CHANGELOG.md is the authoritative statement: the removed-surface inventory, the failure contract and a numbered migration. It is not in the installed package: the link above is absolute for that reason: but everything the upgrade strictly requires is inlined here, under Errors.

See the consumer-side Integrating causl-client into a TypeScript / Node.js app guide for the full producer/consumer split, the node:fs loader hook and the host-tier matrix (also repo-only, hence absolute); the @causl/causl-wasm-ts/wasm entry-point reference ships in this tarball and documents the loader options and the landed Rust engine in detail.

Modules

src
wasm