feat(chord): replace delta tracker with operation log

This commit is contained in:
Mario Zechner 2026-09-17 16:36:37 +02:00
parent 7e19507681
commit 2c995acf44
5 changed files with 768 additions and 274 deletions

View file

@ -38,10 +38,10 @@ The design has a few connected pieces:
stream owns independent path-codec state. Replicas become unready on disconnect
or replacement until they are rehydrated.
- **Delta tracking** derives compact operations from tracked plain JSON at
flush time. It preserves string append/front-truncation and array-append
behavior without retaining mutation history, supports durable base batches,
and validates untrusted operations as they are applied.
- **Delta tracking** records and coalesces operations over tracked plain JSON.
It preserves common string and array operations, supports durable base
batches, and validates untrusted operations as they are applied. Batches
guarantee convergence but are not canonical or necessarily minimal.
- **Remote service sources** advertise services available outside a facet host
and open bindings for the services its facets require. Bindings carry logical
@ -193,10 +193,10 @@ receiving host.
To reload, load a candidate, pass its facets to `FacetHost.reload()`, dispose the
candidate on failure, and dispose the retired `LoadedFacets` only after a
successful cutover. The host activates and validates the candidate while the old
providers remain routed, then replaces each singleton directly without an
unavailable interval. Stable service handles therefore do not become disconnected
during an ordinary reload. Keyed instances
successful cutover. The host activates and validates the candidate while the
currently active providers remain routed, then replaces each singleton directly
without an unavailable interval. Stable service handles therefore do not become
disconnected during an ordinary reload. Keyed instances
remain incarnation-specific and replacements receive fresh generations. The
bundler writes a complete temporary directory before replacing the previous
output, so loaders do not observe partially built generations.

View file

