reference · typescript · pre-release

API reference

Every public export of strata-ecs and its subpaths — signatures as shipped.

v0 · pre-release 106 exports import "@vibecook/strata-ecs"

The scan surface

Master index#

Every export of the five public barrels, by module. Values (functions, classes, consts) get full entries below; the smaller types are grouped — the link takes you to the entry that describes them. Timing and visibility rules link to the concept guide rather than being re-explained here.

@vibecook/strata-ecs — 72 exports

ExportKindRole
defineComponentfnDeclare a component and route its fields to typed columns.
defineTagfnDeclare a zero-data marker tag.
defineRelationfnDeclare a directed, inverse-indexed link between entities.
defineResourcefnDeclare a world-scoped singleton.
fieldfnWrap a field type with options (a per-field default).
enumOffnDeclare an enum field — a closed label set.
entityKeyfnBrand a string as a cross-store EntityKey.
defineQueryfnCompile query terms into a cached, opaque query.
Not Any All RelatedfnQuery operators — negation, disjunction, conjunction, relation term.
BatchtypeThe per-archetype chunk handed to a query body.
defineSystemfnPair a query with a per-chunk body; declare access & gate.
defineTickSystemfnA queryless system — its body runs exactly once per tick.
phasefnGroup systems into a named, ordered, gated phase.
SystemCtxclassWorld access inside a system — values immediate, shape deferred.
SystemAccesstypeA system's declared read/write column envelope.
EntityEditortypeThe immediate whole-component value-write surface.
validatePipelineAccessfnDev-only advisory pipeline access diagnostics.
createWorldfnCreate a world over a fresh runtime store.
WorldclassThe application-facing handle — mutate, query, tick, sync.
ReactiveclassThe poll-at-boundary reactive observer layer.
UnsubscribetypeThe idempotent teardown handle every observe* returns.
WorldObservertypeThe dev-tool telemetry contract (spawn / destroy / tick).
WriteKind WorldMutatorName ReadonlyWorldtypeThe write-hook kind, the mutator-method names, and the compile-time read-only World view.
LocalconstThe framework partition-ownership tag — query-only.
VERSIONconstThe package version string.
Entity EntityKeytypeThe entity handle; its cross-store string identity.
Component Tag Relation ResourcetypeThe four schema handle types; Arity.
FieldType ScalarType EnumType FieldSpec FieldInput FieldMeta FieldIdtypeField types and field metadata.
ValueOf WriteOf ColumnsOf ColumntypeSchema-derived value / write / column type utilities.
Query QueryTerm Atom RelTermtypeThe compiled query and its term union.
System SystemBody ConditiontypeA chunk system, its body callback, and a gate predicate.
TickSystem TickSystemBodytypeA tick system and its once-per-dispatch body.
Phase PipelinetypeA phase and the positional array of phases.
ComponentId TagId RelationId ResourceIdtypeInterned dense numeric ids (per kind).
ECSStore RuntimeStoreadvancedThe storage-agnostic contract and its archetype engine.
SpawnInit SpawnInitOf ComponentEntrytypeThe loose and schema-typed spawn init shapes.
StructuralCommand ComponentInit CommandBufferadvancedThe deferred shape-change format (internal).
InboundSourcetypeThe opaque handle a layer registers for sync() to drain.

@vibecook/strata-ecs/react — 2 exports

ExportKindRole
useComponentfnSubscribe a component to one entity's component value (Tier 3).
useResourcefnSubscribe a component to a world resource's value.

@vibecook/strata-ecs/tools — 18 exports

ExportKindRole
attachObserverfnMount the vanilla-DOM inspector panel over a world.
ObserverOptions ObserverHandletypeThe panel's options and its dispose handle.
attachProfilerfnMount the frame-profiler overlay — fps, percentiles, worst-frame capture.
ProfilerOptions ProfilerHandletypeThe overlay's options and its lane/stats/reset/dispose handle.
ProfilerStats ProfilerPercentiles ProfilerSystemStat ProfilerLaneStat ProfilerFrameCapture ProfilerCapturedSystemtypeThe stats() snapshot family.
createLifecycleRecorderfnThe timeline's data layer — capture over lifecycle hooks.
LifecycleRecorder LifeRecordtypeThe recorder handle and one birth-to-death record.
defaultDescribe DescribeFn EntityDescriptionconst · typeEntity labelling — the default and its callback shape.

@vibecook/strata-ecs/durable — 15 exports

ExportKindRole
createDurableStorefnThe one place a LoroDoc enters the durable layer.
DurableStoreOptionstypeStore options — the undo-stack depth cap.
DurableStoreclassThe durable object — identity, reads, and the transport wires.
Mutator TxEditortypeThe tx mutation vocabulary inside a transaction.
MetaEditortypeThe primitive get/set editor for document-metadata writes.
undo redo undoGroup · HistoryHooks HistoryStackmethods · typeLocal-only undo/redo, grouping, and the selection-restore hooks.
attachDurablefnProject the document into a world; register the drain.
AttachmenttypeThe detach handle attach returns.
PendingImportErrorclassThrown when the doc quarantines on an out-of-order import.
DurableSyncStatus DurableSyncStatusValueconst · typeThe runtime-local sync-status resource and its value shape.
DurableUndoStatus DurableUndoStatusValueconst · typeThe runtime-local undo/redo availability resource and its value shape.

@vibecook/strata-ecs/ephemeral — 8 exports

