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. |
RetentionResult<T>: the discriminated readgraph.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, sooldestRetainedTimeis not its lower edge: it reportsgraph.nowas a placeholder, and reading at that time evicts as well. Measured on a default-cap graph after two commits,readAt(a, now)answersevicted { oldestRetainedTime: 2 }, and clamping to2and re-reading answersevicted { oldestRetainedTime: 2 }again, without end. So a "retry until retained" loop does not terminate on a graph that opted into nothing. Treatevictedas 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.
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.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.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.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/wasm surface@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.
preloadCauslWasm / createCauslWasmSync (SPEC §18A.12)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.