aweft

@aweftjs/core

Observables, the deltas they produce, commits, scopes and identity.

State lives in observables of three kinds: createObject({ ... }) (string slots), createArray([ ... ]) (ordered, addressed by positions that survive edits elsewhere) and createMap([[key, value], ...]) (keyed by id), each taking its initial contents. Assignment is the mutation, and an array's push, splice and index assignment are edits like any other. Every property of an observable belongs to you; everything the library does is a free function that takes the observable, so no field name is reserved.

One assignment is one commit. An atomic block is one commit no matter how much it writes, and a block that throws rolls back and emits nothing. A commit applies whole or not at all, and a watcher never sees a document between deltas.

Quickstart

import {
	apply, atomic, createObject, idOf, observer, snapshot, type Commit,
} from '@aweftjs/core';

const doc = createObject({ title: 'plan', width: 1, height: 1 });

// Watch a scope. The watcher gets the commit; the tree is already updated when it runs.
const stop = observer(doc).path('title').watch((change) => {
	console.log(change.deltas.length, doc.title);
});

doc.title = 'plan b';   // one commit
atomic(() => {          // also one commit, and the title watcher never fires for it
	doc.width = 3;
	doc.height = 4;
});

// Undo: a watcher that asks for it gets the commit that undoes each change.
const undos: Commit[] = [];
const stopRecording = observer(doc).watch(
	(change) => undos.push(change.inverse()), { inverse: true });
doc.title = 'oops';
stopRecording();          // stop before undoing, or the undo records itself
apply(doc, undos.pop()!); // title is 'plan b' again

stop();

Registering a watcher returns the function that stops it, always.

A watcher cannot tell a commit landed with apply from a local mutation. An undo stack that stays subscribed while it undoes will record its own undo; hold a flag for the duration of the call, the way recipes/core does.

That flag works while apply is called from ordinary code. It does not work when apply is called from inside a watcher, which is the shape a replication seam reaches for first. Delivery is deferred, so the nested commit reaches the second document's watchers after the outer watcher has already returned and cleared the flag. Queue the commit and apply it once the delivery has finished:

const queued: Commit[] = [];
observer(source).watch((change) => queued.push({ deltas: [...change.deltas] }));

// later, outside any delivery
while (queued.length > 0) apply(mirror, queued.shift()!);

A real transport queues here anyway, because writing to a socket is not synchronous.

A replica

Two documents stay in step when they share a root id and every commit crosses:

const source = createObject();
const mirror = createObject(undefined, idOf(source));

observer(source).watch((change) => apply(mirror, change));

source.title = 'shared';
// snapshot(mirror) now deep-equals snapshot(source), after every commit

A plain createObject() on the receiving side does not work: it has a different root id, so the source's commits are refused as unreachable. Mint the copy with the source root's id, as above.

A replica built from commits holds only what commits described, and one thing is never described: the slots the watched observable was constructed with. Nothing can watch an observable before it exists, so a watcher wired afterwards never hears about them. Start the source empty and assign its slots after the watcher is wired, as above, or hand the receiving side a starting point with fromSnapshot(snapshot(source)) and replicate from there.

Everything below that is fine. Attaching an observable into a watched document emits the slots it was constructed with, in the same commit as the attach, so a subtree built and attached in one breath replicates whole:

atomic(() => { doc.settings = createObject({ theme: 'dark', limit: 5 }); });
// three deltas: the attach, and one for each slot the child was constructed with

Compare the two by deep equality, not by JSON.stringify. A snapshot's slots are a plain object, so the order they were inserted in is part of the string and is not part of the document: applying the same commits in two orders gives two strings for one document. canonicalJson in the sibling testing package is the comparison that holds.

Refusing a change before it lands

intercept(doc, fn) puts a rule on a document. It is called with the commit about to close, after every delta has been applied and before any watcher is told, and it answers with the reasons to refuse. Empty means the commit closes.

const stop = intercept(doc, (commit) =>
	doc.title === '' ? [{ code: 'invalid', message: 'a title needs a name', path: ['title'] }] : []);

doc.title = '';        // throws RefusedError; doc.title is what it was
apply(doc, arriving);  // refused the same way, and nothing was delivered
stop();

A refusal rolls the whole commit back and tells nobody, exactly as a throwing atomic block does, then throws a RefusedError carrying the refusals at whoever made the commit: the assignment, the block, or the apply. Catching it is a complete recovery, because there is no half-applied state to repair.

One seam covers every way a commit is made, so an assignment, a block and an arriving commit are all read by the same rule, and a block is read once with everything it wrote. A rule is about a whole document, not the subtree under the observable it was registered on.

Inside a rule the document reads as the commit would leave it, which is what a rule across two slots needs. Writing to it from in there throws sealed: a change made from inside the answer would be a change to the commit being answered for. Several rules on one document all run, refusals and all, so one commit reports every problem it has rather than one per retry.

@aweftjs/schema is this with the rule written for you from a description of the document.

Scope where you read, not at the root

A scope registers its listener on the observable it was built from, and delivery walks each delta up its attach path checking every listener it passes. So the cost of a write is the number of listeners standing between it and the top.