ExportKindRole
createEphemeralStorefnWrap an EphemeralSource in a writer-partitioned store.
EphemeralStoreclassThe presence Mutator — your partition only.
EphemeralSourcetypeThe Loro-quarantining blob-and-events interface.
LoroEphemeralSnapshotclassThe one adapter over a Loro EphemeralStore.
attachEphemeralfnProject peers in; start the two outbound timers.
AttachmenttypeThe detach handle (no baseline seam).
EphemeralSyncStatus EphemeralSyncStatusValueconst · typeThe runtime-local presence-status resource and its value shape.

strata-ecs · the runtime core

Runtime core "@vibecook/strata-ecs"#

The default barrel: the schema definers, the query and system APIs, the World facade, the reactive layer, and the storage seam. A complete, fast, local ECS — usable with none of the collaborative layers present. Mutation timing (immediate world.* vs. deferred ctx.*) is the guide's Mutation timing; the once-per-frame settle is The frame.

Schema

functiondefineComponent#

Declare a component: a named set of typed fields, each routed to a backing column (field types). Field names must be unique in the schema. The schema literal is captured as a const type parameter, so the handle's value type is inferred from it — world.read(e, C) is the exact object, and batch.col(C).x is the real typed array, with no hand-written <S> that can drift. Hold the returned handle as a module constant.

  • Timing Immediate; a definer call, not a world mutation.
  • Throws On a duplicate name (the schema registry is process-global and enforces global uniqueness across all kinds) or a reserved framework name (Local).
  • See Schema · field · enumOf

functiondefineTag#

Declare a zero-sized marker tag — Selected, Frozen. A tag carries no data; its presence is the whole signal. Tags are backed by bitsets outside archetype identity, so adding or removing one never migrates a row.

  • Throws On a duplicate name, or on the reserved name Local (the framework mints that one — import it, do not define it).
  • See Entities

functiondefineRelation#

Declare a typed directed link between entities. arity is "one" (default — a single replaceable target) or "many" (a set of edges). Relations are inverse-indexed, so the reverse direction is O(1), and deleting either endpoint cascades the edge away with no cleanup code. In a query, one Related term is a single hop — there is no multi-hop traversal (Related).

functiondefineResource#

Declare a world-scoped singleton — the camera, the active tool, a config object — read and written by handle with world.setResource / getResource, not attached to any entity. The value type is inferred from the schema literal, exactly as with defineComponent.

  • Reactive setResource replaces the value object wholesale, so a resource watch is equality-suppressed (observeResource, useResource).
  • Update world.updateResource(res, fn) is the read-modify-write in one call — fn receives the current value and returns the next. It throws if the resource was never set (there is nothing to update; call setResource first).

functionfield#

Wrap a field type with options. Today the only option is a per-field default: a field with one is optional at spawn (the runtime fills it), a bare type is required. The default is checked against the field's value type, and the returned FieldSpec carries whether a default was declared, so the schema literal can make the field optional at write time (see WriteOf).

  • Types Returns FieldSpec<T, true|false>; a bare type is shorthand for field(type).

functionenumOf#

Declare an enum field — a closed set of string labels interned to small integer discriminants and stored in the smallest unsigned int that fits. The label is the value at the API boundary. The array form assigns positional discriminants (0..n-1) — local-only and reorder-hostile; the object form pins explicit discriminants — safe to persist and sync. The label union is captured so a field's value type is its exact labels.

  • Persist Use the object form for any component that reaches the durable or ephemeral layers; a reordered array silently remaps stored data.

functionentityKey#

Brand a plain string as an EntityKey — the stable, cross-session identity a key field stores. Identity at runtime (the brand is type-level only): the core runtime stores a key field as an ordinary string column and is key-ignorant. The brand exists so an arbitrary string cannot be passed where a durable reference is expected.

  • Identity Keys — not handles — cross the durable boundary. doc.keyOf(e) mints one, doc.resolve(key) turns it back (DurableStore).

typesEntity · EntityKey · Component · Tag · Relation · Resource#

The identity and schema handle types. An Entity is an opaque, branded u32 — a packed generation + slot, runtime-local and disposable. EntityKey is its stable cross-store string identity. Component, Tag, Relation, and Resource are the opaque handles the define* functions return; carry them as module constants. Component and Resource are generic over their decoded value type S and schema literal Sch (both phantom — never present at runtime), which is what powers column inference.

  • Guard A handle is a name, not a pointer: it stays constant as the entity migrates archetypes, and a stale handle reads dead forever (the generation guard), never aliasing a later entity.
  • See Entities

typesField types & schema utilities#

The field-type vocabulary and the type-level utilities derived from a schema literal. A field is a ScalarType tag or an EnumType; field(...) wraps either in a FieldSpec, and FieldInput is "a bare type or a spec". Each declared field gets a FieldMeta (its dense global FieldId, name, spec, routed column kind). The utilities recover, from the schema literal, a component's read value (ValueOf), its spawn/write value with defaulted fields optional (WriteOf), and its backing columns (ColumnsOf, each a Column).

Field typeValue in TSBacking column
f32 f64numberFloat32Array / Float64Array
i8i32 · u8u32numberthe matching integer typed array
boolbooleanUint8Array (0 / 1)
stringstring | null(string | null)[] — per-cell
eidEntityUint32Array (a packed handle)
keyEntityKey | null(string | null)[] — a cross-store reference
enumOf([…])the label unionsmallest unsigned int that fits
  • Replicated A component that reaches a collaborative layer must not carry eid fields — a packed handle is meaningless across sessions; references travel as key fields (the durable/ephemeral eid-ban).
  • Nullable string and key cells read null when unset.

