packages/chord/src/delta/README.md
Chord Delta synchronizes JSON values from an authoritative producer to an
ordered replica. It is available from @earendil-works/chord/delta.
A change is represented by an Op: a JSON tuple for replacing, setting,
deleting, updating a string, or splicing an array. Producers use track();
replicas use apply() or applyImmutable().
import { apply, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "", entries: [] as string[] });
let replica = apply(undefined, tracker.flush());
tracker.state.output += "done\n";
tracker.state.entries.push("result");
replica = apply(replica, tracker.flush());
The first flush() returns one operation containing the complete value. Each
later flush returns the operations needed to transform the previously published
value into the current value. It returns [] when the value has not changed.
applyImmutable() copies only containers along changed paths and shares
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
Chord's replicated-state producers mutate a tracked proxy and publish operation
batches; consumers still observe complete immutable values.
flush() produces decoded Op[] with complete paths. This is convenient for
local use but repeats long paths on the wire or disk.
encoder() compresses those paths and returns WireOp[]. decoder() validates
the encoded tuples, restores complete paths, and returns the Op[] required by
apply():
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "" });
const enc = encoder(); // producer side
const dec = decoder(); // consumer side
let replica: { output: string } | undefined;
const send = () => {
const ops = tracker.flush();
const wire = enc.encode(ops); // serialize or store WireOp[] here
const received = dec.decode(wire);
replica = apply(replica, received);
};
Encoding is optional for local application. Never pass WireOp[] directly to
apply().
An encoder and decoder are stateful. Use one pair for each ordered stream. The encoder assigns numeric IDs to paths used across batches; the decoder remembers the corresponding definitions. A complete-value operation resets both path dictionaries, so replay can begin at that batch with a fresh decoder.
Path omission is local to one batch. Numeric path IDs may span batches. Each independently hydrated replicated-state stream needs its own encoder and decoder. Do not share a pair between state members or subscriptions, even when their batches use the same ordered transport connection.
A path is an array of object keys and array indices:
["operation", "message", "content", 0, "text"]
Optrack().flush() returns these tuples, and apply() accepts them:
| Tuple | Meaning |
|---|---|
["r", value] | Replace the complete value. |
["s", path, value] | Set a property or array element. |
["d", path] | Delete an object property. |
["a", path, text] | Append to a string. |
["t", path, count] | Remove UTF-16 code units from a string's front. |
["p", path, index, remove, items] | Splice an array. |
Except for r, every decoded operation carries its complete path. s, d,
a, and t cannot address the root. p may address a root array.
WireOpA PathRef is either an inline path or a non-negative numeric path ID.
WireOp supports the following tuples:
| Tuple | Meaning |
|---|---|
["r", value] | Complete replacement; identical to decoded form. |
["#", id, path] | Define a numeric path ID. |
["s", pathRef, value] | Set with an inline or interned path. |
["s", value] | Set using the previous path in this batch. |
["d", pathRef] | Delete with an inline or interned path. |
["d"] | Delete using the previous path. |
["a", pathRef, text] | Append with an inline or interned path. |
["a", text] | Append using the previous path. |
["t", pathRef, count] | Front-truncate with an inline or interned path. |
["t", count] | Front-truncate using the previous path. |
["p", pathRef, index, remove, items] | Splice with an inline or interned path. |
["p", index, remove, items] | Splice using the previous path. |
For example, adjacent decoded operations on one path:
[
["t", ["output"], 200],
["a", ["output"], "next chunk"],
]
encode to:
[
["t", ["output"], 200],
["a", "next chunk"], // reuses ["output"]
]
When output is used again in a later batch, the encoder defines an ID on its
second explicit use:
[
["#", 0, ["output"]],
["a", 0, "more"],
]
Later batches can use 0 directly until a complete-value operation resets the
dictionary.
Read and mutate tracker.state as a normal object:
tracker.state.status = "running";
tracker.state.settings.theme = "dark";
tracker.state.messages.push(message);
delete tracker.state.retry;
Only the value at flush time is published:
tracker.state.status = "starting";
tracker.state.status = "running";
tracker.flush(); // one set to "running"
Replacing an object or array is valid. Delta compares its properties and elements with the previously published value:
tracker.state.settings = {
...plainSettings,
theme: "dark",
};
Unchanged properties produce no operations. Changed nested strings and arrays still use string and splice operations.
Appending text produces an a operation:
tracker.state.output += "next line\n";
Moving a bounded text window forward produces t followed by a when the old
suffix matches the new prefix:
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
An unrelated replacement produces s.
Use normal array methods:
tracker.state.messages.push(first);
tracker.state.messages.push(second);
tracker.state.messages.splice(3, 1, replacement);
All push() calls before one flush produce one tail p. Changes to older
elements remain separate, regardless of whether they happen before or after the
pushes. Changes to newly pushed elements are included in the pushed values.
Front or middle insertion, removal, sorting, reversing, fill(), and
copyWithin() are supported. A structural change combined with edits to elements
whose indices moved may compare and publish the retained suffix positionally:
tracker.state.items.shift();
tracker.state.items[0].status = "changed";
// A shift followed by push in the same flush has the same issue.
The emitted data can then scale with the retained suffix, or with the complete array, rather than only the changed element. When batching is under your control, flush the structural change before editing elements at their new indices.
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
length creates explicit null elements; decreasing it removes elements.
fill() and copyWithin() keep normal JavaScript reference semantics. Do not use
them to place one mutable object at multiple live paths.
Optional object properties use absence. They do not require null:
type Settings = { label?: string; count: number };
const tracker = track<Settings>({ count: 0 });
tracker.state.label = "active";
tracker.state.label = undefined; // produces d
// `delete tracker.state.label` is equivalent
undefined is accepted only as assignment syntax for deleting an object
property. It is not a JSON value. Initial and assigned objects cannot contain own
undefined values, and array elements cannot be undefined. Use null when an
array position or explicit empty value must remain present.
The object passed to track() becomes tracker-owned. The same applies to objects
later assigned into state or inserted into arrays.
After insertion, a retained reference may be read but must not be mutated or inserted at another live location. The tracker relies on this ownership rule; it does not recursively validate values or detect aliases:
const item = { status: "new" };
tracker.state.item = item;
tracker.state.item.status = "ready"; // supported: tracked mutation
item.status = "broken"; // unsupported: bypasses tracking
tracker.state.other = item; // unsupported: one object at two live paths
The same restriction applies across separate array calls:
tracker.state.items.push(item);
tracker.state.items.push(item); // unsupported alias
Use distinct objects when values must appear at multiple paths. Perform
mutations through tracker.state; do not put a proxy read from tracker.state
back into tracked state.
Tracked state must be a mutable JSON tree:
null, arrays, and plain objects;Map, or Set.Do not keep a child proxy across an array operation that changes indices. Read the child again from its new index.
tracker.flush(); // publish changes since the previous flush
tracker.rebase(); // make the next flush a complete replacement
tracker.discard(); // accept current changes without publishing them
tracker.state = replacement; // replace the root; next flush is complete
discard() intentionally prevents current changes from reaching existing
replicas. Use it only when those replicas do not need the discarded changes.
apply() adopts object and array payloads from its input batch. Do not freeze a
batch before applying it, and do not apply one in-memory batch to multiple
mutable replicas unless each replica owns that batch. A serialized and decoded
batch is already detached. applyImmutable() instead treats its previous value
and operation payloads as immutable, so one batch can safely fan out in-process.
A decode(), apply(), or applyImmutable() error terminates that stream.
Discard its decoder and replica, then recover from a later base batch. apply()
is not transactional; operations before the failing operation may already have
changed the replica.
__proto__, constructor, or prototype can be read
and serialized, but cannot be mutated through that key. Replace the nearest
ordinarily named parent instead.