observer(doc).path('tasks', 3, 'done').watch(fn);  // checked on every write anywhere
observer(task).path('done').watch(fn);             // checked only on writes under task

Both see the same changes. The first is checked on every write in the document, the second only on writes under task. Measured with bench/write.ts, on one write nobody matches: 1,000 listeners on the root cost 4.56 us and 10,000 cost 51.59 us, while the same listeners registered on the observable they are about stay flat at 0.42 to 0.45 us. Start the scope at the thing you are reading and the question does not arise.

A number in a path names a position, not an element. path('tasks', 0) follows whatever sits at index 0 now, so removing the first task makes it the second task's scope. To follow one element wherever it moves, start the scope at the element.

Derive values from what you read

map turns a scope into a derived value, and derived values compose:

const caps = observer(doc).path('title').map((v) => String(v).toUpperCase());
const area = all([observer(doc).path('width'), observer(doc).path('height')])
	.map(([w, h]) => Number(w) * Number(h));

caps.get();                       // the current value
const stop = area.watch(render);  // the new value, after each change
area.effect(render);              // the value now, and after each change

watch means two things, and the types keep them apart: on a scope it delivers commits, because a scope is about a place in a document; on a derived value it delivers the value, because there is no commit. Derived delivery runs after the whole commit has been delivered, so a value combining two branches never computes against half a commit, and an atomic block is one recompute however much it writes.

A derived value is memoized while something watches it and recomputed on read while nothing does, so an abandoned chain holds no subscription. A change that settles to an equal value (Object.is) is not delivered: a container mutated in place reads as unchanged, so derive the field you mean, not the container holding it. The transform must be pure per input; anything else it reads is not tracked.

bool(a, b), def(fallback), defined() and unwrap() are shorthand over map. Writing goes through a declared path only: map is read-only, setter(fn) declares the write half, and isImmutable() answers before an input renders. selector is per-key selection that scales: a change reaches the two keys it moved between and no others.

const select = observer(app).path('selectedId').selector();
select(id).effect((on) => row.classList.toggle('active', on)); // per row
select(id).set(true);                                          // select this row
select(id).set(false);                                         // clear, only if selected

set(true) writes the key to the source. set(false) clears the source only when this key is the selected one, so deselecting a row that already lost the selection changes nothing.

Interface state lives in cells

A cell is a reactive value outside the document: no delta, no replication, no place in the undo history. Which tab is open is a cell; the document is the document.

const open = mutable(false);
open.set(true);
const label = open.bool('hide', 'show');

timer(1000).map(() => new Date().toLocaleTimeString()).effect(show);
fromEvent(window, 'resize').wait(100).effect(relayout);

mutableArray(items) is a list cell: an array edited in place (push, splice, index assignment) whose watch delivers each edit as a list of changes, with no delta and no place in a document. A list on the page that is not part of the document, such as open toasts.

derive(fn) is a value of the whole list, recomputed on every edit, for the questions a page asks about a list rather than about one row:

const rows = mutableArray<Session>();
const empty = rows.derive((items) => items.length === 0);
const total = rows.derive((items) => items.reduce((sum, r) => sum + r.bytes, 0));

It is an ordinary derived value: watchers hear only the answers that changed, so a fn that builds a fresh array or object is delivered on every edit, and reading it while nothing watches computes it there and then. Unlike watch, it settles inside an atomic block where each edit is made, the same as a plain cell does.

Inside atomic, the calls in the block deliver once at its close, as one list in the order they were made, so a swap written as two index assignments arrives as one change list and a binding over the list moves both rows:

atomic(() => { const t = rows[1]; rows[1] = rows[998]; rows[998] = t; });

A block that throws still delivers them, because nothing rolls the list back. A watcher hears exactly the changes made after it subscribed and before it unsubscribed, so one that subscribes part way through a block is told the rest of it and one that unsubscribes inside a block is told nothing. A plain cell is different: mutable(x).set(v) notifies inside the block, since holding it would make a derived value that is being watched read the value the block just overwrote.

Writing a cell into a document slot is refused (cell-in-document), so whether state replicates stays answerable from the type being written. immutable(x) wraps anything as a read-only view or a constant.

throttle(ms) and wait(ms) exist only on this value surface. A commit stream cannot be rate limited through this API, because a receiver that misses one commit of a burst holds a different document forever after. Reads are never delayed, only delivery.

Watch a shape, not only a place

A scope step can be a wildcard: skip(count) matches any run of keys, tree(key) matches the named key at any depth. They are ordinary steps, so path, ignore and shallow compose with them unchanged.

observer(board).skip().path('done').watch(fn);  // every column's done flag
observer(doc).tree('draft').watch(fn);          // any draft, anywhere

A wildcard scope names many places, so it has no single value: get() is undefined, set() throws, and isImmutable() is true. Only scopes that use wildcards pay for the backtracking matcher.

skip() matches exactly one step unless you say otherwise, and a scope at a depth nothing sits at is silent: it never matches, and a derived value on it sits at its initial value forever, which reads as a counter that works and is always zero. Count the steps from the observable the scope starts at, or use tree, which does not care how deep the thing is.