Queries

functiondefineQuery#

Compile an array of terms, AND-ed together, into an opaque Query — build it once and hold it as a module constant. Compilation sorts terms by where they evaluate: component terms fold into archetype-level checks (cached per archetype), tag and relation terms become per-row filters, and a relation with a concrete target seeds iteration from the reverse index. The compiled plan is stripped from the public type, so a Query is an opaque handle; the store caches its matching archetypes.

  • Types QueryTerm is Atom | Not | All | Any; an Atom is a bare component, a tag, or a RelTerm.
  • Cost Component membership is cheapest (archetype-level, once per table); tags and relations cost a per-row probe.
  • See Queries

functionsNot · Any · All · Related#

The query operators that build non-atomic terms. Not(x) excludes entities matching one atom. Any(...) is a disjunction within one term. All(...) is an explicit conjunction group — useful inside Any to express (A ∧ B) ∨ C. Related(rel, target?) matches entities linked via rel: omit target for "any edge", give one for a specific target (which seeds iteration from the reverse index).

  • Excl. To exclude a disjunction, negate each member — Not takes a single atom.
  • Hop One Related is one hop; walk further edges yourself.

interfaceBatch#

The per-archetype chunk handed to a query body. Read columns with col(C) — typed from the component's schema literal, no cast — then loop the matched rows. The raw for (let i = 0; i < count; i++) { const r = rows[i]; … } is the fastest idiom, valid for every chunk kind; for (const r of batch) is equivalent, with the row filters fused. entity(r) resolves the handle at a row; getRelated / getAllRelated read validated relation targets.

  • Scope rows, the columns, and entity are valid only inside the current each callback — chunk-scoped; never retain them past it (the row buffer may be reused for the next chunk).
  • Count count is the matched-row loop bound for every chunk kind — walk rows[0 .. count).
  • Never empty Every delivered batch has count ≥ 1 — zero-match chunks are skipped before dispatch, so a body needs no count guard.

Systems & the schedule

functiondefineSystem#

Pair a query with a body — a chunk system. The body runs once per matching chunk, and every chunk it sees has at least one matched row (count ≥ 1). Options: a name for the tools, a runIf gate on non-query state, and an access declaration of the columns it reads and writes. runIf is a Condition — a pure predicate over ctx. A gated-off system never runs, so it never stamps; under reactivity that keeps idle frames stamp-free, which is why the gate is load-bearing. For work that must happen once per frame regardless of what matches — global effects, coordination — use defineTickSystem.

functiondefineTickSystem#

Pair a body with the schedule alone — a tick system. The body runs exactly once per dispatch in its phase slot, independent of archetype count or the world's shape history: the scheduler owns system cardinality, queries own data cardinality. This is the home for whole-frame effects a per-chunk body would silently multiply — camera integration, input-queue drains, cross-archetype algorithms that must see all matching rows in one pass. Iterate data inside with ctx.query(q).each(batch => …); runIf composes exactly as on defineSystem (a skipped dispatch is zero invocations and stamps nothing).

  • Access With no query there is no default read set — declare access explicitly; inner-walk col() reads and writes are charged to this system's declaration.
  • Guard The body runs under the same iteration guard as a chunk walk: shape changes go through ctx and land at the phase boundary; structural world.*, tick, and sync throw inside it.
  • Types TickSystem, TickSystemBody = (ctx) => void.
  • See Tick systems · SystemCtx

functionphase#

Group systems into a named, ordered phase, optionally gated with runIf. A Pipeline is just a positional array of phases — array order is run order — so you rebuild it as a plain value to reconfigure the frame. Systems within a phase run in order; the phase boundary flushes that phase's deferred shape changes.

classSystemCtx#

The ctx a system body receives — world access inside a system, for both forms. ctx.query(q).each(batch => …) is the sanctioned in-body walk: same batches as world.query (never empty), with reads and writes attributed to the running system's access declaration. Reads and edit(e).set(...) value writes are immediate; every shape change (spawn, destroy, add/remove component, tags, relations) is deferred to the phase boundary, where it lands from the command buffer. Identity is the exception: ctx.spawn() mints a real handle now — you can tag it and reference it immediately — even though its components are placed at the flush, so it is queryable from the next phase on.

  • Timing Values immediate and order-sensitive within a phase; structure at phase boundaries (Mutation timing).
  • Access Under armed reactivity, a dev build throws if edit().set writes a component outside the system's access.write envelope.

interfaceSystemAccess#

A system's value read/write access declaration — the envelope, an upper bound over all of its conditions on the columns the body may touch on any frame (value read/write only, never structural). write names the columns it may mutate. read is optional; when omitted it defaults to the query's components not in write. It is optional in the base runtime but does real work once reactivity is active: it tells the framework which columns a system stamped, and — in dev builds — enforces that a system only writes what it declared. A third optional member, orderIndependent, is advisory-only metadata: it attests order-tolerant columns to opt out of the same-phase double-writer warning, and touches nothing at runtime.

  • Scope Components only in v1 — tags and relations are membership filters; resources are a deferred extension.
  • Attest orderIndependent lists columns whose writes are order-tolerant against same-phase co-writers (row-disjoint, commutative, or last-write-wins-safe). Every column named must also appear in write (a dev-hygiene warning fires otherwise). It suppresses the double-writer advisory for a column only when every same-phase writer of it attests — an un-attesting newcomer re-fires the warning. It never feeds the read default and never affects runtime access enforcement.
  • Not a boundary Access metadata is diagnostic and scheduling metadata, not a capability or security boundary. The dev-mode subset check covers the typed write path; raw col() writes legally bypass it (and are attributed conservatively by whole-query stamping instead), and production builds run no enforcement at all. It cannot contain untrusted code.
  • See validatePipelineAccess · Access declarations

