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 type | Value in TS | Backing column |
f32 f64 | number | Float32Array / Float64Array |
i8…i32 · u8…u32 | number | the matching integer typed array |
bool | boolean | Uint8Array (0 / 1) |
string | string | null | (string | null)[] — per-cell |
eid | Entity | Uint32Array (a packed handle) |
key | EntityKey | null | (string | null)[] — a cross-store reference |
enumOf([…]) | the label union | smallest 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/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 DurableStore — local
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.