skip(Infinity) is any run of steps. Ending the scope, it reaches every slot at every depth, so observer(doc).skip(Infinity).watch(fn) hears every delta in the document, each on its own. Every step of the run has to be open: a wildcard never consumes an object slot whose key starts with an underscore, so a _draft slot and everything an object under it holds are private from this scope at any depth, and never a slot named in ignore, which drops that slot and everything under it. That is the scope for a recorder or a mirror: what the document holds that its author did not mark private, and nothing the recorder has to know about. Followed by a key, skip(Infinity).path('done') reaches done at any depth, which is what tree('done') spells.

observer(doc).skip(Infinity).watch((change) => record(change.deltas));   // every public delta
observer(doc).skip(Infinity).ignore('scratch').watch(fn);               // except under scratch

A snapshot rebuilds

fromSnapshot(snapshot(doc)) is a live copy: same ids, kinds, slots, positions and aliases, and it accepts commits addressed to the original's ids from then on. It holds what the document says, not the detached observables the original still indexes, so replaying a history that resurrects one is the commit log's job, not a snapshot's.

Removing something takes it out of the document

Taking an observable out of the document leaves it readable and no longer writable. A write to it throws unreachable, which is what a receiver does with the same delta.

const task = tasks[0];
tasks.splice(0, 1);
isReachable(task);  // false
task.done = true;   // throws unreachable
byId(board, id);    // undefined: the document no longer holds it

The commit that detached it takes it and everything under it out of the document as it closes, so nothing that has been removed keeps the document alive. Hold the observable yourself if you still want it; attaching it somewhere again is a commit that carries everything it holds, so a replica gets it back in full (design 084).

Ask isReachable rather than catching the throw. parentOf cannot answer it: it returns undefined for a document root, which is reachable, and for something detached, which is not.

Finding a thing by its id

A delta names the observable it changes by id. byId(doc, id) answers with the observable, and pathOf(observable) answers with the slot names from the root down, spelled the way the format spells a slot. Both cost the depth, never the document, so a check that runs on every commit can afford them.

const task = byId(board, delta.id);
pathOf(task);        // ['tasks', '80a1c2e3']
pathOf(board);       // []
pathOf(orphan);      // undefined: nothing attaches it, and byId no longer finds it

Boundaries

The Change a watcher receives is a Commit: its deltas are exactly what crosses a boundary, and apply on the far side takes them unchanged. The sibling codec package turns a commit into bytes and back.

Deliberately not here: any transport, any persistence, any DOM. sort, reverse, fill and copyWithin on an array throw, because they cannot be expressed as changes to the slots they appear to touch.

The wire format lives in spec/, the reasoning in docs/design/, and a complete program using all of the above in recipes/core/.

Known limits

Nothing records what a function read while it ran. A transform is pure per input and what it reads besides its input is not tracked, and no scope here records reads and re-runs on a change to one of them. So anything reactive has to name what it follows, with all([...]) or with the scope you mean, and a callback that reaches for a cell nobody handed it goes quiet when that cell changes. Settling it means a read-recording scope in this package, priced against what recording adds to every read.

snapshot can produce a document fromSnapshot refuses. An alias may name an observable that a later edit takes out of the document: snapshot leaves the observable out, because nothing attaches it, and still writes the alias, so rebuilding that snapshot throws unreachable: <id> is named but not in the snapshot. You meet it when you alias an observable, detach it, and then save the document and build it again. @aweftjs/store drops the dangling slot as it opens a document (design 050); which answer holds in general, dropping the alias, carrying what is named as well as what is held, or refusing the detach, is open.

The design notes

A design NNN above is the note of that number in docs/design/, which says what was decided, why, what it costs, and what would reverse it.

API

Every export of @aweftjs/core, its signature as the compiler resolves it, and its block comment.

@aweftjs/core

ArrayChange

type ArrayChange<T> = { readonly type: 'add'; readonly at: number; readonly value: T; } | { readonly type: 'replace'; readonly at: number; readonly value: T; } | { readonly type: 'remove'; readonly at: number; };

One step of a change, applied in order to the list as it was: add inserts before index at (or at the end when at is the length), replace overwrites index at, remove takes index at out. The same three words as a delta. A splice says its removes before its adds, so a value it moves is never named at two places at once; replace comes only from an index assignment.

Change

interface Change extends Commit { readonly deltas: readonly Delta[]; inverse(): Commit; }

What a watcher is handed: the deltas of one commit that fell inside its scope, and the commit that undoes them.

deltas is exactly what crosses a boundary, so a watcher on the document root can hand it to an encoder unchanged. inverse is built from the values the slots held before, captured while the change was applied and, for a subtree the commit took out of the document, as the commit closed. It is never transmitted (design 016).

A commit captures those values only for a watcher that asked, so inverse() refuses with inverse-not-asked on a change delivered to a watcher registered without the option (design 156).

Commit

interface Commit { readonly deltas: readonly Delta[]; readonly tag?: Tag; } (from @aweftjs/codec)