interfaceEntityEditor#

The immediate whole-component value-write surface returned by world.edit(e) and ctx.edit(e). set(C, v) overwrites an existing component's value and chains. A value write is immediate everywhere — it reorders no columns, so it is legal mid-system and mid-iteration.

  • Throws If the entity does not already have the component (set is a value write, not an add).

functionvalidatePipelineAccess#

Dev-only advisory diagnostics over a pipeline's declared access. It warns — never reorders — about a manual system order that looks data-inconsistent: two systems in one phase writing the same component (a potential order-dependence, unless every writer attests it order-tolerant), or a system whose read set includes C positioned before a same-phase writer of C (a likely "reads stale C" bug). The whole surface compiles out of production.

  • DEV-only A no-op in production; analyses each pipeline object once (deduped across repeated ticks).
  • Advisory v1 never reorders — execution is positional — so these help you hand-order correctly.
  • Opt out The double-writer warning is silenced for a column once every system that writes it lists it in access.orderIndependent — your certification that its writes don't depend on run order. The stale-read warning has no opt-out.

The world

functioncreateWorld#

Create a World over a fresh RuntimeStore. The optional name is surfaced by the tools. This is the one constructor an app calls; every other core object hangs off the world.

classWorld#

The application-facing handle over the runtime store. world.* mutations run outside any system iteration, so shape changes apply immediately. tick(pipeline) runs systems — where ctx.* shape changes defer to the phase boundary. sync() drains any attached inbound layers (a no-op when none are attached). The frame contract is sync() → tick(s) → reactive.notify() → render. Reads (read/get/readField/has/count/entities/…) and the value surface (edit, setResource, updateResource, removeResource) are iteration-safe; the structural surface is not.

  • Throws Every structural world.* method (spawn/destroy/add/remove component/tags/relations) throws while a query walk is in flight — in all builds. A mid-iteration archetype migration reorders the rows the walk is stepping over (memory corruption, not a style violation). Use a system's deferred ctx.* for shape changes during iteration.
  • Throws tick, sync, reset, and import({replace}) throw if called mid-iteration; sync/reset also require being outside a tick and outside an observer / reactive callback.
  • Queries count(q) returns the matched-row total; entities(q) materializes the matching handles into a fresh array. Both are iteration-safe reads over the same query machinery — prefer query(q).each on the hot path, since entities allocates the array plus one handle per match. updateResource(res, fn) is the read-modify-write over a resource and throws if it was never set.
  • reactive The reactive getter lazily creates and caches the layer; reading it is side-effect-free — stamping and dev-enforcement arm on the first observe* registration, not on property access, so a stray read never flips the profile. isReactiveEnabled probes whether that has happened. A world that never observes pays nothing (Reactive).
  • Law window devOnWrite registers a dev-only, synchronous pre-mutation hook whose throws propagate (unlike an observe callback, which is swallowed) — the runtime half of a "law window" for asserting nothing writes during a read-only pass (devOnWrite · ReadonlyWorld).
  • Persist export() returns readable UTF-8 JSON bytes; plain import needs an empty world; import(bytes, { replace: true }) validates, then resets in place — keeping this World and every observer / reactive registration (Persistence).
  • Reset After reset() / replace-import, every pre-reset handle reads dead, never aliased to a later entity; a single onReset fires, never per-entity onDestroy.
  • spawn The published spawn is generic: spawn<const T>(init?: SpawnInitOf<T>) infers each component's schema literal for per-field checking — condensed to SpawnInit above (store seam).

Reactivity & observability

classReactive#