@ -19,8 +19,10 @@ 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.
later flush returns operations whose application transforms the previously
published value into the current value. It returns `[]` when no tracked mutation
is pending, but a mutation window that restores its starting value may still
produce a redundant batch.
`applyImmutable()` copies only containers along changed paths and shares
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
@ -151,7 +153,7 @@ tracker.state.messages.push(message);
delete tracker.state.retry;
```
Only the value at flush time is published:
Operations are coalesced within a flush window when doing so is cheap and safe:
```ts
tracker.state.status = "starting";
@ -159,8 +161,12 @@ tracker.state.status = "running";
tracker.flush(); // one set to "running"
```
The operation sequence is not canonical. Equivalent changes may use different
verbs, and mutations that cancel can still produce a nonempty batch. Consumers
must depend on the resulting value, not the exact tuples or their minimality.
Replacing an object or array is valid. Delta compares its properties and elements
with the previously published value:
with the outgoing value at assignment time:
```ts
tracker.state.settings = {
@ -180,8 +186,8 @@ 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:
Moving a bounded text window forward produces `t` followed by `a` when the
previous suffix matches the new prefix:
```ts
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
@ -199,23 +205,16 @@ 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.
Adjacent `push()` calls are normally coalesced into one tail `p`. Intervening
operations may keep them separate to preserve ordering. Changes to older
elements remain separate, and changes to newly pushed elements may be folded
into the pushed values when no structural operation intervenes.
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:
```ts
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.
Front or middle insertion and removal are recorded directly. Edits before and
after an index-changing operation remain ordered against the array generation
they addressed. Sorting, reversing, `fill()`, and `copyWithin()` emit a snapshot
of the affected array; repeated whole-array mutators can therefore produce a
redundant snapshot even when their combined result restores the prior value.
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
`length` creates explicit `null` elements; decreasing it removes elements.
@ -223,6 +222,61 @@ Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
them to place one mutable object at multiple live paths.
### Large mutation windows
#### Build once, assign once
Every object or array assignment is diffed immediately. Do not repeatedly assign
large intermediate values before one flush:
```ts
// Avoid: traverses every intermediate tree.
for (const frame of frames) tracker.state.view = render(frame);
// Prefer: only the final tree crosses the tracked boundary.
const nextView = frames.reduce((view, frame) => renderInto(view, frame), initialView);
tracker.state.view = nextView;
```
For a few changes, mutate the leaves directly:
```ts
for (const update of updates) {
tracker.state.view.rows[update.index]!.status = update.status;
}
```
#### Do not cancel whole-array mutators
`sort()`, `reverse()`, `fill()`, and `copyWithin()` record snapshots. Cancelling
them still publishes the final snapshot:
```ts
// Avoid: final value is unchanged, but a snapshot may still be sent.
tracker.state.items.reverse();
tracker.state.items.reverse();
// Prefer: decide before mutating tracked state.
if (needsReverse) tracker.state.items.reverse();
```
#### Publish large inserts and edit sets in chunks
Pending inserted values are cloned for replica ownership. Very large unflushed
pushes therefore temporarily retain both the live values and their operation
payloads. Long operation/path histories may also collapse to a complete base
batch, increasing snapshot and wire cost.
```ts
for (const chunk of chunks(items, 1_000)) {
tracker.state.items.push(...chunk);
replica = apply(replica, tracker.flush()); // send each batch in a real producer
}
```
Use the same pattern for large sets of unrelated edits: apply a bounded chunk,
publish it, then continue.
### Optional properties
Optional object properties use absence. They do not require `null`:
@ -292,6 +346,14 @@ 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.
`flush()` guarantees convergence, not a minimal or canonical diff. Any nonempty
batch advances replicated-state sequence numbers and notifies subscribers, even
if applying it leaves the value deeply equal to the previous revision. To bound
pending operation and path history, a sufficiently long mutation window may
collapse to a complete base batch automatically. This bounds accumulated log
metadata, not payload bytes or peak allocation, and trades one full-value
snapshot for an additional recovery point.
`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

View file

@ -3,7 +3,7 @@ import type { JsonValue } from "../types.ts";
export type { JsonValue } from "../types.ts";
// ─────────────────────────────────────────────────────────────────────────────
// chord/delta — flush-time change tracking over plain JSON.
// chord/delta — operation-log change tracking over plain JSON.
//
// Depends on nothing else in the harness. Session storage, the runtime and the
// facet host consume it; keep the arrows pointing that way.
@ -20,9 +20,9 @@ export type PathRef<P extends Path = Path> = P | number;
* Tuples are the form — in memory, on the wire, on disk.
*
* `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target
* the root: the type forbids it. `p` may, and only because a tracked value can
* itself be an array — but a `p` that replaces its entire target is normalised to
* `r`/`s` at flush time, so a root `p` is always a partial modification.
* the root: the type forbids it. `p` may, because a tracked value can itself be
* an array. Operation shape is not canonical: an array may be emptied by either
* a replacement or a root splice.
*
* `Op` knows nothing about the path dictionary. Interning, id references and
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
@ -130,17 +130,21 @@ const isObj = (value: unknown): value is object => value !== null && typeof valu
const cloneJson = <T extends JsonValue>(value: T): T => {
if (!isObj(value)) return value;
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const [key, child] of Object.entries(value)) {
Object.defineProperty(result, key, {
value: cloneJson(child),
writable: true,
enumerable: true,
configurable: true,
});
const nullProto = Object.getPrototypeOf(value) === null;
const result = (nullProto ? Object.create(null) : {}) as Record<string, JsonValue>;
for (const key of Object.keys(value as Record<string, JsonValue>)) {
const child = (value as Record<string, JsonValue>)[key]!;
const copied = isObj(child) ? cloneJson(child) : child;
if (!nullProto && key in result) {
Object.defineProperty(result, key, {
value: copied,
writable: true,
enumerable: true,
configurable: true,
});
} else {
result[key] = copied;
}
}
return result as T;
};
@ -151,10 +155,6 @@ const norm = (target: object, key: string | symbol): Seg | symbol =>
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
const MISSING = Symbol("missing");
type MaybeJson = JsonValue | typeof MISSING;
type ArrayDirty = { kind: "append"; start: number } | { kind: "diff" } | { kind: "replace" };
type DirtyNode = { valueDirty?: true; array?: ArrayDirty; children: Map<Seg, DirtyNode> };
const dirtyNode = (): DirtyNode => ({ children: new Map() });
const spliceItems = (target: unknown[], index: number, remove: number, items: JsonValue[]): JsonValue[] => {
const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]) as JsonValue[];
@ -186,11 +186,6 @@ const jsonEqual = (left: JsonValue, right: JsonValue): boolean => {
return true;
};
const ownValue = (value: JsonValue, segment: Seg): MaybeJson => {
if (!isObj(value) || !Object.hasOwn(value, segment)) return MISSING;
return (value as Record<Seg, JsonValue>)[segment]!;
};
const emitSet = (path: Path, value: JsonValue, out: Op[]): void => {
const snapshot = cloneJson(value);
if (path.length === 0) out.push(["r", snapshot]);
@ -312,195 +307,453 @@ function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: nu
}
}
const walkDirty = (before: JsonValue, after: JsonValue, node: DirtyNode, path: Path, scan: number, out: Op[]): void => {
if (node.valueDirty) {
diffValue(before, after, path, scan, out);
return;
}
if (node.array !== undefined) {
if (node.array.kind === "replace") {
if (!jsonEqual(before, after)) emitSet(path, after, out);
return;
}
if (!Array.isArray(before) || !Array.isArray(after) || node.array.kind === "diff") {
diffValue(before, after, path, scan, out);
return;
}
const start = node.array.start;
if (before.length !== start || after.length < start) {
diffValue(before, after, path, scan, out);
return;
}
for (const [segment, child] of node.children) {
if (typeof segment !== "number" || segment >= start) continue;
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
} else if (isObj(previous) && isObj(current)) {
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
} else {
diffValue(previous, current, [...path, segment], scan, out);
}
}
const items = after.slice(start);
if (items.length > 0) out.push(["p", [...path], start, 0, cloneJson(items)]);
return;
}
for (const [segment, child] of node.children) {
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
if (!isObj(previous) || !isObj(current)) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
}
};
/**
* Bring `baseline` up to `root` along the dirty paths by sharing references.
* Returns false — having changed nothing — if any dirty node is an array
* change other than a pure append, so the caller can replay ops instead.
* Cloning a whole array there is O(n) per flush; replay is O(changes).
*
* Strings are immutable, so root's `after` is shared outright, and sharing it
* also means the next flush compares against a flat string rather than a cons.
* Objects are cloned because root keeps mutating them.
*/
const syncBaseline = (baseline: JsonValue, root: JsonValue, node: DirtyNode): boolean => {
if (!canSync(node)) return false;
syncInto(baseline, root, node);
return true;
};
const canSync = (node: DirtyNode): boolean => {
if (node.array !== undefined && node.array.kind !== "append") return false;
for (const child of node.children.values()) if (!canSync(child)) return false;
return true;
};
const syncInto = (baseline: JsonValue, root: JsonValue, node: DirtyNode): void => {
const parent = baseline as Record<string | number, JsonValue>;
if (node.array?.kind === "append" && Array.isArray(baseline) && Array.isArray(root)) {
const start = node.array.start;
for (const [index, child] of node.children) {
if (typeof index === "number" && index < start) syncChild(parent, root, index, child);
}
for (let i = start; i < root.length; i++) {
baseline.push(isObj(root[i]) ? cloneJson(root[i] as JsonValue) : (root[i] as JsonValue));
}
return;
}
for (const [segment, child] of node.children) syncChild(parent, root, segment, child);
};
const syncChild = (
parent: Record<string | number, JsonValue>,
root: JsonValue,
segment: Seg,
child: DirtyNode,
): void => {
const current = ownValue(root, segment);
const previous = ownValue(parent as JsonValue, segment);
if (current === MISSING) {
if (Array.isArray(parent)) parent.splice(segment as number, 1);
else delete parent[segment];
return;
}
if (child.valueDirty || !isObj(current) || !isObj(previous) || Array.isArray(current) !== Array.isArray(previous)) {
parent[segment] = isObj(current) ? cloneJson(current as JsonValue) : (current as JsonValue);
return;
}
syncInto(previous as JsonValue, current as JsonValue, child);
};
const cloneOp = (op: Op): Op => {
switch (op[0]) {
case "r":
return ["r", cloneJson(op[1])];
case "s":
return ["s", op[1], cloneJson(op[2])];
case "p":
return ["p", op[1], op[2], op[3], cloneJson(op[4])];
default:
return op;
}
};
export function track<T extends object>(root: T, options: TrackerOptions = {}): Tracker<T> {
const scan = options.maxOverlapScan ?? 65_536;
let pending = dirtyNode();
// Mutations are recorded directly. The active trie supports coalescing while
// retired child generations preserve dominance across array reindexing.
type Slot = {
op: Op;
dead: boolean;
order: number;
index: number;
str?: { anchor: string; value: string };
};
type LogNode = {
slots?: Slot[];
kids?: Map<Seg, LogNode>;
retiredKids?: Map<Seg, LogNode>[];
lastOrder?: number;
};
let log: (Slot | undefined)[] = [];
let trie: LogNode = {};
let nextOrder = 0;
let tombstones = 0;
let liveSlots = 0;
let nodeCount = 1;
let lastAddedSlot: Slot | undefined;
let hasPending = false;
let baseline: JsonValue | undefined;
let forceBase = true;
const clearPending = (): void => {
pending = dirtyNode();
log = [];
trie = {};
nextOrder = 0;
tombstones = 0;
liveSlots = 0;
nodeCount = 1;
lastAddedSlot = undefined;
hasPending = false;
};
const ensureNode = (path: Path): DirtyNode | undefined => {
const logNode = (path: Path): LogNode => {
let at = trie;
for (const segment of path) {
if (!at.kids) at.kids = new Map();
let next = at.kids.get(segment);
if (next === undefined) {
next = {};
at.kids.set(segment, next);
nodeCount++;
}
at = next;
}
return at;
};
const findLogNode = (path: Path): LogNode | undefined => {
let at = trie;
for (const segment of path) {
const next = at.kids?.get(segment);
if (next === undefined) return undefined;
at = next;
}
return at;
};
const compactLog = (): void => {
if (tombstones < 1_024 || tombstones * 2 < log.length) return;
const compacted: Slot[] = [];
for (const slot of log) {
if (slot === undefined) continue;
slot.index = compacted.length;
compacted.push(slot);
}
log = compacted;
tombstones = 0;
};
const killSlot = (slot: Slot): void => {
if (slot.dead) return;
slot.dead = true;
liveSlots--;
if (log[slot.index] === slot) {
log[slot.index] = undefined;
tombstones++;
}
};
const liveSlot = (at: LogNode): Slot | undefined => {
const slots = at.slots;
if (slots === undefined) return undefined;
while (slots.length > 0 && slots[slots.length - 1]!.dead) slots.pop();
if (slots.length === 0) {
at.slots = undefined;
return undefined;
}
return slots[slots.length - 1];
};
const killHere = (at: LogNode): void => {
if (at.slots === undefined) return;
for (const slot of at.slots) killSlot(slot);
at.slots = undefined;
};
const addSlot = (at: LogNode, slot: Slot): void => {
compactLog();
slot.order = nextOrder++;
slot.index = log.length;
at.lastOrder = slot.order;
if (at.slots === undefined) at.slots = [];
at.slots.push(slot);
log.push(slot);
liveSlots++;
lastAddedSlot = slot;
};
const collapsePending = (): void => {
// Long mutation windows can otherwise retain operations and retired path
// generations indefinitely. Fall back to a complete snapshot once either
// history exceeds a bounded coalescing window. Payload bytes and transient
// allocation remain workload-dependent.
if (forceBase || (liveSlots <= 4_096 && nodeCount <= 4_096)) return;
const value = cloneJson(root as unknown as JsonValue);
clearPending();
hasPending = true;
let node = pending;
for (const segment of path) {
if (node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") return undefined;
let child = node.children.get(segment);
if (child === undefined) child = dirtyNode();
else node.children.delete(segment);
node.children.set(segment, child);
node = child;
addSlot(trie, { op: ["r", value], dead: false, order: 0, index: 0 });
};
const killSubtree = (at: LogNode): void => {
killHere(at);
if (at.kids !== undefined) {
for (const child of at.kids.values()) killSubtree(child);
at.kids = undefined;
}
return node;
};
const findNode = (path: Path): DirtyNode | undefined => {
let node = pending;
for (const segment of path) {
const child = node.children.get(segment);
if (child === undefined) return undefined;
node = child;
if (at.retiredKids !== undefined) {
for (const generation of at.retiredKids) {
for (const child of generation.values()) killSubtree(child);
}
at.retiredKids = undefined;
}
return node;
};
const markValue = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined) return;
node.valueDirty = true;
node.array = undefined;
node.children.clear();
const retireKids = (at: LogNode): void => {
if (at.kids === undefined) return;
if (at.retiredKids === undefined) at.retiredKids = [];
at.retiredKids.push(at.kids);
at.kids = undefined;
};
const markArrayAppend = (path: Path, start: number): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
// Deepest live ancestor op carrying a payload we can fold a later write into.
// `s`/`r` carry the whole subtree; `p` carries the items it inserted, so a
// write to one of those indices belongs inside the payload rather than after it.
const foldTarget = (path: Path): { slot: Slot; rest: Path; item?: number } | undefined => {
let at = trie;
let found: { slot: Slot; depth: number; item?: number } | undefined;
let ancestorMax = -1;
for (let depth = 0; depth < path.length; depth++) {
if (found !== undefined && at.lastOrder !== undefined && at.lastOrder > found.slot.order) {
found = undefined;
}
const slot = liveSlot(at);
// A fold is sound only if nothing has been recorded at this path or above it
// since: a later op there (a splice on the same array, a replacement of an
// ancestor) would have to apply after this write, not before it. Writes to
// other branches are irrelevant, which is why this is not "the most recent op".
if (slot !== undefined && slot.order >= ancestorMax && at.lastOrder === slot.order) {
if (slot.op[0] === "s" || slot.op[0] === "r") found = { slot, depth };
else if (slot.op[0] === "p") {
const index = path[depth];
const items = slot.op[4] as JsonValue[];
if (typeof index === "number" && index >= slot.op[2] && index < slot.op[2] + items.length) {
found = { slot, depth: depth + 1, item: index - slot.op[2] };
}
}
}
if (at.lastOrder !== undefined && at.lastOrder > ancestorMax) ancestorMax = at.lastOrder;
const next = at.kids?.get(path[depth]!);
if (next === undefined) break;
at = next;
}
if (found === undefined) return undefined;
return { slot: found.slot, rest: path.slice(found.depth), item: found.item };
};
const foldInto = (container: JsonValue, rest: Path, op: Op): boolean => {
if (rest.length === 0) return false;
let target: JsonValue = container;
for (let index = 0; index < rest.length - 1; index++) {
if (!isObj(target)) return false;
target = (target as Record<Seg, JsonValue>)[rest[index]!]!;
}
if (!isObj(target)) return false;
const key = rest[rest.length - 1]!;
const holder = target as Record<Seg, JsonValue>;
const write = (value: JsonValue): void => {
Object.defineProperty(holder, key, { value, writable: true, enumerable: true, configurable: true });
};
switch (op[0]) {
case "s":
if (key === "__proto__") return false;
write(cloneJson(op[2]));
return true;
case "d":
delete holder[key];
return true;
case "a": {
const value = holder[key];
if (typeof value !== "string") return false;
write(value + op[2]);
return true;
}
case "t": {
const value = holder[key];
if (typeof value !== "string") return false;
write(value.slice(op[2]));
return true;
}
case "p": {
const value = holder[key];
if (!Array.isArray(value)) return false;
spliceItems(value, op[2], op[3], cloneJson(op[4] as JsonValue[]));
return true;
}
default:
return false;
}
};
const recordString = (path: Path, previous: string, value: string): void => {
if (forceBase) return;
hasPending = true;
// a string inside a pending payload belongs in that payload, as for any write
const fold = foldTarget(path);
if (fold !== undefined) {
const op: Op = ["s", path as unknown as NonEmptyPath, value];
if (fold.item !== undefined) {
const items = fold.slot.op[4] as JsonValue[];
if (fold.rest.length === 0) {
items[fold.item] = value;
return;
}
if (foldInto(items[fold.item]!, fold.rest, op)) return;
} else {
const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2];
if (foldInto(payload as JsonValue, fold.rest, op)) return;
}
}
const at = logNode(path);
const live = liveSlot(at);
if (live?.str !== undefined) {
live.str.value = value;
return;
}
if (node.array === undefined) node.array = { kind: "append", start };
if (live !== undefined) {
// a pending set or delete at this path already replaced the value; keep that
// op and carry the new value in it rather than anchoring to it
if (live.op[0] === "s" || live.op[0] === "r") {
live.op = live.op[0] === "r" ? ["r", value] : ["s", live.op[1], value];
return;
}
if (live.op[0] === "d") {
killHere(at);
addSlot(at, {
op: ["s", path as unknown as NonEmptyPath, value],
dead: false,
order: 0,
index: 0,
});
return;
}
// a truncate/append pair from an earlier string diff: both must go
killHere(at);
}
killSubtree(at);
addSlot(at, {
op: ["s", path as unknown as NonEmptyPath, value],
dead: false,
order: 0,
index: 0,
str: { anchor: previous, value },
});
};
const markArrayDiff = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "replace") return;
node.array = { kind: "diff" };
node.children.clear();
const record = (op: Op): void => {
if (forceBase) return;
hasPending = true;
const path = (op[0] === "r" ? [] : (op[1] as Path)) as Path;
const existing = findLogNode(path);
const anchored = existing === undefined ? undefined : liveSlot(existing);
if (anchored?.str !== undefined) {
switch (op[0]) {
case "a":
anchored.str.value += op[2];
return;
case "t":
anchored.str.value = anchored.str.value.slice(op[2]);
return;
case "s":
if (typeof op[2] === "string") {
anchored.str.value = op[2];
return;
}
}
if (existing !== undefined) killSubtree(existing);
} else if (existing !== undefined && (op[0] === "s" || op[0] === "d" || op[0] === "r")) {
// A replacement absorbed into an ancestor payload must still invalidate
// operations already recorded at and below its destination.
killSubtree(existing);
}
if (path.length > 0) {
const fold = foldTarget(path);
if (fold !== undefined) {
if (fold.item !== undefined) {
const items = fold.slot.op[4] as JsonValue[];
if (fold.rest.length === 0) {
if (op[0] === "s") {
items[fold.item] = cloneJson(op[2]);
return;
}
} else if (foldInto(items[fold.item]!, fold.rest, op)) return;
} else {
const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2];
if (foldInto(payload as JsonValue, fold.rest, op)) return;
}
}
}
const at = logNode(path);
const live = liveSlot(at);
if (live !== undefined) {
const previous = live.op;
if (op[0] === "a" && previous[0] === "a") {
live.op = ["a", previous[1], previous[2] + op[2]];
return;
}
if (op[0] === "a" && (previous[0] === "s" || previous[0] === "r")) {
const value = previous[0] === "r" ? previous[1] : previous[2];
if (typeof value === "string") {
live.op = previous[0] === "r" ? ["r", value + op[2]] : ["s", previous[1], value + op[2]];
return;
}
}
if (op[0] === "t" && (previous[0] === "s" || previous[0] === "r")) {
const value = previous[0] === "r" ? previous[1] : previous[2];
if (typeof value === "string") {
const cut = value.slice(op[2]);
live.op = previous[0] === "r" ? ["r", cut] : ["s", previous[1], cut];
return;
}
}
if (op[0] === "p" && previous[0] === "p") {
const previousItems = previous[4] as JsonValue[];
// These rewrites are sound only for adjacent recorded operations. A
// tombstoned operation remains a barrier through lastAddedSlot.
if (
previous[3] === 0 &&
op[3] === 0 &&
previous[2] + previousItems.length === op[2] &&
lastAddedSlot === live
) {
const items = op[4] as JsonValue[];
for (let index = 0; index < items.length; index++) previousItems.push(items[index]!);
return;
}
if (
previous[3] === 0 &&
lastAddedSlot === live &&
op[2] >= previous[2] &&
op[2] + op[3] <= previous[2] + previousItems.length
) {
spliceItems(previousItems, op[2] - previous[2], op[3], op[4] as JsonValue[]);
if (previousItems.length === 0 && previous[3] === 0) killSlot(live);
return;
}
if (
op[3] > 0 &&
(op[4] as JsonValue[]).length === 0 &&
previousItems.length > 0 &&
lastAddedSlot === live
) {
const from = op[2] - previous[2];
if (from >= 0 && from + op[3] === previousItems.length) {
previousItems.length = from;
if (previousItems.length === 0 && previous[3] === 0) killSlot(live);
return;
}
}
}
if (op[0] === "s" || op[0] === "d" || op[0] === "r") killHere(at);
}
if (op[0] === "s" || op[0] === "r" || op[0] === "d") {
// Replacements dominate all earlier descendants, including generations
// detached by array splices.
killSubtree(at);
} else if (op[0] === "p") {
// Splices preserve earlier writes but form a barrier for later folding.
retireKids(at);
}
addSlot(at, { op, dead: false, order: 0, index: 0 });
};
const markArrayReplace = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty) return;
node.array = { kind: "replace" };
node.children.clear();
};
const appendStart = (path: Path): number | undefined => {
const array = findNode(path)?.array;
return array?.kind === "append" ? array.start : undefined;
// Local diff, used when a whole container is assigned: keeps op quality
// without a baseline by comparing the outgoing value with the incoming one at
// the moment of the write. String leaves go through recordString so every
// string path keeps a single anchored slot; otherwise a later write to the
// same string would have to supersede ops whose starting value it no longer
// knows.
const diffInto = (before: JsonValue, after: JsonValue, at: Seg[]): void => {
if (forceBase) return;
hasPending = true;
if (before === after) return;
if (typeof before === "string" && typeof after === "string") {
recordString(at.slice() as Path, before, after);
return;
}
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
for (let index = 0; index < after.length; index++) {
at.push(index);
diffInto(before[index]!, after[index]!, at);
at.pop();
}
return;
}
if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
const beforeObject = before as Record<string, JsonValue>;
const afterObject = after as Record<string, JsonValue>;
const beforeKeys = Object.keys(beforeObject);
const afterKeys = Object.keys(afterObject);
if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {
record(["s", at.slice() as unknown as NonEmptyPath, cloneJson(after)]);
return;
}
for (const key of afterKeys) {
at.push(key);
if (Object.hasOwn(beforeObject, key)) diffInto(beforeObject[key]!, afterObject[key]!, at);
else record(["s", at.slice() as unknown as NonEmptyPath, cloneJson(afterObject[key]!)]);
at.pop();
}
for (const key of beforeKeys) {
if (!Object.hasOwn(afterObject, key)) record(["d", [...at, key] as unknown as NonEmptyPath]);
}
return;
}
// arrays of differing length, and everything else: chord's own diff
const out: Op[] = [];
diffValue(before, after, at as Path, scan, out);
for (const op of out) record(op);
};
const guard = (segment: Seg | symbol): Seg => {
@ -541,47 +794,51 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
switch (key) {
case "push": {
const items = adoptItems(args);
if (items.length > 0) markArrayAppend(path, before);
if (items.length > 0) record(["p", [...path], before, 0, cloneJson(items)]);
spliceItems(target, before, 0, items);
result = target.length;
break;
}
case "unshift": {
const items = adoptItems(args);
if (items.length > 0) markArrayDiff(path);
if (items.length > 0) record(["p", [...path], 0, 0, cloneJson(items)]);
spliceItems(target, 0, 0, items);
result = target.length;
break;
}
case "pop":
if (before > 0) {
const start = appendStart(path);
if (start === undefined || before - 1 < start) markArrayDiff(path);
}
if (before > 0) record(["p", [...path], before - 1, 1, []]);
result = Reflect.apply(Array.prototype.pop, target, args);
break;
case "shift":
if (before > 0) markArrayDiff(path);
if (before > 0) record(["p", [...path], 0, 1, []]);
result = Reflect.apply(Array.prototype.shift, target, args);
break;
case "splice": {
const items = adoptItems(args.slice(2));
const { index, remove } = spliceRange(before, args);
if (remove > 0 || items.length > 0) {
const start = appendStart(path);
if (index === 0 && remove === before) markArrayReplace(path);
else if (start !== undefined && index >= start) {
// The final append payload includes all tail edits.
} else if (index === before && remove === 0) markArrayAppend(path, before);
else markArrayDiff(path);
// a splice that clears the whole array is a replacement of it
if (index === 0 && remove === before) {
if (path.length === 0) record(["r", cloneJson(items as JsonValue)]);
else record(["s", [...path] as unknown as NonEmptyPath, cloneJson(items as JsonValue)]);
} else record(["p", [...path], index, remove, cloneJson(items)]);
}
result = spliceItems(target, index, remove, items);
break;
}
default:
markArrayDiff(path);
default: {
result = Reflect.apply(Array.prototype[key as "sort"], target, args);
if (path.length === 0) record(["r", cloneJson(target as unknown as JsonValue)]);
else
record([
"s",
[...path] as unknown as NonEmptyPath,
cloneJson(target as unknown as JsonValue),
]);
}
}
collapsePending();
if (key === "pop") childProxies.delete(String(before - 1));
else if (key !== "push") childProxies.clear();
return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? proxy : result;
@ -619,16 +876,19 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
return Reflect.set(target, key, value);
}
if (next < before) {
const start = appendStart(path);
if (next === 0) markArrayReplace(path);
else if (start === undefined || next < start) markArrayDiff(path);
if (next === 0) {
if (path.length === 0) record(["r", []]);
else record(["s", [...path] as unknown as NonEmptyPath, []]);
} else record(["p", [...path], next, before - next, []]);
Reflect.set(target, key, next);
childProxies.clear();
} else if (next > before) {
markArrayAppend(path, before);
target.length = next;
target.fill(null, before);
const grown = new Array(next - before).fill(null) as JsonValue[];
record(["p", [...path], before, 0, grown]);
}
collapsePending();
return true;
}
@ -643,25 +903,36 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
if (Array.isArray(target)) {
throw new TypeError("undefined would create a sparse array; use splice instead");
}
markValue(at);
if (Object.hasOwn(target, key)) record(["d", at]);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
const deleted = Reflect.deleteProperty(target, key);
if (deleted) collapsePending();
return deleted;
}
const previous = (target as Record<string | symbol, unknown>)[key];
if (previous === value) return true;
const cached = childProxies.get(key);
if (cached !== undefined && cached.target === previous && cached.proxy === value) return true;
if (Array.isArray(target)) {
const index = segment as number;
if (index === target.length) markArrayAppend(path, target.length);
else {
const start = appendStart(path);
if (start === undefined || index < start) markValue(at);
}
} else markValue(at);
if (Array.isArray(target) && (segment as number) === target.length) {
record(["p", [...path], target.length, 0, [cloneJson(value as JsonValue)]]);
} else if (isObj(previous) && isObj(value)) {
// whole-container assignment: diff locally so a producer that rebuilds
// its partial each frame still emits appends rather than replacements
diffInto(previous as JsonValue, value as JsonValue, [...path, segment] as Seg[]);
} else if (typeof previous === "string" && typeof value === "string") {
// A string path keeps an anchor: the value it had at the first write in
// this window. Every later write updates the anchored value, and flush
// diffs anchor -> final once. That gives the same truncate/append pair a
// baseline diff would, without keeping a baseline for the whole document.
recordString([...path, segment] as Path, previous, value);
} else {
record(["s", at, cloneJson(value as JsonValue)]);
}
childProxies.delete(key);
return Reflect.set(target, key, value);
const updated = Reflect.set(target, key, value);
if (updated) collapsePending();
return updated;
},
deleteProperty(target, key) {
@ -671,9 +942,11 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
if (typeof segment !== "number") throw new UnsafePathError(segment);
throw new TypeError("delete would create a sparse array; use splice instead");
}
markValue([...path, segment]);
if (Object.hasOwn(target, key)) record(["d", [...path, segment] as unknown as NonEmptyPath]);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
const deleted = Reflect.deleteProperty(target, key);
if (deleted) collapsePending();
return deleted;
},
defineProperty() {
@ -708,7 +981,6 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
clearPending();
root = next;
state = wrap(root, []);
baseline = undefined;
forceBase = true;
},
rebase() {
@ -716,31 +988,29 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
forceBase = true;
},
get dirty() {
// conservative: true if anything was written since the last flush, even
// if the writes cancelled out
return forceBase || hasPending;
},
discard() {
baseline = cloneJson(root as unknown as JsonValue);
clearPending();
},
flush() {
if (forceBase) {
const value = cloneJson(root as unknown as JsonValue);
baseline = cloneJson(root as unknown as JsonValue);
forceBase = false;
clearPending();
return [["r", value]];
}
if (!hasPending || baseline === undefined) return [];
if (!hasPending) return [];
const out: Op[] = [];
walkDirty(baseline, root as unknown as JsonValue, pending, [], scan, out);
// Advance the baseline by SHARING references from root where that is
// cheap and exact — scalars, strings, and array appends. Replaying the
// ops rebuilds every touched string via slice + concat: two window-sized
// allocations per flush and a cons the next flush must flatten. For
// anything the sync cannot express cheaply (a non-append array change),
// it declines and the original replay runs unchanged.
if (!syncBaseline(baseline as JsonValue, root as unknown as JsonValue, pending)) {
if (out.length > 0) baseline = apply(baseline, out.map(cloneOp));
for (const slot of log) {
if (slot === undefined || slot.dead) continue;
if (slot.str !== undefined) {
diffValue(slot.str.anchor, slot.str.value, slot.op[1] as Path, scan, out);
continue;
}
out.push(slot.op);
}
clearPending();
return out;

View file

@ -124,6 +124,36 @@ describe("tracker: intent", () => {
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("updates an anchored string through a later container replacement", () => {
const initial = { xs: [{ k: "a" }] };
const t = track(structuredClone(initial));
t.flush();
t.state.xs[0]!.k += "b";
t.state.xs = [{ k: "abc" }, { k: "z" }];
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("preserves string history across a null-prototype container replacement", () => {
const initial = { value: { text: "a" } };
const t = track(structuredClone(initial));
t.flush();
const replacement = Object.assign(Object.create(null) as { text: string }, { text: "ab" });
t.state.value = replacement;
t.state.value.text = "abc";
const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]);
expect(applyImmutable(structuredClone(initial), ops)).toEqual(JSON.parse(JSON.stringify(t.state)));
});
it("front-truncates a pending string set from the correct end", () => {
const initial = { xs: [0] as Array<number | string> };
const t = track(structuredClone(initial));
t.flush();
t.state.xs[0] = "abc";
t.state.xs = ["bc", 0];
const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]);
expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state);
});
it("emits nothing when a replacement is deeply equal", () => {
const t = track({ value: { nested: [1, { text: "same" }] } });
t.flush();
@ -261,6 +291,53 @@ describe("tracker: root ops", () => {
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("drops detached element writes when a root array is replaced", () => {
const initial = [{ k: "a" }, { k: "b" }];
const t = track(structuredClone(initial));
t.flush();
t.state[0]!.k += "x";
t.state.unshift({ k: "head" });
t.state.splice(0, t.state.length, { k: "final" });
const ops = t.flush();
expect(ops[0]?.[0]).toBe("r");
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("keeps nested writes ordered across an inserted array reindex", () => {
const initial: number[][] = [];
const t = track(structuredClone(initial));
t.flush();
t.state.push([10, 20]);
t.state[0]!.shift();
t.state[0]![0] = 30;
const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]);
expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state);
});
it.each([
[
"replacement",
(state: JsonValue[]) => {
state[0] = 0;
},
],
[
"clear",
(state: JsonValue[]) => {
(state[0] as JsonValue[]).length = 0;
},
],
] as const)("invalidates nested operations folded into an inserted payload on %s", (_name, replace) => {
const initial: JsonValue[] = [];
const t = track(structuredClone(initial));
t.flush();
t.state.push([]);
(t.state[0] as JsonValue[]).push(1);
replace(t.state);
const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]);
expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state);
});
it("round-trips deterministic element mutations across reindexing splices", () => {
let seed = 0x1234abcd;
const next = (): number => {
@ -480,7 +557,7 @@ describe("the first flush", () => {
expect(t.flush()).toEqual([]);
});
it("discard accepts pending changes into the local baseline", () => {
it("discard accepts pending changes without publishing them", () => {
const t = track({ x: 0, y: 0 });
t.flush();
t.state.x = 1;
@ -568,9 +645,9 @@ describe("apply and fan-out", () => {
});
});
describe("flush-time minimization", () => {
// Mutations only mark dirty paths. Flush compares the last published baseline
// with the current value, so repeated writes never accumulate pending ops.
describe("pending operation coalescing", () => {
// Repeated operations are coalesced when ordering and array index semantics
// make that safe. Exact operation shape is not part of the contract.
type DeadOpState = { a: Record<string, JsonValue>; x?: number; y?: number };
const run = (mutate: (state: DeadOpState) => void, init: DeadOpState = { a: { b: 1 }, x: 1, y: 2 }) => {
const t = track(structuredClone(init));
@ -642,13 +719,29 @@ describe("flush-time minimization", () => {
).toEqual([["s", ["x"], 5]]);
});
it("derives an append after delete-then-recreate", () => {
const t = track<{ x?: string; y: number }>({ x: "", y: 1 });
it("converges after delete-then-recreate without requiring an append", () => {
const initial = { x: "", y: 1 };
const t = track<{ x?: string; y: number }>(structuredClone(initial));
t.flush();
delete t.state.x;
t.state.x = "ab";
t.state.x += "cd";
expect(t.flush()).toEqual([["a", ["x"], "abcd"]]);
const ops = t.flush();
expect(ops).toEqual([["s", ["x"], "abcd"]]);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("may publish a redundant batch when mutations restore the prior value", () => {
const initial = { x: 1, xs: [1, 2] };
const t = track(structuredClone(initial));
t.flush();
t.state.x = 2;
t.state.x = 1;
t.state.xs.reverse();
t.state.xs.reverse();
const ops = t.flush();
expect(ops.length).toBeGreaterThan(0);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("is linear in the number of ops", () => {
@ -682,6 +775,20 @@ describe("flush-time minimization", () => {
expect(ops).toHaveLength(3);
expect(apply({ a: { b: 1 }, x: 1 }, ops)).toEqual(t.state);
});
it("bounds long structural windows by collapsing them to a base", () => {
const initial = { xs: [{ value: 0 }, { value: 1 }] };
const t = track(structuredClone(initial));
t.flush();
for (let i = 0; i < 5000; i++) {
t.state.xs[0]!.value = i;
t.state.xs.shift();
t.state.xs.push({ value: i });
}
const ops = t.flush();
expect(isBase(ops)).toBe(true);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
});
describe("flush", () => {
@ -740,6 +847,47 @@ describe("flush", () => {
expect(out).toBeDefined();
});
it("clones tracked values without invoking inherited setters", () => {
const root = {} as Record<string, JsonValue>;
Object.defineProperty(root, "trap", { value: 1, writable: true, enumerable: true, configurable: true });
Object.defineProperty(Object.prototype, "trap", {
set() {
throw new Error("inherited setter ran");
},
configurable: true,
});
try {
expect(() => track(root).flush()).not.toThrow();
} finally {
delete (Object.prototype as Record<string, unknown>).trap;
}
let arrayOps: Op[] | undefined;
Object.defineProperty(Array.prototype, "0", {
set() {
throw new Error("inherited array setter ran");
},
configurable: true,
});
try {
arrayOps = track({ values: [1, 2] }).flush();
} finally {
delete (Array.prototype as unknown as Record<number, unknown>)[0];
}
expect(arrayOps).toEqual([["r", { values: [1, 2] }]]);
});
it("replaces the safe parent when an assigned object removes a reserved value key", () => {
const initial: { value: Record<string, JsonValue> } = {
value: JSON.parse('{"constructor":{"label":"data"},"x":1}') as Record<string, JsonValue>,
};
const t = track(structuredClone(initial));
t.flush();
t.state.value = { x: 2 };
const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]);
expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state);
});
it("clones and reads reserved value keys without invoking prototype setters", () => {
const t = track({ value: JSON.parse('{"__proto__":{"z":1}}') as JsonValue });
const out = apply<{ value: JsonValue }>(undefined, t.flush());
@ -1031,7 +1179,7 @@ describe("codec: path interning and arity omission", () => {
});
});
describe("property: flush-time tracking", () => {
describe("property: operation-log tracking", () => {
it("converges across mixed nested writes, replacements, and array mutations", () => {
type State = { rows: { text: string; count: number }[]; meta: { revision: number } };
let seed = 0x5eed1234;

View file

@ -111,6 +111,20 @@ describe("remote services", () => {
unsubscribe();
});
test("publishes a redundant update when tracked mutations restore the prior value", () => {
const state = replicatedState({ value: 1 });
const deliveries: Array<{ kind: string; sequence: number }> = [];
state.subscribe((_value, _context, delivery) => deliveries.push(delivery));
state.state.value = 2;
state.state.value = 1;
state.publish(BACKGROUND_CONTEXT);
expect(state.value).toEqual({ value: 1 });
expect(deliveries).toEqual([
{ kind: "hydrate", sequence: 0 },
{ kind: "update", sequence: 1 },
]);
});
test("flushes pending mutations before hydrating a new state subscriber", () => {
const state = replicatedState({ entries: [{ id: "one" }] });
const first: { entries: { id: string }[] }[] = [];