The unit that crosses every boundary.

A commit applies whole or not at all, and it carries at least one delta. Its deltas are a set, written in the canonical order of section 6.9, and a receiver replaying them one at a time would pass through states the sender never had.

tag is 4 to 32 bytes and is not a checksum of these bytes. It is a digest over the prior values of the slots this commit addresses, computed by the sender against its own state before the commit, and it answers whether the commit landed on the state the sender expected. A receiver computing a different tag treats the replicas as diverged and resynchronizes. The algorithm that fills it is still open, so nothing here computes one.

Delta

interface Delta { readonly type: DeltaType; readonly id: Id; readonly ref: Ref; readonly value?: Value; } (from @aweftjs/codec)

One change to one slot.

The target is id plus ref and never a path, so a delta means the same thing whatever else moved in the same commit. value is absent exactly when the type is remove: absent as in the key is omitted, not present holding undefined, and a decoded delta reads the same way.

Derived

interface Derived<T> { get(): T; set(value: T): void; isImmutable(): boolean; watch(fn: (value: T) => void): () => void; effect(fn: (value: T) => void): () => void; map<U>(fn: (value: T) => U): Derived<U>; setter(fn: (value: T) => void): Derived<T>; unwrap(): Derived<unknown>; bool<U, V>(truthy: U, falsy: V): Derived<U | V>; def<U>(fallback: U): Derived<NonNullable<T> | U>; defined(): Derived<boolean>; selector(compare?: (value: T, key: unknown) => boolean): (key: unknown) => Derived<boolean>; throttle(ms: number): Derived<T>; wait(ms: number): Derived<T>; }

The value surface. get reads, watch delivers the value after it changes, and the combinators return new chains without touching this one. set works only where a write path was declared; ask isImmutable first.

EdgeKind

type EdgeKind = 'attach' | 'alias'; (from @aweftjs/codec)

What a reference to an observable means.

attach is where the observable lives, and it has exactly one. alias names it from somewhere else and moves nothing.

EventEmitting

interface EventEmitting<E> { addEventListener(type: string, listener: (event: E) => void): void; removeEventListener(type: string, listener: (event: E) => void): void; }

Anything events can be heard from. Structural on purpose: core knows no DOM.

Interceptor

type Interceptor = (commit: Commit) => readonly Refusal[];

A rule that reads a closing commit and answers with the reasons to refuse it.

An empty answer lets the commit close. Anything else rolls it back. The rule runs inside the transaction, so it may read the document and may not write to it.

MutableArray

interface MutableArray<T> extends Array<T> { watch(fn: (changes: readonly ArrayChange<T>[]) => void): () => void; derive<U>(fn: (items: readonly T[]) => U): Derived<U>; }

An array whose every edit can be heard. Reads and mutators are the array's own.

ObservableKind

type ObservableKind = 'object' | 'array' | 'map'; (from @aweftjs/codec)

Which of the three kinds an observable is. It is fixed when the observable is made.

ObservableMap

interface ObservableMap<T> { get(key: unknown): T | undefined; has(key: unknown): boolean; set(key: unknown, value: T): void; add(observable: T & object): void; delete(key: unknown): boolean; readonly size: number; keys(): string[]; values(): T[]; entries(): Generator<[ string, T ]>; [Symbol.iterator](): Generator<[ string, T ]>; }

What a map observable answers to. Slots are named by id, so methods cannot collide.

Observer

interface Observer extends Omit<Derived<unknown>, 'get' | 'set' | 'watch' | 'effect'> { get(): unknown; set(value: unknown): void; path(...keys: ScopeKey[]): Observer; ignore(...keys: ScopeKey[]): Observer; shallow(): Observer; skip(count?: number): Observer; tree(key: ScopeKey): Observer; watch(fn: (change: Change) => void, options?: { readonly inverse?: boolean; }): () => void; effect(fn: (value: unknown) => void): () => void; }

A scope: what part of the document a listener is about. Narrow it before watching.

A scope also carries the value combinators of design 023, inherited below: map is the bridge from this surface to derived values, and bool, def, selector and the rest ride on it.

Primitive

type Primitive = null | boolean | number | string | Uint8Array;

Everything a slot can hold that is not another observable.

Refusal

interface Refusal { readonly code: string; readonly message: string; readonly path?: readonly string[]; }

One reason a commit was refused.

code is the stable token to branch on and message is for a person. path names the slot the reason is about, from the document root down, spelled the way the document spells its own keys: an object key as itself, a map id in text form, an array position in hex. Core never reads any of the three; it carries them so that a rule, a link and an application all say refusal the same way.

RefusedError

RefusedError: typeof RefusedError

What is thrown at whoever made a commit a rule refused.

The commit is already rolled back when this arrives, and no watcher was told anything, so catching it is a complete recovery: there is no half-applied state to repair.

Example

try { doc.title = ''; } catch (error) {
  if (error instanceof RefusedError) show(error.refusals[0]!.message);
}

ScopeKey

type ScopeKey = string | number;

A step in a path: an object or map slot by name, or an array position by index.

