feat(chord): generate deltas at flush

This commit is contained in:
Mario Zechner 2026-08-31 03:53:50 +02:00
parent f7079d5628
commit 9af45be821
5 changed files with 1025 additions and 408 deletions

View file

@ -36,9 +36,10 @@ The design has a few connected pieces:
ordered updates, and become unready on disconnect or replacement until they
are rehydrated.
- **Delta tracking** records mutations to plain JSON as compact operations. It
preserves append, front-truncation, and array-splice intent, supports durable
base batches, and validates untrusted operations before applying them.
- **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 before applying them.
- **Remote service sources** advertise services available outside a facet host
and open bindings for the services its facets require. Bindings carry logical
@ -80,6 +81,9 @@ const replica = apply({ output: "", count: 0 }, ops);
The first flush is always a complete base batch. Later flushes contain path-based
changes. String assignments preserve pure appends and rolling-window movement as
append and front-truncate operations; unrelated rewrites fall back to a set.
Values inserted into tracked state become tracker-owned and must subsequently be
mutated only through `state`. See the [Delta guide](src/delta/README.md) for
mutation, array, lifecycle, and consumer-ownership rules.
## Bundling and loading facets

View file

@ -36,7 +36,8 @@
"sideEffects": false,
"files": [
"dist",
"README.md"
"README.md",
"src/delta/README.md"
],
"scripts": {
"clean": "shx rm -rf dist",

View file

@ -0,0 +1,218 @@
# Chord Delta
Chord Delta synchronizes one mutable JSON value from an authoritative producer
to an ordered replica. It is available from `@earendil-works/chord/delta`.
```ts
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 a complete snapshot. Each later `flush()` returns the
changes needed to transform the previously published value into the current
value. `flush()` returns `[]` when the value has not changed.
## Producing changes
Read and mutate `tracker.state` as a normal object:
```ts
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. Intermediate assignments are not:
```ts
tracker.state.status = "starting";
tracker.state.status = "running";
tracker.flush(); // publishes only "running"
```
Replacing an object or array is also valid. Delta compares its properties and
elements with the previously published value:
```ts
tracker.state.settings = {
...plainSettings,
theme: "dark",
};
```
Unchanged properties produce no operations. Changed nested strings and arrays
still use the compact forms described below.
### Strings
Appending text produces an append operation:
```ts
tracker.state.output += "next line\n";
```
Moving a bounded text window forward produces a front-truncate followed by an
append when the old suffix exactly matches the new prefix:
```ts
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
```
An unrelated replacement produces a normal set operation. Overlap is always
verified; Delta never applies a guessed string match.
### Arrays
Use normal array methods:
```ts
tracker.state.messages.push(first);
tracker.state.messages.push(second);
tracker.state.messages.splice(3, 1, replacement);
```
All `push()` calls before one flush are published as one tail splice. 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. These operations can require comparison or
replacement of a larger array region than a tail append.
Sparse arrays are not supported. Writing beyond the next index throws. Increasing
`length` creates explicit `null` elements; decreasing it removes elements.
`fill(object)` copies the object independently into each affected position.
`copyWithin()` likewise separates duplicated objects. This keeps tracked state a
tree rather than creating shared mutable array elements.
### Optional properties
Optional object properties use absence. They do not require `null`:
```ts
type Settings = { label?: string; count: number };
const tracker = track<Settings>({ count: 0 });
tracker.state.label = "active";
tracker.state.label = undefined; // deletes label
// `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.
## State ownership
The object passed to `track()` becomes tracker-owned. The same applies to objects
later assigned into state or inserted into arrays.
After insertion, retained references may be read or reused, but must not be
mutated directly:
```ts
const item = { status: "new" };
tracker.state.item = item;
tracker.state.item.status = "ready"; // tracked
item.status = "broken"; // unsupported: bypasses tracking
```
Perform mutations through `tracker.state`. Do not put a proxy read from
`tracker.state` back into tracked state. Mutate it in place or construct a
replacement from plain data.
Tracked state must be a mutable JSON tree:
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
- no cycles or one mutable object stored at multiple locations;
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
`Map`, or `Set`.
Do not keep a child proxy across an array operation that changes indices. Read
the child again from its new index.
## Operation formats
Delta has two operation formats.
### `Op`: decoded operations
`track().flush()` returns `Op[]`, and `apply()` accepts `Op[]`. Every path is an
inline array.
| 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. |
### `WireOp`: encoded operations
`WireOp[]` is the transport and storage form. `encoder()` replaces repeated
paths with numeric IDs and may omit a path when it equals the preceding path in
the same batch. `decoder()` restores inline paths.
```ts
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "" });
const enc = encoder();
const dec = decoder();
let replica: { output: string } | undefined;
const send = () => {
const wire = enc.encode(tracker.flush()); // Op[] -> WireOp[]
replica = apply(replica, dec.decode(wire)); // WireOp[] -> Op[]
};
```
Use one encoder and decoder per ordered stream. Numeric path IDs remain valid
across batches. Omitted paths never carry across a batch boundary. A complete
snapshot resets both path dictionaries, allowing replay to start there. Do not
pass `WireOp[]` directly to `apply()`.
## Tracker lifecycle
```ts
tracker.flush(); // publish changes since the previous flush
tracker.rebase(); // make the next flush a complete snapshot
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
replicas unless each replica receives its own clone. A serialized and decoded
batch is already detached.
## Limits
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
gap detection, retries, and persistence policy belong to the surrounding
protocol or storage format.
- Object identity is not replicated. State must have tree structure rather than
aliases or cycles.
- Object key insertion order is not replicated. Do not compare or hash replicas
using serialized key order.
- Array operations that change indices may publish a wider array region than a
tail append.
- Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
and serialized, but cannot be mutated through that key. Replace the nearest
ordinarily named parent instead.

View file

@ -3,7 +3,7 @@ import type { JsonValue } from "../types.ts";
export type { JsonValue } from "../types.ts";
// ─────────────────────────────────────────────────────────────────────────────
// chord/delta — intent-recording change tracking over plain JSON.
// chord/delta — flush-time 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.
@ -22,7 +22,7 @@ export type PathRef<P extends Path = Path> = P | number;
* `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 record time, so a root `p` is always a partial modification.
* `r`/`s` at flush time, so a root `p` is always a partial modification.
*
* `Op` knows nothing about the path dictionary. Interning, id references and
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
@ -107,41 +107,24 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi
export interface TrackerOptions {
maxOverlapScan?: number;
coalesce?: boolean;
}
export interface Tracker<T extends object> {
/**
* Mutate this. Plain TS: assignment, push, splice, delete, all of it.
*
* Assigning to it replaces the whole value: emits `["r", next]` and discards
* ops recorded before it, which describe a value that no longer exists.
*
* `tracker.state = tracker.target` is therefore a checkpoint — it forces a base
* batch without changing anything. Discarding the pending ops is correct
* because the proxy mutates the target directly, so the value already carries
* them.
* The tracked value. Mutate and read state only through this proxy. Values
* inserted into it are adopted: callers may retain read-only references, but
* must not mutate them outside this proxy.
*/
state: T;
/** The untracked backing object. Reads only — writing here emits nothing. */
readonly target: T;
flush(): Op[];
/**
* Clear pending ops and make the next flush a base batch, without changing
* the value.
*
* Recovery replays from the last base batch, so a producer that wants "at most
* N batches to replay" calls this every N. The pending ops are already
* reflected in the value — the proxy mutates the target directly — so
* discarding them loses nothing.
*/
/** Make the next flush a complete base batch without changing the value. */
rebase(): void;
/** Discard without emitting. */
/** Accept pending mutations locally without emitting them. */
discard(): void;
readonly dirty: boolean;
}
const isObj = (v: unknown): v is object => v !== null && typeof v === "object";
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
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;
@ -159,220 +142,526 @@ const cloneJson = <T extends JsonValue>(value: T): T => {
}
return result as T;
};
const INDEX = /^(?:0|[1-9]\d*)$/;
const norm = (t: object, k: string | symbol): Seg | symbol =>
typeof k === "symbol" ? k : Array.isArray(t) && INDEX.test(k) ? Number(k) : k;
const toIntegerOrInfinity = (value: unknown): number => {
const number = Number(value);
if (Number.isNaN(number) || number === 0) return 0;
return Number.isFinite(number) ? Math.trunc(number) : number;
};
const norm = (target: object, key: string | symbol): Seg | symbol =>
typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
const TRACKED_PROXIES = new WeakSet<object>();
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[];
const chunkSize = 10_000;
for (let offset = 0; offset < items.length; offset += chunkSize) {
Reflect.apply(Array.prototype.splice, target, [index + offset, 0, ...items.slice(offset, offset + chunkSize)]);
}
return removed;
};
const jsonEqual = (left: JsonValue, right: JsonValue): boolean => {
if (left === right) return true;
if (!isObj(left) || !isObj(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
if (!jsonEqual(left[index]!, right[index]!)) return false;
}
return true;
}
const leftObject = left as Record<string, JsonValue>;
const rightObject = right as Record<string, JsonValue>;
const leftKeys = Object.keys(leftObject);
const rightKeys = Object.keys(rightObject);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!Object.hasOwn(rightObject, key) || !jsonEqual(leftObject[key]!, rightObject[key]!)) return false;
}
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]);
else out.push(["s", [...path] as unknown as NonEmptyPath, snapshot]);
};
const emitDelete = (path: Path, out: Op[]): void => {
if (path.length === 0) throw new TypeError("the tracked root cannot be deleted");
out.push(["d", [...path] as unknown as NonEmptyPath]);
};
const diffString = (before: string, after: string, path: Path, scan: number, out: Op[]): void => {
if (before === after) return;
if (path.length === 0) {
emitSet(path, after, out);
return;
}
const at = [...path] as unknown as NonEmptyPath;
if (after.length > before.length && after.startsWith(before)) {
out.push(["a", at, after.slice(before.length)]);
return;
}
const shared = overlap(before, after, scan);
if (shared === 0) {
out.push(["s", at, after]);
return;
}
out.push(["t", at, before.length - shared]);
if (after.length > shared) out.push(["a", at, after.slice(shared)]);
};
const diffValue = (before: MaybeJson, after: MaybeJson, path: Path, scan: number, out: Op[]): void => {
if (before === MISSING) {
if (after !== MISSING) emitSet(path, after, out);
return;
}
if (after === MISSING) {
emitDelete(path, out);
return;
}
if (before === after) return;
if (typeof before === "string" && typeof after === "string") {
diffString(before, after, path, scan, out);
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
diffArray(before, after, path, scan, out);
return;
}
if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
diffObject(before as Record<string, JsonValue>, after as Record<string, JsonValue>, path, scan, out);
return;
}
emitSet(path, after, out);
};
function diffObject(
before: Record<string, JsonValue>,
after: Record<string, JsonValue>,
path: Path,
scan: number,
out: Op[],
): void {
if (path.length > 0 && [...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
emitSet(path, after, out);
return;
}
for (const key of Object.keys(after)) {
diffValue(Object.hasOwn(before, key) ? before[key]! : MISSING, after[key]!, [...path, key], scan, out);
}
for (const key of Object.keys(before)) {
if (!Object.hasOwn(after, key)) emitDelete([...path, key], out);
}
}
function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: number, out: Op[]): void {
if (before.length === after.length) {
for (let index = 0; index < after.length; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
return;
}
let prefix = 0;
while (prefix < before.length && prefix < after.length && jsonEqual(before[prefix]!, after[prefix]!)) prefix++;
let suffix = 0;
while (
suffix < before.length - prefix &&
suffix < after.length - prefix &&
jsonEqual(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)
) {
suffix++;
}
const shorter = Math.min(before.length, after.length);
if (prefix + suffix === shorter) {
const remove = before.length - prefix - suffix;
const items = after.slice(prefix, after.length - suffix);
if (prefix === 0 && remove === before.length) emitSet(path, after, out);
else out.push(["p", [...path], prefix, remove, cloneJson(items)]);
return;
}
// Structural movement combined with retained-index edits has no unique
// alignment. Preserve the retained index deltas and express only the tail
// length change structurally. It may be broader than the producer's intent,
// but never degrades those edits to a whole-array replacement.
for (let index = 0; index < shorter; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
if (after.length > before.length) {
out.push(["p", [...path], before.length, 0, cloneJson(after.slice(before.length))]);
} else if (before.length > after.length) {
if (after.length === 0) emitSet(path, after, out);
else out.push(["p", [...path], after.length, before.length - after.length, []]);
}
}
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);
}
};
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> {
validateJsonValue(root);
const scan = options.maxOverlapScan ?? 65_536;
const doCoalesce = options.coalesce ?? true;
let pending = dirtyNode();
let hasPending = false;
let baseline: JsonValue | undefined;
let forceBase = true;
let ops: Op[] = [];
const emit = (op: Op) => {
ops.push(op);
const clearPending = (): void => {
pending = dirtyNode();
hasPending = false;
};
/**
* Reject reserved keys at record time, not just on apply.
*
* `JSON.parse('{"__proto__":{}}')` yields an OWN property, so a producer can
* hold state containing one without doing anything unusual. The tracker would
* then emit a path the applier refuses — state that cannot be replicated. Fail
* where the mistake is, at the write.
*/
const guard = (seg: Seg | symbol): Seg => {
if (typeof seg === "symbol") throw new UnsafePathError(String(seg));
if (typeof seg === "string" && RESERVED_SEGMENTS.has(seg)) throw new UnsafePathError(seg);
return seg;
const ensureNode = (path: Path): DirtyNode | undefined => {
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;
}
return node;
};
/**
* A splice covering the whole target is a replacement written the long way.
* Only the interception knows the pre-splice length, so this is a record-time
* rewrite, not something flush can recover.
*/
const spliceOrReplace = (path: Path, before: number, index: number, remove: number, items: JsonValue[]) => {
if (index === 0 && remove === before) {
if (path.length === 0) emit(["r", items]);
else emit(["s", path as NonEmptyPath, items]);
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;
}
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 markArrayAppend = (path: Path, start: number): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
return;
}
emit(["p", [...path], index, remove, items]);
if (node.array === undefined) node.array = { kind: "append", start };
};
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 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;
};
const guard = (segment: Seg | symbol): Seg => {
if (typeof segment === "symbol") throw new UnsafePathError(String(segment));
if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) throw new UnsafePathError(segment);
return segment;
};
const assertNoTrackedProxy = (value: unknown, seen = new Set<object>()): void => {
if (!isObj(value) || seen.has(value)) return;
if (TRACKED_PROXIES.has(value)) throw new TypeError("tracked proxies cannot be assigned as values");
if (TRACKED_PROXIES.has(value)) {
throw new TypeError(
"value contains tracked state; build replacements from plain data, not from state proxies",
);
}
seen.add(value);
for (const child of Array.isArray(value) ? value : Object.values(value)) assertNoTrackedProxy(child, seen);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor !== undefined && "value" in descriptor) assertNoTrackedProxy(descriptor.value, seen);
}
};
const wrap = <V extends object>(obj: V, path: Path): V => {
const adoptItems = (values: readonly unknown[]): JsonValue[] => {
assertNoTrackedProxy(values);
assertJsonValue(values);
return values as JsonValue[];
};
const integer = (value: unknown): number => {
const number = Number(value);
if (Number.isNaN(number) || number === 0) return 0;
return Number.isFinite(number) ? Math.trunc(number) : number;
};
const spliceRange = (length: number, args: readonly unknown[]): { index: number; remove: number } => {
const rawStart = args.length === 0 ? 0 : integer(args[0]);
const index = rawStart < 0 ? Math.max(0, length + rawStart) : Math.min(rawStart, length);
const remove =
args.length === 0
? 0
: args.length === 1
? length - index
: Math.max(0, Math.min(integer(args[1]), length - index));
return { index, remove };
};
const wrap = <V extends object>(object: V, path: Path, blockedSegment?: Seg): V => {
const childProxies = new Map<string | symbol, { target: object; proxy: object }>();
const proxy = new Proxy(obj, {
get(t, k, r) {
if (Array.isArray(t) && typeof k === "string" && MUTATORS.has(k)) {
const proxy = new Proxy(object, {
get(target, key, receiver) {
if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
return (...args: unknown[]) => {
const before = t.length;
const cloneItems = (values: readonly unknown[]): JsonValue[] => {
for (const value of values) {
assertNoTrackedProxy(value);
assertJsonValue(value);
}
return values.map((value) => cloneJson(value as JsonValue));
};
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const before = target.length;
let result: unknown;
switch (k) {
switch (key) {
case "push": {
const items = cloneItems(args);
result = Reflect.apply(Array.prototype.push, t, items);
spliceOrReplace(path, before, before, 0, cloneJson(items));
const items = adoptItems(args);
if (items.length > 0) markArrayAppend(path, before);
spliceItems(target, before, 0, items);
result = target.length;
break;
}
case "unshift": {
const items = cloneItems(args);
result = Reflect.apply(Array.prototype.unshift, t, items);
spliceOrReplace(path, before, 0, 0, cloneJson(items));
const items = adoptItems(args);
if (items.length > 0) markArrayDiff(path);
spliceItems(target, 0, 0, items);
result = target.length;
break;
}
case "pop":
result = Reflect.apply(Array.prototype.pop, t, args);
if (before > 0) spliceOrReplace(path, before, before - 1, 1, []);
if (before > 0) {
const start = appendStart(path);
if (start === undefined || before - 1 < start) markArrayDiff(path);
}
result = Reflect.apply(Array.prototype.pop, target, args);
break;
case "shift":
result = Reflect.apply(Array.prototype.shift, t, args);
if (before > 0) spliceOrReplace(path, before, 0, 1, []);
if (before > 0) markArrayDiff(path);
result = Reflect.apply(Array.prototype.shift, target, args);
break;
case "splice": {
const items = cloneItems(args.slice(2));
const rawStart = args.length === 0 ? 0 : toIntegerOrInfinity(args[0]);
const index = rawStart < 0 ? Math.max(0, before + rawStart) : Math.min(rawStart, before);
const remove =
args.length === 0
? 0
: args.length === 1
? before - index
: Math.max(0, Math.min(toIntegerOrInfinity(args[1]), before - index));
result = Reflect.apply(Array.prototype.splice, t, [index, remove, ...items]);
spliceOrReplace(path, before, index, remove, cloneJson(items));
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);
}
result = spliceItems(target, index, remove, items);
break;
}
// sort/reverse/fill/copyWithin permute wholesale; intent is not a splice.
default: {
let methodArgs = args;
let fillValue: JsonValue | undefined;
if (k === "fill") {
if (key === "fill") {
assertNoTrackedProxy(args[0]);
assertJsonValue(args[0]);
fillValue = cloneJson(args[0]);
methodArgs = [fillValue, ...args.slice(1)];
}
result = Reflect.apply(Array.prototype[k as "sort"], t, methodArgs);
markArrayDiff(path);
result = Reflect.apply(Array.prototype[key as "sort"], target, methodArgs);
if (isObj(fillValue)) {
for (let index = 0; index < t.length; index++) {
if (t[index] === fillValue) t[index] = cloneJson(fillValue as JsonValue);
for (let index = 0; index < target.length; index++) {
if (target[index] === fillValue) target[index] = cloneJson(fillValue as JsonValue);
}
} else if (k === "copyWithin") {
for (let index = 0; index < t.length; index++) {
if (isObj(t[index])) t[index] = cloneJson(t[index] as JsonValue);
} else if (key === "copyWithin") {
for (let index = 0; index < target.length; index++) {
if (isObj(target[index])) target[index] = cloneJson(target[index] as JsonValue);
}
}
assertJsonValue(t);
if (path.length === 0) emit(["r", cloneJson(t as JsonValue)]);
else emit(["s", path as NonEmptyPath, cloneJson(t as JsonValue)]);
assertJsonValue(target);
}
}
if (k === "pop") childProxies.delete(String(before - 1));
else if (k !== "push") childProxies.clear();
return k === "sort" || k === "reverse" || k === "fill" || k === "copyWithin" ? proxy : result;
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;
};
}
const value = Reflect.get(t, k, r);
const value = Reflect.get(target, key, receiver);
if (!isObj(value)) return value;
const cached = childProxies.get(k);
const cached = childProxies.get(key);
if (cached?.target === value) return cached.proxy;
const child = wrap(value, [...path, guard(norm(t, k))]);
childProxies.set(k, { target: value, proxy: child });
const rawSegment = norm(target, key);
let segment: Seg;
let childBlocked = blockedSegment;
if (blockedSegment !== undefined) {
if (typeof rawSegment === "symbol") throw new UnsafePathError(String(rawSegment));
segment = rawSegment;
} else if (
typeof rawSegment === "string" &&
RESERVED_SEGMENTS.has(rawSegment) &&
Object.hasOwn(target, key)
) {
segment = rawSegment;
childBlocked = rawSegment;
} else segment = guard(rawSegment);
const child = wrap(value, [...path, segment], childBlocked);
childProxies.set(key, { target: value, proxy: child });
return child;
},
set(t, k, v, r) {
// `length` is a real mutation but not a document location.
if (Array.isArray(t) && k === "length") {
const before = t.length;
const next = Number(v);
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) return Reflect.set(t, k, v, r);
set(target, key, value) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
if (Array.isArray(target) && key === "length") {
const before = target.length;
const next = Number(value);
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
return Reflect.set(target, key, value);
}
if (next < before) {
Reflect.set(t, k, next, r);
const start = appendStart(path);
if (next === 0) markArrayReplace(path);
else if (start === undefined || next < start) markArrayDiff(path);
Reflect.set(target, key, next);
childProxies.clear();
spliceOrReplace(path, before, next, before - next, []);
} else if (next > before) {
const items = Array<JsonValue>(next - before).fill(null);
t.length = next;
t.fill(null, before);
emit(["p", [...path], before, 0, items]);
markArrayAppend(path, before);
target.length = next;
target.fill(null, before);
}
return true;
}
const segment = guard(norm(t, k));
if (Array.isArray(t)) {
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
if (segment > t.length) throw new UnsafePathError(segment);
if (segment > target.length) throw new UnsafePathError(segment);
}
const at = [...path, segment] as unknown as NonEmptyPath;
if (v === undefined) {
// JSON has no undefined, and deleting an array element would create a hole.
if (Array.isArray(t)) throw new TypeError("undefined would create a sparse array; use splice instead");
emit(["d", at]);
childProxies.delete(k);
return Reflect.deleteProperty(t, k);
if (value === undefined) {
if (Array.isArray(target)) {
throw new TypeError("undefined would create a sparse array; use splice instead");
}
markValue(at);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
}
const prev = (t as Record<string | symbol, unknown>)[k];
if (typeof prev === "string" && typeof v === "string") {
if (prev === v) return true;
if (v.length > prev.length && v.startsWith(prev)) {
emit(["a", at, v.slice(prev.length)]); // fast path, no probe
} else {
const ov = overlap(prev, v, scan);
if (ov === 0) emit(["s", at, v]);
else {
emit(["t", at, prev.length - ov]);
if (v.length > ov) emit(["a", at, v.slice(ov)]);
}
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;
assertNoTrackedProxy(value);
assertJsonValue(value);
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 {
assertNoTrackedProxy(v);
assertJsonValue(v);
const stored = cloneJson(v);
emit(["s", at, cloneJson(stored)]);
childProxies.delete(k);
return Reflect.set(t, k, stored, r);
}
childProxies.delete(k);
return Reflect.set(t, k, v, r);
} else markValue(at);
childProxies.delete(key);
return Reflect.set(target, key, value);
},
deleteProperty(t, k) {
const segment = guard(norm(t, k));
if (Array.isArray(t)) {
deleteProperty(target, key) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
throw new TypeError("delete would create a sparse array; use splice instead");
}
emit(["d", [...path, segment] as unknown as NonEmptyPath]);
childProxies.delete(k);
return Reflect.deleteProperty(t, k);
markValue([...path, segment]);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
},
defineProperty() {
throw new TypeError("defineProperty is not supported on tracked state; use assignment");
},
setPrototypeOf() {
throw new TypeError("setPrototypeOf is not supported on tracked state");
},
preventExtensions() {
throw new TypeError("preventExtensions is not supported on tracked state");
},
});
@ -381,195 +670,55 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
};
let state = wrap(root, []);
// The first flush is always a base batch. A consumer starts with nothing, so
// a stream that opens with deltas has nothing to apply them to — and that
// fails at runtime, in the consumer, rather than where the mistake is.
let forceBase = true;
return {
get state() {
return state;
},
// A setter, so the obvious thing works. Without it `tracker.state = next`
// silently swaps the proxy for a plain object and stops tracking.
set state(next: T) {
if (next === state) {
ops = [];
clearPending();
forceBase = true;
return;
}
if (TRACKED_PROXIES.has(next)) throw new TypeError("tracker state cannot be replaced with a tracked proxy");
if (TRACKED_PROXIES.has(next)) throw new TypeError("tracker state cannot be replaced with tracked state");
assertNoTrackedProxy(next);
validateJsonValue(next);
ops = [];
clearPending();
root = next;
state = wrap(root, []);
baseline = undefined;
forceBase = true;
},
get target() {
return root;
},
// Same effect as assigning the current value back, minus the re-wrap: the
// value has not changed, so the proxy cache is still valid.
rebase() {
ops = [];
clearPending();
forceBase = true;
},
get dirty() {
return forceBase || ops.length > 0;
return forceBase || hasPending;
},
discard() {
ops = [];
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;
ops = [];
return [["r", cloneJson(root as unknown as JsonValue)]];
clearPending();
return [["r", value]];
}
if (ops.length === 0) return [];
let out = ops;
ops = [];
// Everything before a replacement is dead.
let lastReplace = -1;
for (let index = out.length - 1; index >= 0; index--) {
if (!isReplace(out[index]!)) continue;
lastReplace = index;
break;
}
if (lastReplace > 0) out = out.slice(lastReplace);
out = dropDead(out);
return doCoalesce ? coalesce(out) : out;
if (!hasPending || baseline === undefined) return [];
const out: Op[] = [];
walkDirty(baseline, root as unknown as JsonValue, pending, [], scan, out);
clearPending();
if (out.length > 0) baseline = apply(baseline, out.map(cloneOp));
return out;
},
};
}
const samePath = (a: Op, b: Op): boolean => {
const left = pathOf(a);
const right = pathOf(b);
if (left === undefined || right === undefined || left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
if (left[index] !== right[index]) return false;
}
return true;
};
function pathOf(op: Op): Path | undefined {
return op[0] === "r" ? undefined : op[1];
}
/**
* Drop ops that a later op makes unreachable.
*
* Ops apply in order, so an op is dead if a later one overwrites the whole
* subtree it lives in. Only `s`, `d` and `r` do that — `a`, `t` and `p` modify
* what is already there, so they never dominate.
*
* O(n * d): walk backwards keeping a map of overwritten paths, and for each op
* test its own prefixes against that map. `d` is the path depth, which is small
* and bounded. The obvious formulation — compare each op against every
* dominator — is O(n^2 * d) and degrades on exactly the wide-flush input this
* pass exists to clean up. Measured: 12-14 microseconds per op, flat from 100 to
* 5000 ops, and 10001 redundant writes collapse to 2 emitted ops.
*
* OBJECT KEY ORDER IS NOT PRESERVED. A producer that does
* `set c; set a; delete c; set c` leaves `c` last, because deleting and
* reinserting moves it. The replica receives only the surviving `set c`, applies
* it to a base where `c` never existed, and `c` lands first. Values are
* identical — verified over 1823 randomised sequences with zero value
* mismatches — but the serialised forms can differ.
*
* This is fine because nothing compares a replica against its producer: the
* consumer holds the state and nobody diffs it back. The one visible case is a
* consumer that iterates keys for display, where two views could order rows
* differently. It requires a delete-and-recreate of the same key within one
* flush. Do not hash or content-address a replicated value.
*/
function dropDead(ops: Op[]): Op[] {
type Overwrite = { kind: "s" | "d"; path: Path };
const overwritten = new Map<string, Overwrite>();
const kept: Op[] = [];
const rootKey = JSON.stringify([]);
for (let i = ops.length - 1; i >= 0; i--) {
const op = ops[i]!;
if (op[0] === "r") {
kept.push(op);
overwritten.clear();
overwritten.set(rootKey, { kind: "s", path: [] });
continue;
}
const path = opPath(op);
if (path === undefined) {
kept.push(op);
continue;
}
let dead = overwritten.has(rootKey);
for (let length = 1; length <= path.length && !dead; length++) {
const overwrite = overwritten.get(JSON.stringify(path.slice(0, length)));
if (overwrite === undefined) continue;
// A `d` at exactly a later `s`'s path is NOT dead. Dropping it leaves the
// key in its original slot, while the producer's delete-then-set moved it
// to the end: same value, different serialisation.
if (length === path.length && op[0] === "d" && overwrite.kind === "s") break;
dead = true;
}
if (dead) continue;
kept.push(op);
if (op[0] === "p") {
// A splice can reindex every descendant, so path equality below it cannot
// prove that a later overwrite dominates an earlier operation.
for (const [key, overwrite] of overwritten) {
if (
overwrite.path.length > path.length &&
path.every((segment, index) => overwrite.path[index] === segment)
) {
overwritten.delete(key);
}
}
} else if (op[0] === "s" || op[0] === "d") {
overwritten.set(JSON.stringify(path), { kind: op[0], path });
}
}
return kept.reverse();
}
/** Total, because `Op` paths are always inline. */
function opPath(op: Op): Path | undefined {
return op[0] === "r" ? undefined : op[1];
}
/** Merge adjacent ops describing the same path. Lossless. */
function coalesce(ops: Op[]): Op[] {
const out: Op[] = [];
for (const op of ops) {
const last = out[out.length - 1];
if (last !== undefined && samePath(last, op)) {
if (last[0] === "a" && op[0] === "a") {
out[out.length - 1] = ["a", last[1], `${last[2]}${op[2]}`];
continue;
}
if (last[0] === "s" && op[0] === "s") {
// later write wins
out[out.length - 1] = op;
continue;
}
if (last[0] === "s" && op[0] === "a" && typeof last[2] === "string") {
out[out.length - 1] = ["s", last[1], `${last[2]}${op[2]}`];
continue;
}
}
out.push(op);
}
return out;
}
// ─── Path safety ─────────────────────────────────────────────────────────────
/**
@ -613,18 +762,38 @@ export function assertJsonValue(value: unknown): asserts value is JsonValue {
if (type !== "object") throw new TypeError(`${type} is not a JsonValue`);
if (seen.has(candidate)) throw new TypeError("shared or cyclic object is not JSON state");
seen.add(candidate);
if (!Object.isExtensible(candidate)) throw new TypeError("non-extensible object is not mutable JSON state");
if (Object.getOwnPropertySymbols(candidate).length > 0) throw new TypeError("symbol keys are not JSON");
const visitProperty = (key: string): void => {
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
if (descriptor === undefined) throw new TypeError("sparse arrays are not JSON state");
if (!("value" in descriptor)) throw new TypeError("accessor properties are not JSON state");
if (!descriptor.enumerable || !descriptor.writable || !descriptor.configurable) {
throw new TypeError("JSON state properties must be enumerable, writable, and configurable");
}
visit(descriptor.value, seen);
};
if (Array.isArray(candidate)) {
for (const item of candidate) visit(item, seen);
} else {
const prototype = Object.getPrototypeOf(candidate);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("non-plain object is not a JsonValue");
if (Object.getOwnPropertyDescriptor(candidate, "length")?.writable !== true) {
throw new TypeError("array length must be writable");
}
if (Object.getOwnPropertySymbols(candidate).length > 0) {
throw new TypeError("symbol keys are not JSON");
for (const key of Object.getOwnPropertyNames(candidate)) {
if (key === "length") continue;
if (!INDEX.test(key) || Number(key) >= candidate.length) {
throw new TypeError("arrays cannot have non-index properties");
}
}
for (const item of Object.values(candidate as object)) visit(item, seen);
for (let index = 0; index < candidate.length; index++) visitProperty(String(index));
return;
}
const prototype = Object.getPrototypeOf(candidate);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("non-plain object is not a JsonValue");
}
for (const key of Object.getOwnPropertyNames(candidate)) visitProperty(key);
};
visit(value, new Set());
}

View file

@ -73,6 +73,20 @@ describe("tracker: intent", () => {
expect(t.flush()).toEqual([["d", ["a"]]]);
});
it("uses absence and delete for optional object properties", () => {
type Settings = { something?: string; foo: number };
const t = track<Settings>({ foo: 1 });
let replica = apply<Settings>(undefined, t.flush());
expect(t.state.something).toBeUndefined();
t.state.something = "enabled";
replica = apply(replica, t.flush());
expect(replica).toEqual({ foo: 1, something: "enabled" });
t.state.something = undefined;
const ops = t.flush();
expect(ops).toEqual([["d", ["something"]]]);
expect(apply(replica, ops)).toEqual({ foo: 1 });
});
it("round-trips interleaved writes", () => {
const initial = { out: "x".repeat(500), total: 0 };
const t = track(structuredClone(initial));
@ -82,7 +96,41 @@ describe("tracker: intent", () => {
t.state.total += 10;
}
const ops = t.flush();
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
expect(ops.length).toBeLessThanOrEqual(3);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("deep-diffs reassigned objects", () => {
const initial = { message: { content: [{ text: "hello" }], count: 0 } };
const t = track(structuredClone(initial));
t.flush();
t.state.message = { content: [{ text: "hello world" }], count: 1 };
expect(t.flush()).toEqual([
["a", ["message", "content", 0, "text"], " world"],
["s", ["message", "count"], 1],
]);
});
it("deep-diffs retained array edits combined with append in a replacement", () => {
const initial = { view: { messages: [{ text: "a" }, { text: "b" }] } };
const t = track(structuredClone(initial));
t.flush();
t.state.view = { messages: [{ text: "ax" }, { text: "b" }, { text: "c" }] };
const ops = t.flush();
expect(ops).toEqual([
["a", ["view", "messages", 0, "text"], "x"],
["p", ["view", "messages"], 2, 0, [{ text: "c" }]],
]);
expect(apply(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();
t.state.value = { nested: [1, { text: "same" }] };
expect(t.dirty).toBe(true);
expect(t.flush()).toEqual([]);
expect(t.dirty).toBe(false);
});
it("invalidates a pending child when its parent is overwritten", () => {
@ -91,7 +139,7 @@ describe("tracker: intent", () => {
t.flush();
t.state.a.b = 99;
t.state.a = { c: 2 };
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
});
@ -126,8 +174,8 @@ describe("tracker: root ops", () => {
const t = track(structuredClone(initial));
t.flush();
(t.state.xs.splice as (...args: never[]) => number[])();
expect(t.target).toEqual(initial);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(t.state).toEqual(initial);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("normalises splice arguments to integers", () => {
@ -142,7 +190,7 @@ describe("tracker: root ops", () => {
expect(Number.isInteger(op[2])).toBe(true);
expect(Number.isInteger(op[3])).toBe(true);
}
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("normalises length = 0 on the root", () => {
@ -160,7 +208,7 @@ describe("tracker: root ops", () => {
t.flush();
for (let i = 2; i <= 100; i++) t.state.xs.push(i);
const ops = t.flush();
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("does not fold a child path across a parent splice", () => {
@ -170,7 +218,7 @@ describe("tracker: root ops", () => {
t.state.xs[0] += "x";
t.state.xs.shift();
t.state.xs[0] += "y";
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("drops child ops when repeated parent splices collapse to a snapshot", () => {
@ -180,7 +228,7 @@ describe("tracker: root ops", () => {
t.state.xs.push("q");
t.state.xs[0] += "cd";
t.state.xs.push("z");
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("does not let a post-splice set dominate an earlier element append", () => {
@ -190,7 +238,7 @@ describe("tracker: root ops", () => {
t.state.xs[0] += "x";
t.state.xs.unshift("q");
t.state.xs[0] = "Z";
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("does not discard a nested append through a reindexed set", () => {
@ -200,7 +248,7 @@ describe("tracker: root ops", () => {
(t.state.xs[0] as { k: string }).k += "x";
t.state.xs.unshift(9);
t.state.xs[0] = 7;
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("preserves element operations across middle-array insertions", () => {
@ -210,7 +258,7 @@ describe("tracker: root ops", () => {
t.state.xs[1] += "x";
t.state.xs.splice(1, 0, "inserted");
t.state.xs[1] = "changed";
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("round-trips deterministic element mutations across reindexing splices", () => {
@ -246,7 +294,7 @@ describe("tracker: root ops", () => {
}
if (t.state.xs.length > 10) t.state.xs.shift();
}
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
}
});
@ -255,7 +303,7 @@ describe("tracker: root ops", () => {
const t = track(structuredClone(initial));
t.flush();
t.state.xs.sort().push(4);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
it("detaches objects supplied to fill", () => {
@ -264,18 +312,77 @@ describe("tracker: root ops", () => {
t.flush();
t.state.xs.fill(supplied);
supplied.value = 2;
expect(t.target.xs).toEqual([{ value: 1 }, { value: 1 }]);
expect(t.target.xs[0]).not.toBe(t.target.xs[1]);
expect(apply({ xs: [{ value: 0 }, { value: 0 }] }, t.flush())).toEqual(t.target);
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
expect(apply({ xs: [{ value: 0 }, { value: 0 }] }, t.flush())).toEqual(t.state);
});
it("keeps copyWithin results as a JSON tree", () => {
const t = track({ xs: [{ value: 1 }, { value: 2 }] });
t.flush();
t.state.xs.copyWithin(1, 0, 1);
expect(t.target.xs).toEqual([{ value: 1 }, { value: 1 }]);
expect(t.target.xs[0]).not.toBe(t.target.xs[1]);
expect(apply({ xs: [{ value: 1 }, { value: 2 }] }, t.flush())).toEqual(t.target);
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
expect(apply({ xs: [{ value: 1 }, { value: 2 }] }, t.flush())).toEqual(t.state);
});
it("combines retained element edits with one append", () => {
const initial = { messages: [{ text: "a" }, { text: "b" }], flag: 0 };
const t = track(structuredClone(initial));
t.flush();
t.state.messages[0]!.text += "x";
t.state.flag = 1;
t.state.messages.push({ text: "c" });
t.state.messages[1]!.text += "y";
t.state.messages[2]!.text += "z";
const ops = t.flush();
expect(ops.filter((op) => op[0] === "p")).toEqual([["p", ["messages"], 2, 0, [{ text: "cz" }]]]);
expect(ops.some((op) => op[0] === "s" && op[1].length === 1 && op[1][0] === "messages")).toBe(false);
expect(ops.some((op) => op[0] !== "r" && op[1].includes(2))).toBe(false);
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
});
it("collapses many pushes into one append", () => {
const t = track({ xs: [1] });
t.flush();
for (let value = 2; value <= 100; value++) t.state.xs.push(value);
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, Array.from({ length: 99 }, (_, index) => index + 2)]]);
});
it("keeps direct tail writes and length growth in append mode", () => {
const t = track({ xs: [1] as JsonValue[] });
t.flush();
t.state.xs[1] = { value: 2 };
(t.state.xs[1] as { value: number }).value = 3;
t.state.xs.length = 4;
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 3 }, null, null]]]);
});
it("keeps tail-only splices in append mode", () => {
const t = track({ xs: [{ value: 1 }] });
t.flush();
t.state.xs.push({ value: 2 }, { value: 3 });
t.state.xs.splice(1, 1, { value: 4 });
t.state.xs[2]!.value = 5;
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 4 }, { value: 5 }]]]);
});
it("forgets append-tail mutations that cancel out", () => {
const t = track({ xs: [1] });
t.flush();
t.state.xs.push(2);
t.state.xs.push(3);
t.state.xs.pop();
t.state.xs.pop();
expect(t.flush()).toEqual([]);
});
it("accepts large append argument lists without spreading them internally", () => {
const t = track({ xs: [] as JsonValue[] });
t.flush();
const items = Array<JsonValue>(100_000).fill(null);
expect(Reflect.apply(t.state.xs.push, t.state.xs, items)).toBe(items.length);
expect(t.flush()).toEqual([["p", ["xs"], 0, 0, items]]);
});
it("grows arrays with explicit null values", () => {
@ -283,8 +390,8 @@ describe("tracker: root ops", () => {
const t = track(structuredClone(initial));
t.flush();
t.state.xs.length = 4;
expect(t.target.xs).toEqual([1, null, null, null]);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
expect(t.state.xs).toEqual([1, null, null, null]);
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
});
});
@ -300,7 +407,7 @@ describe("replacing the whole value", () => {
const ops = t.flush();
expect(ops).toEqual([["r", { r: 9, s: "new" }]]);
expect(isBase(ops)).toBe(true);
expect(apply({}, ops)).toEqual(t.target);
expect(apply({}, ops)).toEqual(t.state);
t.state.r = 10; // the new value is tracked
expect(t.flush()).toEqual([["s", ["r"], 10]]);
@ -324,6 +431,14 @@ describe("replacing the whole value", () => {
}).toThrow();
});
it("allows a tracked child self-assignment as a no-op", () => {
const t = track({ child: { value: 1 } });
t.flush();
const child = t.state.child;
t.state.child = child;
expect(t.flush()).toEqual([]);
});
it("rejects assigning a nested tracked proxy into state", () => {
const t = track<{
left: Record<string, number>;
@ -334,16 +449,16 @@ describe("replacing the whole value", () => {
t.flush();
expect(() => {
t.state.right = t.state.left;
}).toThrow(/tracked prox/);
}).toThrow(/state prox/);
expect(() => {
t.state.wrapped = { inner: t.state.left };
}).toThrow(/tracked prox/);
}).toThrow(/state prox/);
t.state.items = [];
expect(() => t.state.items?.push(t.state.left)).toThrow(/tracked prox/);
expect(() => t.state.items?.push(t.state.left)).toThrow(/state prox/);
const other = track({ value: 2 });
expect(() => {
t.state.wrapped = { inner: other.state as unknown as Record<string, number> };
}).toThrow(/tracked prox/);
}).toThrow(/state prox/);
});
it("assigning to state discards ops recorded before it", () => {
@ -373,7 +488,7 @@ describe("replacing the whole value", () => {
t.rebase();
const ops = t.flush();
expect(isBase(ops)).toBe(true);
expect(apply({}, ops)).toEqual(t.target);
expect(apply({}, ops)).toEqual(t.state);
});
it("rebase() applies once, not to every later flush", () => {
@ -415,14 +530,16 @@ describe("the first flush", () => {
expect(t.flush()).toEqual([]);
});
it("discard clears pending deltas", () => {
const t = track({ x: 0 });
it("discard accepts pending changes into the local baseline", () => {
const t = track({ x: 0, y: 0 });
t.flush();
t.state.x = 1;
expect(t.dirty).toBe(true);
t.discard();
expect(t.dirty).toBe(false);
expect(t.flush()).toEqual([]);
t.state.y = 1;
expect(t.flush()).toEqual([["s", ["y"], 1]]);
});
});
@ -450,16 +567,14 @@ describe("apply and fan-out", () => {
expect(replica.x).toBe(0);
});
it("does not preserve aliases assigned to separate paths", () => {
const t = track<{ a?: { value: number }; b?: { value: number } }>({});
const replica = apply<{ a?: { value: number }; b?: { value: number } }>(undefined, t.flush());
const shared = { value: 0 };
t.state.a = shared;
t.state.b = shared;
t.state.a.value = 1;
const next = apply(replica, t.flush());
expect(next).toEqual(t.target);
expect(t.target.b?.value).toBe(0);
it("adopts assigned values without cloning them", () => {
const t = track<{ item?: { value: number } }>({});
const replica = apply<{ item?: { value: number } }>(undefined, t.flush());
const supplied = { value: 0 };
t.state.item = supplied;
t.state.item.value = 1;
expect(supplied.value).toBe(1);
expect(apply(replica, t.flush())).toEqual(t.state);
});
it("does not alias pushed values with an in-process consumer", () => {
@ -468,7 +583,7 @@ describe("apply and fan-out", () => {
t.state.xs.push({ value: 1 });
const next = apply(replica, t.flush());
next.xs[0]!.value = 2;
expect(t.target.xs[0]!.value).toBe(1);
expect(t.state.xs[0]!.value).toBe(1);
});
it("folds a whole stream without a base-batch branch", () => {
@ -487,13 +602,13 @@ describe("apply and fan-out", () => {
send();
t.state.x = 101;
send();
expect(replica).toEqual(t.target);
expect(replica).toEqual(t.state);
});
});
describe("dead-op elimination", () => {
// Ops apply in order, so an op is dead if a later one overwrites the whole
// subtree it lives in. Only s/d/r dominate; a/t/p modify what is there.
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.
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));
@ -531,19 +646,19 @@ describe("dead-op elimination", () => {
s.a.b = 99;
s.a = { c: 5 };
}),
).toEqual([["s", ["a"], { c: 5 }]]);
).toEqual([
["s", ["a", "c"], 5],
["d", ["a", "b"]],
]);
});
it("keeps a child write that follows the parent replacement", () => {
it("folds a child write into its pending parent replacement", () => {
expect(
run((s) => {
s.a = { b: 1 };
s.a.b = 7;
}),
).toEqual([
["s", ["a"], { b: 1 }],
["s", ["a", "b"], 7],
]);
).toEqual([["s", ["a", "b"], 7]]);
});
it("collapses set-then-delete to the delete", () => {
@ -555,30 +670,23 @@ describe("dead-op elimination", () => {
).toEqual([["d", ["x"]]]);
});
it("keeps delete-then-set, to preserve key position", () => {
// Dropping the delete would leave the key in its original slot while the
// producer moved it to the end. Same value, different serialisation.
it("collapses delete-then-set to the final value", () => {
// Delta replication preserves JSON values, not object insertion order.
expect(
run((s) => {
delete s.x;
s.x = 5;
}),
).toEqual([
["d", ["x"]],
["s", ["x"], 5],
]);
).toEqual([["s", ["x"], 5]]);
});
it("keeps the delete when a later append folds into the set", () => {
it("derives an append after delete-then-recreate", () => {
const t = track<{ x?: string; y: number }>({ x: "", y: 1 });
t.flush();
delete t.state.x;
t.state.x = "ab";
t.state.x += "cd";
expect(t.flush()).toEqual([
["d", ["x"]],
["s", ["x"], "abcd"],
]);
expect(t.flush()).toEqual([["a", ["x"], "abcd"]]);
});
it("is linear in the number of ops", () => {
@ -608,7 +716,9 @@ describe("dead-op elimination", () => {
t.state.a.b = i;
}
t.state.a = { done: true };
expect(t.flush()).toHaveLength(2);
const ops = t.flush();
expect(ops).toHaveLength(3);
expect(apply({ a: { b: 1 }, x: 1 }, ops)).toEqual(t.state);
});
});
@ -668,10 +778,14 @@ describe("flush", () => {
expect(out).toBeDefined();
});
it("clones reserved value keys without invoking prototype setters", () => {
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());
expect(Object.hasOwn(out.value as object, "__proto__")).toBe(true);
expect(JSON.parse(JSON.stringify(t.state))).toEqual({ value: JSON.parse('{"__proto__":{"z":1}}') });
expect(() => {
((t.state.value as Record<string, JsonValue>).__proto__ as Record<string, JsonValue>).z = 2;
}).toThrow(/unsafe path/);
expect(({} as Record<string, unknown>).z).toBeUndefined();
});
});
@ -703,7 +817,7 @@ describe("safety: array indices", () => {
expect(() => {
t.state.xs[5] = 9;
}).toThrow();
expect(t.target.xs).toEqual([1, 2, 3]);
expect(t.state.xs).toEqual([1, 2, 3]);
});
it("rejects array deletes at the producer", () => {
const t = track({ xs: [1, 2, 3] });
@ -711,7 +825,7 @@ describe("safety: array indices", () => {
expect(() => {
delete t.state.xs[1];
}).toThrow();
expect(t.target.xs).toEqual([1, 2, 3]);
expect(t.state.xs).toEqual([1, 2, 3]);
});
it("rejects undefined array elements at the producer", () => {
const t = track<{ xs: (number | undefined)[] }>({ xs: [1, 2, 3] });
@ -719,7 +833,7 @@ describe("safety: array indices", () => {
expect(() => {
t.state.xs[1] = undefined;
}).toThrow();
expect(t.target.xs).toEqual([1, 2, 3]);
expect(t.state.xs).toEqual([1, 2, 3]);
});
it("allows explicit growth with nulls", () => {
expect(apply({ xs: [1] }, [["p", ["xs"], 1, 0, [null, null, 9]]])).toEqual({ xs: [1, null, null, 9] });
@ -803,15 +917,54 @@ describe("safety: what a facet can put on the wire", () => {
const t = track<{ xs: JsonValue[] }>({ xs: [] });
t.flush();
expect(() => t.state.xs.push(new Map() as unknown as JsonValue)).toThrow();
expect(t.target.xs).toEqual([]);
expect(t.state.xs).toEqual([]);
});
it("allows a getter on state, recording the computed result", () => {
it("rejects duplicate objects inserted in one array call", () => {
const t = track<{ xs: JsonValue[] }>({ xs: [] });
t.flush();
const shared = { value: 1 };
expect(() => t.state.xs.push(shared, shared)).toThrow(/shared/);
expect(t.state.xs).toEqual([]);
});
it("rejects accessors without invoking them", () => {
const t = track<Record<string, unknown>>({});
t.flush();
let reads = 0;
const supplied = Object.defineProperty({}, "value", {
get() {
reads++;
return 1;
},
enumerable: true,
configurable: true,
});
expect(() => {
t.state.value = supplied;
}).toThrow(/accessor/);
expect(reads).toBe(0);
expect(t.state).toEqual({});
});
it("rejects frozen and otherwise non-extensible values", () => {
const t = track<Record<string, unknown>>({});
t.flush();
expect(() => {
t.state.frozen = Object.freeze({ nested: { value: 1 } });
}).toThrow(/non-extensible/);
expect(() => track(Object.freeze({ value: 1 }))).toThrow(/non-extensible/);
expect(t.state).toEqual({});
});
it("rejects descriptor and object-shape bypasses", () => {
const t = track<Record<string, unknown>>({ pad: PAD });
t.flush(); // drain the base batch
Object.defineProperty(t.target, "g", { get: () => 42, enumerable: true });
t.state.x = 1;
expect(t.flush()).toEqual([["s", ["x"], 1]]);
t.flush();
expect(() => Object.defineProperty(t.state, "g", { value: 42, enumerable: true })).toThrow(/assignment/);
expect(() => Object.setPrototypeOf(t.state, null)).toThrow(/setPrototypeOf/);
expect(() => Object.preventExtensions(t.state)).toThrow(/preventExtensions/);
expect(t.state).toEqual({ pad: PAD });
expect(t.flush()).toEqual([]);
});
it("accepts a value that merely looks like an op", () => {
@ -979,7 +1132,7 @@ describe("codec: path interning and arity omission", () => {
const ops = dec.decode(w);
replica = replica === undefined ? structuredClone((ops[0] as ["r", unknown])[1]) : apply(replica, ops);
}
expect(replica).toEqual(t.target);
expect(replica).toEqual(t.state);
});
it("round-trips random streams", () => {
@ -1007,6 +1160,78 @@ describe("codec: path interning and arity omission", () => {
});
});
describe("property: flush-time tracking", () => {
it("converges across mixed nested writes, replacements, and array mutations", () => {
type State = { rows: { text: string; count: number }[]; meta: { revision: number } };
let seed = 0x5eed1234;
const next = (): number => {
seed = (seed * 1_664_525 + 1_013_904_223) >>> 0;
return seed;
};
for (let round = 0; round < 100; round++) {
const initial: State = {
rows: [
{ text: "a", count: 0 },
{ text: "b", count: 0 },
],
meta: { revision: 0 },
};
const tracker = track(structuredClone(initial));
const enc = encoder();
const dec = decoder();
let replica = apply<State>(undefined, dec.decode(enc.encode(tracker.flush())));
for (let step = 0; step < 60; step++) {
const rows = tracker.state.rows;
switch (next() % 10) {
case 0:
if (rows.length > 0) rows[next() % rows.length]!.text += String.fromCharCode(97 + (next() % 26));
break;
case 1:
if (rows.length > 0) rows[next() % rows.length]!.count++;
break;
case 2:
rows.push({ text: `tail-${round}-${step}`, count: step });
break;
case 3:
if (rows.length > 0) rows.pop();
break;
case 4:
rows.unshift({ text: `head-${round}-${step}`, count: step });
break;
case 5:
if (rows.length > 0) rows.shift();
break;
case 6: {
const index = next() % (rows.length + 1);
const remove = rows.length > 0 && next() % 2 === 0 ? 1 : 0;
rows.splice(index, remove, { text: `mid-${round}-${step}`, count: step });
break;
}
case 7: {
const replacement = JSON.parse(JSON.stringify(rows)) as State["rows"];
if (replacement.length > 0) replacement[0]!.text += "r";
if (next() % 2 === 0) replacement.push({ text: "replacement-tail", count: step });
tracker.state.rows = replacement;
break;
}
case 8:
tracker.state.meta = { revision: tracker.state.meta.revision + 1 };
break;
default:
if (rows.length > 1) rows.reverse();
}
if (tracker.state.rows.length > 12) tracker.state.rows.splice(0, tracker.state.rows.length - 12);
if (next() % 5 === 0) {
replica = apply(replica, dec.decode(enc.encode(tracker.flush())));
expect(replica).toEqual(tracker.state);
}
}
replica = apply(replica, dec.decode(enc.encode(tracker.flush())));
expect(replica).toEqual(tracker.state);
}
});
});
describe("property: random round-trip", () => {
// 3000 random sequences found the root-splice hole on the first run, before any
// hand-written case did.
@ -1035,7 +1260,7 @@ describe("property: random round-trip", () => {
for (const k of Object.keys(s)) if (!(k in next)) delete s[k];
for (const [k, v] of Object.entries(next)) s[k] = v;
} else continue;
expect(apply(structuredClone(base), t.flush())).toEqual(t.target);
expect(apply(structuredClone(base), t.flush())).toEqual(t.state);
checked++;
}
// Most random pairs are shape-incompatible and skipped; this only guards