diff --git a/packages/chord/README.md b/packages/chord/README.md index fa1dc5ac7..2b0dd8357 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -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 diff --git a/packages/chord/package.json b/packages/chord/package.json index 870bd0f11..6b6a3b196 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -36,7 +36,8 @@ "sideEffects": false, "files": [ "dist", - "README.md" + "README.md", + "src/delta/README.md" ], "scripts": { "clean": "shx rm -rf dist", diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md new file mode 100644 index 000000000..d69e99095 --- /dev/null +++ b/packages/chord/src/delta/README.md @@ -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({ 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. diff --git a/packages/chord/src/delta/index.ts b/packages/chord/src/delta/index.ts index 26cd587b4..ee5ed2b0d 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -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 | 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 { /** - * 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 = (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 = (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(); +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 }; + +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; + const rightObject = right as Record; + 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)[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, after as Record, path, scan, out); + return; + } + emitSet(path, after, out); +}; + +function diffObject( + before: Record, + after: Record, + 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(root: T, options: TrackerOptions = {}): Tracker { 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()): 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 = (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 = (object: V, path: Path, blockedSegment?: Seg): V => { const childProxies = new Map(); - 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(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)[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)[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(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(); - 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()); } diff --git a/packages/chord/test/delta.test.ts b/packages/chord/test/delta.test.ts index dc58e1aa7..322e45fbe 100644 --- a/packages/chord/test/delta.test.ts +++ b/packages/chord/test/delta.test.ts @@ -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({ foo: 1 }); + let replica = apply(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(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; @@ -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 }; - }).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; 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).__proto__ as Record).z = 2; + }).toThrow(/unsafe path/); expect(({} as Record).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>({}); + 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>({}); + 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>({ 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(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