Snapshot

interface Snapshot { readonly root: string; readonly observables: Record<string, SnapshotObservable>; }

A whole document as plain data, flat, keyed by id in text form, with the root named.

SnapshotObservable

interface SnapshotObservable { readonly kind: ObservableKind; readonly slots: Record<string, SnapshotValue>; }

One observable as plain data: its kind, and its slots by key.

SnapshotRef

interface SnapshotRef { readonly ref: string; readonly kind: ObservableKind; readonly edge: EdgeKind; }

A slot naming another observable: which one, what kind, and which edge names it.

SnapshotValue

type SnapshotValue = Primitive | SnapshotRef;

What a slot holds in a snapshot: a primitive, or the name of another observable.

alias

alias: (observable: object) => object

Name an observable without giving it a home.

Params

  • observable: the observable to name

Returns a marker to assign. The slot then holds an alias edge, which grants nothing and revokes nothing. Every observable lives at exactly one attach edge, and an alias is not it.

Throws not-observable when observable is not an observable.

Example

post.author = alias(users.get(id));

all

all: (inputs: readonly unknown[]) => Derived<unknown[]>

Combine several inputs into one derived value of their current values, in order.

Params

  • inputs: scopes, cells, derived values, or plain values, mixed freely. A plain value is carried as itself and never changes

Returns a derived value of an array, one slot per input. It recomputes when any input changes, once per burst, after the burst's commit has fully delivered.

Example

all([observer(doc).path('width'), observer(doc).path('height')])
  .map(([w, h]) => Number(w) * Number(h))
  .effect((area) => console.log(area));

apply

apply: (observable: unknown, commit: Commit) => void

Apply a whole commit to a document.

Params

  • observable: any observable in the document. The commit is applied to its root

  • commit: the commit, its deltas in any order

Throws an error naming the rule broken, having changed nothing. An integrity tag is not checked here: the algorithm is open in spec/format.md 8, and a tag is opaque bytes until it is settled. Watchers are called once, after every delta has been applied, with the deltas that fell in their scope. A watcher cannot tell an applied commit from a local mutation, so anything that records what a watcher delivers, an undo stack included, receives the commits it applies itself and wants a way to tell its own apart, such as a flag held for the duration of the call (recipes/core does exactly this). That flag only covers this call when the call is made from ordinary code. Userspace calls are deferred, so calling apply from inside a watcher hands the commit to the second document's watchers after the outer watcher has returned and cleared the flag. Queue the commit and apply it once the delivery has finished, which is what a transport does anyway. A commit only names observables reachable in the receiving document, so a replica of an existing document starts from its root id, createObject(undefined, idOf(source)) for an object root; from there, applying the source's commits in the order they happened rebuilds it. A plain empty observable has a different root id and refuses them as unreachable.

Example

apply(doc, decodeCommit(bytes));

atomic

atomic: <T>(run: () => T) => T

Make everything inside one commit.

Params

  • run: the mutations. Nesting joins the block already open and closes with it.

Returns whatever run returned.

Throws whatever run threw, after rolling every mutation it made back out of the tree. No commit is emitted and no document watcher hears one, but a list cell the block edited is not rolled back and tells its watchers after the rollback (the README says why, beside atomic).

Example

atomic(() => { doc.width = 3; doc.height = 4; });

byId

byId: (document: unknown, id: string | Id) => object | undefined

The observable in a document with a given id.

Params

  • document: any observable in the document

  • id: the twelve bytes a delta carries, or the same id in text form

Returns the observable, or undefined when the document holds nothing by that id. An observable that lost its attach edge is not found: the commit that detached it took it out of the document as it closed, and attaching it again re-sends its contents (design 084). Hold the observable itself if you need it after a detach, and ask isReachable whether it is still in the document.

Throws not-observable when document is not an observable, and invalid-id when id is bytes that are not an id.

Example

const task = byId(board, delta.id);

createArray

createArray: <T = unknown>(items?: Iterable<T> | undefined, id?: Id | undefined) => T[]

Make an array observable.

Params

  • items: the values it starts with

  • id: its id, when it has to be a particular one. Minted otherwise

Returns a proxy that reads as an array. map, filter, find, join, iteration and length all work. push, pop, shift, unshift and splice produce commits, and sort, reverse, fill and copyWithin throw, because they cannot be expressed as changes to the slots they appear to touch.

Throws invalid-value, cell-in-document or inline-container for an item a slot cannot hold, multiple-attach, unreachable or duplicate-id for an observable that already has a home or an id, and invalid-id for an id that is not twelve bytes. Attaching an observable that already has slots carries those slots in the same commit, one delta for the attach and one for each slot under it, however deep. That is what lets a replica be built from the commits alone: pushing a filled object sends its contents, not just its id.

Example

const blocks = createArray([createObject({ text: 'hi' })]);
blocks.push(createObject({ text: 'there' }));

createMap

createMap: <T = unknown>(entries?: Iterable<readonly [unknown, T]> | undefined, id?: Id | undefined) => ObservableMap<T>

Make a map observable.