The reactive observer layer for one world, reached via world.reactive. It is a poll-at-boundary read surface over change-detection stamps the store already carries — nothing here touches the hot loop. Observers are compared against those stamps at a single settled point per frame, notify(), which you call once after all ticks. Two entity granularities form a cost/precision ladder — observeQuery (did anything matching this query move) and observeValue (this entity's value actually differs, equality-checked) — plus observeResource for singletons. To watch one entity, watch its value, not a stamp: observeValue. peek / peekResource read the current value without subscribing; invalidate(C) is the manual stamp for a write no chokepoint saw.

  • Registration Subscribing is a frame boundary: a change made in the same frame surfaces at the very next notify(); nothing stamped before you subscribed ever fires. No priming needed.
  • Guarantee observeQuery may over-fire but never misses; observeValue / observeResource are equality-suppressed. All zero-cost until the first observer registers.
  • Precision A row-filtered observeQuery (tag / relation filters, Not, mixed Any, concrete-target Related) wakes only when a tag or relation its own plan depends on changes membership — unrelated tag/relation churn elsewhere in the world stays silent, so interaction-rate state (hover, drop targets, gesture claims) needs no change-only write discipline to keep other observers quiet.
  • Lifetime Per-world and mortal — it does not survive a world.import() swap; entity watches on a reset world fire undefined once and self-remove. Every observe* returns an idempotent Unsubscribe.
  • Callbacks A value write from inside a callback — edit().set, setResource, removeResource — is legal and deterministic: it lands at the next notify(), delivered exactly once. A structural mutation from inside one is still dev-rejected; schedule it for the next frame boundary.
  • See Reactivity

interfaceWorldObserver#

The dev-tool telemetry contract. Attach one with world.observe(obs) (returns a detach function); it receives spawn/destroy, reset, and per-tick / per-system / per-phase-flush events. It is zero-cost when nothing is attached — the hot paths pay one branch-on-null and take no timings; performance.now() is called only while at least one observer is attached.

  • Contract Callbacks must not mutate the world and must not throw — a throwing callback is swallowed and reported via devError, never propagated. A structural mutation from inside one is dev-diagnosed.
  • Timing onSpawn fires once per entity (after placement for world.spawn; at the eager mint for ctx.spawn). onDestroy fires before teardown — the dying entity is still fully readable. A wholesale reset fires one onReset after teardown, not per-entity onDestroy.
  • See Observability & tools · @vibecook/strata-ecs/tools

method · typesdevOnWrite · WriteKind · ReadonlyWorld#

The dev-time law window — a runtime hook and a compile-time type that together let you assert nothing mutates across a stretch that should only read (a render pass, a selector, a derivation). world.devOnWrite(cb) registers a dev-only, synchronous, pre-mutation hook and returns a disposer; it fires once for every mutation, classified by a WriteKind, before the write lands. ReadonlyWorld is Omit<World, WorldMutatorName> — the same world with every mutating method removed, so handing a function a ReadonlyWorld makes a stray world.spawn(…) fail to typecheck.

The hook fires at strata's internal chokepoints, downstream of any bound method, so it observes every route into the runtime, not just world.*: the world.* mutators and edit().set editors, the deferred ctx.* command-buffer flush, a durable transaction's identity mint and its seal's value writes, sync() / attachDurable projection, undo / redo echoes, the ephemeral mutators and inbound presence projection, snapshot import, and reset() — including strata's own runtime-local status-resource writes, which fire "resource".

Rule — a throw vetoes, but only per single write

A throwing hook propagates to the mutator's caller — deliberately unlike an observe callback, which is swallowed and reported. For a single-op mutation the fire precedes the first state change, so a throw is a clean veto: the op does not happen. But a compound op fires more than once and may span kinds, and a throw partway through leaves the earlier writes applied — so arm a throwing hook only in a window where no legitimate mutation runs, never across tick() / sync(). The intended pattern: register once, keep your own armed flag, and throw from the hook only while armed.

  • Kinds WriteKind is "structural" | "component" | "tag" | "relation" | "resource". addComponent / removeComponent surface as "structural" (they migrate archetypes); a value overwrite is "component". The guarantee is fire-at-least-once-per-logical-write, may-over-fire, never-miss.
  • Carve-out A raw batch.col() TypedArray write — the system hot path — has no chokepoint by design (the same carve-out as reactive.invalidate). The hook sees every object-level mutation route above, but not direct column writes.
  • ReadonlyWorld Types erase, so a cast back to World defeats the compile-time half — pair it with a throwing devOnWrite hook when the window must hold at runtime. WorldMutatorName is the union of stripped method names; registerInboundSource / unregisterInboundSource are deliberately kept (they bind layers, not ECS data).
Note — production honesty

Where DEV is false — a runtime with process under NODE_ENV=production (Node / SSR, or a browser build that shims or defines process) — registration is a no-op returning a no-op disposer and every fire site is dead. But in an un-shimmed browser production bundle DEV evaluates true at runtime, so registration stays live and each mutation pays one roster null-check (nothing fires while nothing is registered). Until strata ships separate dev / prod builds, gate your registration behind your own production flag if you target un-shimmed browsers.

The store seam

advancedECSStore · RuntimeStore & the spawn / deferral types#

The storage seam — you rarely touch it directly, but it is public because Parts II–IV are written against it. ECSStore is the complete, representation-agnostic operational contract over ECS state (general CRUD + query, saying nothing about how data is stored); every operation on it is immediate — deferral is the SystemCtx wrapper's concern. RuntimeStore is the archetype / typed-array engine strata ships, reached as world.runtime for the loose spawn form. SpawnInit is the loose init; SpawnInitOf<T> is the schema-typed form the facade uses; ComponentEntry is a [Component, value] pair. StructuralCommand, ComponentInit, and the opaque CommandBuffer are the internal deferred-shape-change format. InboundSource is the opaque handle a layer registers via registerInboundSource for sync() to drain.

  • Advanced Application code uses World; reach for world.runtime only for the loose spawn surface (a union of differently-shaped tuples, or a pre-built ComponentEntry[]) the typed facade cannot infer through.
  • Interned ComponentId / TagId / RelationId / ResourceId / FieldId are dense per-kind numeric ids; internal, surfaced on FieldMeta and the command union.

Constants

constLocal#

The framework-exported partition-ownership tag. The ephemeral store auto-applies Local to every entity you spawn in your own partition, so Not(Local) selects remote peers. Its name is reserved — defineTag("Local") throws; import it, do not define it.

  • Query-only Read it (Local / Not(Local)); never addTag / removeTag it yourself — the store owns its lifecycle. It is applied locally and never transmitted.
  • See The ephemeral layer

constVERSION#

The package version string, kept in sync with package.json at release time.

strata-ecs/react · the react binding

React binding "@vibecook/strata-ecs/react"#

Two hooks, each a useSyncExternalStore adapter over world.reactive's Tier-3 channel: they subscribe to the value channel and re-render exactly once per real change at your next reactive.notify(). An equal-value write does not re-render. Swapping the world / entity / resource argument re-subscribes — which is how a world swap tears down the old watch. The peer dependency is react >= 18.

functionuseComponent#

Subscribe to one entity's component value (Tier 3). Returns the current value, or undefined when the entity is dead or lacks the component. Re-renders once per real change at the app's next reactive.notify(); an equal-value write does not re-render. The binding memoizes the snapshot so its reference is stable before the subscription exists (React's first-render getSnapshot), and reads death as undefined immediately.

  • Re-sub Swapping the world, entity, or component argument re-subscribes.
  • See React binding

functionuseResource#

Subscribe to a world resource's value (Tier-3-equivalent — setResource replaces the object wholesale, so it is equality-suppressed too). Returns the current value, or undefined for a never-set resource. Re-renders once per real change at the next notify(); an equal-value setResource does not re-render. It reads the stored object, which is reference-stable between sets and has no death path, so it needs none of useComponent's corrections.

strata-ecs/tools · observability

Observability "@vibecook/strata-ecs/tools"#

Zero-dependency vanilla-DOM devtools built on the core's WorldObserver contract, usable from any host app: an inspector panel you open and a frame-profiler overlay you leave on. The world pays the attached-telemetry path only while attached (and one branch-on-null when not). See the guide's Observability & tools.

functionattachObserver#

Mount the observer panel over a world with one call; the returned handle's dispose() detaches everything (world hooks, DOM, timers). Three tabs: entities (a virtualized live list with a component/tag/relation detail pane), systems (per-system microseconds, run/skip idle percentage, per-phase flush timings), and timeline (a canvas waterfall of every entity's birth-to-death, so even one-tick entities appear). Pass a describe callback to label entities in your own domain terms.

  • Options container, describe, recorderCap (default 4000), defaultTab, and tab (force-open, for shareable links).
  • Cost The panel polls at ~8 Hz and draws the timeline on its own rAF only while that tab is visible.

functionattachProfiler#

Mount the frame-profiler overlay — the panel's sibling, a meter you leave on rather than an inspector you open. Collapsed it shows fps, the last tick's cost, and a frame-time sparkline (gray bars = whole frame, purple = the ECS share, green line = budget); expanded it adds frame and tick percentiles (p50/p95/p99/max — averages hide the spikes profilers exist to find), per-lane costs, the hottest systems, and a worst-frame capture: the full per-system breakdown of the worst frame since attach/reset, so "what made that frame slow" stays answerable after the fact.

A "frame" is one tick-to-tick interval — in the canonical loop shape (one world.tick() per rAF) that is the render frame, so attaching is one line with no loop rewiring. Apps ticking on another cadence get honest tick-cadence stats instead. lane(name, ms) is the optional extra fidelity: report host-side costs (paint, layout, …) and the tick is read in context of the whole frame without the tool knowing anything about rendering.

  • Options container, budgetMs (default 16.7), windowSize (default 240 frames), lanes (pre-register for stable order), corner, expanded.
  • Cost Telemetry is paid only while attached (the core's WorldObserver contract); the sparkline draws on the overlay's own rAF, text re-renders at 10 Hz, aggregation runs on demand — never inside a tick.
  • Headless stats() returns the whole snapshot programmatically — assert frame budgets in integration tests, ship the numbers to your own telemetry.

function · const · typescreateLifecycleRecorder & describe#

The timeline's data layer, exposed for building your own tooling. createLifecycleRecorder captures birth-to-death over the core's lifecycle hooks (not a poll), so entities born and destroyed between UI refreshes are recorded; cap bounds memory by evicting the oldest dead records (living entities are never evicted). defaultDescribe is the fallback labeller — a label off the component set and a stable color from the first component name; DescribeFn and EntityDescription are its callback and result shapes. A LifeRecord is one entity's frozen-at-death row.

  • Types LifecycleRecorder (records(), nowMs(), clear(), dispose()), LifeRecord, DescribeFn, EntityDescription.
  • Freeze Descriptions resolve live while an entity is alive and are frozen at death (inside onDestroy, pre-teardown).

strata-ecs/durable · persistence + multiplayer

Durable layer "@vibecook/strata-ecs/durable"#

The converging document: persistence and multiplayer as one Loro-CRDT-backed layer that projects into the same runtime your systems read. The CRDT is quarantined behind a single adapter; the app owns the LoroDoc and the transport. The peer dependency is loro-crdt >= 1. See the guide's The durable layer and Transport.

function · interfacecreateDurableStore · DurableStoreOptions#

Wrap a caller-owned LoroDoc in a DurableStore — the one place a LoroDoc enters the durable layer. You own the doc's lifecycle (construction, loading bytes, transport); this constructs the adapter, seeds peer-prefixed key minting, and establishes the shared docId. Everything downstream speaks only framework interfaces plus the store's methods. The optional DurableStoreOptions caps undo depth (maxUndoSteps, default 100 — see undo / redo).

  • Channel The durable layer reserves the Loro commit-message channel on any doc it is attached to (it tags every commit to recover per-commit boundaries).

classDurableStore#

The durable object app code holds: it opens transactions, resolves identity, reads the converged document, and wires to transport. keyOf / resolve convert between a session-local Entity and a stable EntityKey; getComponent reads the document's converged value (distinct from the runtime read). subscribeOutbound ships each sealed local commit's bytes to transport; applyRemote is the fire-and-forget inbound entry; exportSnapshot is the full snapshot a late joiner bootstraps from.

  • Pre-attach keyOf / resolve / getComponent are handle-addressed and return undefined until attachDurable installs the bijection.
  • Throws applyRemote lets PendingImportError propagate uncaught — the transport must resync (fresh doc + snapshot).
  • Transport A subscriber attaching mid-life misses commits sealed before it subscribed; a joiner bootstraps from exportSnapshot, not the increment stream.

method · interfacestransaction · Mutator · TxEditor#

doc.transaction(fn) is the durable layer's one upward boundary — the only way app code changes the document. It runs fn synchronously, letting it record a batch through the tx Mutator (the same vocabulary as world/ctx: spawn/despawn, add/remove component, edit().set, tags, relations, resources), then seals that batch as one Loro commit — one undo unit, one sync message — and returns fn's result. Identity is minted eagerly (tx.spawn returns a usable handle now); on any throw nothing commits and the transaction rolls back (minted identities invalidated, keys burned).

Rule — visibility

A value write to a pre-existing component applies to runtime, document, and baseline synchronously at seal; every structural effect (spawn, add/remove component, tags, relations, despawn) reaches the runtime — and advances the baseline — only via projection at the next world.sync(). (guide.)

  • Legal only On an attached store, and not re-entrantly — one open transaction per store (a nested transaction throws).
  • Access A value commit to a pre-existing component inside a system rides 001 enforcement — the system must declare it in access.write, or a dev build throws once an observer has armed enforcement.
  • Undoability opts.undoable: false excludes this transaction's commit from the local undo stack — for document migrations, format upgrades, and read-repair at open. It is otherwise an ordinary commit: peers receive it as a normal remote batch, history hooks don't fire for it, and a pending redo survives it. Contrast clearHistory(), which empties both stacks wholesale.

method · interfacemetaTransaction · MetaEditor#

The sanctioned door for an embedder's own document metadata — a version stamp, a schema marker, a feature flag that should travel and persist with the document but is not an ECS entity or component. store.metaTransaction(fn) hands fn a small MetaEditor with primitive get / set over the document's reserved metadata map, and seals whatever it writes as one document change. Values are string | number | boolean (metadata slots are write-once markers, not nested documents); keys should carry a dotted namespace of your own — e.g. "engine.schema" — and the reserved document-id key is refused.

Rule — transparent to the runtime

A meta write produces no entity or component change: attached observers and useResource never see it, and it does not wake a world.sync(). It is excluded from undo / redo — a user's undo() never rolls back a version stamp, and writing one does not clear the redo stack. It still travels to peers and persists in the snapshot like any other change, converging per key by last-writer-wins.

  • Attachment Unlike transaction this needs no attachment — legal on a bare store, since it only touches the document — but not mid-transaction (the open transaction's ops are not sealed yet). An empty callback is a no-op.
  • Throws set throws on the reserved document-id key and on any non-primitive value. The editor is valid only for the synchronous duration of the callback — a leaked reference throws on later use.
  • Reads get(key) returns the value when it is a string | number | boolean, else undefined; a read carries no protocol obligation, so you may equivalently read straight off your own doc.

methods · interfacesundo · redo · undoGroup · HistoryHooks#

Undo and redo on the DurableStorelocal history only, the collaborative-undo consensus rule (Figma, Liveblocks, Google Docs): a peer's ops are never on your stack, and yours never land on theirs. One transaction is one undoable step — unless it opted out with { undoable: false } (migrations, read-repair), which adds no step at all; undoGroup(fn) collapses a gesture's several commits into one. undo / redo return false on an empty stack (drive UI disablement from canUndo / canRedo, or read DurableUndoStatus reactively). The redo stack clears on a new local commit but survives a remote import. setHistoryHooks stores a JSON-safe value (typically the selection) with each pushed step and hands it back on the matching undo / redo.

Rule — newest-wins

An undo re-asserts the pre-op state as the newest ops, so it can overwrite a collaborator's causally-newer change: undoing an edit clobbers a peer's later write to the same component, and undoing a spawn removes the entity including components peers added after it. Everyone still converges — undo is a real forward edit, not a rewind of history. Attached, the reversal reaches the runtime at the next world.sync().

  • Grouping Groups do not nest; a throwing fn still closes the group. A remote import arriving mid-group ends it early (the committed steps stay undoable).
  • Throws undo / redo / clearHistory / undoGroup throw inside a transaction or a history hook; undo / redo also throw on a quarantined store (clearHistory stays allowed).
  • Hooks A hook runs inside the history call and must touch app state only — any document re-entry (transaction / undo / redo / applyRemote) throws. A throwing hook is isolated; the step still lands, its metadata just null.
  • Detach History lives on the store (its doc), not the attachment — the stacks survive detach and re-attach.

function · interfaceattachDurable · Attachment#

Attach a store to a world: project the document in (two-phase, seeding the baseline — the founding agreement), install the key↔handle bijection, register the binding as the world's inbound source, and subscribe to local echoes. Returns an Attachment whose idempotent detach() reverses all of it. It is a package-level function, not world.attachDurable — the core never names a durable type.

  • Throws Guards mirror sync(): throws mid query-iteration or mid-tick (an immediate migration would corrupt a live walk), dev-throws inside an observer / reactive emit (projection primitives are unguarded), and throws on double attach.

classPendingImportError#

Thrown by applyRemote once the doc has taken a pending (missing-dependency) import — the precursor to a wasm panic that would poison the doc and lose unexported local work. The adapter quarantines itself the instant it detects this, before any panic, so export() still recovers local work. This and every future applyRemote throw it: the adapter is permanently quarantined.

  • Recover Catch it, discard the doc, and re-bootstrap on a fresh doc + snapshot. A reconnect is a full re-bootstrap, never a resumed half-synced stream.
  • Prevent Deliver each peer's messages causally in order, or exchange snapshots (Transport).

const · interfaceDurableSyncStatus · DurableSyncStatusValue#

A framework-defined, runtime-local resource the binding publishes at sync-activity boundaries — never written to the document, never itself durable. Read it with useResource(world, DurableSyncStatus): pendingInbound (queued undrained batches, measured at enqueue), heldCells (the held-cell ledger size), and lastAppliedFrame (advances only when a drain applied at least one fact).

  • Idle-free Every field is activity-driven and the binding skips the set when nothing changed, so an idle network produces zero re-renders even though sync() drains each frame.

const · interfaceDurableUndoStatus · DurableUndoStatusValue#

A framework-defined, runtime-local resource the binding publishes as the history stacks change — never written to the document, never itself durable. Read it with useResource(world, DurableUndoStatus) to drive toolbar enablement: canUndo and canRedo mirror the store's canUndo() / canRedo() so a button re-renders exactly when its enabled state flips. Present only while attached — when detached, use the store's pull-based methods instead.

  • Idle-free Set-on-change with producer-side diffing, so an idle stack produces zero re-renders even though a history-change signal fires on every commit.

strata-ecs/ephemeral · presence

Ephemeral layer "@vibecook/strata-ecs/ephemeral"#

Presence — cursors, selection highlights, live previews — modelled as entities on a writer-partitioned, self-expiring store. Each peer owns its own partition and writes only there; the binding projects every other peer's blobs in as Not(Local) entities that expire on a TTL. Deliberately simpler than durable: no relations, no resources, no reconcile baseline (writer partitioning means no cell is ever contended). See The ephemeral layer.

functioncreateEphemeralStore#

Wrap a caller-owned EphemeralSource in an EphemeralStore — the ephemeral twin of createDurableStore. You own the source's lifecycle (creating the backing Loro EphemeralStore, wiring its inbound apply to transport) and supply the options: a session-unique peerId, the outbound send sink, and optional throttleMs (default 16) and ttlMs (default 5000, both clamped).

Rule — partitioning & identity

The peerId must be session-unique (crypto.randomUUID(), not a stable per-user id). A reused id makes a crashed session's ghost keys arrive under your own prefix, and the keepalive would refresh them forever. Display identity (name, color) belongs in a component, never the key prefix.

classEphemeralStore#

The writer-partitioned Mutator you hold to spawn and mutate your own presence entities. Every mutation applies to the runtime immediately (Option A — a spawn then edit().set in one input handler works) and re-encodes the entity's outbound blob; the timers (started by attach) decide when the bytes leave. Every spawn is auto-tagged Local on your runtime (never transmitted). leave() is best-effort early departure — wire it to pagehide.

  • Throws Structural methods (spawn/despawn/add/remove component/tags) throw mid query-iteration/tick in all builds; a value edit().set is exempt (legal in-system). All mutation is dev-rejected inside an observer / reactive emit.
  • Throws Partition ownership is absolute — every mutator, including value writes, throws on an entity not in your live minted set. addTag / removeTag of Local throws (store-owned). A replicated component carrying eid fields dev-throws (use key fields).
  • Guarantee The TTL timeout is the guaranteed despawn on every receiver; leave() only shortens latency (a same-ms delete can lose to LWW).
  • Absent No relations, no resources.

interface · classEphemeralSource · LoroEphemeralSnapshot#

EphemeralSource is the Loro-quarantining seam: per-key value blobs and three coarse events (set/delete/encodeChanged/encodeKeys/ encodeDeletes/apply/subscribe) — not cell-addressable, so it does not extend the substrate Snapshot. LoroEphemeralSnapshot is the one adapter that implements it over a Loro EphemeralStore — the second and last Loro-aware class in the codebase. It moves blobs and reports events; all projection logic lives above it in the store. Construct it as new LoroEphemeralSnapshot(new EphemeralStore(ttlMs)) from loro-crdt, and pass it to createEphemeralStore.

  • Ordering Per-key last-writer-wins is guaranteed natively; a stale out-of-order blob is dropped and never surfaced (the store's blob-diff depends on this).
  • Encode encodeChanged / encodeKeys return Uint8Array | null (null when nothing to send); deletes cross the wire only as tombstones from encodeDeletes.

function · interfaceattachEphemeral · Attachment#

Attach a store to a world: install the runtime seam (making the store's mutators legal), subscribe to the source (enqueue-never-apply), register the binding as the world's inbound source, and start the two outbound timers (change-throttle + keepalive). Returns an Attachment whose detach() reverses all of it. Ephemeral starts empty — there is no seed phase — so attach is lighter than durable's. Its Attachment has no baseline seam.

  • Throws Guards mirror sync(): throws mid query-iteration / mid-tick, dev-throws inside an observer / reactive emit, throws on double attach.
  • Drain Inbound projection is batch-atomic in one sync() pass — a departed peer's entities despawn together; no render lands mid-burst showing a half-gone peer.

const · interfaceEphemeralSyncStatus · EphemeralSyncStatusValue#

The ephemeral twin of DurableSyncStatus — a framework-defined, runtime-local resource published at inbound-projection boundaries. Read it with useResource(world, EphemeralSyncStatus): peerCount (distinct remote peer prefixes with at least one live projected entity) and lastInboundFrame (a monotonic counter that advances only when a drain projected at least one fact).

  • Idle-free Both fields are activity-driven with producer-side set-on-change, so an idle network produces zero re-renders. The peer list stays a [PresenceInfo, Not(Local)] query — this resource is the count, not the roster.