UNPKG

lib0

Version:

isomorphic utility functions

285 lines (273 loc) • 16.6 kB
/** * # Replicated Data Types (RDTs) and bindings * * An **RDT** ("replicated data type") is any object that represents some state and communicates changes * to that state as {@link import('./delta.js').Delta deltas}. Every RDT is an * {@link import('../observable.js').ObservableV2 observable}: * - it **emits** a `'delta'` event carrying a delta and its {@link RDT origin} whenever its own state * changes, and * - it **accepts** foreign changes through `applyDelta(delta, origin)`, which mutates its state to match. * * Because the changes are deltas, two different representations of "the same" data can be kept in sync * even when their shapes differ. That is the job of a {@link Binding}: it connects two RDTs `a` and `b` * through a {@link dt.Template transformer template}. The template is instantiated once (against `a`'s * schema) into a stateful {@link dt.Transformer}, and every change is routed through it: * * - a change on `a` is fed to `transformer.applyA(d)`, whose result `{ a, b }` is pushed back via * `a.applyDelta` (self-heal) and `b.applyDelta` (the mapped change), and symmetrically for `b` via `applyB`. * * Feeding the mapped change back into the other RDT makes it emit its own `'delta'`, which would loop * forever; a {@link mux mutex} shared per side breaks that echo so a change is only transformed once. * * Applying a mapped change can make the receiving RDT enforce its own invariants and return a **fix** * (see {@link RDT}) — a further change it made to itself. Because the echo mutex swallows the RDT's * re-emit during propagation, the binding reads that fix from `applyDelta`'s return value and feeds it * back as a fresh change on its side. A fix on one side and a fix on the other are concurrent, so they * are rebased against each other: the pending pair `{ a, b }` is run through `transformer.apply`, whose * machinery already transposes the two sides. This repeats until neither side reports a fix, so fixes * must converge to a fixpoint (a well-behaved RDT applies them idempotently). All of this fix traffic * is applied with the {@link correctionOrigin} origin, while an ordinary mapped change carries the binding * itself — see the "Origins" section of {@link RDT}. * * On creation a binding first **synchronizes the initial state**: `a`'s current state (`a.delta`) * is projected through the transformer, and the projection is diffed against `b`'s current state * (`b.delta`); the resulting difference is applied to `b` so it matches `a`'s projection (and any * self-heal correction is applied back to `a`). `a` is the source of truth — pre-existing state on `b` * that `a` does not project to is overwritten. * * Two reference RDTs live in `./rdt/`: * - `./rdt/delta.js` ({@link RDT}) — an in-memory delta whose state is just the accumulated delta. * - `./rdt/dom.js` — a live DOM subtree, observed with a `MutationObserver`, that turns DOM mutations * into deltas and applies incoming deltas back onto the DOM. * * @module delta/rdt */ import * as dt from './transformer.js' import * as delta from './delta.js' import * as mux from '../mutex.js' import * as s from '../schema.js' // Re-export the two reference RDTs this module's doc references, so `bind`, `deltaRDT` and `domRDT` are // reachable from one import. The leaf modules (`lib0/delta/rdt/delta`, `lib0/delta/rdt/dom`) stay // independently importable, so a consumer who wants only one keeps it tree-shakeable. export { deltaRDT } from './rdt/delta.js' export { $domDelta, domRDT } from './rdt/dom.js' /** * @template T * @typedef {import('../schema.js').Schema<T>} Schema */ /** * Abstract interface for a delta-based Replicated Data Type. * * An RDT is an observable that emits a `'delta'` (and, on teardown, a `'destroy'`) event, exposes the * {@link Schema schema} of the deltas it produces via `$delta` (so a {@link Binding} can initialize a * transformer for it), exposes its current state as a delta via the `delta` getter, and accepts foreign * deltas via `applyDelta`. * * `applyDelta(d, origin)` applies `d` and then, if the change would violate the RDT's own invariants, * applies a **fix** of its own (e.g. reversing part of `d`, or inserting missing content) and returns * that fix — `null` when none was needed. The fix is a regular change on this RDT, so a {@link Binding} * maps it onto the other side just like any other change. `applyDelta` also emits the *effective* change * (`d` together with the fix) on the `'delta'` channel. * * ## Origins * * Every `'delta'` event carries a second argument, `origin` — an opaque `any` value (modelled after * {@link https://docs.yjs.dev/api/transactions Yjs transaction origins}) that identifies **where the * change came from**. Whoever produces the change sets it, and it is usually the producing instance * itself: a communication provider, an editor binding, or the {@link Binding} that mapped the change * over from the other side. A change an RDT observes locally (e.g. a `MutationObserver` firing on a DOM * edit) uses that RDT itself as the origin. * * Origins let a listener tell **its own** changes apart from foreign ones so it can skip the ones it * already knows about — a change it produced is not looped back to where it came from. The canonical use * is a network provider: it tags every remote update it applies with itself (`rdt.applyDelta(update, * this)`) and then ignores `'delta'` events whose `origin === this`, so it never re-broadcasts an update * it just received. The `origin` argument to `applyDelta` is optional and defaults to `null` (an * anonymous/local change); the emitted event always carries whatever value was supplied. * * A {@link Binding} distinguishes the two kinds of changes it produces. An ordinary change mapped * over from the other side carries the binding itself as its origin. A change that adjusts a side's * own earlier change after the fact — a fix returned by the opposite RDT transformed back, or a * transformer self-heal — carries {@link correctionOrigin}, so a consumer that applied a delta can recognise * that (part of) its change was reverted or amended. * * ## `Delta` vs `DeltaBuilder` * * The interface is parameterized by a {@link import('./delta.js').DeltaConf DeltaConf} (like a * {@link import('./transformer.js').Transformer transformer}). Almost everything it exposes is the * *read* form — {@link import('./delta.js').Delta Delta} — because those values are **shared** and must * not be mutated in place: `$delta` is the delta schema, the `delta` getter returns a state snapshot, * `applyDelta` reads a change into the RDT, and the `'delta'` event payload is broadcast to every * listener. The one *owned, mutable* form — {@link import('./delta.js').DeltaBuilder DeltaBuilder} — is * the fix `applyDelta` returns to the {@link Binding}. The binding never mutates a shared read delta: it * {@link import('./delta.js').cloneDeep deep-clones} every change into a private builder before the * transformer rebases it (transformers manipulate deltas in place — see {@link Binding}). * * @template {import('./delta.js').DeltaConf} Conf * @typedef {import('../observable.js').ObservableV2<{ delta: (delta: delta.Delta<Conf>, origin: any) => void, destroy: (rdt: RDT<Conf>) => void }> & { * $delta: Schema<delta.Delta<Conf>>, * delta: delta.Delta<Conf>, * applyDelta: (delta: delta.Delta<Conf>, origin?: any) => delta.DeltaBuilder<Conf> | null, * destroy: () => void * }} RDT */ /** * Schema guard for any {@link RDT}. RDTs are **duck-typed** — they have several independent * implementations (`deltaRDT`, `domRDT`, …) and no common constructor — so this matches by shape * rather than by `instanceof`: an object exposing a `$delta` {@link Schema}, an `applyDelta` method, * and the observable `on`/`destroy` channel. The (possibly expensive) `delta` getter is not invoked. * * @type {Schema<RDT<any>>} */ export const $rdt = /** @type {Schema<RDT<any>>} */ (/* @__PURE__ */ s.$custom(o => o != null && s.$$schema.check(o.$delta) && typeof o.applyDelta === 'function' && typeof o.on === 'function' && typeof o.destroy === 'function' )) /** * The {@link RDT origin} carried by every change a {@link Binding} applies as an after-the-fact * adjustment to a side's own change: a fix returned by the opposite RDT's `applyDelta` transformed back * onto the side that produced the original change, a transformer self-heal (including during the * initial-state sync), and every later round of the fix-convergence loop (fixes of fixes). A "fix" * covers invariant corrections as well as rebased concurrent edits (see `./rdt/dom.js`). An ordinary * mapped change carries the binding itself instead — see the "Origins" section of {@link RDT}. The * value is a plain string, so `origin === 'correction'` works without importing this constant. * * @type {'correction'} */ export const correctionOrigin = 'correction' /** * Propagate a pair of concurrent changes between the two sides of `binding` until they converge. * * `ta` / `tb` are changes that have ALREADY been applied to `a` / `b` (an incoming `'delta'`, or a fix * returned by a previous `applyDelta`). Each is mapped onto the other side via the transformer — * `apply` rebases the two against each other — and the mapped results are applied, which may yield * further RDT fixes. We repeat until neither side reports a fix. `aOrigin` / `bOrigin` are the * {@link RDT origins} for the first round's applications: the side receiving the ordinary mapped * change is handed the `binding` itself, while the side that produced the triggering change only ever * receives adjustments (a transformer self-heal), tagged {@link correctionOrigin}. After the first round * every applied change derives from a fix, so both origins become {@link correctionOrigin}. * * `ta`/`tb` are shared read deltas (an event payload, or a fix that was also written into the producing * RDT's state), so they are {@link delta.cloneDeep deep-cloned} into private builders before the * transformer — which rebases its inputs in place — touches them (see {@link Binding}). * * @template {delta.DeltaConf} A * @template {delta.DeltaConf} B * @param {Binding<A,B>} binding * @param {any} ta change already applied on `a` — a shared read delta (event payload or a fix), or `null` * @param {any} tb change already applied on `b` — a shared read delta (event payload or a fix), or `null` * @param {any} [aOrigin] origin for the first-round application onto `a`; defaults to {@link correctionOrigin} * @param {any} [bOrigin] origin for the first-round application onto `b`; defaults to {@link correctionOrigin} */ const propagate = (binding, ta, tb, aOrigin = correctionOrigin, bOrigin = correctionOrigin) => { while ((ta != null && !ta.isEmpty()) || (tb != null && !tb.isEmpty())) { const tres = binding.t.apply(dt.createTransformResult( ta != null ? delta.cloneDeep(ta) : null, tb != null ? delta.cloneDeep(tb) : null )) ta = tres.a != null ? binding.a.applyDelta(tres.a, aOrigin) : null tb = tres.b != null ? binding.b.applyDelta(tres.b, bOrigin) : null aOrigin = correctionOrigin bOrigin = correctionOrigin } } /** * @typedef {object} BindOptions * @property {delta.DiffOptions['compare']} [diffCompare] **Experimental** (may be changed or removed * in a future release). Forwarded as `compare` to {@link delta.diff} when the initial-state sync diffs * `a`'s projection against `b`'s current state, controlling how nodes are paired into `modify` ops. */ /** * Connects two RDTs so that changes on either side are transformed and reflected on the other. * * The {@link dt.Transformer transformer} it drives **manipulates deltas in place** (it splices op lists * and rebases nodes directly, rather than only via `apply`/`rebase`) — a deliberate exception to the * general rule that deltas are edited through `apply`/`rebase`. Consequently every change handed to it * must be a privately-owned, fully-mutable delta, so the binding {@link delta.cloneDeep deep-clones} * each incoming change (which is a shared read delta — an event payload or a snapshot) before the * transformer touches it. See the initial-state sync below and {@link propagate}. * * @template {delta.DeltaConf} A * @template {delta.DeltaConf} B */ export class Binding { /** * @param {RDT<A>} a * @param {RDT<B>} b * @param {dt.TemplateFactory<A,B>} [template] a `$d => Template` factory; defaults to the * {@link dt.id identity} transformer. `bind` injects `a`'s schema, then materializes via `.init()`. * @param {BindOptions} [options] */ constructor (a, b, template = /** @type {dt.TemplateFactory<A,B>} */ (dt.id), options = {}) { /** * @type {dt.Transformer<A,B>} */ this.t = template(a.$delta).init() /** * @type {RDT<A>} */ this.a = a /** * @type {RDT<B>} */ this.b = b this._mux = mux.createMutex() this._achanged = this.a.on('delta', d => this._mux(() => propagate(this, d, null, correctionOrigin, this))) this._bchanged = this.b.on('delta', d => this._mux(() => propagate(this, null, d, this, correctionOrigin))) this.a.on('destroy', this.destroy) this.b.on('destroy', this.destroy) // Sync the initial state. `a` is the source of truth: project its current state through the // transformer (`applyA`) — which also yields any self-heal correction for `a` (`tres.a`) — then // diff the projection against `b`'s current state and apply the difference so `b` ends up // matching `a`'s projection. Any fixes the two sides report are reconciled via `propagate`. // The self-heal onto `a` adjusts a's own state, so it is a `correction`; the diff onto `b` is an // ordinary binding-produced change. // NOTE: `delta.diff` is content-only (it excludes marks), so cursor marks present on `a` at bind // time are NOT transferred to `b` here; marks ride only on subsequent live `applyA`/`applyB`. // Wrapped in the mutex so these `applyDelta` calls don't echo back through the listeners above. this._mux(() => { // `a.delta` is a shared read snapshot (for an in-memory RDT it IS the live state, whose nested // children are frozen `done`), so DEEP-clone it into a private, fully-mutable builder before the // transformer — which rebases its input in place — projects it. A shallow `clone` would still share // those frozen children and corrupt / fail on the live state (see `delta.cloneDeep`). const tres = this.t.applyA(delta.cloneDeep(this.a.delta)) const fa = tres.a ? this.a.applyDelta(tres.a, correctionOrigin) : null // `diff` shares nested children with `tres.b` by default, and `tres.b` is a (possibly stateful) // transformer's output that may still alias its internal state — so `clone` keeps the diff // independent, since it is then applied into `b` (which freezes it) and propagated onward. const diffB = tres.b ? delta.diff(this.b.delta, tres.b, { clone: true, compare: options.diffCompare }) : null const fb = diffB ? this.b.applyDelta(diffB, this) : null propagate(this, fa, fb) }) } /** * Tear the binding down: unsubscribe both sides' `'delta'` and `'destroy'` listeners so changes are no * longer propagated. Call this when you no longer need the two RDTs kept in sync. It is also invoked * automatically when either bound RDT emits `'destroy'`. The RDTs themselves are NOT destroyed. */ destroy = () => { this.a.off('destroy', this.destroy) this.b.off('destroy', this.destroy) this.a.off('delta', this._achanged) this.b.off('delta', this._bchanged) } } /** * Connect two RDTs through a transformer template. Changes on `a` are mapped onto `b` and vice versa. * Without a `template` the {@link dt.id identity} transformer is used, keeping both sides equal. Call * {@link Binding#destroy} on the returned binding to stop syncing (it also self-destroys when either RDT * emits `'destroy'`). * * @template {delta.DeltaConf} A * @template {delta.DeltaConf} B * @param {RDT<A>} a * @param {RDT<B>} b * @param {dt.TemplateFactory<A,B>} [template] a `$d => Template` factory (e.g. `dt.id` or * `$d => dt.renameAttrs($d, {a:'b'})`) * @param {BindOptions} [options] `diffCompare` is **experimental** — see {@link BindOptions}. * @return {Binding<A,B>} */ export const bind = (a, b, template, options = {}) => new Binding(a, b, template, options)