diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index d8527dd2d..db959c86b 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -7,7 +7,7 @@ Immutability is an ownership contract. Nothing is frozen or defensively copied, so an illegal mutation is not detected. It silently corrupts state. ```ts -import { applyImmutable, track } from "@earendil-works/chord/delta"; +import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta"; const initial = { output: "", entries: [] as { id: number }[] }; const tracker = track(initial); // `initial` is transferred: never mutate it again @@ -34,7 +34,7 @@ const replica = applyImmutable(prepared.base, prepared.ops); | External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. | | `tracker.value`, `prepared.base`, retained older revisions | No. | | `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. | -| `applyImmutable()` inputs and result | No. The result shares containers with both inputs. | +| `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. | | Values from replicated state (`value`, listener values, loopback consumers) | No. In-process consumers may share the provider's containers. | | Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. | | Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. | @@ -50,6 +50,20 @@ let replica = tracker.value; // shared with authority; never mutated replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base ``` +When only the final result of an ordered backlog is needed, replay its batches +without concatenating their operations. One copy-on-write scope is shared across +the complete call, so no intermediate revision is exposed or safe to retain: + +```ts +replica = applyImmutableBatches( + replica, + queuedFrames.map((frame) => frame.ops), +); +``` + +Use separate `applyImmutable()` calls when every intermediate revision is +published or retained. + Mutable replay needs a detached starting root and a detached copy of every batch for every replica. Never apply one in-memory batch to two mutable replicas: @@ -155,8 +169,8 @@ and large edit sets may fold into a region splice, an ancestor `s`, or `r`. Reserved keys: the tracker never emits `__proto__`, `constructor`, or `prototype` as a path segment. A mutation at or below such a key is folded into a -set of the nearest safe ancestor, or `r` at the root. `apply()`, -`applyImmutable()`, and `decoder()` reject those segments with `UnsafePathError`. +set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject +those segments with `UnsafePathError`. The appliers write values as own data properties, never through a prototype setter. Appliers check op shape and path safety but not payload strictness. Chord's replicated-state replicas validate each resulting revision. diff --git a/packages/chord/src/delta/apply-immutable-batch.ts b/packages/chord/src/delta/apply-immutable-batch.ts deleted file mode 100644 index 338e251b1..000000000 --- a/packages/chord/src/delta/apply-immutable-batch.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { JsonValue } from "../types.ts"; -import { - apply as applyMutable, - assertValidOp, - type Op, - type Path, - PathError, - type Seg, - UnsafePathError, -} from "./index.ts"; - -type JsonContainer = JsonValue[] | Record; - -/** Apply one batch while copying each touched container at most once. */ -export function applyImmutable(target: T | undefined, operations: readonly Op[]): T { - let root = target as unknown as JsonValue; - const owned = new WeakSet(); - for (const operation of operations) { - assertValidOp(operation); - if (operation[0] === "r") { - root = operation[1]; - continue; - } - const path = operation[0] === "p" || operation[0] === "m" ? operation[1] : operation[1].slice(0, -1); - root = copyPath(root, path, owned); - root = applyMutable(root, [operation]); - } - return root as unknown as T; -} - -function copyPath(root: JsonValue, path: Path, owned: WeakSet): JsonValue { - if (!isContainer(root)) throw new PathError(path); - let copiedRoot = root; - if (!owned.has(root)) { - copiedRoot = shallowCopy(root); - owned.add(copiedRoot); - } - let destination = copiedRoot as JsonContainer; - for (const segment of path) { - if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment); - if (!Object.hasOwn(destination, segment)) throw new PathError(path); - let child = (destination as Record)[segment]!; - if (!isContainer(child)) throw new PathError(path); - if (!owned.has(child)) { - const copy = shallowCopy(child); - defineData(destination, segment, copy); - owned.add(copy); - child = copy; - } - destination = child; - } - return copiedRoot; -} - -function shallowCopy(value: JsonContainer): JsonContainer { - if (Array.isArray(value)) return Array.from(value); - const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record< - string, - JsonValue - >; - for (const key of Object.keys(value)) defineData(result, key, value[key]!); - return result; -} - -function defineData(target: object, key: PropertyKey, value: JsonValue): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function isContainer(value: unknown): value is JsonContainer { - return value !== null && typeof value === "object"; -} diff --git a/packages/chord/src/delta/index.ts b/packages/chord/src/delta/index.ts index 1d4aa89fc..982a6f464 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -405,22 +405,35 @@ function applyOps(target: T | undefined, ops: readonly Op[]): T { return root as unknown as T; } -/** Apply decoded operations without mutating the previous immutable value. */ +/** Apply one decoded operation batch without mutating the previous immutable value. */ export function applyImmutable(target: T | undefined, ops: readonly Op[]): T { + return applyImmutableBatches(target, [ops]); +} + +/** + * Apply decoded operation batches as one final-result-only replay. + * + * Containers copied for an earlier batch may be mutated while applying a later + * batch, so this deliberately exposes no intermediate revisions. + */ +export function applyImmutableBatches(target: T | undefined, batches: Iterable): T { let root = target as unknown as JsonValue; - for (const op of ops) { - assertValidOp(op); - if (op[0] === "r") { - root = op[1]; - continue; + const owned = new WeakSet(); + for (const ops of batches) { + for (const op of ops) { + assertValidOp(op); + if (op[0] === "r") { + root = op[1]; + continue; + } + root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned); + root = applyOps(root, [op]); } - root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1)); - root = applyOps(root, [op]); } return root as unknown as T; } -function copyContainers(root: JsonValue, path: Path): JsonValue { +function copyContainers(root: JsonValue, path: Path, owned: WeakSet): JsonValue { const copy = (value: JsonValue): JsonValue[] | Record => { if (Array.isArray(value)) return value.slice(); if (!isObj(value)) throw new PathError(path); @@ -438,13 +451,23 @@ function copyContainers(root: JsonValue, path: Path): JsonValue { } return result; }; - const copiedRoot = copy(root); - let source = root; + if (!isObj(root)) throw new PathError(path); + let copiedRoot: JsonValue[] | Record; + if (owned.has(root)) copiedRoot = root as JsonValue[] | Record; + else { + copiedRoot = copy(root); + owned.add(copiedRoot); + } let destination = copiedRoot; for (const segment of path) { - if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path); - if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment); - const child = (source as Record)[segment]!; + if (!Object.hasOwn(destination, segment)) throw new PathError(path); + if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment); + const child = (destination as Record)[segment]!; + if (!isObj(child)) throw new PathError(path); + if (owned.has(child)) { + destination = child as JsonValue[] | Record; + continue; + } const copiedChild = copy(child); Object.defineProperty(destination, segment, { value: copiedChild, @@ -452,7 +475,7 @@ function copyContainers(root: JsonValue, path: Path): JsonValue { enumerable: true, configurable: true, }); - source = child; + owned.add(copiedChild); destination = copiedChild; } return copiedRoot; diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index 1d2dd4627..6cad0ee8c 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -1,9 +1,8 @@ import { copyJson } from "../json.ts"; import type { JsonValue } from "../types.ts"; -import { applyImmutable } from "./apply-immutable-batch.ts"; import { applyImmutableTrusted } from "./apply-immutable-trusted.ts"; import type { Draft } from "./draft.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts"; +import { applyImmutable, type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts"; export type { Draft, JsonValue, Op }; diff --git a/packages/chord/test/delta-apply-immutable.test.ts b/packages/chord/test/delta-apply-immutable.test.ts new file mode 100644 index 000000000..c74167da1 --- /dev/null +++ b/packages/chord/test/delta-apply-immutable.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + apply, + applyImmutable, + applyImmutableBatches, + type JsonValue, + type Op, + PathError, + track, + UnsafePathError, +} from "../src/delta/index.ts"; + +function freezeDeep(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) freezeDeep(child); + return Object.freeze(value); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +describe("checked immutable operation application", () => { + it("copies each touched container once while preserving input and payload ownership", () => { + const shared = freezeDeep({ nested: { value: 1 } }); + const untouched = freezeDeep({ value: 9 }); + const rowPayload = freezeDeep({ id: 4, label: "placed" }); + const base = freezeDeep({ + text: "abcdef", + stable: { value: 7 }, + branch: { value: 1 }, + copy: null as { value: number } | null, + placed: null as typeof shared | null, + untouched: null as typeof untouched | null, + left: null as typeof shared | null, + right: null as typeof shared | null, + meta: { count: 0, obsolete: true }, + rows: [ + { id: 1, label: "one" }, + { id: 2, label: "two" }, + { id: 3, label: "three" }, + ], + }); + const operations = freezeDeep([ + ["t", ["text"], 2], + ["a", ["text"], "!"], + ["s", ["meta", "count"], 1], + ["s", ["meta", "count"], 2], + ["d", ["meta", "obsolete"]], + ["s", ["copy"], base.branch], + ["s", ["copy", "value"], 2], + ["s", ["placed"], shared], + ["s", ["placed", "nested", "value"], 2], + ["s", ["untouched"], untouched], + ["s", ["left"], shared], + ["s", ["right"], shared], + ["s", ["left", "nested", "value"], 3], + ["p", ["rows"], 1, 1, [rowPayload]], + ["s", ["rows", 1, "label"], "edited"], + ["m", ["rows"], [1, 0, 2]], + ["s", ["rows", 0, "label"], "moved"], + ] satisfies Op[]); + + const result = applyImmutable(base, operations); + const mutableResult = apply(clone(base), clone(operations)); + + expect(result).toEqual(mutableResult); + expect(result.text).toBe("cdef!"); + expect(result.stable).toBe(base.stable); + expect(result.untouched).toBe(untouched); + expect(result.copy).not.toBe(base.branch); + expect(result.copy).toEqual({ value: 2 }); + expect(result.placed).not.toBe(shared); + expect(result.placed).toEqual({ nested: { value: 2 } }); + expect(result.left).not.toBe(shared); + expect(result.right).toBe(shared); + expect(result.rows[0]).not.toBe(rowPayload); + expect(result.rows[0]).toEqual({ id: 4, label: "moved" }); + expect(base.branch.value).toBe(1); + expect(shared.nested.value).toBe(1); + expect(rowPayload.label).toBe("placed"); + }); + + it("protects root replacement payloads before later object and array edits", () => { + const replacement = freezeDeep({ nested: { value: 1 }, values: [1, 2, 3] }); + const result = applyImmutableBatches(undefined, [ + [["r", replacement]], + [["s", ["nested", "value"], 2]], + [ + ["p", ["values"], 1, 1, [4, 5]], + ["m", ["values"], [3, 0, 1, 2]], + ], + ]); + expect(result).toEqual({ nested: { value: 2 }, values: [3, 1, 4, 5] }); + expect(replacement).toEqual({ nested: { value: 1 }, values: [1, 2, 3] }); + + const array = freezeDeep([1, 2, 3]); + const arrayResult = applyImmutable(array, [ + ["p", [], 1, 1, [4, 5]], + ["m", [], [3, 0, 1, 2]], + ["d", [1]], + ]); + expect(arrayResult).toEqual([3, 4, 5]); + expect(array).toEqual([1, 2, 3]); + }); + + it("shares one private copy-on-write scope across batch partitions", () => { + const base = freezeDeep({ + text: "abcdef", + meta: { count: 0 }, + values: [ + { id: 1, value: 1 }, + { id: 2, value: 2 }, + { id: 3, value: 3 }, + ], + }); + const batches = [ + [ + ["s", ["meta", "count"], 1], + ["p", ["values"], 1, 1, [{ id: 4, value: 4 }]], + ] satisfies Op[], + [] satisfies Op[], + [ + ["m", ["values"], [2, 0, 1]], + ["s", ["values", 2, "value"], 40], + ] satisfies Op[], + [ + ["t", ["text"], 2], + ["a", ["text"], "!"], + ] satisfies Op[], + ]; + + const intermediate = applyImmutable(base, batches[0]); + const intermediateSnapshot = clone(intermediate); + let sequential = intermediate; + for (let index = 1; index < batches.length; index++) sequential = applyImmutable(sequential, batches[index]!); + const streamed = applyImmutableBatches(base, batches); + const flattened = applyImmutable(base, batches.flat()); + + expect(streamed).toEqual(sequential); + expect(streamed).toEqual(flattened); + expect(intermediate).toEqual(intermediateSnapshot); + expect(base.meta.count).toBe(0); + expect(base.values[1]!.id).toBe(2); + }); + + it("handles tracker-produced batches across arbitrary revision boundaries", () => { + type Document = { text: string; values: { id: number; score: number }[]; revision: number }; + const initial: Document = { + text: "start", + values: Array.from({ length: 8 }, (_, id) => ({ id, score: 0 })), + revision: 0, + }; + const tracker = track(initial); + const batches: Array = []; + for (let revision = 1; revision <= 40; revision++) { + const change = tracker.beginChange(); + change.state.revision = revision; + change.state.text = `${change.state.text.slice(1)}${revision}`; + switch (revision % 5) { + case 0: + change.state.values.reverse(); + break; + case 1: + change.state.values.push({ id: 100 + revision, score: revision }); + break; + case 2: + change.state.values.shift(); + break; + case 3: + change.state.values[revision % change.state.values.length]!.score = revision; + break; + default: + change.state.values.splice(1, 1, { id: 200 + revision, score: revision }); + } + const prepared = change.prepare(); + batches.push(prepared.ops); + tracker.adopt(prepared); + } + + expect(applyImmutableBatches(initial, batches)).toEqual(tracker.value); + }); + + it("does not expose partial application when validation or iteration fails", () => { + const base = freezeDeep({ nested: { value: 1 } }); + const invalidBatches: readonly (readonly Op[])[] = [ + [["s", ["nested", "value"], 2]], + [["s", ["constructor", "prototype", "polluted"], true]], + ]; + expect(() => applyImmutableBatches(base, invalidBatches)).toThrow(); + expect(base.nested.value).toBe(1); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + + function* throwingBatches(): Generator { + yield [["s", ["nested", "value"], 3]]; + throw new Error("revision stream failed"); + } + expect(() => applyImmutableBatches(base, throwingBatches())).toThrow("revision stream failed"); + expect(base.nested.value).toBe(1); + + let advancedPastInvalid = false; + let iteratorClosed = false; + function* invalidOperationBatches(): Generator { + try { + yield [["s", ["nested", "value"], 4]]; + yield [["s", ["__proto__", "polluted"], true]]; + advancedPastInvalid = true; + } finally { + iteratorClosed = true; + } + } + expect(() => applyImmutableBatches(base, invalidOperationBatches())).toThrow(UnsafePathError); + expect(advancedPastInvalid).toBe(false); + expect(iteratorClosed).toBe(true); + expect(base.nested.value).toBe(1); + + let reads = 0; + const target = Object.defineProperty({}, "trap", { + enumerable: true, + get() { + reads++; + return {}; + }, + }); + const malformed: Op[] = [["s", "bad-path", 1] as never]; + expect(() => applyImmutableBatches(target, [malformed])).toThrow(/path/); + expect(reads).toBe(0); + + expect(() => applyImmutable({ values: [] }, [["s", ["values", "missing", "value"], 1]])).toThrow(PathError); + expect(() => applyImmutable({ values: [{}] }, [["s", ["values", "0", "value"], 1]])).toThrow(UnsafePathError); + }); + + it("allows one immutable batch to fan out without mutating shared payloads", () => { + const payload = freezeDeep({ nested: { value: 1 } }); + const operations: readonly Op[] = [ + ["s", ["placed"], payload], + ["s", ["placed", "nested", "value"], 2], + ]; + const base = freezeDeep({ placed: null as typeof payload | null }); + const first = applyImmutable(base, operations); + const second = applyImmutable(base, operations); + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(first.placed).not.toBe(second.placed); + expect(payload.nested.value).toBe(1); + }); +}); + +describe("immutable application complexity", () => { + it("copies a wide object once rather than once per repeated write", () => { + const width = 20_000; + const base: Record = {}; + for (let index = 0; index < width; index++) base[`field${index}`] = index; + const operations: Op[] = Array.from({ length: 1_000 }, (_, index): Op => ["s", [`field${index}`], -index]); + const originalKeys = Object.keys; + let shallowCopies = 0; + Object.keys = ((value: object): string[] => { + shallowCopies++; + return originalKeys(value); + }) as typeof Object.keys; + let result: Record; + try { + result = applyImmutable(base, operations); + } finally { + Object.keys = originalKeys; + } + + expect(result.field999).toBe(-999); + expect(base.field999).toBe(999); + expect(shallowCopies).toBe(1); + }); +}); diff --git a/packages/chord/test/delta-traversal.bench.ts b/packages/chord/test/delta-traversal.bench.ts index 04cbb5647..cf2eb231b 100644 --- a/packages/chord/test/delta-traversal.bench.ts +++ b/packages/chord/test/delta-traversal.bench.ts @@ -95,7 +95,6 @@ function sources() { "../src/delta/draft.ts", "../src/delta/diff.ts", "../src/delta/revision-validator.ts", - "../src/delta/apply-immutable-batch.ts", "../src/delta/apply-immutable-trusted.ts", "./delta-traversal.bench.ts", ].map((path) => [