Params

  • entries: the entries it starts with, as pairs of id and value

  • T: what the values are, so get answers with something better than unknown

  • id: its id, when it has to be a particular one. Minted otherwise

Returns a map of ids to values. add files an observable under its own id, which is the common case; set names the id itself.

Throws invalid-key or invalid-id for an entry key that is not an id, invalid-value, cell-in-document or inline-container for a value a slot cannot hold, and multiple-attach, unreachable or duplicate-id for an observable that already has a home or an id.

Example

const presence = createMap();
presence.add(createObject({ cursor: 42 }));

createObject

createObject: <T extends object = Record<string, unknown>>(init?: Readonly<Partial<T>> | undefined, id?: Id | undefined) => T

Make an object observable.

Params

  • init: the slots it starts with. Values are primitives or other observables; a plain object is refused rather than copied

  • id: its id, when it has to be a particular one. Minted otherwise. The one common case is a replica, which starts from the source root's id: see apply

Returns a proxy whose properties are its slots. Assigning one is a commit; so is deleting one. Reading gives the primitive, or the observable the slot names.

Throws invalid-value, cell-in-document or inline-container for an init value a slot cannot hold, multiple-attach, unreachable or duplicate-id for an observable that already has a home or an id, and invalid-id for an id that is not twelve bytes. Construction is not a commit: the slots in init exist before anything can watch, so a replica wired afterwards never hears about them. A document that will replicate starts empty and assigns its slots once the watcher is wired, or hands the receiver fromSnapshot(snapshot(doc)) as its starting point. Once it is attached, taking it back out leaves it readable but no longer writable: a write to a detached observable throws unreachable, because a receiver refuses the same delta. isReachable asks before writing, rather than finding out from the throw. It also leaves the document, so byId stops answering for it; attaching it somewhere again re-sends everything it holds (design 084).

Example

const doc = createObject({ title: 'notes', blocks: createArray([]) });
doc.title = 'aweft';

fromEvent

fromEvent: <E>(target: EventEmitting<E>, type: string) => Derived<E | undefined>

A cell of the last event of a type, or undefined before the first one.

Params

  • target: anything with addEventListener and removeEventListener

  • type: the event type to hear

Returns a chain of the most recent event. The listener is registered only while the chain is observed.

Example

fromEvent(window, 'resize').wait(100).effect(relayout);

fromSnapshot

fromSnapshot: (snap: Snapshot) => object

Build a live document from a snapshot (design 029).

Params

  • snap: what snapshot returned, or the same shape written by hand

Returns the root observable, with the snapshot's ids, kinds, slots, positions and aliases. snapshot(fromSnapshot(s)) deep-equals s, and commits addressed to the original document's ids apply to the rebuilt one. The rebuilt document holds what the snapshot says, which is the document, not the detached observables the original may still index. A commit that re-attaches one of those names an id the rebuilt document has never held, so its subtree arrives empty and deltas over its old slots are refused. A replica that must replay that kind of history replays the commit log rather than starting from a snapshot. Throws with the vocabulary apply uses when the snapshot does not describe a document: a ref naming an id the snapshot does not hold, an observable attached twice or not at all, a kind that disagrees with its target, or a slot key invalid for its kind.

Example

const copy = fromSnapshot(snapshot(doc));

idOf

idOf: (observable: unknown) => Id

An observable's id.

Params

  • observable: any observable

Returns its 12 bytes of id. Two replicas agree on which observable a change is about by this and nothing else. It is published to everyone who can read the document, so it is never a credential.

Throws not-observable when observable is not an observable.

Example

const mirror = createObject(undefined, idOf(doc));

immutable

immutable: { <T>(value: Derived<T>): Derived<T>; <T>(value: T): Derived<T>; }

A read-only view.

Params

  • value: a chain, cell or scope to wrap read-only, or any plain value to carry as a constant

Returns a chain that reads what value reads and can never be written. Wrapping a chain keeps its changes flowing; wrapping a plain value never delivers one.

Example

const label = immutable('untitled');
const width = immutable(observer(doc).path('width'));

insertAt

insertAt: (list: object, position: Position, value: unknown) => void

Insert a value at a position of the caller's choosing.

Params

  • list: the array observable

  • position: the position key. It must be free, and it decides where the value sorts

  • value: what to put there

Throws not-observable when list is not an array observable, invalid-position for a key the format forbids, and slot-exists when something already holds that position. This is what bridging two trees needs: a receiver that has been told a position must be able to honour it rather than generate its own and diverge.

Example

insertAt(mirror, positionsOf(list)[0]!, list[0]);

intercept

intercept: (document: unknown, fn: Interceptor) => () => void

Let a rule refuse commits on a document.

Params

  • document: any observable in the document. The rule covers the whole document it belongs to, not the subtree under the observable named here

  • fn: the rule. It is handed the commit about to close, after every delta has been applied and before anything has been delivered, and answers with the reasons to refuse it. An empty answer lets the commit close

Returns the function that removes the rule. Every way of making a commit closes through one transaction, so an assignment, an atomic block and a commit landed with apply are all read by the same rule, and a block is read once with everything it wrote. Refusing rolls the whole commit back, tells no watcher, and throws a RefusedError at whoever made it. The rule may read the document, which reads as the commit would leave it, and may not write to it: a write from inside a rule throws sealed, because it would be a change to the commit being decided. Several rules on one document all run and their refusals are concatenated.

Throws not-observable when document is not an observable.

Example

const stop = intercept(doc, (commit) =>
  doc.title === '' ? [{ code: 'invalid', message: 'a title cannot be empty' }] : []);

isMutableArray

isMutableArray: (value: unknown) => boolean

Is this a mutable array?

Params

  • value: anything

Returns true for a list made by mutableArray; false for a document array, a plain array, or anything else.

Example

if (isMutableArray(items)) items.watch(reconcile);

isObservable

isObservable: (value: unknown) => boolean

Is this an observable?

Params

  • value: anything

Returns true for an object, array or map made by this library.

Example

const doc = isObservable(input) ? input : createObject(input);

isReachable

isReachable: (observable: unknown) => boolean

Can this observable still be written to?

Params

  • observable: any observable

Returns true while an attach path from the document root reaches it. Detaching an observable takes that path away, and a write to it then throws unreachable, which is exactly what a receiver does with the same delta. parentOf cannot answer this: it returns undefined both for a document root, which is reachable, and for something detached, which is not. Ask here rather than by catching the throw, because control flow through an error hides the ordinary case.

Throws not-observable when observable is not an observable.

Example

if (isReachable(task)) task.done = true;

kindOf

kindOf: (observable: unknown) => ObservableKind

Which of the three kinds this is.

Params

  • observable: any observable

Returns 'object', 'array' or 'map'. The kind never changes, and a commit that calls one observable two kinds is refused.

Throws not-observable when observable is not an observable.

Example

if (kindOf(value) === 'array') count = (value as unknown[]).length;

mutable

mutable: <T>(initial: T) => Derived<T>

A standalone reactive value.

Params

  • initial: the value it starts with

Returns a chain whose set writes the cell. A write to an equal value (Object.is) changes nothing and notifies nobody. Cells are not observables: assigning one into a document slot is refused, because a cell does not replicate and a document does.

Example

const open = mutable(false);
open.watch((now) => menu.hidden = !now);
open.set(true);

mutableArray

mutableArray: <T = unknown>(items?: Iterable<T> | undefined) => MutableArray<T>

A list outside the document, whose slots hold anything.

Params

  • items: what it starts with

Returns an array. push, pop, shift, unshift, splice, index assignment and length assignment work and each delivers its changes to watch; every read is the array's own. sort, reverse, fill and copyWithin throw unsupported, as on a document array. Writing one into a document slot is refused with cell-in-document: it does not replicate. Inside atomic, the calls in the block deliver once at its close, as one list in call order, so a swap written as two index assignments is one change list (design 087). A block that throws still delivers them: the list was mutated and nothing rolls it back.

Example

const layers = mutableArray<Layer>();
layers.watch((changes) => { for (const c of changes) reconcile(c); });
layers.push(popup);

observer

observer: (observable: unknown) => Observer

Start a scope at an observable.

Params

  • observable: where the scope is rooted. Paths and delivery are relative to it

Returns an observer. Every narrowing returns a new one, so an observer can be kept and narrowed differently in two places without either affecting the other.

Throws not-observable when observable is not an observable.

Example

const stop = observer(doc).path('settings').ignore('draft').watch((change) => {
  send(change.deltas);
});

parentOf

parentOf: (observable: unknown) => object | undefined

Where an observable lives.

Params

  • observable: any observable

Returns the observable holding its one attach edge, or undefined when nothing does, which is the case for a document root and for anything that has been detached. A reference from somewhere else is an alias and does not answer this question, which is the point: where something lives is a walk up, never a search.

Throws not-observable when observable is not an observable.

Example

const list = parentOf(entry); // the array the entry sits in, or undefined

pathOf

pathOf: (observable: unknown) => readonly string[] | undefined

The attach path from the document root down to an observable.

Params

  • observable: any observable

Returns the slot names from the root down, spelled the way the format spells a slot: an object key, a map id in text form, an array position in hex. Empty for the root, and undefined for anything nothing attaches. A walk up the parent pointers, so it costs the depth and never the document.

Throws not-observable when observable is not an observable.

Example

pathOf(task);   // ['tasks', '80a1c2e3']

positionsOf

positionsOf: (list: object) => Position[]

The position keys of an array, in order.

Params

  • list: the array observable

Returns one key per element, so a caller can name a place rather than an index.

Throws not-observable when list is not an array observable.

Example

const first = positionsOf(list)[0]; // survives edits elsewhere; list[0] does not

snapshot

snapshot: (observable: unknown) => Snapshot

Read a document out as plain data.

Params

  • observable: any observable in the document. The document is read from its root

Returns every observable reachable from the root, by id in text form, with each slot either a primitive or the name of another observable and the kind of edge naming it. An observable that has lost its attach edge is not here. Nothing reaches it, and a delta naming it is refused, so it is not part of what the document says.

Throws not-observable when observable is not an observable.

Example

assert.deepStrictEqual(snapshot(mirror), snapshot(doc));

textIdOf

textIdOf: (observable: unknown) => string

The same id in text form.

Params

  • observable: any observable

Returns sixteen base64url characters, safe in a URL, a log line, or a key in a plain object. It is the form a map slot is named by.

Throws not-observable when observable is not an observable.

Example

history.set(textIdOf(entry), createObject({ opened: true }));

timer

timer: (ms: number) => Derived<number>

A cell that counts up while something observes it.

Params

  • ms: milliseconds between ticks

Returns a chain of the tick count. The interval runs only while the chain is observed; an abandoned timer holds nothing. Map over it for a clock:

Example

timer(1000).map(() => new Date().toLocaleTimeString()).effect(show);

Refusals

Every error this package raises carries a reason to switch on and a fix that says what to do. These are its reasons, from errors.txt.

reasonfix
async-atomicAwait outside the block, then pass the settled values to atomic.
cell-in-documentWrite its get() into the slot, and keep the cell in the interface.
duplicate-idLet createObject mint the id rather than reusing one this document holds.
empty-commitDrop the commit instead of applying it, or add the delta it was meant to carry.
inline-containerBuild it with createObject, createArray or createMap, then assign that.
invalid-keyAssign a whole number index of zero or more.
invalid-keyIndex the array with a number, and keep symbol-keyed data elsewhere.
invalid-keyIndex the list with a number, and keep symbol-keyed data elsewhere.
invalid-keyKey an array slot with the hex of a position from positionsOf, not an index.
invalid-keyPass an id, the same id in text form, or the observable it names.
invalid-keyUse a string key, and keep symbol-keyed data outside the document.
invalid-positionName the slot yourself with insertAt rather than asking for one between these.
invalid-positionPass the lower position as a and the higher as b, or null for an open end.
invalid-valueStore a string, number, boolean, null, Uint8Array, or an observable.
invalid-valueUse delete on the slot, or assign null when the slot should stay.
invalid-writeAssign the property instead of calling Object.defineProperty.
invalid-writeCall push or splice to grow it; assign a smaller length to shorten it.
invalid-writeCall splice to take the element out and close the gap.
invalid-writePush the value on the end, or splice it in where you want it.
inverse-not-askedRegister the watcher as watch(fn, { inverse: true }).
kind-conflictGive the ref the kind the observable it names declares.
kind-conflictKeep one kind per id; make a new observable rather than reusing the id.
kind-conflictSet kind to 'object', 'array' or 'map'.
multi-targetNarrow the scope with path until it names one slot, then set that.
multiple-attachKeep one attach edge and make the others 'alias'.
multiple-attachKeep one attach edge in the commit and send the rest as 'alias'.
multiple-attachLeave one slot holding it, and make the other an alias.
multiple-attachWrap it in alias here, or clear the slot holding it first.
not-observablePass an observable, or call set to file a value under an id you choose.
not-observablePass the document createObject, createArray or createMap returned.
not-observablePass what createArray returned.
not-observablePass what createObject, createArray or createMap returned.
read-onlyBuild the chain over a writable source rather than an immutable one.
read-onlyGive the chain a write path with setter before calling set.
sealedReturn a Refusal from the rule instead of writing to the document.
slot-existsPick a position no element holds, or assign over the element already there.
slot-existsReplace the slot instead of adding it, or remove what is there first.
slot-missingAdd the slot before replacing or removing it.
slot-missingCreate the observables along the path first, or check get() before setting.
unreachableAdd the observable it names to observables, or drop the slot naming it.
unreachableAdd the root to observables, or name a root that is already there.
unreachableApply the commit that attaches it first, or replicate from the source root id.
unreachableAttach it somewhere outside its own subtree.
unreachableAttach it under the root, or take it out of the snapshot.
unreachableAttach the observable to the document before writing to it.
unsupportedAssign the order you want, or hold the sort outside the list.
unsupportedAssign the order you want, or hold the sort outside the state.
unsupportedAssign the order you want.
unsupportedAssign the slots you mean.

Recipes

The programs in the stack's gate that use this package, each a job someone would have.

  • recipes/todo-list: A list you add to, toggle, filter and reorder, with the list following each edit rather than being rebuilt

  • recipes/two-clients: Two people editing one document at once, including what happens when they write the same slot and who yields

  • recipes/optimistic-write: A write that applies locally before the server sees it, is refused, and is rolled back

  • recipes/debug: Finding a bug in a document you did not write

  • recipes/ui: A page with themes, contexts, control flow, a popup and a suspend, built by vite and driven in a real browser

  • recipes/routed-site: A site with real URLs: nested pages, a page with a parameter, a page that arrives later, a dialog the back button dismisses, and a title per page

  • recipes/translated-site: A site written in one language and launched in three: the build finds every string and writes the catalog an agent fills, each language is a tree of pages with the plural rules of its own, a stored act is compiled where it runs, and the Ukrainian page hydrates in place

  • recipes/core: the package's own recipe