From d5cba1d97c4e3a295f383fab49af319b6ef9e644 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 24 Sep 2026 15:06:54 +0200 Subject: [PATCH] feat(chord): make immutable delta tracker canonical Replace the alternate tracker implementations with the optimized immutable overlay, validate draft placements as strict JSON, and document the ownership contract. Consolidate tracker tests and benchmarks around the canonical implementation. --- .../agent/src/harness/pico3/legacy-tracker.ts | 28 +- .../test/harness/pico3/legacy-tracker.test.ts | 31 + .../harness/pico3/spec-view-events.test.ts | 21 +- packages/chord/README.md | 65 +- packages/chord/src/api.ts | 4 + packages/chord/src/delta/README.md | 531 ++-- ...-immutable.ts => apply-immutable-batch.ts} | 4 +- .../src/delta/apply-immutable-trusted.ts | 128 + packages/chord/src/delta/astra/README.md | 24 - packages/chord/src/delta/astra/index.ts | 2005 --------------- packages/chord/src/delta/cow/README.md | 12 - packages/chord/src/delta/cow/diff.ts | 562 ---- packages/chord/src/delta/cow/draft.ts | 717 ------ packages/chord/src/delta/cow/index.ts | 20 - packages/chord/src/delta/cow/tracker.ts | 225 -- packages/chord/src/delta/draft.ts | 611 ----- .../chord/src/delta/revision-validator.ts | 75 + packages/chord/src/delta/tracker.ts | 2251 ++++++++++++++++- packages/chord/src/delta/value.ts | 179 -- packages/chord/src/services/state.ts | 8 +- packages/chord/src/types.ts | 21 +- .../test/delta-astra/benchmark-head.worker.ts | 142 -- packages/chord/test/delta-astra/benchmark.ts | 134 - .../test/delta-astra/benchmark.worker.ts | 142 -- .../chord/test/delta-astra/retention.test.ts | 27 - .../test/delta-astra/retention.worker.ts | 106 - .../chord/test/delta-benchmark/benchmark.ts | 210 ++ .../test/delta-benchmark/benchmark.worker.ts | 436 ++++ .../conversation-view-benchmark.ts | 337 +++ .../conversation-view-benchmark.worker.ts | 1140 +++++++++ .../test/delta-benchmark/memory-benchmark.ts | 142 ++ .../memory-benchmark.worker.ts | 163 ++ packages/chord/test/delta-clone.test.ts | 71 +- packages/chord/test/delta-cow/benchmark.ts | 95 - .../chord/test/delta-cow/benchmark.worker.ts | 145 -- packages/chord/test/delta-cow/core.test.ts | 501 ---- packages/chord/test/delta-cow/cow.test.ts | 538 ---- packages/chord/test/delta-cow/draft.test.ts | 99 - packages/chord/test/delta-cow/fuzz.test.ts | 99 - packages/chord/test/delta-cow/value.test.ts | 34 - .../diff.test.ts => delta-diff.test.ts} | 8 +- packages/chord/test/delta-retention.test.ts | 19 - packages/chord/test/delta-retention.worker.ts | 82 - .../retention.test.ts | 8 +- .../retention.worker.ts | 143 +- .../tracker.test.ts} | 369 ++- packages/chord/test/delta-traversal.bench.ts | 4 +- packages/chord/test/delta.test.ts | 56 +- packages/chord/test/services.test.ts | 3 +- packages/chord/test/state-draft.test.ts | 33 +- packages/chord/test/state-fuzz.test.ts | 27 +- packages/chord/test/state-value.test.ts | 16 +- packages/chord/test/state.test.ts | 39 +- packages/durable/docs/chord-delta-findings.md | 2 +- 54 files changed, 5639 insertions(+), 7253 deletions(-) create mode 100644 packages/agent/test/harness/pico3/legacy-tracker.test.ts rename packages/chord/src/delta/{cow/apply-immutable.ts => apply-immutable-batch.ts} (96%) create mode 100644 packages/chord/src/delta/apply-immutable-trusted.ts delete mode 100644 packages/chord/src/delta/astra/README.md delete mode 100644 packages/chord/src/delta/astra/index.ts delete mode 100644 packages/chord/src/delta/cow/README.md delete mode 100644 packages/chord/src/delta/cow/diff.ts delete mode 100644 packages/chord/src/delta/cow/draft.ts delete mode 100644 packages/chord/src/delta/cow/index.ts delete mode 100644 packages/chord/src/delta/cow/tracker.ts create mode 100644 packages/chord/src/delta/revision-validator.ts delete mode 100644 packages/chord/src/delta/value.ts delete mode 100644 packages/chord/test/delta-astra/benchmark-head.worker.ts delete mode 100644 packages/chord/test/delta-astra/benchmark.ts delete mode 100644 packages/chord/test/delta-astra/benchmark.worker.ts delete mode 100644 packages/chord/test/delta-astra/retention.test.ts delete mode 100644 packages/chord/test/delta-astra/retention.worker.ts create mode 100644 packages/chord/test/delta-benchmark/benchmark.ts create mode 100644 packages/chord/test/delta-benchmark/benchmark.worker.ts create mode 100644 packages/chord/test/delta-benchmark/conversation-view-benchmark.ts create mode 100644 packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts create mode 100644 packages/chord/test/delta-benchmark/memory-benchmark.ts create mode 100644 packages/chord/test/delta-benchmark/memory-benchmark.worker.ts delete mode 100644 packages/chord/test/delta-cow/benchmark.ts delete mode 100644 packages/chord/test/delta-cow/benchmark.worker.ts delete mode 100644 packages/chord/test/delta-cow/core.test.ts delete mode 100644 packages/chord/test/delta-cow/cow.test.ts delete mode 100644 packages/chord/test/delta-cow/draft.test.ts delete mode 100644 packages/chord/test/delta-cow/fuzz.test.ts delete mode 100644 packages/chord/test/delta-cow/value.test.ts rename packages/chord/test/{delta-cow/diff.test.ts => delta-diff.test.ts} (97%) delete mode 100644 packages/chord/test/delta-retention.test.ts delete mode 100644 packages/chord/test/delta-retention.worker.ts rename packages/chord/test/{delta-cow => delta-tracker}/retention.test.ts (75%) rename packages/chord/test/{delta-cow => delta-tracker}/retention.worker.ts (50%) rename packages/chord/test/{delta-astra/astra.test.ts => delta-tracker/tracker.test.ts} (75%) diff --git a/packages/agent/src/harness/pico3/legacy-tracker.ts b/packages/agent/src/harness/pico3/legacy-tracker.ts index 8bd952d12..e97e9b7a4 100644 --- a/packages/agent/src/harness/pico3/legacy-tracker.ts +++ b/packages/agent/src/harness/pico3/legacy-tracker.ts @@ -19,7 +19,6 @@ export interface Tracker { class LegacyTracker implements Tracker { private readonly tracker: ChordTracker; private change: Change | undefined; - private wrappers = new WeakMap(); private forceBase = true; constructor(initial: T) { @@ -28,7 +27,7 @@ class LegacyTracker implements Tracker { get state(): T { this.change ??= this.tracker.beginChange(); - return this.wrap(this.change.state as T); + return this.change.state as T; } get target(): T { @@ -44,7 +43,6 @@ class LegacyTracker implements Tracker { if (this.change !== undefined) { const change = this.change; this.change = undefined; - this.wrappers = new WeakMap(); prepared = change.prepare(); // Pico3 adopts before storage. A storage failure faults the owning Session. this.tracker.adopt(prepared); @@ -59,30 +57,6 @@ class LegacyTracker implements Tracker { rebase(): void { this.forceBase = true; } - - private wrap(draft: V): V { - const cached = this.wrappers.get(draft); - if (cached !== undefined) return cached as V; - const tracker = this; - const proxy = new Proxy(draft, { - get(target, key, receiver) { - const value = Reflect.get(target, key, receiver); - if (typeof value === "function") { - return (...args: unknown[]) => { - const result = Reflect.apply(value, target, args); - return typeof result === "object" && result !== null ? tracker.wrap(result) : result; - }; - } - return typeof value === "object" && value !== null ? tracker.wrap(value) : value; - }, - set(target, key, value, receiver) { - if (value === undefined && !Array.isArray(target)) return Reflect.deleteProperty(target, key); - return Reflect.set(target, key, value, receiver); - }, - }); - this.wrappers.set(draft, proxy); - return proxy; - } } export function track(initial: T): Tracker { diff --git a/packages/agent/test/harness/pico3/legacy-tracker.test.ts b/packages/agent/test/harness/pico3/legacy-tracker.test.ts new file mode 100644 index 000000000..39b2ff5d0 --- /dev/null +++ b/packages/agent/test/harness/pico3/legacy-tracker.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { applyImmutable } from "@earendil-works/chord/delta"; +import { test } from "vitest"; +import { track } from "../../../src/harness/pico3/legacy-tracker.ts"; + +test("preserves draft-sourced descendant edits without exposing tracker metadata", () => { + const tracker = track({ + a: { child: { value: 1 } }, + b: null as { child: { value: number } } | null, + rows: [{ child: { value: 1 } }, { child: { value: 2 } }], + }); + let replica = applyImmutable(undefined, tracker.flush()); + + tracker.state.a.child.value = 2; + tracker.state.b = tracker.state.a; + tracker.state.rows[1] = tracker.state.rows[0]!; + tracker.state.rows[1]!.child.value = 3; + const operations = tracker.flush(); + replica = applyImmutable(replica, operations); + + assert.deepEqual(tracker.target, { + a: { child: { value: 2 } }, + b: { child: { value: 2 } }, + rows: [{ child: { value: 1 } }, { child: { value: 3 } }], + }); + assert.deepEqual(replica, tracker.target); + assert.notEqual(tracker.target.a, tracker.target.b); + assert.notEqual(tracker.target.a.child, tracker.target.b?.child); + assert.notEqual(tracker.target.rows[0], tracker.target.rows[1]); + assert.notEqual(tracker.target.rows[0]!.child, tracker.target.rows[1]!.child); +}); diff --git a/packages/agent/test/harness/pico3/spec-view-events.test.ts b/packages/agent/test/harness/pico3/spec-view-events.test.ts index 4a2ed4f43..7b54a8ed1 100644 --- a/packages/agent/test/harness/pico3/spec-view-events.test.ts +++ b/packages/agent/test/harness/pico3/spec-view-events.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { isDeepStrictEqual } from "node:util"; import type { Context } from "@earendil-works/chord"; +import { apply } from "@earendil-works/chord/delta"; import type { AssistantMessage, AssistantMessageEvent } from "@earendil-works/pi-ai"; import { onTestFinished, test } from "vitest"; import type { ConversationHandle } from "../../../src/harness/pico3/harness.ts"; @@ -175,23 +177,20 @@ test("a self-head commit rewrites one transcript entry with matching entry/head onTestFinished(() => env.close()); await env.root.write({ kind: "before" }, ctx); const watch = await specWatch(env.root); + const before = structuredClone(watch.view); let envelope: Envelope | undefined; watch.start((next) => { envelope = next; }); await env.root.reset("fresh", ctx); assert.ok(envelope); - const entryOps = envelope.ops.filter((op) => op[0] !== "r" && op[1][0] === "entries"); - assert.deepEqual( - entryOps - .map((op) => { - if (op[0] !== "s") throw new Error(`Unexpected transcript operation ${op[0]}`); - assert.equal(op[1][1], 0); - return op[1][2]; - }) - .sort(), - ["head", "id", "kind", "model"], - ); + const after = apply(before, envelope.ops); + const previousEntry = watch.view.entries[0] as unknown as Record; + const nextEntry = after.entries[0] as unknown as Record; + const changedKeys = [...new Set([...Object.keys(previousEntry), ...Object.keys(nextEntry)])] + .filter((key) => !isDeepStrictEqual(previousEntry[key], nextEntry[key])) + .sort(); + assert.deepEqual(changedKeys, ["head", "id", "kind", "model"]); assert.deepEqual( envelope.events.map((event) => event.type), ["head.moved", "entry.added"], diff --git a/packages/chord/README.md b/packages/chord/README.md index a782a3e51..153a71337 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -32,12 +32,12 @@ The design has a few connected pieces: stable facade while a provider disconnects or is replaced. - **Replicated state** exposes authoritative state to local and remote - connected consumers. Producers publish atomic copy-on-write transactions with + connected consumers. Producers publish atomic overlay transactions with `change(context, callback)`; consumers receive complete immutable values. Draft - proxies exist only during the callback and are revoked afterward. Chord compares - the previous and next immutable revisions to produce one decoded operation batch, - while each remote client/state stream owns independent path-codec state. Replicas - become unready on disconnect or replacement until they are rehydrated. + proxies exist only during the callback and become unusable afterward. Preparation + materializes a structurally shared immutable candidate and one exact decoded + operation batch, while each remote client/state stream owns independent path-codec + state. Replicas become unready on disconnect or replacement until rehydrated. - **Delta tracking** records and coalesces operations over tracked plain JSON. It preserves common string and array operations, supports durable base @@ -89,26 +89,41 @@ outer protocol. ## Tracking JSON deltas -Import the standalone delta primitive from `@earendil-works/chord/delta`: +Import the standalone transactional tracker from `@earendil-works/chord/delta`: ```ts -import { apply, track } from "@earendil-works/chord/delta"; +import { applyImmutable, track } from "@earendil-works/chord/delta"; -const changes = track({ output: "", count: 0 }); -changes.flush(); // opening base batch -changes.state.output += "done\n"; -changes.state.count += 1; +const tracker = track({ output: "", count: 0 }); +const change = tracker.beginChange(); +change.state.output += "done\n"; +change.state.count += 1; +const prepared = change.prepare(); -const ops = changes.flush(); -const replica = apply({ output: "", count: 0 }, ops); +tracker.adopt(prepared); +const replica = applyImmutable(prepared.base, prepared.ops); ``` -The first flush is always a complete base batch. Later flushes contain path-based -changes. `applyImmutable()` applies those batches while preserving prior replica -revisions. Replicated state instead uses transaction-scoped copy-on-write drafts: +`tracker.value` is always the latest adopted immutable revision. Preparation does +not change authority; adoption validates the preparation and swaps the root pointer. +Assigned containers are copied by value and unchanged subtrees may be shared between +revisions. + +The tracker uses trusted immutable ownership rather than defensive copying or +freezing. `track(initial)`, `prepareReplace(value)`, `replicatedState(initial)`, and +`replace(context, value)` take ownership of alias-free strict-JSON roots without +walking them. Callers must not mutate transferred or published data. Values placed +through drafts are already copied, so that walk also rejects non-strict JSON before +the draft changes. Published values are not frozen. In-process loopback consumers +may share their containers with the provider; mutating a consumed value violates +the contract and can corrupt authority. Clone or serialize at any mutable trust +boundary. + +Replicated state provides the same model through a callback: ```ts -const status = env.replicatedState({ output: "", count: 0 }); +const initial = { output: "", count: 0 }; +const status = env.replicatedState(initial); // transfers ownership of initial status.change(context, (draft) => { draft.output += "done\n"; draft.count += 1; @@ -116,16 +131,14 @@ status.change(context, (draft) => { ``` A successful `change()` publishes exactly one atomic revision. If its callback -throws, the original value and sequence remain unchanged. Draft handles are revoked -when the callback returns and assigned containers are copied by value. Unchanged -subtrees are shared between immutable revisions. Chord derives string append and -front-truncate operations, array splices and permutations, sets, and deletes from -the two revisions; a large delta falls back to a complete snapshot. Remote -connection plumbing encodes each batch independently for every client/state -pairing. `replace(context, value)` publishes a detached complete value directly. +throws, the original value and sequence remain unchanged. Draft handles become +unusable when the callback returns. Chord emits string append and front-truncate +operations, array splices and permutations, sets, and deletes; large edit sets may +fold into a complete replacement. Remote connection plumbing encodes each batch +independently for every client/state pairing. -The standalone [Delta guide](src/delta/README.md) documents the lower-level mutable -tracker, which remains available separately from replicated state. +The standalone [Delta guide](src/delta/README.md) defines the complete ownership, +lifecycle, operation, and replica contracts. ## Bundling and loading facets diff --git a/packages/chord/src/api.ts b/packages/chord/src/api.ts index d7961eed8..b566965c1 100644 --- a/packages/chord/src/api.ts +++ b/packages/chord/src/api.ts @@ -92,6 +92,10 @@ export function replicatedState( source: ReplicatedStateSource, options?: ReplicatedStateSourceOptions, ): AttachedReplicatedState; +/** + * Create authoritative state by taking immutable ownership of an alias-free strict-JSON root. + * The caller must not mutate `initial` after this call. + */ export function replicatedState(initial: T): MutableReplicatedState; export function replicatedState( initialOrSource: object | ReplicatedStateSource, diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index ea7d53a0a..d8527dd2d 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -1,408 +1,177 @@ # Chord Delta -Chord Delta synchronizes JSON values from an authoritative producer to an -ordered replica. It is available from `@earendil-works/chord/delta`. +Chord Delta produces immutable JSON revisions and exact operation batches for +ordered replicas. Import it from `@earendil-works/chord/delta`. -A change is represented by an `Op`: a JSON tuple for replacing, setting, -deleting, updating a string, splicing an array, or permuting an array. Producers -use `track()`; replicas use `apply()` or `applyImmutable()`. +Immutability is an ownership contract. Nothing is frozen or defensively copied, +so an illegal mutation is not detected. It silently corrupts state. ```ts -import { apply, track } from "@earendil-works/chord/delta"; +import { applyImmutable, track } from "@earendil-works/chord/delta"; -const tracker = track({ output: "", entries: [] as string[] }); -let replica = apply(undefined, tracker.flush()); +const initial = { output: "", entries: [] as { id: number }[] }; +const tracker = track(initial); // `initial` is transferred: never mutate it again -tracker.state.output += "done\n"; -tracker.state.entries.push("result"); -replica = apply(replica, tracker.flush()); +const change = tracker.beginChange(); +change.state.output += "done\n"; // the draft is mutable only while the change is open +change.state.entries.push({ id: 1 }); // placed values are cloned; the caller keeps its object +const prepared = change.prepare(); // draft handles are unusable from here on + +// tracker.value, prepared.base, prepared.value, and prepared.ops (including paths +// and payloads) are immutable by contract. +tracker.adopt(prepared); // tracker.value === prepared.value + +// Shares containers with prepared.base and the op payloads: never mutate it either. +const replica = applyImmutable(prepared.base, prepared.ops); ``` -The first `flush()` returns one operation containing the complete value. Each -later flush returns operations whose application transforms the previously -published value into the current value. It returns `[]` when no tracked mutation -is pending, but a mutation window that restores its starting value may still -produce a redundant batch. +## Mutation rights -`applyImmutable()` copies only containers along changed paths and shares -unchanged subtrees. It does not mutate, clone, or freeze either complete input. -Chord's replicated-state producers use a separate transaction-scoped -copy-on-write draft and derive these operation batches from immutable revisions; -consumers still observe complete immutable values. +| Value | May it be mutated? | +| --- | --- | +| Root passed to `track()`, `prepareReplace()`, `replicatedState()`, or `replace()` | No. Ownership moved to the tracker. | +| `change.state` and handles read from it | Yes, only while that change is open. After `prepare()`, `abort()`, or another adoption, every use throws. Writes through a handle whose element was removed from the draft are ignored. | +| 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. | +| 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. | -## Sending or storing changes +## Replaying batches -`flush()` produces decoded `Op[]` with complete paths. This is convenient for -local use but repeats long paths on the wire or disk. - -`encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates -the encoded tuples, restores complete paths, and returns the `Op[]` required by -`apply()`: +Immutable replay is the preferred in-process path. One batch can fan out to any +number of immutable replicas: ```ts -import { apply, decoder, encoder, track } from "@earendil-works/chord/delta"; - -const tracker = track({ output: "" }); -const enc = encoder(); // producer side -const dec = decoder(); // consumer side -let replica: { output: string } | undefined; - -const send = () => { - const ops = tracker.flush(); - const wire = enc.encode(ops); // serialize or store WireOp[] here - const received = dec.decode(wire); - replica = apply(replica, received); -}; +let replica = tracker.value; // shared with authority; never mutated +// For each adopted batch, in order: +replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base ``` -Encoding is optional for local application. Never pass `WireOp[]` directly to -`apply()`. - -An encoder and decoder are stateful. Use one pair for each ordered stream. The -encoder assigns numeric IDs to paths used across batches; the decoder remembers -the corresponding definitions. A complete-value operation resets both path -dictionaries, so replay can begin at that batch with a fresh decoder. - -Path omission is local to one batch. Numeric path IDs may span batches. Each -independently hydrated replicated-state stream needs its own encoder and decoder. -Do not share a pair between state members or subscriptions, even when their -batches use the same ordered transport connection. - -## Operation vocabulary - -A path is an array of object keys and array indices: +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: ```ts -["operation", "message", "content", 0, "text"] +import { apply } from "@earendil-works/chord/delta"; + +const detach = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +let replica = detach(tracker.value); // exclusively owned starting root +// For each adopted batch, in order: +replica = apply(replica, detach(prepared.ops)); // fresh copy for this replica only +// Only apply() may change `replica`; do not edit it in application code. ``` -### Decoded `Op` +Parsing a separately serialized message per replica is also a valid detachment. +`decode()` does not copy payloads, so decoding one in-memory `WireOp[]` for two +mutable replicas makes them alias each other. -`track().flush()` returns these tuples, and `apply()` accepts them: +## Do and don't + +- Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary. +- Do keep structural sharing between revisions, for example `replace(ctx, { ...state.value, changed })`. +- Do clone or serialize before handing any published value to code that may mutate it. +- Do settle every change with `prepare()` or `abort()`. +- Don't reuse one container at two places in a transferred root. +- Don't mutate anything the tracker owns, published, or received from a batch. +- Don't keep draft handles past their change or place a settled handle. +- Don't infer meaning from op shape. Only the resulting value is contractual. + +## Roots and placements + +Full roots are trusted. `track()` and `prepareReplace()` take ownership in O(1) +and never walk the tree. Roots must be alias-free, acyclic strict JSON: dense +plain arrays, plain or null-prototype objects with enumerable own data +properties, strings, booleans, finite numbers, and `null`. Violations have +unspecified behavior. For example, a container shared by two keys can make +`prepared.value` diverge from what replicas compute from `prepared.ops`. + +Draft placements are copied, and that copy walk also validates. Placements come +from property writes, index writes, `push`, `unshift`, `splice`, `fill`, and +`copyWithin`. A non-strict value throws `TypeError` before the draft changes; +for example, `push(valid, invalid)` inserts nothing. Rejected values include: +cycles, accessors (never invoked), symbol keys, class instances, sparse or +non-plain arrays, functions, bigint, `NaN`, infinities, and `undefined`. +Null-prototype objects are accepted and preserved. Placing a draft handle clones +its current content. Placing one value at several paths yields independent +containers. + +`undefined` rules: + +- `draft.obj.key = undefined` deletes `key`. +- `arr[i] = undefined`, `push(undefined)`, `unshift`, `splice`, and `fill` with `undefined` throw. +- `undefined` nested anywhere inside a placed object or array throws. It is not dropped like `JSON.stringify` does. + +Arrays stay dense. Writing past the next index and deleting an element throw. +Growing `length` inserts `null`; shrinking it removes elements. + +## Lifecycle + +`tracker.value` is the latest adopted revision. `beginChange()` opens an overlay +draft over it and never modifies it. A draft may stay open across `await`. +`prepare()` materializes the candidate and ops and does not change authority. +`adopt()` checks the preparation and swaps the root pointer. + +Several changes may be open or prepared from one revision. Adopting one makes all +others stale: open drafts become unusable and their `prepare()` throws, and +prepared competitors are rejected by `adopt()`. `adopt()` also rejects foreign, +aborted, and already consumed preparations. `Change.abort()` or +`Prepared.abort()` after `prepare()` prevents adoption. The candidate stays +readable. + +No-op batches: + +- Empty `ops` means `prepared.value === prepared.base`. Writes restored to their original value and deeply equal container assignments usually normalize to empty. +- Equality ignores key order and prototype. A deeply equal assignment keeps the previous revision's order and prototype, even if the draft showed the new ones. +- Structural array edits may emit a nonempty exact batch even when the result is deeply equal. +- Adopting a no-op still advances `tracker.revision` and stales competitors. Replicated state does not publish no-ops. + +`prepareReplace(value)` is a whole-root operation, not a diff. If `value` is +deeply equal to the current root, `ops` is empty and the current root is kept +(the comparison may traverse both trees). Otherwise `ops` is `[["r", value]]` +and `prepared.value === value`, so replicas receive the complete root. + +Array mutators: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`, +`fill`, and `copyWithin`. Held handles follow elements through reindexing. + +## Operations + +Paths contain object keys and non-negative integer array indices. | Tuple | Meaning | | --- | --- | | `["r", value]` | Replace the complete value. | -| `["s", path, value]` | Set a property or array element. | -| `["d", path]` | Delete an object property. | +| `["s", path, value]` | Set an object property or array element. | +| `["d", path]` | Delete an object property or remove an array element. | | `["a", path, text]` | Append to a string. | -| `["t", path, count]` | Remove UTF-16 code units from a string's front. | +| `["t", path, count]` | Remove `count` UTF-16 code units from a string's front. | | `["p", path, index, remove, items]` | Splice an array. | -| `["m", path, permutation]` | Reorder an array so `new[i] = old[permutation[i]]`. | - -Except for `r`, every decoded operation carries its complete path. `s`, `d`, -`a`, and `t` cannot address the root. `p` and `m` may address a root array. - -### Encoded `WireOp` - -A `PathRef` is either an inline path or a non-negative numeric path ID. -`WireOp` supports the following tuples: - -| Tuple | Meaning | -| --- | --- | -| `["r", value]` | Complete replacement; identical to decoded form. | -| `["#", id, path]` | Define a numeric path ID. | -| `["s", pathRef, value]` | Set with an inline or interned path. | -| `["s", value]` | Set using the previous path in this batch. | -| `["d", pathRef]` | Delete with an inline or interned path. | -| `["d"]` | Delete using the previous path. | -| `["a", pathRef, text]` | Append with an inline or interned path. | -| `["a", text]` | Append using the previous path. | -| `["t", pathRef, count]` | Front-truncate with an inline or interned path. | -| `["t", count]` | Front-truncate using the previous path. | -| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. | -| `["p", index, remove, items]` | Splice using the previous path. | -| `["m", pathRef, permutation]` | Reorder with an inline or interned path. | -| `["m", permutation]` | Reorder using the previous path. | - -For example, adjacent decoded operations on one path: - -```ts -[ - ["t", ["output"], 200], - ["a", ["output"], "next chunk"], -] -``` - -encode to: - -```ts -[ - ["t", ["output"], 200], - ["a", "next chunk"], // reuses ["output"] -] -``` - -When `output` is used again in a later batch, the encoder defines an ID on its -second explicit use: - -```ts -[ - ["#", 0, ["output"]], - ["a", 0, "more"], -] -``` - -Later batches can use `0` directly until a complete-value operation resets the -dictionary. - -## 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; -``` - -Operations are coalesced within a flush window when doing so is cheap and safe: - -```ts -tracker.state.status = "starting"; -tracker.state.status = "running"; -tracker.flush(); // one set to "running" -``` - -The operation sequence is not canonical. Equivalent changes may use different -verbs, and mutations that cancel can still produce a nonempty batch. Consumers -must depend on the resulting value, not the exact tuples or their minimality. - -Replacing an object or array is valid. Delta compares its properties and elements -with the outgoing value at assignment time: - -```ts -tracker.state.settings = { - ...plainSettings, - theme: "dark", -}; -``` - -Unchanged properties produce no operations. Changed nested strings and arrays -still use string and splice operations. - -### Strings - -Appending text produces an `a` operation: - -```ts -tracker.state.output += "next line\n"; -``` - -Moving a bounded text window forward produces `t` followed by `a` when the -previous suffix matches the new prefix: - -```ts -tracker.state.output = tracker.state.output.slice(200) + nextChunk; -``` - -An unrelated replacement produces `s`. - -### Arrays - -Use normal array methods: - -```ts -tracker.state.messages.push(first); -tracker.state.messages.push(second); -tracker.state.messages.splice(3, 1, replacement); -``` - -Adjacent `push()` calls are normally coalesced into one tail `p`. Intervening -operations may keep them separate to preserve ordering. Changes to older -elements remain separate, and changes to newly pushed elements may be folded -into the pushed values when no structural operation intervenes. - -Front or middle insertion and removal are recorded directly. Edits before and -after an index-changing operation remain ordered against the array generation -they addressed. Sorting, reversing, `fill()`, and `copyWithin()` emit a snapshot -of the affected array; repeated whole-array mutators can therefore produce a -redundant snapshot even when their combined result restores the prior value. - -Sparse arrays are unsupported. Writing beyond the next index throws. Increasing -`length` creates explicit `null` elements; decreasing it removes elements. - -`fill()` and `copyWithin()` keep normal JavaScript reference semantics; an object -they place at several indices is published at each. - -### Large mutation windows - -#### Build once, assign once - -Every object or array assignment is diffed immediately. Do not repeatedly assign -large intermediate values before one flush: - -```ts -// Avoid: traverses every intermediate tree. -for (const frame of frames) tracker.state.view = render(frame); - -// Prefer: only the final tree crosses the tracked boundary. -const nextView = frames.reduce((view, frame) => renderInto(view, frame), initialView); -tracker.state.view = nextView; -``` - -For a few changes, mutate the leaves directly: - -```ts -for (const update of updates) { - tracker.state.view.rows[update.index]!.status = update.status; -} -``` - -#### Do not cancel whole-array mutators - -`sort()`, `reverse()`, `fill()`, and `copyWithin()` record snapshots. Cancelling -them still publishes the final snapshot: - -```ts -// Avoid: final value is unchanged, but a snapshot may still be sent. -tracker.state.items.reverse(); -tracker.state.items.reverse(); - -// Prefer: decide before mutating tracked state. -if (needsReverse) tracker.state.items.reverse(); -``` - -#### Publish large inserts and edit sets in chunks - -Pending inserted values are cloned for replica ownership. Very large unflushed -pushes therefore temporarily retain both the live values and their operation -payloads. Long operation/path histories may also collapse to a complete base -batch, increasing snapshot and wire cost. - -```ts -for (const chunk of chunks(items, 1_000)) { - tracker.state.items.push(...chunk); - replica = apply(replica, tracker.flush()); // send each batch in a real producer -} -``` - -Use the same pattern for large sets of unrelated edits: apply a bounded chunk, -publish it, then continue. - -### Optional properties - -Optional object properties use absence. They do not require `null`: - -```ts -type Settings = { label?: string; count: number }; -const tracker = track({ count: 0 }); - -tracker.state.label = "active"; -tracker.state.label = undefined; // produces d -// `delete tracker.state.label` is equivalent -``` - -`undefined` is accepted only as assignment syntax for deleting an object -property. It is not a JSON value. Initial and assigned objects cannot contain own -`undefined` values, and array elements cannot be `undefined`. Use `null` when an -array position or explicit empty value must remain present. - -## State ownership - -The object passed to `track()` becomes tracker-owned, as does any object later -assigned into state or inserted into an array. Mutate through `tracker.state`: - -```ts -const item = { status: "new" }; -tracker.state.item = item; - -tracker.state.item.status = "ready"; // tracked -item.status = "broken"; // NOT tracked: silently diverges from the replica -``` - -A reference retained from `tracker.state` stays correct across operations that -renumber it, and across the removal of the element it points at: - -```ts -const held = tracker.state.items[2]; -tracker.state.items.unshift(other); -held.name = "edited"; // publishes items[3].name - -tracker.state.items.splice(3, 1); -held.name = "gone"; // element is no longer in the tree: mutated, nothing published -``` - -One object may occupy several paths. Each live path is published: - -```ts -tracker.state.a = tracker.state.items[0]; -tracker.state.items[0].k = 1; // publishes both a.k and items[0].k -``` - -Tracked state must be a mutable JSON tree: - -- strings, booleans, finite numbers, `null`, arrays, and plain objects; -- no cycles; -- no sparse arrays, accessors, frozen objects, symbols, classes, functions, - `Map`, or `Set`. - -## Proxy lifetime and large reads - -Proxy caches are weak. Reading a subtree does not permanently retain its proxies -just because the underlying plain objects remain in the document. A proxy still -held by application code keeps its identity; held descendants retain the ancestor -tracking metadata needed to follow array reindexing. Explicit alias locations are -remembered separately from the lifetime of their public proxies. - -Collection is automatic, not a `flush()` side effect. JavaScript keeps newly -created or dereferenced `WeakRef` targets alive until the current job ends, and -finalizer cleanup can run later. A synchronous traversal can therefore still have -a substantial allocation peak. Retained-memory measurements must allow event-loop -turns as well as GC; a synchronous `gc()` immediately after the traversal is not -sufficient to measure weak-cache reclamation. - -This does not eliminate proxy construction/trap costs or full comparisons on -container assignment. `tracker.target` is available for read-only bulk inspection -without creating proxies. Never mutate through it; all tracked mutations must go -through `tracker.state` or a proxy obtained from it. - -See the [delta investigation findings](../../../durable/docs/chord-delta-findings.md) -for the full-traversal regression, measured trade-offs, and reproduction commands. - -## Tracker lifecycle - -```ts -tracker.flush(); // publish changes since the previous flush -tracker.rebase(); // make the next flush a complete replacement -tracker.discard(); // accept current changes without publishing them -tracker.state = replacement; // replace the root; next flush is complete -``` - -`discard()` intentionally prevents current changes from reaching existing -replicas. Use it only when those replicas do not need the discarded changes. - -`flush()` guarantees convergence, not a minimal or canonical diff. Any nonempty -batch advances replicated-state sequence numbers and notifies subscribers, even -if applying it leaves the value deeply equal to the previous revision. To bound -pending operation and path history, a sufficiently long mutation window may -collapse to a complete base batch automatically. This bounds accumulated log -metadata, not payload bytes or peak allocation, and trades one full-value -snapshot for an additional recovery point. - -`apply()` adopts object and array payloads from its input batch. Do not freeze a -batch before applying it, and do not apply one in-memory batch to multiple -mutable replicas unless each replica owns that batch. A serialized and decoded -batch is already detached. `applyImmutable()` instead treats its previous value -and operation payloads as immutable, so one batch can safely fan out in-process. - -A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream. -Discard its decoder and replica, then recover from a later base batch. `apply()` -is not transactional; operations before the failing operation may already have -changed the replica. - -## 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. One object at several paths publishes each - path separately, and a replica holds a distinct value at each. -- 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, as - described under Arrays. -- 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. +| `["m", path, permutation]` | Reorder an array: `new[i] = old[permutation[i]]`. | + +Batches are exact but not canonical. The same change may use different tuples, +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`. +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. + +`encoder()` interns repeated paths into `WireOp` tuples; `decoder()` validates +and restores `Op` tuples. Use one encoder/decoder pair per ordered state stream. +Path IDs span batches and an `r` resets both dictionaries. After a decode or +apply error, discard the decoder and replica and recover from a later `r`. + +## Limits and footguns + +- No freezing. Mutating any immutable value corrupts state silently. +- Loopback sharing: in-process service consumers may receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge. +- A change inside a large flat array copies that array's pointer storage for the new revision. +- Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap. +- External proxies as placements, argument coercion callbacks that mutate the draft, non-primitive array indices, and non-numeric comparator results are out of contract. So are sort comparators that mutate, prepare, abort, or adopt. Behavior is unspecified. +- Object identity is not replicated. Each path is an independent value placement. +- One tracker is one revision sequence. Delivery order and persistence belong to the surrounding protocol. diff --git a/packages/chord/src/delta/cow/apply-immutable.ts b/packages/chord/src/delta/apply-immutable-batch.ts similarity index 96% rename from packages/chord/src/delta/cow/apply-immutable.ts rename to packages/chord/src/delta/apply-immutable-batch.ts index 991bba005..338e251b1 100644 --- a/packages/chord/src/delta/cow/apply-immutable.ts +++ b/packages/chord/src/delta/apply-immutable-batch.ts @@ -1,4 +1,4 @@ -import type { JsonValue } from "../../types.ts"; +import type { JsonValue } from "../types.ts"; import { apply as applyMutable, assertValidOp, @@ -7,7 +7,7 @@ import { PathError, type Seg, UnsafePathError, -} from "../index.ts"; +} from "./index.ts"; type JsonContainer = JsonValue[] | Record; diff --git a/packages/chord/src/delta/apply-immutable-trusted.ts b/packages/chord/src/delta/apply-immutable-trusted.ts new file mode 100644 index 000000000..9049ab040 --- /dev/null +++ b/packages/chord/src/delta/apply-immutable-trusted.ts @@ -0,0 +1,128 @@ +import type { JsonValue } from "../types.ts"; +import type { Op, Path, Seg } from "./index.ts"; + +type JsonContainer = JsonValue[] | Record; +type CopiedPath = { root: JsonValue; target: JsonContainer }; + +const DATA_DESCRIPTOR: PropertyDescriptor = { + value: undefined, + writable: true, + enumerable: true, + configurable: true, +}; + +/** Apply self-produced trusted operations while copying each touched container once. */ +export function applyImmutableTrusted(target: T, operations: readonly Op[]): T { + let root = target as unknown as JsonValue; + const owned = new WeakSet(); + for (const operation of operations) { + if (operation[0] === "r") { + root = operation[1]; + continue; + } + if (operation[0] === "p" || operation[0] === "m") { + const copied = copyPath(root, operation[1], operation[1].length, owned); + root = copied.root; + if (!Array.isArray(copied.target)) throw new TypeError("Trusted array operation target is not an array"); + if (operation[0] === "p") spliceTrusted(copied.target, operation[2], operation[3], operation[4]); + else permuteTrusted(copied.target, operation[2]); + continue; + } + + const path = operation[1]; + const copied = copyPath(root, path, path.length - 1, owned); + root = copied.root; + const key = path[path.length - 1]!; + switch (operation[0]) { + case "s": + defineData(copied.target, key, operation[2]); + break; + case "d": + if (Array.isArray(copied.target)) { + if (typeof key !== "number") throw new TypeError("Trusted array deletion key is not numeric"); + spliceTrusted(copied.target, key, 1, []); + } else Reflect.deleteProperty(copied.target, key); + break; + case "a": { + const current = read(copied.target, key); + if (typeof current !== "string") throw new TypeError("Trusted append target is not a string"); + defineData(copied.target, key, `${current}${operation[2]}`); + break; + } + case "t": { + const current = read(copied.target, key); + if (typeof current !== "string") throw new TypeError("Trusted truncate target is not a string"); + defineData(copied.target, key, current.slice(operation[2])); + break; + } + } + } + return root as unknown as T; +} + +function copyPath(root: JsonValue, path: Path, length: number, owned: WeakSet): CopiedPath { + if (!isContainer(root)) throw new TypeError("Trusted operation root is not a container"); + let copiedRoot = root; + if (!owned.has(root)) { + copiedRoot = shallowCopy(root); + owned.add(copiedRoot); + } + let destination = copiedRoot as JsonContainer; + for (let index = 0; index < length; index++) { + const segment = path[index]!; + const child = read(destination, segment); + if (!isContainer(child)) throw new TypeError("Trusted operation path is not a container"); + if (owned.has(child)) { + destination = child; + continue; + } + const copy = shallowCopy(child); + defineData(destination, segment, copy); + owned.add(copy); + destination = copy; + } + return { root: copiedRoot, target: destination }; +} + +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 read(target: JsonContainer, key: Seg): JsonValue { + return (target as Record)[key]!; +} + +function defineData(target: JsonContainer, key: Seg, value: JsonValue): void { + DATA_DESCRIPTOR.value = value; + Object.defineProperty(target, key, DATA_DESCRIPTOR); + DATA_DESCRIPTOR.value = undefined; +} + +function spliceTrusted(target: JsonValue[], start: number, remove: number, items: readonly JsonValue[]): void { + const oldLength = target.length; + const delta = items.length - remove; + if (delta > 0) { + target.length = oldLength + delta; + target.copyWithin(start + items.length, start + remove, oldLength); + } else if (delta < 0) { + target.copyWithin(start + items.length, start + remove, oldLength); + target.length = oldLength + delta; + } + for (let index = 0; index < items.length; index++) defineData(target, start + index, items[index]!); +} + +function permuteTrusted(target: JsonValue[], permutation: readonly number[]): void { + if (target.length !== permutation.length) throw new TypeError("Trusted permutation length mismatch"); + const previous = target.slice(); + for (let index = 0; index < permutation.length; index++) target[index] = previous[permutation[index]!]!; +} + +function isContainer(value: unknown): value is JsonContainer { + return value !== null && typeof value === "object"; +} diff --git a/packages/chord/src/delta/astra/README.md b/packages/chord/src/delta/astra/README.md deleted file mode 100644 index 82449d6e6..000000000 --- a/packages/chord/src/delta/astra/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Astra trusted contract - -Astra is an exact overlay tracker for trusted mutable live state. `track()` and -`prepareReplace()` take ownership of their roots in O(1); callers must not mutate -transferred roots except through an active Astra draft. Successful adoption -mutates the committed root in place, so previously retained committed references -observe later transactions. - -Inputs and placements must already be alias-free strict JSON trees: plain objects, -dense arrays, strings, booleans, finite numbers, and `null`. Cycles, accessors, -proxies, symbols, classes, sparse arrays, `undefined`, and non-finite numbers are -outside the contract. Astra deliberately does not validate or freeze complete -input trees. - -Supported draft operations use ordinary property access, primitive array indices, -and non-reentrant array callbacks. The following are outside the trusted contract: - -- coercion callbacks that mutate the draft while an array method is converting an argument; -- mutations or `prepare()` calls from inside a `sort()` comparator; -- bigint or object-valued comparator results requiring coercion; -- non-primitive array indices. - -These exclusions avoid callback-order and coercion machinery on the overlay hot -paths. Behavior for excluded operations is unspecified. diff --git a/packages/chord/src/delta/astra/index.ts b/packages/chord/src/delta/astra/index.ts deleted file mode 100644 index 24e6889c6..000000000 --- a/packages/chord/src/delta/astra/index.ts +++ /dev/null @@ -1,2005 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import type { Draft } from "../draft.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; - -export type { Draft, JsonValue, Op }; - -type Primitive = null | boolean | number | string; -type Container = JsonValue[] | Record; -type Stored = JsonValue; -type Status = "open" | "prepared" | "consumed" | "aborted" | "stale" | "faulted"; - -type InsertSource = { values: Stored[] }; -type ParentKind = 0 | 1 | 2; // object, base-array entry, inserted-array entry - -type BasePiece = { kind: "base"; start: number; length: number; step: 1 | -1 }; -type InsertPiece = { kind: "insert"; source: InsertSource; start: number; length: number; step: 1 | -1 }; -type Piece = BasePiece | InsertPiece; - -type PieceNode = { - piece: Piece; - left: PieceNode | undefined; - right: PieceNode | undefined; - priority: number; - elements: number; -}; - -type PieceLocation = { piece: Piece; logicalStart: number; minimum: number; maximum: number }; - -type DenseRegion = { start: number; length: number }; -type DenseCandidates = { indices: number[]; bits: Uint8Array | undefined; length: number }; - -type ArrayPlan = { - removeRuns: number[]; - permutation: number[] | undefined; - insertRuns: number[]; -}; - -type ArrayOverlay = { - root: PieceNode | undefined; - pieces: Piece[] | undefined; - baseOverrides: Map; - insertOverrides: Map>; - structural: boolean; - generation: number; - plan: ArrayPlan | undefined; - seed: number; - locatedOffset: number; - baseLocations: PieceLocation[] | undefined; - insertLocations: Map | undefined; -}; - -type OverlayNode = { - context: OverlayContext; - base: Container; - parent: OverlayNode | undefined; - parentKind: ParentKind; - parentKey: string | number; - parentSource?: InsertSource; - parentExpected?: Container; - parentPlacement: boolean; - target: object; - proxy: object; - writeKey?: string; - writeValue?: Stored; - writes?: Map; - deleteKey?: string; - deletes?: Set; - readded?: Set; - array?: ArrayOverlay; - dirty?: boolean; - preparedPath?: Path; - applyDepth?: number; -}; - -type OverlayContext = { - owner: object; - tracker: WeakRef>; - baseRevision: number; - status: Status; - root: OverlayNode | undefined; - dirty: OverlayNode[]; - nodes: OverlayNode[]; - rawNodes: WeakMap; - ops: Op[] | undefined; - replacement: boolean; - replacementNoop: boolean; - baseValue: object | undefined; - adoptedValue: object | undefined; - registryRef: WeakRef | undefined; -}; - -type PreparedMetadata = { context: OverlayContext }; - -const NODE = Symbol("astra.overlay.node"); -const PREPARED = new WeakMap(); -const RELEASED: Record = {}; -const ARRAY_MUTATORS = new Set([ - "push", - "pop", - "shift", - "unshift", - "splice", - "reverse", - "sort", - "fill", - "copyWithin", -]); -const MAX_SPLICE_CHUNK = 10_000; -const MAX_DELTA_OPERATIONS = 4_096; - -export interface Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - readonly baseRevision: number; - abort(): void; -} - -export interface Change { - readonly state: Draft; - prepare(): Prepared; - abort(): void; -} - -export interface Tracker { - readonly value: T; - readonly revision: number; - beginChange(): Change; - prepareReplace(value: T): Prepared; - adopt(prepared: Prepared): void; -} - -class PreparedImpl implements Prepared { - readonly #context: OverlayContext; - readonly #view: T; - readonly base: T; - - constructor(context: OverlayContext, value: T) { - this.#context = context; - this.#view = value; - this.base = context.baseValue as T; - PREPARED.set(this, { context }); - } - - get value(): T { - return (this.#context.status === "consumed" ? this.#context.adoptedValue : this.#view) as T; - } - - get baseRevision(): number { - return this.#context.baseRevision; - } - - get ops(): readonly Op[] { - return ensureOperations(this.#context); - } - - abort(): void { - abortContext(this.#context); - } -} - -class ChangeImpl implements Change { - readonly #state: WeakRef; - #context: OverlayContext | undefined; - #preparedContext: WeakRef | undefined; - #settled = false; - - constructor(context: OverlayContext) { - this.#context = context; - this.#state = new WeakRef(context.root!.proxy); - } - - get state(): Draft { - const state = this.#state.deref(); - if (state === undefined) throw new TypeError("Cannot use a settled overlay"); - return state as Draft; - } - - prepare(): Prepared { - if (this.#settled) throw new Error("Change has already been settled"); - const context = this.#context!; - assertWritable(context); - context.status = "prepared"; - try { - if (!context.replacement) context.ops = emitOperations(context); - const prepared = new PreparedImpl(context, context.root!.proxy as T); - this.#preparedContext = new WeakRef(context); - this.#settled = true; - return prepared; - } catch (error) { - context.status = "aborted"; - clearContext(context); - this.#settled = true; - throw error; - } finally { - this.#context = undefined; - } - } - - abort(): void { - if (this.#settled) { - const context = this.#preparedContext?.deref(); - this.#preparedContext = undefined; - if (context !== undefined) abortContext(context); - return; - } - this.#settled = true; - abortContext(this.#context!); - this.#context = undefined; - } -} - -class TrackerImpl implements Tracker { - readonly #owner = {}; - readonly #contexts = new Set>(); - #value: T; - #revision = 0; - #pruneBudget = 256; - #fault: unknown; - - constructor(initial: T) { - this.#value = initial; - } - - get value(): T { - this.#assertHealthy(); - return this.#value; - } - - get revision(): number { - return this.#revision; - } - - beginChange(): Change { - this.#assertHealthy(); - const context = createContext(this, this.#owner, this.#revision, this.#value, false, this.#value); - this.#register(context); - return new ChangeImpl(context); - } - - prepareReplace(value: T): Prepared { - this.#assertHealthy(); - const context = createContext(this, this.#owner, this.#revision, value, true, this.#value); - context.status = "prepared"; - this.#register(context); - return new PreparedImpl(context, context.root!.proxy as T); - } - - adopt(prepared: Prepared): void { - this.#assertHealthy(); - const context = PREPARED.get(prepared as object)?.context; - if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (context.status === "consumed") throw new Error("Prepared change has already been used"); - if (context.status === "aborted") throw new Error("Prepared change has been aborted"); - if (context.status === "stale") throw new Error("Prepared change is stale"); - if (context.status !== "prepared") throw new Error("Prepared change is not ready"); - if (context.baseRevision !== this.#revision) { - context.status = "stale"; - clearContext(context); - throw new Error("Prepared change is stale"); - } - - // Detach payloads before the candidate placements become committed mutable data. - ensureOperations(context); - try { - if (context.replacement) { - if (!context.replacementNoop) this.#value = context.root!.base as T; - } else applyOverlay(context); - } catch (error) { - context.status = "faulted"; - this.#fault = error; - this.#invalidate(context); - throw new Error("Tracker faulted after a partial trusted adoption", { cause: error }); - } - context.adoptedValue = this.#value; - context.status = "consumed"; - this.#revision += 1; - this.#invalidate(context); - } - - releaseContext(context: OverlayContext): void { - if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef); - context.registryRef = undefined; - } - - #register(context: OverlayContext): void { - const reference = new WeakRef(context); - context.registryRef = reference; - this.#contexts.add(reference); - this.#pruneBudget -= 1; - if (this.#pruneBudget === 0) { - this.#prune(); - this.#pruneBudget = Math.max(256, this.#contexts.size); - } - } - - #prune(): void { - for (const reference of this.#contexts) { - if (reference.deref() === undefined) this.#contexts.delete(reference); - } - } - - #invalidate(winner: OverlayContext): void { - for (const reference of this.#contexts) { - const context = reference.deref(); - if (context === undefined) { - this.#contexts.delete(reference); - continue; - } - if (context !== winner && (context.status === "open" || context.status === "prepared")) { - context.status = "stale"; - } - clearContext(context); - this.#contexts.delete(reference); - } - this.#pruneBudget = 256; - } - - replacementBase(context: OverlayContext): T | undefined { - return context.baseRevision === this.#revision ? this.#value : undefined; - } - - #assertHealthy(): void { - if (this.#fault !== undefined) throw new Error("Tracker is faulted", { cause: this.#fault }); - } -} - -/** Take ownership of an alias-free mutable strict-JSON root in O(1). */ -export function track(initial: T): Tracker { - return new TrackerImpl(initial); -} - -function createContext( - tracker: TrackerImpl, - owner: object, - baseRevision: number, - root: T, - replacement: boolean, - base: T, -): OverlayContext { - const context: OverlayContext = { - owner, - tracker: new WeakRef(tracker as unknown as TrackerImpl), - baseRevision, - status: "open", - root: undefined, - dirty: [], - nodes: [], - rawNodes: new WeakMap(), - ops: undefined, - replacement, - replacementNoop: false, - baseValue: base, - adoptedValue: undefined, - registryRef: undefined, - }; - context.root = createNode(context, root as Container, undefined); - return context; -} - -const sharedObjectHandler: ProxyHandler = { - deleteProperty(target, property) { - return deleteProperty(nodeForTarget(target), property); - }, - defineProperty(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Defining overlay properties is not supported"); - }, - get(target, property) { - const node = nodeForTarget(target); - if (property === NODE) return node; - return getProperty(node, property); - }, - getOwnPropertyDescriptor(target, property) { - return getDescriptor(nodeForTarget(target), property); - }, - getPrototypeOf(target) { - const node = nodeForTarget(target); - assertReadable(node.context); - return Object.getPrototypeOf(node.base); - }, - has(target, property) { - return hasProperty(nodeForTarget(target), property); - }, - isExtensible(target) { - assertReadable(nodeForTarget(target).context); - return true; - }, - ownKeys(target) { - return ownKeys(nodeForTarget(target)); - }, - preventExtensions(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Overlays cannot be made non-extensible"); - }, - set(target, property, value) { - return setProperty(nodeForTarget(target), property, value); - }, - setPrototypeOf(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Changing an overlay prototype is not supported"); - }, -}; - -const sharedArrayHandler: ProxyHandler = sharedObjectHandler; - -function createNode( - context: OverlayContext, - base: Container, - parent: OverlayNode | undefined, - parentKind: ParentKind = 0, - parentKey: string | number = "", - parentSource: InsertSource | undefined = undefined, - parentExpected: Container | undefined = undefined, - parentPlacement = false, -): OverlayNode { - const existing = context.rawNodes.get(base); - if (existing !== undefined) return existing; - const target: object = Array.isArray(base) ? [] : {}; - if (Array.isArray(target)) target.length = (base as JsonValue[]).length; - const node: OverlayNode = { - context, - base, - parent, - parentKind, - parentKey, - parentPlacement, - target, - proxy: target, - }; - if (parentSource !== undefined) node.parentSource = parentSource; - if (parentExpected !== undefined) node.parentExpected = parentExpected; - Object.defineProperty(target, NODE, { value: node, configurable: true }); - node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); - context.rawNodes.set(base, node); - context.nodes.push(node); - return node; -} - -function nodeForTarget(target: object): OverlayNode { - const node = Object.getOwnPropertyDescriptor(target, NODE)?.value as OverlayNode | undefined; - if (node === undefined) throw new TypeError("Cannot use a settled overlay"); - return node; -} - -function assertReadable(context: OverlayContext): void { - if ( - context.status === "consumed" || - context.status === "aborted" || - context.status === "stale" || - context.status === "faulted" - ) { - throw new TypeError("Cannot use a settled overlay"); - } -} - -function assertWritable(context: OverlayContext): void { - assertReadable(context); - if (context.status !== "open") throw new TypeError("Prepared overlays are read-only"); -} - -function getProperty(node: OverlayNode, property: PropertyKey): unknown { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - if (property === "length") return arrayLength(overlay); - if (ARRAY_MUTATORS.has(property)) return arrayMutators[property as keyof typeof arrayMutators]; - const index = arrayIndex(property); - if (index !== undefined) { - if (index >= arrayLength(overlay)) return undefined; - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const value = entryValueAt(node, piece, sourceIndex); - if (!isContainer(value)) return value; - return createNode( - node.context, - value, - node, - piece.kind === "base" ? 1 : 2, - sourceIndex, - piece.kind === "insert" ? piece.source : undefined, - value, - piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex), - ).proxy; - } - return Reflect.get(Array.prototype, property, node.proxy); - } - if (typeof property === "symbol") return Reflect.get(node.base, property, node.proxy); - const key = String(property); - if (!objectHas(node, key)) { - if (isObjectDeleted(node, key) || Object.hasOwn(node.base, key)) return undefined; - return Reflect.get(node.base, property, node.proxy); - } - const value = objectValue(node, key); - if (!isContainer(value)) return value; - return createNode(node.context, value, node, 0, key, undefined, value, hasObjectWrite(node, key)).proxy; -} - -function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { - assertWritable(node.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(node.base)) { - if (property === "length") { - setArrayLength(node, toArrayLength(supplied)); - return true; - } - const index = arrayIndex(property); - if (index === undefined) throw new TypeError("Only array indices and length can be written"); - if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); - setArrayIndex(node, index, clonePlacement(supplied)); - return true; - } - const key = String(property); - if (supplied === undefined) return deleteProperty(node, key); - const stored = clonePlacement(supplied); - const current = objectValue(node, key); - const wasDeleted = isObjectDeleted(node, key); - if (!wasDeleted && !isContainer(stored) && current === stored) return true; - setObjectWrite(node, key, stored); - if (wasDeleted && Object.hasOwn(node.base, key)) { - if (node.readded === undefined) node.readded = new Set(); - node.readded.add(key); - } - deleteObjectDeletion(node, key); - markDirty(node); - return true; -} - -function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { - assertWritable(node.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(node.base)) throw new TypeError("Overlay arrays cannot contain holes"); - const key = String(property); - if (!objectHas(node, key)) return true; - deleteObjectWrite(node, key); - node.readded?.delete(key); - setObjectDeletion(node, key); - markDirty(node); - return true; -} - -function hasProperty(node: OverlayNode, property: PropertyKey): boolean { - assertReadable(node.context); - if (Array.isArray(node.base)) { - if (property === "length") return true; - const index = arrayIndex(property); - if (index !== undefined) return index < arrayLength(arrayOverlay(node)); - return property in Array.prototype; - } - return typeof property === "symbol" ? property in node.base : objectHas(node, String(property)); -} - -function ownKeys(node: OverlayNode): ArrayLike { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const length = arrayLength(arrayOverlay(node)); - return [...Array.from({ length }, (_, index) => String(index)), "length"]; - } - const keys = Object.keys(node.base).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); - const seen = new Set(keys); - if (node.writeKey !== undefined && !seen.has(node.writeKey)) { - keys.push(node.writeKey); - seen.add(node.writeKey); - } - for (const key of node.writes?.keys() ?? []) { - if (seen.has(key)) continue; - keys.push(key); - seen.add(key); - } - const indices: number[] = []; - const strings: string[] = []; - for (const key of keys) { - const index = arrayIndex(key); - if (index === undefined) strings.push(key); - else indices.push(index); - } - indices.sort((left, right) => left - right); - return [...indices.map(String), ...strings]; -} - -function getDescriptor(node: OverlayNode, property: PropertyKey): PropertyDescriptor | undefined { - assertReadable(node.context); - if (Array.isArray(node.base)) { - if (property === "length") { - const length = arrayLength(arrayOverlay(node)); - (node.target as unknown[]).length = length; - return Reflect.getOwnPropertyDescriptor(node.target, "length"); - } - const index = arrayIndex(property); - if (index === undefined || index >= arrayLength(arrayOverlay(node))) return undefined; - } else { - if (typeof property === "symbol" || !objectHas(node, String(property))) return undefined; - } - return { - configurable: true, - enumerable: true, - writable: node.context.status === "open", - value: getProperty(node, property), - }; -} - -function hasObjectWrite(node: OverlayNode, key: string): boolean { - return node.writeKey === key || (node.writes?.has(key) ?? false); -} - -function setObjectWrite(node: OverlayNode, key: string, value: Stored): void { - if (node.writes !== undefined) { - node.writes.set(key, value); - return; - } - if (node.writeKey === undefined || node.writeKey === key) { - node.writeKey = key; - node.writeValue = value; - return; - } - node.writes = new Map([ - [node.writeKey, node.writeValue!], - [key, value], - ]); - node.writeKey = undefined; - node.writeValue = undefined; -} - -function deleteObjectWrite(node: OverlayNode, key: string): void { - if (node.writeKey === key) { - node.writeKey = undefined; - node.writeValue = undefined; - } else node.writes?.delete(key); -} - -function isObjectDeleted(node: OverlayNode, key: string): boolean { - return node.deleteKey === key || (node.deletes?.has(key) ?? false); -} - -function setObjectDeletion(node: OverlayNode, key: string): void { - if (node.deletes !== undefined) { - node.deletes.add(key); - return; - } - if (node.deleteKey === undefined || node.deleteKey === key) { - node.deleteKey = key; - return; - } - node.deletes = new Set([node.deleteKey, key]); - node.deleteKey = undefined; -} - -function deleteObjectDeletion(node: OverlayNode, key: string): void { - if (node.deleteKey === key) node.deleteKey = undefined; - else node.deletes?.delete(key); -} - -function objectHas(node: OverlayNode, key: string): boolean { - if (isObjectDeleted(node, key)) return false; - return hasObjectWrite(node, key) || Object.hasOwn(node.base, key); -} - -function objectValue(node: OverlayNode, key: string): Stored { - if (node.writeKey === key) return node.writeValue!; - if (node.writes?.has(key)) return node.writes.get(key)!; - return (node.base as Record)[key]!; -} - -function markDirty(node: OverlayNode): void { - if (node.dirty) return; - node.dirty = true; - node.context.dirty.push(node); -} - -function arrayOverlay(node: OverlayNode): ArrayOverlay { - if (node.array !== undefined) return node.array; - const base = node.base as JsonValue[]; - const overlay: ArrayOverlay = { - root: undefined, - pieces: undefined, - baseOverrides: new Map(), - insertOverrides: new Map(), - structural: false, - generation: 0, - plan: undefined, - seed: 0x9e3779b9, - locatedOffset: 0, - baseLocations: undefined, - insertLocations: undefined, - }; - if (base.length > 0) - overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); - node.array = overlay; - return overlay; -} - -function nextPiecePriority(overlay: ArrayOverlay): number { - let value = overlay.seed; - value ^= value << 13; - value ^= value >>> 17; - value ^= value << 5; - overlay.seed = value >>> 0; - return overlay.seed; -} - -function treeElements(node: PieceNode | undefined): number { - return node?.elements ?? 0; -} - -function updatePieceNode(node: PieceNode): void { - node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); -} - -function createPieceNode(overlay: ArrayOverlay, piece: Piece): PieceNode { - return { piece, left: undefined, right: undefined, priority: nextPiecePriority(overlay), elements: piece.length }; -} - -function mergePieceTrees(left: PieceNode | undefined, right: PieceNode | undefined): PieceNode | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - if (left.priority >= right.priority) { - left.right = mergePieceTrees(left.right, right); - updatePieceNode(left); - return left; - } - right.left = mergePieceTrees(left, right.left); - updatePieceNode(right); - return right; -} - -function splitPieceTree( - overlay: ArrayOverlay, - root: PieceNode | undefined, - index: number, -): [PieceNode | undefined, PieceNode | undefined] { - if (root === undefined) return [undefined, undefined]; - const leftLength = treeElements(root.left); - if (index < leftLength) { - const [left, right] = splitPieceTree(overlay, root.left, index); - root.left = right; - updatePieceNode(root); - return [left, root]; - } - const pieceEnd = leftLength + root.piece.length; - if (index > pieceEnd) { - const [left, right] = splitPieceTree(overlay, root.right, index - pieceEnd); - root.right = left; - updatePieceNode(root); - return [root, right]; - } - if (index === leftLength) { - const left = root.left; - root.left = undefined; - updatePieceNode(root); - return [left, root]; - } - if (index === pieceEnd) { - const right = root.right; - root.right = undefined; - updatePieceNode(root); - return [root, right]; - } - const offset = index - leftLength; - const first = { ...root.piece, length: offset }; - const second = { - ...root.piece, - start: root.piece.start + root.piece.step * offset, - length: root.piece.length - offset, - }; - return [ - mergePieceTrees(root.left, createPieceNode(overlay, first)), - mergePieceTrees(createPieceNode(overlay, second), root.right), - ]; -} - -function leftmostPieceNode(node: PieceNode): PieceNode { - while (node.left !== undefined) node = node.left; - return node; -} - -function rightmostPieceNode(node: PieceNode): PieceNode { - while (node.right !== undefined) node = node.right; - return node; -} - -function mergeablePieces(left: Piece, right: Piece): boolean { - if (left.kind !== right.kind) return false; - if (left.kind === "insert" && left.source !== (right as InsertPiece).source) return false; - if (left.length === 1 && right.length === 1) return Math.abs(right.start - left.start) === 1; - return left.step === right.step && left.start + left.step * left.length === right.start; -} - -function joinNormalized( - overlay: ArrayOverlay, - left: PieceNode | undefined, - right: PieceNode | undefined, -): PieceNode | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - const leftPiece = rightmostPieceNode(left).piece; - const rightPiece = leftmostPieceNode(right).piece; - if (!mergeablePieces(leftPiece, rightPiece)) return mergePieceTrees(left, right); - const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); - const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); - const step = leftPiece.length === 1 ? ((rightPiece.start - leftPiece.start) as 1 | -1) : leftPiece.step; - const combined: Piece = - leftPiece.kind === "base" - ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } - : { - kind: "insert", - source: leftPiece.source, - start: leftPiece.start, - length: leftPiece.length + rightPiece.length, - step, - }; - return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); -} - -function flattenPieceTree(node: PieceNode | undefined, output: Piece[]): void { - if (node === undefined) return; - flattenPieceTree(node.left, output); - output.push(node.piece); - flattenPieceTree(node.right, output); -} - -function piecesOf(overlay: ArrayOverlay): readonly Piece[] { - if (overlay.pieces === undefined) { - overlay.pieces = []; - flattenPieceTree(overlay.root, overlay.pieces); - } - return overlay.pieces; -} - -function treeFromPieces(overlay: ArrayOverlay, pieces: Piece[]): PieceNode | undefined { - mergePieces(pieces); - let root: PieceNode | undefined; - for (const piece of pieces) root = mergePieceTrees(root, createPieceNode(overlay, piece)); - return root; -} - -function replaceAllPieces(overlay: ArrayOverlay, pieces: Piece[]): void { - overlay.root = treeFromPieces(overlay, pieces); - overlay.pieces = undefined; - overlay.baseLocations = undefined; - overlay.insertLocations = undefined; -} - -function arrayLength(overlay: ArrayOverlay): number { - return treeElements(overlay.root); -} - -function locatePiece(overlay: ArrayOverlay, index: number): Piece { - let node = overlay.root; - while (node !== undefined) { - const leftLength = treeElements(node.left); - if (index < leftLength) node = node.left; - else if (index >= leftLength + node.piece.length) { - index -= leftLength + node.piece.length; - node = node.right; - } else { - overlay.locatedOffset = index - leftLength; - return node.piece; - } - } - throw new RangeError("Array overlay index is out of range"); -} - -function arrayIndex(property: PropertyKey): number | undefined { - if (typeof property !== "string" || property === "") return undefined; - const index = Number(property); - if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; - return index; -} - -function entryValueAt(node: OverlayNode, piece: Piece, sourceIndex: number): Stored { - const overlay = arrayOverlay(node); - if (piece.kind === "base") { - return overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex)! - : (node.base as JsonValue[])[sourceIndex]!; - } - const overrides = overlay.insertOverrides.get(piece.source); - return overrides?.has(sourceIndex) ? overrides.get(sourceIndex)! : piece.source.values[sourceIndex]!; -} - -function hasEntryOverrideAt(overlay: ArrayOverlay, piece: Piece, sourceIndex: number): boolean { - return piece.kind === "base" - ? overlay.baseOverrides.has(sourceIndex) - : (overlay.insertOverrides.get(piece.source)?.has(sourceIndex) ?? false); -} - -function extendRightmostPiece(node: PieceNode, amount: number): void { - if (node.right !== undefined) extendRightmostPiece(node.right, amount); - else node.piece.length += amount; - updatePieceNode(node); -} - -function invalidatePieceCaches(overlay: ArrayOverlay): void { - overlay.pieces = undefined; - overlay.baseLocations = undefined; - overlay.insertLocations = undefined; - overlay.plan = undefined; -} - -function replacePieceRange(node: OverlayNode, index: number, remove: number, inserted: Piece[]): void { - if (remove === 0 && inserted.length === 0) return; - const overlay = arrayOverlay(node); - if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== undefined) { - const addition = inserted[0]!; - const tail = rightmostPieceNode(overlay.root).piece; - if ( - addition.kind === "insert" && - tail.kind === "insert" && - tail.step === 1 && - tail.start + tail.length === tail.source.values.length - ) { - for (const value of addition.source.values) tail.source.values.push(value); - extendRightmostPiece(overlay.root, addition.length); - invalidatePieceCaches(overlay); - overlay.structural = true; - overlay.generation += 1; - (node.target as unknown[]).length = arrayLength(overlay); - markDirty(node); - return; - } - } - const [left, rest] = splitPieceTree(overlay, overlay.root, index); - const [, right] = splitPieceTree(overlay, rest, remove); - const middle = treeFromPieces(overlay, inserted); - overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); - invalidatePieceCaches(overlay); - overlay.structural = true; - overlay.generation += 1; - (node.target as unknown[]).length = arrayLength(overlay); - markDirty(node); -} - -function mergePieces(pieces: Piece[]): void { - for (let index = 1; index < pieces.length; ) { - const left = pieces[index - 1]!; - const right = pieces[index]!; - const sameSource = - left.kind === right.kind && (left.kind === "base" || left.source === (right as InsertPiece).source); - if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { - left.step = (right.start - left.start) as 1 | -1; - left.length = 2; - pieces.splice(index, 1); - } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { - left.length += right.length; - pieces.splice(index, 1); - } else index += 1; - } -} - -function insertPiece(items: Stored[]): InsertPiece[] { - if (items.length === 0) return []; - return [{ kind: "insert", source: { values: items }, start: 0, length: items.length, step: 1 }]; -} - -function setArrayIndex(node: OverlayNode, index: number, stored: Stored): void { - const overlay = arrayOverlay(node); - const length = arrayLength(overlay); - if (index === length) { - replacePieceRange(node, length, 0, insertPiece([stored])); - return; - } - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const current = entryValueAt(node, piece, sourceIndex); - if (!isContainer(stored) && current === stored) return; - if (piece.kind === "base") { - if (!isContainer(stored) && stored === (node.base as JsonValue[])[sourceIndex]) { - overlay.baseOverrides.delete(sourceIndex); - } else overlay.baseOverrides.set(sourceIndex, stored); - } else { - let overrides = overlay.insertOverrides.get(piece.source); - if (!isContainer(stored) && stored === piece.source.values[sourceIndex]) { - overrides?.delete(sourceIndex); - if (overrides?.size === 0) overlay.insertOverrides.delete(piece.source); - } else { - if (overrides === undefined) { - overrides = new Map(); - overlay.insertOverrides.set(piece.source, overrides); - } - overrides.set(sourceIndex, stored); - } - } - markDirty(node); -} - -function setArrayLength(node: OverlayNode, next: number): void { - const current = arrayLength(arrayOverlay(node)); - if (next === current) return; - if (next < current) replacePieceRange(node, next, current - next, []); - else replacePieceRange(node, current, 0, insertPiece(Array.from({ length: next - current }, () => null))); -} - -function toArrayLength(value: unknown): number { - const number = Number(value); - if (!Number.isInteger(number) || number < 0 || number >= 4_294_967_296) throw new RangeError("Invalid array length"); - return number; -} - -function toIntegerOrInfinity(value: unknown): number { - const number = Number(value); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function mutatorNode(receiver: unknown): OverlayNode | undefined { - if (!isContainer(receiver)) return undefined; - const node = Reflect.get(receiver, NODE) as OverlayNode | undefined; - if (node === undefined) return undefined; - if (!Array.isArray(node.base)) throw new TypeError("Array mutator called on incompatible receiver"); - assertWritable(node.context); - return node; -} - -const arrayMutators = { - push(this: unknown, ...items: unknown[]): number { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.push, this, items) as number; - const length = arrayLength(arrayOverlay(node)); - replacePieceRange(node, length, 0, insertPiece(items.map(clonePlacement))); - return length + items.length; - }, - pop(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.pop, this, []); - const length = arrayLength(arrayOverlay(node)); - if (length === 0) return undefined; - const value = getProperty(node, String(length - 1)); - replacePieceRange(node, length - 1, 1, []); - return value; - }, - shift(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.shift, this, []); - const length = arrayLength(arrayOverlay(node)); - if (length === 0) return undefined; - const value = getProperty(node, "0"); - replacePieceRange(node, 0, 1, []); - return value; - }, - unshift(this: unknown, ...items: unknown[]): number { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.unshift, this, items) as number; - replacePieceRange(node, 0, 0, insertPiece(items.map(clonePlacement))); - return arrayLength(arrayOverlay(node)); - }, - splice(this: unknown, ...args: unknown[]): unknown[] { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; - const length = arrayLength(arrayOverlay(node)); - const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const removed = Array.from({ length: remove }, (_, offset) => getProperty(node, String(start + offset))); - const items = args.slice(2).map(clonePlacement); - replacePieceRange(node, start, remove, insertPiece(items)); - setArrayLength(node, length - remove + items.length); - return removed; - }, - reverse(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.reverse, this, []); - const overlay = arrayOverlay(node); - if (arrayLength(overlay) < 2) return node.proxy; - const pieces = [...piecesOf(overlay)].reverse(); - for (const piece of pieces) { - piece.start += piece.step * (piece.length - 1); - piece.step = piece.step === 1 ? -1 : 1; - } - replaceAllPieces(overlay, pieces); - overlay.structural = true; - overlay.generation += 1; - overlay.plan = undefined; - markDirty(node); - return node.proxy; - }, - sort(this: unknown, comparator?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); - if (comparator !== undefined && typeof comparator !== "function") - throw new TypeError("Comparator must be a function"); - const overlay = arrayOverlay(node); - const insertedSources: InsertSource[] = []; - const insertedIndices: number[] = []; - const insertedValues: unknown[] = []; - const baseValues: unknown[] = new Array((node.base as JsonValue[]).length); - const order: number[] = []; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - if (piece.kind === "base") { - order.push(sourceIndex); - baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); - } else { - insertedSources.push(piece.source); - insertedIndices.push(sourceIndex); - const token = -insertedSources.length; - order.push(token); - insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); - } - } - } - const baseSnapshot = new Map(overlay.baseOverrides); - const insertSnapshots = new Map>(); - for (const [source, overrides] of overlay.insertOverrides) insertSnapshots.set(source, new Map(overrides)); - const generation = overlay.generation; - order.sort((left, right) => { - const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; - const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; - if (typeof comparator === "function") - return Number(Reflect.apply(comparator, undefined, [leftValue, rightValue])); - const a = String(leftValue); - const b = String(rightValue); - return a < b ? -1 : a > b ? 1 : 0; - }); - if ( - baseSnapshot.size > 0 || - insertSnapshots.size > 0 || - overlay.baseOverrides.size > 0 || - overlay.insertOverrides.size > 0 - ) { - for (const token of order) { - restoreSortOverride(overlay, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); - } - } - const comparatorWasStructural = overlay.generation !== generation; - const currentLength = arrayLength(overlay); - const samePrefix = - currentLength >= order.length && - order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); - if (!samePrefix) { - replacePieceRange( - node, - 0, - Math.min(order.length, currentLength), - piecesFromSortOrder(order, insertedSources, insertedIndices), - ); - } - if (comparatorWasStructural) deduplicateArrayEntries(node); - return node.proxy; - }, - fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); - const length = arrayLength(arrayOverlay(node)); - const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - if (end <= start) return node.proxy; - const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); - replacePieceRange(node, start, end - start, insertPiece(items)); - return node.proxy; - }, - copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); - const length = arrayLength(arrayOverlay(node)); - const target = clampIndex(toIntegerOrInfinity(targetArg), length); - const start = clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - const count = Math.min(Math.max(end - start, 0), length - target); - const values = Array.from({ length: count }, (_, offset) => - clonePlacement(getProperty(node, String(start + offset))), - ); - replacePieceRange(node, target, count, insertPiece(values)); - return node.proxy; - }, -}; - -function sortTokenSource(token: number, sources: readonly InsertSource[]): InsertSource | undefined { - return token < 0 ? sources[-token - 1] : undefined; -} - -function sortTokenIndex(token: number, indices: readonly number[]): number { - return token < 0 ? indices[-token - 1]! : token; -} - -function publicSortValue( - node: OverlayNode, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], -): unknown { - const overlay = arrayOverlay(node); - const source = sortTokenSource(token, sources); - const sourceIndex = sortTokenIndex(token, indices); - const value = - source === undefined - ? overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex)! - : (node.base as JsonValue[])[sourceIndex]! - : (overlay.insertOverrides.get(source)?.has(sourceIndex) ?? false) - ? overlay.insertOverrides.get(source)!.get(sourceIndex)! - : source.values[sourceIndex]!; - if (!isContainer(value)) return value; - return createNode( - node.context, - value, - node, - source === undefined ? 1 : 2, - sourceIndex, - source, - value, - source !== undefined || overlay.baseOverrides.has(sourceIndex), - ).proxy; -} - -function restoreSortOverride( - overlay: ArrayOverlay, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], - baseSnapshot: ReadonlyMap, - insertSnapshots: ReadonlyMap>, -): void { - const source = sortTokenSource(token, sources); - const sourceIndex = sortTokenIndex(token, indices); - if (source === undefined) { - if (baseSnapshot.has(sourceIndex)) overlay.baseOverrides.set(sourceIndex, baseSnapshot.get(sourceIndex)!); - else overlay.baseOverrides.delete(sourceIndex); - return; - } - let overrides = overlay.insertOverrides.get(source); - const snapshot = insertSnapshots.get(source); - if (snapshot?.has(sourceIndex)) { - if (overrides === undefined) { - overrides = new Map(); - overlay.insertOverrides.set(source, overrides); - } - overrides.set(sourceIndex, snapshot.get(sourceIndex)!); - } else { - overrides?.delete(sourceIndex); - if (overrides?.size === 0) overlay.insertOverrides.delete(source); - } -} - -function sameSortTokenAt( - overlay: ArrayOverlay, - logicalIndex: number, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], -): boolean { - const piece = locatePiece(overlay, logicalIndex); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const tokenSource = sortTokenSource(token, sources); - return ( - sourceIndex === sortTokenIndex(token, indices) && - ((piece.kind === "base" && tokenSource === undefined) || - (piece.kind === "insert" && piece.source === tokenSource)) - ); -} - -function deduplicateArrayEntries(node: OverlayNode): void { - const overlay = arrayOverlay(node); - const seenBase = new Set(); - const seenInsert = new Map>(); - const next: Piece[] = []; - let duplicated = false; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - let seen: boolean; - if (piece.kind === "base") { - seen = seenBase.has(sourceIndex); - seenBase.add(sourceIndex); - } else { - let indices = seenInsert.get(piece.source); - if (indices === undefined) { - indices = new Set(); - seenInsert.set(piece.source, indices); - } - seen = indices.has(sourceIndex); - indices.add(sourceIndex); - } - if (seen) { - duplicated = true; - appendMergedPiece( - next, - insertPiece([cloneStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]!, - ); - } else appendMergedPiece(next, singletonPiece(piece, sourceIndex)); - } - } - if (!duplicated) return; - replaceAllPieces(overlay, next); - overlay.structural = true; - overlay.generation += 1; - overlay.plan = undefined; - markDirty(node); -} - -function piecesFromSortOrder( - order: readonly number[], - sources: readonly InsertSource[], - indices: readonly number[], -): Piece[] { - const pieces: Piece[] = []; - for (const token of order) { - const source = token < 0 ? sources[-token - 1] : undefined; - const sourceIndex = token < 0 ? indices[-token - 1]! : token; - const previous = pieces.at(-1); - const sameSource = - previous !== undefined && - ((source === undefined && previous.kind === "base") || - (source !== undefined && previous.kind === "insert" && previous.source === source)); - if (previous !== undefined && sameSource) { - if (previous.length === 1) { - const step = sourceIndex - previous.start; - if (step === 1 || step === -1) { - previous.step = step; - previous.length = 2; - continue; - } - } else if (previous.start + previous.step * previous.length === sourceIndex) { - previous.length += 1; - continue; - } - } - pieces.push( - source === undefined - ? { kind: "base", start: sourceIndex, length: 1, step: 1 } - : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }, - ); - } - return pieces; -} - -function appendMergedPiece(pieces: Piece[], piece: Piece): void { - const previous = pieces.at(-1); - const sameSource = - previous !== undefined && - previous.kind === piece.kind && - (previous.kind === "base" || previous.source === (piece as InsertPiece).source); - if (previous !== undefined && sameSource && previous.length === 1 && piece.length === 1) { - const step = piece.start - previous.start; - if (step === 1 || step === -1) { - previous.step = step; - previous.length = 2; - return; - } - } - if ( - previous !== undefined && - sameSource && - previous.step === piece.step && - previous.start + previous.step * previous.length === piece.start - ) { - previous.length += piece.length; - } else pieces.push(piece); -} - -function singletonPiece(piece: Piece, sourceIndex: number): Piece { - return piece.kind === "base" - ? { kind: "base", start: sourceIndex, length: 1, step: 1 } - : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; -} - -function clonePlacement(value: unknown): Stored { - const proxyNode = isContainer(value) ? (Reflect.get(value, NODE) as OverlayNode | undefined) : undefined; - return proxyNode === undefined ? cloneJson(value) : cloneNode(proxyNode); -} - -function cloneJson(value: unknown): Stored { - if (!isContainer(value)) return value as Primitive; - if (Array.isArray(value)) return value.map(cloneJson); - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); - return result; -} - -function cloneNode(node: OverlayNode): Container { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - const result: JsonValue[] = []; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - } - return result; - } - const result = Object.create(Object.getPrototypeOf(node.base)) as Record; - for (const key of ownKeys(node) as string[]) - defineData(result, key, cloneStored(objectValue(node, key), node.context)); - return result; -} - -function cloneStored(value: Stored, context: OverlayContext): Stored { - if (!isContainer(value)) return value; - const node = context.rawNodes.get(value); - return node === undefined ? cloneJson(value) : cloneNode(node); -} - -function defineData(target: object, key: PropertyKey, value: JsonValue): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function emitOperations(context: OverlayContext): Op[] { - const operations: Op[] = []; - const forcedFolds = new Map(); - const emissionPaths = new Map(); - const denseIndices = new Map(); - for (const node of context.dirty) { - recordDenseArrayPosition(node, denseIndices); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - if (!overlay.structural && overlay.baseOverrides.size > 0) { - let candidates = denseIndices.get(node); - if (candidates === undefined) { - candidates = { indices: [], bits: undefined, length: (node.base as JsonValue[]).length }; - denseIndices.set(node, candidates); - } - for (const index of overlay.baseOverrides.keys()) addDenseCandidate(candidates, index); - } - } - } - const denseRegions = new Map(); - for (const [array, candidates] of denseIndices) { - const path = resolvePath(array); - if (path === undefined || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { - continue; - } - const regions = buildDenseRegions(candidates); - if (regions.length > 0) denseRegions.set(array, regions); - } - for (const node of context.dirty) { - const path = resolvePath(node); - if (path === undefined) continue; - node.applyDepth = path.length; - if (hasCoveringDenseRegion(node, path, denseRegions)) continue; - emissionPaths.set(node, path); - if (!Array.isArray(node.base) && hasReservedMutation(node)) forcedFolds.set(node, path); - const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); - if (reservedAt >= 0) { - let ancestor = node; - for (let depth = path.length; depth > reservedAt; depth--) ancestor = ancestor.parent!; - forcedFolds.set(ancestor, path.slice(0, reservedAt)); - } - } - for (const [node, path] of forcedFolds) emissionPaths.set(node, path); - for (const node of denseRegions.keys()) { - const path = resolvePath(node); - if (path !== undefined && !hasCoveringDenseRegion(node, path, denseRegions)) emissionPaths.set(node, path); - } - let maxDepth = 0; - for (const path of emissionPaths.values()) maxDepth = Math.max(maxDepth, path.length); - const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); - for (const [node, path] of emissionPaths) buckets[path.length]!.push(node); - const folded = new Set(); - for (const bucket of buckets) { - for (const node of bucket) { - const path = emissionPaths.get(node)!; - if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; - if (forcedFolds.has(node)) { - emitSet(operations, path, cloneNode(node)); - folded.add(node); - continue; - } - if (Array.isArray(node.base)) emitArrayOperations(node, path, operations, denseRegions.get(node)); - else emitObjectOperations(node, path, operations); - if (operations.length > MAX_DELTA_OPERATIONS) { - locatedDenseArray = undefined; - return [["r", cloneNode(context.root!)]]; - } - } - } - locatedDenseArray = undefined; - return operations; -} - -let locatedDenseArray: OverlayNode | undefined; -let locatedDenseIndex = 0; - -function locateDenseArrayPosition(node: OverlayNode): boolean { - let child = node; - while (child.parent !== undefined) { - const parent = child.parent; - if (Array.isArray(parent.base)) { - const overlay = arrayOverlay(parent); - if (child.parentKind !== 1 || overlay.structural) return false; - const sourceIndex = child.parentKey as number; - const current = overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex) - : (parent.base as JsonValue[])[sourceIndex]; - if (current !== child.parentExpected) return false; - locatedDenseArray = parent; - locatedDenseIndex = sourceIndex; - return true; - } - child = parent; - } - return false; -} - -function hasCoveringDenseRegion( - node: OverlayNode, - path: Path, - denseRegions: ReadonlyMap, -): boolean { - for (let parent = node.parent; parent !== undefined; parent = parent.parent) { - const regions = denseRegions.get(parent); - if (regions === undefined) continue; - const parentPath = resolvePath(parent); - if (parentPath === undefined || path.length <= parentPath.length) continue; - let matches = true; - for (let index = 0; index < parentPath.length; index++) { - if (path[index] !== parentPath[index]) { - matches = false; - break; - } - } - if (!matches) continue; - const index = path[parentPath.length]; - if (typeof index === "number" && regionContaining(regions, index)) return true; - } - return false; -} - -function addDenseCandidate(candidates: DenseCandidates, index: number): void { - if (candidates.bits !== undefined) { - candidates.bits[index] = 1; - return; - } - candidates.indices.push(index); - if (candidates.indices.length < 256) return; - candidates.bits = new Uint8Array(candidates.length); - for (const existing of candidates.indices) candidates.bits[existing] = 1; - candidates.indices.length = 0; -} - -function recordDenseArrayPosition(node: OverlayNode, groups: Map): void { - if (!locateDenseArrayPosition(node)) return; - let candidates = groups.get(locatedDenseArray!); - if (candidates === undefined) { - candidates = { indices: [], bits: undefined, length: (locatedDenseArray!.base as JsonValue[]).length }; - groups.set(locatedDenseArray!, candidates); - } - addDenseCandidate(candidates, locatedDenseIndex); -} - -function buildDenseRegions(candidates: DenseCandidates): DenseRegion[] { - if (candidates.bits === undefined) return []; - const regions: DenseRegion[] = []; - const bits = candidates.bits; - for (let at = 0; at < bits.length; ) { - while (at < bits.length && bits[at] === 0) at += 1; - if (at === bits.length) break; - const start = at; - let end = at; - let count = 0; - let gap = 0; - while (at < bits.length) { - if (bits[at] !== 0) { - count += 1; - end = at; - gap = 0; - } else if (++gap > 1) break; - at += 1; - } - const length = end - start + 1; - if (count >= 256 && count * 2 >= length) regions.push({ start, length }); - } - return regions; -} - -function regionContaining(regions: readonly DenseRegion[], index: number): boolean { - for (const region of regions) if (index >= region.start && index < region.start + region.length) return true; - return false; -} - -function hasReservedMutation(node: OverlayNode): boolean { - if (node.writeKey !== undefined && RESERVED_SEGMENTS.has(node.writeKey)) return true; - if (node.deleteKey !== undefined && RESERVED_SEGMENTS.has(node.deleteKey)) return true; - for (const key of node.writes?.keys() ?? []) if (RESERVED_SEGMENTS.has(key)) return true; - for (const key of node.deletes ?? []) if (RESERVED_SEGMENTS.has(key)) return true; - return false; -} - -function hasFoldedAncestor(node: OverlayNode, folded: ReadonlySet): boolean { - for (let parent = node.parent; parent !== undefined; parent = parent.parent) if (folded.has(parent)) return true; - return false; -} - -function hasPlacementAncestor(node: OverlayNode): boolean { - for (let current: OverlayNode | undefined = node; current?.parent !== undefined; current = current.parent) { - if (current.parentPlacement) return true; - } - return false; -} - -function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): void { - const nextPath = [...path, key] as unknown as NonEmptyPath; - const before = Object.hasOwn(node.base, key) ? (node.base as Record)[key] : undefined; - emitChangedValue(operations, nextPath, before, cloneStored(value, node.context)); -} - -function emitObjectOperations(node: OverlayNode, path: Path, operations: Op[]): void { - if (node.writeKey !== undefined) emitObjectWrite(node, path, operations, node.writeKey, node.writeValue!); - for (const [key, value] of node.writes ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - emitObjectWrite(node, path, operations, key, value); - } - if (node.deleteKey !== undefined && Object.hasOwn(node.base, node.deleteKey)) { - operations.push(["d", [...path, node.deleteKey] as unknown as NonEmptyPath]); - } - for (const key of node.deletes ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - if (Object.hasOwn(node.base, key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); - } -} - -function buildArrayPlan(node: OverlayNode): ArrayPlan { - const overlay = arrayOverlay(node); - if (overlay.plan !== undefined) return overlay.plan; - const base = node.base as JsonValue[]; - const pieces = piecesOf(overlay); - const retained = new Uint8Array(base.length); - const targetBase: number[] = []; - for (const piece of pieces) { - if (piece.kind !== "base") continue; - for (let offset = 0; offset < piece.length; offset++) { - const index = piece.start + piece.step * offset; - retained[index] = 1; - targetBase.push(index); - } - } - const removeRuns: number[] = []; - for (let end = base.length; end > 0; ) { - if (retained[end - 1] !== 0) { - end -= 1; - continue; - } - let start = end - 1; - while (start > 0 && retained[start - 1] === 0) start -= 1; - removeRuns.push(start, end - start); - end = start; - } - const retainedBase: number[] = []; - for (let index = 0; index < retained.length; index++) if (retained[index] !== 0) retainedBase.push(index); - let permutation: number[] | undefined; - if (targetBase.some((value, index) => value !== retainedBase[index])) { - const positions = new Map(retainedBase.map((value, index) => [value, index])); - permutation = targetBase.map((value) => positions.get(value)!); - } - const insertRuns: number[] = []; - let logicalIndex = 0; - for (let pieceIndex = 0; pieceIndex < pieces.length; ) { - const piece = pieces[pieceIndex]!; - if (piece.kind === "base") { - logicalIndex += piece.length; - pieceIndex += 1; - continue; - } - const startPiece = pieceIndex; - while (pieceIndex < pieces.length && pieces[pieceIndex]!.kind === "insert") { - logicalIndex += pieces[pieceIndex]!.length; - pieceIndex += 1; - } - insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); - } - overlay.plan = { removeRuns, permutation, insertRuns }; - return overlay.plan; -} - -function rangeLength(pieces: readonly Piece[], start: number, end: number): number { - let length = 0; - for (let index = start; index < end; index++) length += pieces[index]!.length; - return length; -} - -function emitArrayOperations( - node: OverlayNode, - path: Path, - operations: Op[], - denseRegions: readonly DenseRegion[] | undefined, -): void { - const overlay = arrayOverlay(node); - const base = node.base as JsonValue[]; - for (const region of denseRegions ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", [...path], region.start, region.length, cloneArrayRegion(node, region)]); - } - if (overlay.structural) { - const plan = buildArrayPlan(node); - for (let index = 0; index < plan.removeRuns.length; index += 2) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", [...path], plan.removeRuns[index]!, plan.removeRuns[index + 1]!, []]); - } - if (plan.permutation !== undefined) operations.push(["m", [...path], plan.permutation.slice()]); - const pieces = piecesOf(overlay); - for (let run = 0; run < plan.insertRuns.length; run += 3) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - const logicalIndex = plan.insertRuns[run]!; - const items: JsonValue[] = []; - for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { - const piece = pieces[pieceIndex]! as InsertPiece; - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - } - operations.push(["p", [...path], logicalIndex, 0, items]); - } - } - for (const [baseIndex, value] of overlay.baseOverrides) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - const index = findEntryIndex(overlay, 1, baseIndex, undefined); - if (index === undefined || (denseRegions !== undefined && regionContaining(denseRegions, index))) continue; - emitChangedValue( - operations, - [...path, index] as unknown as NonEmptyPath, - base[baseIndex], - cloneStored(value, node.context), - ); - } -} - -function cloneArrayRegion(node: OverlayNode, region: DenseRegion): JsonValue[] { - const overlay = arrayOverlay(node); - const result: JsonValue[] = []; - for (let index = region.start; index < region.start + region.length; index++) { - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - return result; -} - -function emitChangedValue(operations: Op[], path: NonEmptyPath, before: JsonValue | undefined, after: JsonValue): void { - if (!isContainer(after) && before === after) return; - if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return; - if (typeof before === "string" && typeof after === "string") { - if (after.startsWith(before)) { - if (after.length > before.length) operations.push(["a", path, after.slice(before.length)]); - return; - } - const shared = overlap(before, after, 65_536); - if (shared > 0) { - operations.push(["t", path, before.length - shared]); - if (after.length > shared) operations.push(["a", path, after.slice(shared)]); - return; - } - } - operations.push(["s", path, after]); -} - -function emitSet(operations: Op[], path: Path, value: JsonValue): void { - if (path.length === 0) operations.push(["r", value]); - else operations.push(["s", path as NonEmptyPath, value]); -} - -function resolvePath(node: OverlayNode): Path | undefined { - if (node.preparedPath !== undefined) return node.preparedPath; - if (node.parent === undefined) { - node.preparedPath = []; - return node.preparedPath; - } - const parentPath = resolvePath(node.parent); - if (parentPath === undefined) return undefined; - if (node.parentKind === 0) { - const key = node.parentKey as string; - if (!objectHas(node.parent, key) || objectValue(node.parent, key) !== node.parentExpected) return undefined; - node.preparedPath = [...parentPath, key]; - return node.preparedPath; - } - const overlay = arrayOverlay(node.parent); - const index = findEntryIndex(overlay, node.parentKind, node.parentKey as number, node.parentSource); - if (index === undefined) return undefined; - const piece = locatePiece(overlay, index); - if (entryValueAt(node.parent, piece, node.parentKey as number) !== node.parentExpected) return undefined; - node.preparedPath = [...parentPath, index]; - return node.preparedPath; -} - -function ensurePieceLocations(overlay: ArrayOverlay): void { - if (overlay.baseLocations !== undefined) return; - overlay.baseLocations = []; - overlay.insertLocations = new Map(); - let logicalStart = 0; - for (const piece of piecesOf(overlay)) { - const last = piece.start + piece.step * (piece.length - 1); - const location = { - piece, - logicalStart, - minimum: Math.min(piece.start, last), - maximum: Math.max(piece.start, last), - }; - if (piece.kind === "base") overlay.baseLocations.push(location); - else { - let locations = overlay.insertLocations.get(piece.source); - if (locations === undefined) { - locations = []; - overlay.insertLocations.set(piece.source, locations); - } - locations.push(location); - } - logicalStart += piece.length; - } - const byMinimum = (left: PieceLocation, right: PieceLocation): number => left.minimum - right.minimum; - overlay.baseLocations.sort(byMinimum); - for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); -} - -function findEntryIndex( - overlay: ArrayOverlay, - kind: ParentKind, - sourceIndex: number, - source: InsertSource | undefined, -): number | undefined { - ensurePieceLocations(overlay); - const locations = kind === 1 ? overlay.baseLocations! : overlay.insertLocations!.get(source!); - if (locations === undefined) return undefined; - let low = 0; - let high = locations.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (locations[middle]!.minimum <= sourceIndex) low = middle + 1; - else high = middle; - } - const location = locations[low - 1]; - if (location === undefined || sourceIndex > location.maximum) return undefined; - const offset = (sourceIndex - location.piece.start) / location.piece.step; - return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : undefined; -} - -function ensureOperations(context: OverlayContext): Op[] { - if (context.ops !== undefined) return context.ops; - assertReadable(context); - if (context.replacement) { - const base = context.tracker.deref()?.replacementBase(context); - context.replacementNoop = - base !== undefined && equalTrustedJson(base as unknown as JsonValue, context.root!.base as JsonValue); - context.ops = context.replacementNoop ? [] : [["r", cloneNode(context.root!)]]; - } else context.ops = emitOperations(context); - return context.ops; -} - -function equalTrustedJson(left: JsonValue, right: JsonValue): boolean { - if (left === right) return true; - if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left)) { - const other = right as JsonValue[]; - if (left.length !== other.length) return false; - for (let index = 0; index < left.length; index++) - if (!equalTrustedJson(left[index]!, other[index]!)) return false; - return true; - } - const other = right as Record; - const keys = Object.keys(left); - if (keys.length !== Object.keys(other).length) return false; - for (const key of keys) if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key]!, other[key]!)) return false; - return true; -} - -function applyOverlay(context: OverlayContext): void { - let maxDepth = 0; - for (const node of context.dirty) if (node.applyDepth !== undefined) maxDepth = Math.max(maxDepth, node.applyDepth); - const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); - for (const node of context.dirty) if (node.applyDepth !== undefined) buckets[node.applyDepth]!.push(node); - for (let depth = buckets.length - 1; depth >= 0; depth--) { - for (const node of buckets[depth]!) { - if (Array.isArray(node.base)) applyArrayNode(node); - else applyObjectNode(node); - } - } -} - -function applyObjectNode(node: OverlayNode): void { - const target = node.base as Record; - if (node.deleteKey !== undefined) Reflect.deleteProperty(target, node.deleteKey); - for (const key of node.deletes ?? []) Reflect.deleteProperty(target, key); - for (const key of node.readded ?? []) Reflect.deleteProperty(target, key); - if (node.writeKey !== undefined) applyObjectWrite(target, node, node.writeKey, node.writeValue!); - for (const [key, value] of node.writes ?? []) applyObjectWrite(target, node, key, value); -} - -function applyObjectWrite(target: Record, node: OverlayNode, key: string, value: JsonValue): void { - if (Object.hasOwn(target, key) && !node.readded?.has(key)) { - const current = target[key]!; - if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) return; - target[key] = value; - } else defineData(target, key, value); -} - -function applyArrayNode(node: OverlayNode): void { - const target = node.base as JsonValue[]; - const overlay = arrayOverlay(node); - if (overlay.structural) { - const plan = buildArrayPlan(node); - for (let index = 0; index < plan.removeRuns.length; index += 2) { - target.splice(plan.removeRuns[index]!, plan.removeRuns[index + 1]!); - } - if (plan.permutation !== undefined) permuteInPlace(target, plan.permutation); - const pieces = piecesOf(overlay); - for (let run = 0; run < plan.insertRuns.length; run += 3) { - const items: JsonValue[] = []; - for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { - const piece = pieces[pieceIndex]! as InsertPiece; - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - items.push(entryValueAt(node, piece, sourceIndex)); - } - } - insertChunked(target, plan.insertRuns[run]!, items); - } - } - for (const [baseIndex, value] of overlay.baseOverrides) { - const index = findEntryIndex(overlay, 1, baseIndex, undefined); - if (index === undefined) continue; - const current = target[index]!; - if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) continue; - target[index] = value; - } -} - -function permuteInPlace(values: JsonValue[], permutation: readonly number[]): void { - const visited = new Uint8Array(permutation.length); - for (let start = 0; start < permutation.length; start++) { - if (visited[start] !== 0) continue; - let current = start; - const saved = values[start]!; - while (true) { - visited[current] = 1; - const source = permutation[current]!; - if (source === start) { - values[current] = saved; - break; - } - values[current] = values[source]!; - current = source; - } - } -} - -function insertChunked(target: JsonValue[], index: number, items: JsonValue[]): void { - for (let offset = 0; offset < items.length; offset += MAX_SPLICE_CHUNK) { - target.splice(index + offset, 0, ...items.slice(offset, offset + MAX_SPLICE_CHUNK)); - } -} - -function abortContext(context: OverlayContext): void { - if ( - context.status === "aborted" || - context.status === "consumed" || - context.status === "stale" || - context.status === "faulted" - ) - return; - context.status = "aborted"; - clearContext(context); -} - -function clearContext(context: OverlayContext): void { - context.tracker.deref()?.releaseContext(context); - if (locatedDenseArray?.context === context) locatedDenseArray = undefined; - for (const node of context.nodes) { - Reflect.deleteProperty(node.target, NODE); - node.writes?.clear(); - node.deletes?.clear(); - node.readded?.clear(); - node.array?.pieces?.splice(0); - if (node.array !== undefined) { - node.array.root = undefined; - node.array.baseLocations = undefined; - node.array.insertLocations = undefined; - } - node.array?.baseOverrides.clear(); - node.array?.insertOverrides.clear(); - node.base = RELEASED; - node.parent = undefined; - node.parentSource = undefined; - node.parentExpected = undefined; - node.target = RELEASED; - node.proxy = RELEASED; - node.writeKey = undefined; - node.writeValue = undefined; - node.writes = undefined; - node.deleteKey = undefined; - node.deletes = undefined; - node.readded = undefined; - node.array = undefined; - node.preparedPath = undefined; - node.applyDepth = undefined; - } - context.dirty.length = 0; - context.nodes.length = 0; - context.rawNodes = new WeakMap(); - context.root = undefined; - context.baseValue = undefined; -} - -function isContainer(value: unknown): value is Container { - return value !== null && typeof value === "object"; -} diff --git a/packages/chord/src/delta/cow/README.md b/packages/chord/src/delta/cow/README.md deleted file mode 100644 index 49e4d132d..000000000 --- a/packages/chord/src/delta/cow/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Trusted COW contract - -This variant provides immutable copy-on-write revisions without recursive input validation or deep freezing. - -- `track(initial)` and `prepareReplace(value)` take ownership. Inputs are alias-free, acyclic strict JSON made from dense arrays, plain data objects, finite numbers, strings, booleans, and `null`. Accessors, custom mutation hooks, symbols, classes, sparse arrays, and external proxies are outside the contract. -- Do not mutate transferred roots, `tracker.value`, `Prepared.base`, `Prepared.value`, or `Prepared.ops` externally. `Prepared.value` and `Prepared.ops` are independent immutable ownership transfers. -- Values assigned through a draft are cloned by value for each placement. Mutate transaction state only through its draft handles. -- Every change must settle exactly once through `prepare()` followed by `adopt()`, or through `abort()`. Draft handles are invalid after preparation or abortion. -- Array indices and mutator index arguments must be primitive numbers. Objects with coercion callbacks and mutations caused during argument coercion are outside the contract. -- Sort comparators must be synchronous and pure: they return ordinary numbers and do not mutate drafts, prepare transactions, or start nested operations. - -Violating this contract is programmer error and may not be detected at runtime. diff --git a/packages/chord/src/delta/cow/diff.ts b/packages/chord/src/delta/cow/diff.ts deleted file mode 100644 index e635a58fd..000000000 --- a/packages/chord/src/delta/cow/diff.ts +++ /dev/null @@ -1,562 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; -import type { DiffHints } from "./draft.ts"; - -const DEFAULT_OVERLAP_SCAN = 65_536; -const MAX_DELTA_OPERATIONS = 4_096; -const overflowedBatches = new WeakSet(); - -const emitOperation = (operations: Op[], operation: Op): void => { - if (overflowedBatches.has(operations)) return; - if (operations.length >= MAX_DELTA_OPERATIONS) { - overflowedBatches.add(operations); - return; - } - operations.push(operation); -}; - -const isContainer = (value: JsonValue): value is JsonValue[] | Record => - value !== null && typeof value === "object"; - -const emitSet = (path: Path, value: JsonValue, operations: Op[]): void => { - if (path.length === 0) emitOperation(operations, ["r", value]); - else emitOperation(operations, ["s", path as NonEmptyPath, value]); -}; - -const equalJson = (left: JsonValue, right: JsonValue): boolean => { - if (left === right) return true; - if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left)) { - const other = right as JsonValue[]; - if (left.length !== other.length) return false; - for (let index = 0; index < left.length; index++) { - if (!equalJson(left[index]!, other[index]!)) return false; - } - return true; - } - const other = right as Record; - const keys = Object.keys(left); - if (keys.length !== Object.keys(other).length) return false; - for (const key of keys) { - if (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false; - } - return true; -}; - -const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => { - if (before.length !== after.length) return undefined; - // Cheap native probes before building a map: shifted/appended arrays fail here. - if (before.indexOf(after[after.length - 1]!) === -1 || before.indexOf(after[0]!) === -1) return undefined; - // A single index per value; duplicates upgrade to an index list. - const positions = new Map(); - for (let index = 0; index < before.length; index++) { - const value = before[index]!; - const entry = positions.get(value); - if (entry === undefined) positions.set(value, index); - else if (typeof entry === "number") positions.set(value, [entry, index]); - else entry.push(index); - } - const used = new Map(); - const result = new Array(after.length); - for (let index = 0; index < after.length; index++) { - const value = after[index]!; - const entry = positions.get(value); - if (entry === undefined) return undefined; - if (typeof entry === "number") { - if (used.has(value)) return undefined; - used.set(value, 1); - result[index] = entry; - continue; - } - const taken = used.get(value) ?? 0; - if (taken === entry.length) return undefined; - used.set(value, taken + 1); - result[index] = entry[taken]!; - } - return result; -}; - -const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => { - if (before === after) return; - if (after.length > before.length && after.slice(0, before.length) === before) { - emitOperation(operations, ["a", path, after.slice(before.length)]); - return; - } - const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN); - if (shared === 0) { - emitOperation(operations, ["s", path, after]); - return; - } - emitOperation(operations, ["t", path, before.length - shared]); - if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]); -}; - -type ArrayMatch = readonly [before: number, after: number]; - -type MatchCandidate = { - before: number; - after: number; - previous: number; -}; - -const MAX_IDENTITY_CANDIDATES = 200_000; -const MAX_SEMANTIC_CELLS = 65_536; - -const sameValue = (left: JsonValue, right: JsonValue): boolean => left === right || equalJson(left, right); - -const lcsMatches = ( - before: readonly JsonValue[], - after: readonly JsonValue[], - equal: (left: JsonValue, right: JsonValue) => boolean, - maxCells: number, -): ArrayMatch[] | undefined => { - if (before.length === 0 || after.length === 0) return []; - if (before.length * after.length > maxCells) return undefined; - const width = after.length + 1; - const lengths = new Uint32Array((before.length + 1) * width); - for (let left = before.length - 1; left >= 0; left--) { - for (let right = after.length - 1; right >= 0; right--) { - const at = left * width + right; - lengths[at] = equal(before[left]!, after[right]!) - ? lengths[(left + 1) * width + right + 1]! + 1 - : Math.max(lengths[(left + 1) * width + right]!, lengths[left * width + right + 1]!); - } - } - const matches: ArrayMatch[] = []; - let left = 0; - let right = 0; - while (left < before.length && right < after.length) { - if ( - equal(before[left]!, after[right]!) && - lengths[left * width + right] === lengths[(left + 1) * width + right + 1]! + 1 - ) { - matches.push([left++, right++]); - } else if (lengths[(left + 1) * width + right]! >= lengths[left * width + right + 1]!) left += 1; - else right += 1; - } - return matches; -}; - -const semanticallyAligned = (left: JsonValue, right: JsonValue): boolean => { - if (sameValue(left, right)) return true; - if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) return false; - return left.some((value, index) => isContainer(value) && value === right[index]); - } - const leftObject = left as Record; - const rightObject = right as Record; - for (const key of Object.keys(leftObject)) { - const value = leftObject[key]; - if (isContainer(value) && Object.hasOwn(rightObject, key) && value === rightObject[key]) return true; - } - return false; -}; - -const indexOfIdentity = (values: readonly JsonValue[], value: JsonValue, start: number, end: number): number => { - if (!isContainer(value)) return -1; // primitives are not identity anchors - const at = values.indexOf(value, start); - return at === -1 || at >= end ? -1 : at; -}; - -const lowerBound = (values: readonly number[], value: number): number => { - let low = 0; - let high = values.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (values[middle]! < value) low = middle + 1; - else high = middle; - } - return low; -}; - -const identitySubsequence = ( - before: readonly JsonValue[], - beforeStart: number, - beforeEnd: number, - after: readonly JsonValue[], - afterStart: number, - afterEnd: number, -): ArrayMatch[] | undefined => { - const beforeCount = beforeEnd - beforeStart; - const afterCount = afterEnd - afterStart; - const matches: ArrayMatch[] = []; - if (afterCount < beforeCount) { - let beforeIndex = beforeStart; - for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { - while (beforeIndex < beforeEnd && before[beforeIndex] !== after[afterIndex]) beforeIndex += 1; - if (beforeIndex === beforeEnd) return undefined; - matches.push([beforeIndex++, afterIndex]); - } - return matches; - } - if (beforeCount < afterCount) { - let afterIndex = afterStart; - for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex++) { - while (afterIndex < afterEnd && before[beforeIndex] !== after[afterIndex]) afterIndex += 1; - if (afterIndex === afterEnd) return undefined; - matches.push([beforeIndex, afterIndex++]); - } - return matches; - } - return undefined; -}; - -const greedyIdentityAnchors = ( - positions: ReadonlyMap, - after: readonly JsonValue[], - afterStart: number, - afterEnd: number, -): ArrayMatch[] => { - const matches: ArrayMatch[] = []; - let previous = -1; - for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { - const candidates = positions.get(after[afterIndex]!); - if (candidates === undefined) continue; - const at = lowerBound(candidates, previous + 1); - const beforeIndex = candidates[at]; - if (beforeIndex === undefined) continue; - matches.push([beforeIndex, afterIndex]); - previous = beforeIndex; - } - return matches; -}; - -const identityAnchors = ( - before: readonly JsonValue[], - beforeStart: number, - beforeEnd: number, - after: readonly JsonValue[], - afterStart: number, - afterEnd: number, -): ArrayMatch[] => { - const positions = new Map(); - for (let index = beforeStart; index < beforeEnd; index++) { - const value = before[index]!; - const existing = positions.get(value); - if (existing === undefined) positions.set(value, [index]); - else existing.push(index); - } - let candidateCount = 0; - for (let index = afterStart; index < afterEnd; index++) { - candidateCount += positions.get(after[index]!)?.length ?? 0; - if (candidateCount > MAX_IDENTITY_CANDIDATES) { - return greedyIdentityAnchors(positions, after, afterStart, afterEnd); - } - } - if (candidateCount === 0) return []; - - const candidates: MatchCandidate[] = []; - const tails: number[] = []; - const tailValues: number[] = []; - for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { - const beforePositions = positions.get(after[afterIndex]!); - if (beforePositions === undefined) continue; - for (let index = beforePositions.length - 1; index >= 0; index--) { - const beforeIndex = beforePositions[index]!; - const at = lowerBound(tailValues, beforeIndex); - const candidateIndex = candidates.length; - candidates.push({ before: beforeIndex, after: afterIndex, previous: at === 0 ? -1 : tails[at - 1]! }); - tails[at] = candidateIndex; - tailValues[at] = beforeIndex; - } - } - const matches: ArrayMatch[] = []; - let candidateIndex = tails[tails.length - 1] ?? -1; - while (candidateIndex >= 0) { - const candidate = candidates[candidateIndex]!; - matches.push([candidate.before, candidate.after]); - candidateIndex = candidate.previous; - } - matches.reverse(); - return matches; -}; - -const processArrayMatches = ( - before: JsonValue[], - after: JsonValue[], - path: Path, - operations: Op[], - hints: DiffHints, - beforeStart: number, - beforeEnd: number, - afterStart: number, - afterEnd: number, - outputStart: number, - matches: readonly ArrayMatch[], -): void => { - let beforeAt = beforeStart; - let afterAt = afterStart; - let outputAt = outputStart; - for (const [beforeMatch, afterMatch] of matches) { - if (overflowedBatches.has(operations)) return; - diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeMatch, afterAt, afterMatch, outputAt); - outputAt += afterMatch - afterAt; - if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) { - diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations, hints); - } - outputAt += 1; - beforeAt = beforeMatch + 1; - afterAt = afterMatch + 1; - } - if (!overflowedBatches.has(operations)) { - diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeEnd, afterAt, afterEnd, outputAt); - } -}; - -function diffArrayRegion( - before: JsonValue[], - after: JsonValue[], - path: Path, - operations: Op[], - hints: DiffHints, - beforeStart: number, - beforeEnd: number, - afterStart: number, - afterEnd: number, - outputStart: number, -): void { - if (overflowedBatches.has(operations)) return; - while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeStart]!, after[afterStart]!)) { - beforeStart += 1; - afterStart += 1; - outputStart += 1; - } - while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeEnd - 1]!, after[afterEnd - 1]!)) { - beforeEnd -= 1; - afterEnd -= 1; - } - const beforeCount = beforeEnd - beforeStart; - const afterCount = afterEnd - afterStart; - if (beforeCount === 0 && afterCount === 0) return; - if (beforeCount === 0 || afterCount === 0) { - emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); - return; - } - - if (beforeCount === afterCount) { - // Streaming positional alignment: equivalent to collecting positional - // matches and diffing the gaps between them, without a tuple per match. - let matched = false; - let gapStart = -1; - for (let offset = 0; offset <= beforeCount; offset++) { - const same = offset < beforeCount && sameValue(before[beforeStart + offset]!, after[afterStart + offset]!); - if (same) { - matched = true; - if (gapStart !== -1) { - if (overflowedBatches.has(operations)) return; - diffArrayRegion( - before, - after, - path, - operations, - hints, - beforeStart + gapStart, - beforeStart + offset, - afterStart + gapStart, - afterStart + offset, - outputStart + gapStart, - ); - gapStart = -1; - } - } else if (gapStart === -1) gapStart = offset; - } - if (matched) { - if (gapStart !== -1 && !overflowedBatches.has(operations)) { - diffArrayRegion( - before, - after, - path, - operations, - hints, - beforeStart + gapStart, - beforeEnd, - afterStart + gapStart, - afterEnd, - outputStart + gapStart, - ); - } - return; - } - } - - // Identity run fast paths (queue shift+push, unshift+pop). The run must be - // longer than both remainders so it is the unique longest anchor chain; the - // output then equals the LIS alignment below without a map or match list. - { - const k = indexOfIdentity(before, after[afterStart]!, beforeStart, beforeEnd); - if (k !== -1) { - let run = 0; - const limit = Math.min(beforeEnd - k, afterCount); - // Container-only runs: object identity is unique, so this run is the unique longest anchor chain. - while (run < limit && before[k + run] === after[afterStart + run] && isContainer(before[k + run]!)) run += 1; - if (k + run === beforeEnd && run > k - beforeStart && run > afterCount - run) { - if (k > beforeStart) emitOperation(operations, ["p", path, outputStart, k - beforeStart, []]); - if (run < afterCount) { - emitOperation(operations, ["p", path, outputStart + run, 0, after.slice(afterStart + run, afterEnd)]); - } - return; - } - } - const j = indexOfIdentity(after, before[beforeStart]!, afterStart, afterEnd); - if (j !== -1) { - let run = 0; - const limit = Math.min(afterEnd - j, beforeCount); - while (run < limit && before[beforeStart + run] === after[j + run] && isContainer(after[j + run]!)) run += 1; - if (j + run === afterEnd && run > j - afterStart && run > beforeCount - run) { - const inserted = j - afterStart; - if (inserted > 0) emitOperation(operations, ["p", path, outputStart, 0, after.slice(afterStart, j)]); - if (run < beforeCount) { - emitOperation(operations, ["p", path, outputStart + inserted + run, beforeCount - run, []]); - } - return; - } - } - } - - if (beforeCount === 1 && afterCount === 1) { - // Every alignment strategy below ends in diffValue for a 1x1 region. - diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); - return; - } - - const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd); - if (subsequence !== undefined && subsequence.length > 0) { - processArrayMatches( - before, - after, - path, - operations, - hints, - beforeStart, - beforeEnd, - afterStart, - afterEnd, - outputStart, - subsequence, - ); - return; - } - - const identity = identityAnchors(before, beforeStart, beforeEnd, after, afterStart, afterEnd); - if (identity.length > 0) { - processArrayMatches( - before, - after, - path, - operations, - hints, - beforeStart, - beforeEnd, - afterStart, - afterEnd, - outputStart, - identity, - ); - return; - } - - const semantic = lcsMatches( - before.slice(beforeStart, beforeEnd), - after.slice(afterStart, afterEnd), - semanticallyAligned, - MAX_SEMANTIC_CELLS, - ); - if (semantic !== undefined && semantic.length > 0) { - const absolute = semantic.map( - ([beforeIndex, afterIndex]) => [beforeStart + beforeIndex, afterStart + afterIndex] as const, - ); - processArrayMatches( - before, - after, - path, - operations, - hints, - beforeStart, - beforeEnd, - afterStart, - afterEnd, - outputStart, - absolute, - ); - return; - } - - if (beforeCount === 1 && afterCount === 1) { - diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); - return; - } - emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); -} - -const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[], hints: DiffHints): void => { - if (before === after) return; - const hint = hints.get(after); - if (hint?.base === before && hint.keys.length < 2_048) { - for (const index of hint.keys) diffValue(before[index]!, after[index]!, [...path, index], operations, hints); - return; - } - if (equalJson(before, after)) return; - if ( - before.length === after.length && - before.length > 1 && - !sameValue(before[0]!, after[0]!) && - !sameValue(before[before.length - 1]!, after[after.length - 1]!) - ) { - const order = permutation(before, after); - if (order !== undefined) { - emitOperation(operations, ["m", path, order]); - return; - } - } - diffArrayRegion(before, after, path, operations, hints, 0, before.length, 0, after.length, 0); -}; - -const diffObject = ( - before: Record, - after: Record, - path: Path, - operations: Op[], - hints: DiffHints, -): void => { - const beforeKeys = Object.keys(before); - const afterKeys = Object.keys(after); - if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) { - if (!equalJson(before, after)) emitSet(path, after, operations); - return; - } - for (const key of afterKeys) { - if (overflowedBatches.has(operations)) return; - if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations, hints); - else emitSet([...path, key], after[key]!, operations); - } - for (const key of beforeKeys) { - if (overflowedBatches.has(operations)) return; - if (!Object.hasOwn(after, key)) emitOperation(operations, ["d", [...path, key] as unknown as NonEmptyPath]); - } -}; - -const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[], hints: DiffHints): void => { - if (before === after || overflowedBatches.has(operations)) return; - if (typeof before === "string" && typeof after === "string" && path.length > 0) { - emitString(before, after, path as NonEmptyPath, operations); - return; - } - if (Array.isArray(before) && Array.isArray(after)) { - diffArray(before, after, path, operations, hints); - return; - } - if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) { - diffObject(before, after, path, operations, hints); - return; - } - emitSet(path, after, operations); -}; - -/** Compute a normalized batch, with deterministic operation-count safety folding only. */ -export function diffRevisions(before: JsonValue, after: JsonValue, hints: DiffHints = new WeakMap()): Op[] { - const operations: Op[] = []; - diffValue(before, after, [], operations, hints); - if (overflowedBatches.has(operations)) return [["r", after]]; - return operations; -} diff --git a/packages/chord/src/delta/cow/draft.ts b/packages/chord/src/delta/cow/draft.ts deleted file mode 100644 index 75d966de5..000000000 --- a/packages/chord/src/delta/cow/draft.ts +++ /dev/null @@ -1,717 +0,0 @@ -import type { JsonValue } from "../../types.ts"; - -/** Mutable transaction-scoped view of a JSON value, preserving tuple positions. */ -export type Draft = Depth["length"] extends 8 - ? T - : T extends null | boolean | number | string - ? T - : T extends (...args: never[]) => unknown - ? T - : T extends object - ? { -readonly [Key in keyof T]: Draft } - : T; - -type Container = Record | unknown[]; -type ChildKey = string | number; - -export type ArrayDiffHint = { - readonly base: JsonValue[]; - readonly keys: readonly number[]; -}; - -export type DiffHints = WeakMap; - -export type ProduceMetadata = { - value: T; - hints: DiffHints; -}; - -type DraftContext = { - active: boolean; - states: WeakMap; - created: DraftState[]; - hints: DiffHints; -}; - -type ChildIndex = DraftState | Map; -type ChangedIndex = number | Set; - -class DraftState { - context: DraftContext; - base: Container; - copy: Container | undefined; - parent: DraftState | undefined; - key: ChildKey | undefined; - proxy: object; - target: object; - dirty = false; - structural = false; - children: ChildIndex | undefined; - changedIndices: ChangedIndex | undefined; - finalized: Container | undefined; - - constructor(context: DraftContext, base: Container, parent: DraftState | undefined, key: ChildKey | undefined) { - this.context = context; - this.base = base; - this.copy = undefined; - this.parent = parent; - this.key = key; - this.proxy = this; - this.target = this; - this.children = undefined; - this.changedIndices = undefined; - this.finalized = undefined; - } -} - -const DRAFT_STATE = Symbol("cow draft state"); -const RELEASED: Record = {}; -const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000; -const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); - -export type DraftTransaction = { - readonly state: Draft; - finish(): ProduceMetadata; - abort(): void; -}; - -export function createDraftTransaction(base: T): DraftTransaction { - const context: DraftContext = { - active: true, - states: new WeakMap(), - created: [], - hints: new WeakMap(), - }; - const root = stateFor(context, base as Container, undefined, undefined); - const release = (): void => { - assertActive(context); - context.active = false; - for (const state of context.created) { - state.base = RELEASED; - state.copy = undefined; - state.parent = undefined; - state.key = undefined; - state.proxy = RELEASED; - state.target = RELEASED; - state.children = undefined; - state.changedIndices = undefined; - state.finalized = undefined; - } - context.created.length = 0; - context.states = new WeakMap(); - }; - return { - state: root.proxy as Draft, - finish() { - assertActive(context); - try { - return { value: finalize(root) as T, hints: context.hints }; - } finally { - release(); - } - }, - abort: release, - }; -} - -/** Compatibility helper for synchronous COW recipes. */ -export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { - const transaction = createDraftTransaction(base); - try { - const outcome = (recipe as (draft: Draft) => unknown)(transaction.state); - if (isPromiseLike(outcome)) { - void Promise.resolve(outcome).catch(() => undefined); - throw new TypeError("Replicated state change callbacks must be synchronous"); - } - return transaction.finish(); - } catch (error) { - try { - transaction.abort(); - } catch { - // finish() already released the transaction. - } - throw error; - } -} - -export function produce(base: T, recipe: (draft: Draft) => void): T { - return produceWithMetadata(base, recipe).value; -} - -function stateFor( - context: DraftContext, - base: Container, - parent: DraftState | undefined, - key: ChildKey | undefined, -): DraftState { - const existing = context.states.get(base); - if (existing !== undefined) return existing; - const state = new DraftState(context, base, parent, key); - const target: object = Array.isArray(base) ? [] : state; - if (Array.isArray(base)) { - (target as unknown[]).length = base.length; - Object.defineProperty(target, DRAFT_STATE, { value: state, configurable: true }); - } - state.target = target; - state.proxy = new Proxy(target, SHARED_HANDLER); - context.states.set(base, state); - context.created.push(state); - return state; -} - -const SHARED_HANDLER: ProxyHandler = { - deleteProperty(target, property): boolean { - const state = targetState(target); - assertWritable(state, property); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(state.base)) throw new TypeError("Draft arrays cannot contain holes"); - return deleteObjectProperty(state, property); - }, - defineProperty(target): never { - assertActive(targetState(target).context); - throw new TypeError("Defining draft properties is not supported"); - }, - get(target, property): unknown { - const state = targetState(target); - assertActive(state.context); - if (property === DRAFT_STATE) return state; - if (Array.isArray(state.base) && typeof property === "string" && ARRAY_MUTATORS.has(property)) { - return MUTATORS[property as keyof typeof MUTATORS]; - } - const value = Reflect.get(currentValue(state), property, state.proxy); - return draftValue(state.context, value, state, childKey(property)); - }, - getOwnPropertyDescriptor(target, property): PropertyDescriptor | undefined { - const state = targetState(target); - assertActive(state.context); - const descriptor = Reflect.getOwnPropertyDescriptor(currentValue(state), property); - if (descriptor === undefined) return undefined; - if (Array.isArray(state.base) && property === "length") { - syncArrayTarget(state); - return Reflect.getOwnPropertyDescriptor(state.target, property); - } - return { - configurable: true, - enumerable: descriptor.enumerable, - writable: true, - value: - "value" in descriptor ? draftValue(state.context, descriptor.value, state, childKey(property)) : undefined, - }; - }, - getPrototypeOf(target): object | null { - const state = targetState(target); - assertActive(state.context); - return Object.getPrototypeOf(state.base); - }, - has(target, property): boolean { - const state = targetState(target); - assertActive(state.context); - return Reflect.has(currentValue(state), property); - }, - isExtensible(target): boolean { - assertActive(targetState(target).context); - return true; - }, - ownKeys(target): ArrayLike { - const state = targetState(target); - assertActive(state.context); - return Reflect.ownKeys(currentValue(state)); - }, - preventExtensions(target): never { - assertActive(targetState(target).context); - throw new TypeError("Drafts cannot be made non-extensible"); - }, - set(target, property, value): boolean { - const state = targetState(target); - assertWritable(state, property); - return writeProperty(state, property, value); - }, - setPrototypeOf(target): never { - assertActive(targetState(target).context); - throw new TypeError("Changing a draft prototype is not supported"); - }, -}; - -function targetState(target: object): DraftState { - return Array.isArray(target) - ? (target as unknown as Record)[DRAFT_STATE]! - : (target as DraftState); -} - -function childKey(property: PropertyKey): ChildKey | undefined { - if (typeof property === "symbol") return undefined; - const index = arrayIndex(property); - return index ?? property; -} - -function draftState(value: unknown): DraftState | undefined { - if (!isContainer(value)) return undefined; - return (value as Record)[DRAFT_STATE]; -} - -function draftValue(context: DraftContext, value: unknown, parent?: DraftState, key?: ChildKey): unknown { - if (!isContainer(value)) return value; - const existingDraft = draftState(value); - if (existingDraft !== undefined) return value; - return stateFor(context, value, parent, key).proxy; -} - -function currentValue(state: DraftState): Container { - assertActive(state.context); - return state.copy ?? state.base; -} - -function ensureCopy(state: DraftState): Container { - markChanged(state); - if (state.copy !== undefined) return state.copy; - state.copy = shallowCopy(state.base); - return state.copy; -} - -function shallowCopy(value: Container): Container { - if (Array.isArray(value)) return Array.from(value); - if (Object.getPrototypeOf(value) === null) - return Object.assign(Object.create(null) as Record, value); - return { ...value }; -} - -function markChanged(state: DraftState): void { - let current: DraftState | undefined = state; - while (current !== undefined && !current.dirty) { - current.dirty = true; - const parent: DraftState | undefined = current.parent; - if (parent !== undefined && current.key !== undefined) registerChild(parent, current.key, current); - current = parent; - } -} - -function registerChild(parent: DraftState, key: ChildKey, child: DraftState): void { - const children = parent.children; - if (children === undefined) parent.children = child; - else if (children instanceof Map) children.set(key, child); - else if (children !== child) - parent.children = new Map([ - [children.key!, children], - [key, child], - ]); - if (Array.isArray(parent.base) && typeof key === "number") markArrayIndex(parent, key); -} - -function markArrayIndex(state: DraftState, index: number): void { - const changed = state.changedIndices; - if (changed === undefined) state.changedIndices = index; - else if (changed instanceof Set) changed.add(index); - else if (changed !== index) state.changedIndices = new Set([changed, index]); -} - -function writeProperty(state: DraftState, property: string | symbol, supplied: unknown): boolean { - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(state.base)) { - if (property === "length") return writeArrayLength(state, supplied); - const index = arrayIndex(property); - if (index === undefined) throw new TypeError("Only array indices and length can be written"); - const current = currentValue(state) as unknown[]; - if (index > current.length) throw new TypeError("Draft arrays cannot contain holes"); - const stored = clonePlacement(supplied, state.context); - if (index < current.length && Object.is(current[index], stored)) return true; - if (index < current.length) detachCurrentChild(state, current[index]); - const copy = ensureCopy(state) as unknown[]; - defineData(copy, String(index), stored); - if (index === current.length) state.structural = true; - else markArrayIndex(state, index); - syncArrayTarget(state); - return true; - } - if (supplied === undefined) return deleteObjectProperty(state, property); - const stored = clonePlacement(supplied, state.context); - const current = currentValue(state) as Record; - if (Object.hasOwn(current, property) && !isContainer(stored) && Object.is(current[property], stored)) return true; - if (Object.hasOwn(current, property)) detachCurrentChild(state, current[property]); - const copy = ensureCopy(state) as Record; - defineData(copy, property, stored); - return true; -} - -function writeArrayLength(state: DraftState, supplied: unknown): boolean { - const probe: unknown[] = []; - Reflect.set(probe, "length", supplied); - const next = probe.length; - const current = (currentValue(state) as unknown[]).length; - if (next === current) return true; - const copy = ensureCopy(state) as unknown[]; - if (next < current) copy.length = next; - else { - copy.length = next; - for (let index = current; index < next; index++) copy[index] = null; - } - state.structural = true; - syncArrayTarget(state); - return true; -} - -function deleteObjectProperty(state: DraftState, property: string): boolean { - const current = currentValue(state); - if (!Object.hasOwn(current, property)) return true; - detachCurrentChild(state, (current as Record)[property]); - Reflect.deleteProperty(ensureCopy(state), property); - return true; -} - -function detachCurrentChild(parent: DraftState, value: unknown): void { - if (!isContainer(value)) return; - const child = draftState(value) ?? parent.context.states.get(value); - if (child?.parent !== parent) return; - const children = parent.children; - if (children === child) parent.children = undefined; - else if (children instanceof Map && child.key !== undefined && children.get(child.key) === child) { - children.delete(child.key); - } - child.parent = undefined; - child.key = undefined; -} - -function arrayIndex(property: PropertyKey): number | undefined { - if (typeof property !== "string" || property === "") return undefined; - const index = Number(property); - if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; - return index; -} - -function mutatorState(receiver: unknown): DraftState | undefined { - if (!isContainer(receiver)) return undefined; - return draftState(receiver); -} - -const MUTATORS = { - push(this: unknown, ...args: unknown[]): number { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.push, this, args) as number; - assertActive(state.context); - if (args.length === 0) return (currentValue(state) as unknown[]).length; - const target = ensureCopy(state) as unknown[]; - for (const item of cloneItems(args, state.context)) defineData(target, String(target.length), item); - state.structural = true; - syncArrayTarget(state); - return target.length; - }, - pop(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.pop, this, []); - assertActive(state.context); - const current = currentValue(state) as unknown[]; - if (current.length === 0) return undefined; - const target = ensureCopy(state) as unknown[]; - const value = target[target.length - 1]; - target.length -= 1; - state.structural = true; - syncArrayTarget(state); - return draftValue(state.context, value, state, target.length); - }, - shift(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.shift, this, []); - assertActive(state.context); - const current = currentValue(state) as unknown[]; - if (current.length === 0) return undefined; - const target = ensureCopy(state) as unknown[]; - const value = Reflect.apply(Array.prototype.shift, target, []); - state.structural = true; - syncArrayTarget(state); - return draftValue(state.context, value, state, 0); - }, - unshift(this: unknown, ...args: unknown[]): number { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.unshift, this, args) as number; - assertActive(state.context); - if (args.length === 0) return (currentValue(state) as unknown[]).length; - const target = ensureCopy(state) as unknown[]; - const items = cloneItems(args, state.context); - insertChunked(target, 0, items); - state.structural = true; - syncArrayTarget(state); - return target.length; - }, - splice(this: unknown, ...args: unknown[]): unknown[] { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const inputs = cloneItems(args.slice(2), state.context); - if (remove === 0 && inputs.length === 0) return []; - const target = ensureCopy(state) as unknown[]; - const removed = spliceCapturedLength(target, start, remove, inputs, length); - state.structural = true; - syncArrayTarget(state); - return removed.map((value, offset) => draftValue(state.context, value, state, start + offset)); - }, - sort(this: unknown, comparator?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); - assertActive(state.context); - if (comparator !== undefined && typeof comparator !== "function") - throw new TypeError("Comparator must be a function"); - const target = ensureCopy(state) as unknown[]; - state.structural = true; - Reflect.apply(Array.prototype.sort, target, [ - (left: unknown, right: unknown): number => { - const draftedLeft = draftValue(state.context, left, state); - const draftedRight = draftValue(state.context, right, state); - if (typeof comparator === "function") { - // Array.prototype.sort performs ToNumber on the return value. Returning - // it directly preserves native bigint TypeError behavior. - return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number; - } - const leftString = String(draftedLeft); - const rightString = String(draftedRight); - return leftString < rightString ? -1 : leftString > rightString ? 1 : 0; - }, - ]); - syncArrayTarget(state); - return state.proxy; - }, - reverse(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.reverse, this, []); - assertActive(state.context); - const target = ensureCopy(state) as unknown[]; - if (target.length > 1) { - Reflect.apply(Array.prototype.reverse, target, []); - state.structural = true; - } - return state.proxy; - }, - fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - if (end <= start) return state.proxy; - const target = ensureCopy(state) as unknown[]; - for (let index = start; index < end; index++) target[index] = clonePlacement(supplied, state.context); - state.structural = true; - return state.proxy; - }, - copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const targetIndex = clampIndex(toIntegerOrInfinity(targetArg), length); - const start = clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - const count = Math.min(Math.max(end - start, 0), length - targetIndex); - if (count === 0) return state.proxy; - const source = (currentValue(state) as unknown[]).slice(start, start + count); - const target = ensureCopy(state) as unknown[]; - for (let offset = 0; offset < count; offset++) - target[targetIndex + offset] = cloneLogical(source[offset], state.context); - state.structural = true; - return state.proxy; - }, -}; - -function cloneItems(values: readonly unknown[], context: DraftContext): unknown[] { - return values.map((value) => clonePlacement(value, context)); -} - -function clonePlacement(value: unknown, _context: DraftContext): unknown { - if (!isContainer(value)) return value; - const state = draftState(value); - return state === undefined ? cloneJson(value) : cloneState(state); -} - -/** Fast trusted strict-JSON clone. Inputs are caller-guaranteed plain, dense, acyclic trees. */ -export function cloneJson(value: T): T { - if (!isContainer(value)) return value; - if (Array.isArray(value)) return value.map((child) => cloneJson(child)) as T; - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); - return result as T; -} - -function cloneState(state: DraftState): Container { - assertActive(state.context); - const context = state.context; - const current = state.copy ?? state.base; - if (Array.isArray(current)) { - const result = new Array(current.length); - for (let index = 0; index < current.length; index++) result[index] = cloneLogical(current[index], context); - return result; - } - const result = Object.create(Object.getPrototypeOf(current)) as Record; - for (const key of Object.keys(current)) defineData(result, key, cloneLogical(current[key], context)); - return result; -} - -function cloneLogical(value: unknown, context: DraftContext): unknown { - if (!isContainer(value)) return value; - const proxyState = draftState(value); - if (proxyState !== undefined) return cloneState(proxyState); - const state = context.states.get(value); - return state === undefined ? cloneJson(value) : cloneState(state); -} - -function finalize(state: DraftState): Container { - if (state.finalized !== undefined) return state.finalized; - if (!state.dirty) return state.base; - const current = state.copy ?? state.base; - let result = current; - if (Array.isArray(current) && state.structural) { - for (let index = 0; index < current.length; index++) { - const value = current[index]; - if (!isContainer(value)) continue; - const child = draftState(value) ?? state.context.states.get(value); - if (child === undefined || !child.dirty) continue; - const next = finalize(child); - if (next === value) continue; - if (result === state.base) result = shallowCopy(state.base); - (result as unknown[])[index] = next; - } - } else { - forEachDirtyChild(state, (key, child) => { - const value = (current as Record)[key]; - if (value !== child.base && draftState(value) !== child) return; - const next = finalize(child); - if (next === value) return; - if (result === state.base) result = shallowCopy(state.base); - defineData(result, String(key), next); - if (Array.isArray(current) && typeof key === "number") markArrayIndex(state, key); - }); - } - if (result !== state.base && shallowEqual(state.base, result)) result = state.base; - state.finalized = result; - if ( - Array.isArray(state.base) && - Array.isArray(result) && - result !== state.base && - !state.structural && - state.base.length === result.length - ) { - const base = state.base as unknown[]; - const output = result as unknown[]; - const changed = changedIndexList(state); - if (changed !== undefined) { - const keys = changed.filter((index) => index < base.length && index < output.length); - state.context.hints.set(result, { base: state.base as JsonValue[], keys }); - } - } - return result; -} - -function forEachDirtyChild(state: DraftState, visit: (key: ChildKey, child: DraftState) => void): void { - const children = state.children; - if (children === undefined) return; - if (children instanceof Map) { - for (const [key, child] of children) if (child.dirty) visit(key, child); - } else if (children.dirty && children.key !== undefined) visit(children.key, children); -} - -function changedIndexList(state: DraftState): number[] | undefined { - const changed = state.changedIndices; - if (changed === undefined) return []; - // The differ only consumes narrow hints. Avoid materializing and sorting a - // dense set that it would immediately discard. - if (changed instanceof Set) { - if (changed.size >= 2_048) return undefined; - return [...changed].sort((left, right) => left - right); - } - return [changed]; -} - -function shallowEqual(left: Container, right: Container): boolean { - if (Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - const leftRecord = left as Record; - const rightRecord = right as Record; - for (const key of leftKeys) { - if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false; - } - return true; -} - -function defineData(target: object, key: PropertyKey, value: unknown): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function spliceCapturedLength( - target: unknown[], - start: number, - remove: number, - items: readonly unknown[], - capturedLength: number, -): unknown[] { - const removed = new Array(remove); - for (let offset = 0; offset < remove; offset++) { - defineData(removed, String(offset), denseArrayValue(target, start + offset)); - } - if (items.length < remove) { - for (let source = start + remove; source < capturedLength; source++) { - defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); - } - } else if (items.length > remove) { - for (let source = capturedLength - 1; source >= start + remove; source--) { - defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); - } - } - for (let offset = 0; offset < items.length; offset++) defineData(target, String(start + offset), items[offset]); - target.length = capturedLength - remove + items.length; - return removed; -} - -function denseArrayValue(target: readonly unknown[], index: number): unknown { - return index < target.length && Object.hasOwn(target, index) && target[index] !== undefined ? target[index] : null; -} - -function insertChunked(target: unknown[], index: number, values: readonly unknown[]): void { - for (let offset = 0; offset < values.length; offset += MAX_NATIVE_ARRAY_INSERT_ITEMS) { - target.splice(index + offset, 0, ...values.slice(offset, offset + MAX_NATIVE_ARRAY_INSERT_ITEMS)); - } -} - -function toIntegerOrInfinity(value: unknown): number { - const number = +(value as number); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function syncArrayTarget(state: DraftState): void { - (state.target as unknown[]).length = ((state.copy ?? state.base) as unknown[]).length; -} - -function assertWritable(state: DraftState, property: PropertyKey): void { - assertActive(state.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); -} - -function assertActive(context: DraftContext): void { - if (!context.active) throw new TypeError("Cannot use a draft outside its change callback"); -} - -function isContainer(value: unknown): value is Container { - return value !== null && typeof value === "object"; -} - -function isPromiseLike(value: unknown): value is PromiseLike { - return ( - ((typeof value === "object" && value !== null) || typeof value === "function") && - typeof (value as { then?: unknown }).then === "function" - ); -} diff --git a/packages/chord/src/delta/cow/index.ts b/packages/chord/src/delta/cow/index.ts deleted file mode 100644 index 118806804..000000000 --- a/packages/chord/src/delta/cow/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type { JsonValue, NonEmptyPath, Op, Path, PathRef, Seg, WireOp } from "../index.ts"; -export { - apply, - assertSafePath, - assertValidOp, - assertValidWireOp, - decoder, - encoder, - isBase, - isReplace, - overlap, - PathError, - RESERVED_SEGMENTS, - UnsafePathError, -} from "../index.ts"; -export { applyImmutable } from "./apply-immutable.ts"; -export { diffRevisions } from "./diff.ts"; -export type { Draft } from "./draft.ts"; -export type { Change, Prepared, Tracker } from "./tracker.ts"; -export { track } from "./tracker.ts"; diff --git a/packages/chord/src/delta/cow/tracker.ts b/packages/chord/src/delta/cow/tracker.ts deleted file mode 100644 index 20a34d4ae..000000000 --- a/packages/chord/src/delta/cow/tracker.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import type { Op } from "../index.ts"; -import { applyImmutable } from "./apply-immutable.ts"; -import { diffRevisions } from "./diff.ts"; -import { cloneJson, createDraftTransaction, type Draft, type DraftTransaction, type ProduceMetadata } from "./draft.ts"; - -export interface Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - abort(): void; -} - -export interface Change { - readonly state: Draft; - prepare(): Prepared; - abort(): void; -} - -export interface Tracker { - readonly value: T; - readonly revision: number; - beginChange(): Change; - prepareReplace(value: T): Prepared; - adopt(prepared: Prepared): void; -} - -type PreparedMetadata = { - readonly owner: object; - readonly revision: number; - status: "prepared" | "consumed" | "aborted"; -}; - -const PREPARED = new WeakMap(); - -class PreparedImpl implements Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - - constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) { - this.base = base; - this.value = value; - this.ops = ops; - PREPARED.set(this, metadata); - Object.freeze(this); - } - - abort(): void { - const metadata = PREPARED.get(this)!; - if (metadata.status === "prepared") metadata.status = "aborted"; - } -} - -class ChangeImpl implements Change { - readonly state: Draft; - #tracker: TrackerImpl | undefined; - #transaction: DraftTransaction | undefined; - #preparedMetadata: PreparedMetadata | undefined; - #status: "open" | "prepared" | "aborted" = "open"; - - constructor(tracker: TrackerImpl, transaction: DraftTransaction) { - this.#tracker = tracker; - this.#transaction = transaction; - this.state = transaction.state; - } - - prepare(): Prepared { - if (this.#status !== "open") throw new Error("Change has already been settled"); - const tracker = this.#tracker!; - const transaction = this.#transaction!; - try { - const prepared = tracker.prepareDraft(transaction); - this.#preparedMetadata = PREPARED.get(prepared)!; - this.#status = "prepared"; - return prepared; - } catch (error) { - this.#status = "aborted"; - throw error; - } finally { - this.#tracker = undefined; - this.#transaction = undefined; - } - } - - abort(): void { - if (this.#status === "aborted") return; - if (this.#status === "prepared") { - if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted"; - this.#preparedMetadata = undefined; - this.#status = "aborted"; - return; - } - this.#status = "aborted"; - const tracker = this.#tracker!; - const transaction = this.#transaction!; - this.#tracker = undefined; - this.#transaction = undefined; - try { - transaction.abort(); - } finally { - tracker.releaseChange(); - } - } -} - -class TrackerImpl implements Tracker { - readonly #owner = {}; - #value: T; - #revision = 0; - #changing = false; - - constructor(initial: T) { - this.#value = initial; - } - - get value(): T { - return this.#value; - } - - get revision(): number { - return this.#revision; - } - - beginChange(): Change { - this.#reserveChange(); - try { - return new ChangeImpl(this, createDraftTransaction(this.#value)); - } catch (error) { - this.#changing = false; - throw error; - } - } - - prepareReplace(value: T): Prepared { - this.#reserveChange(); - try { - return this.#prepare({ value, hints: new WeakMap() }, true); - } finally { - this.#changing = false; - } - } - - adopt(prepared: Prepared): void { - const metadata = PREPARED.get(prepared as object); - if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (metadata.status === "consumed") throw new Error("Prepared change has already been used"); - if (metadata.status === "aborted") throw new Error("Prepared change has been aborted"); - if (this.#changing) throw new Error("Cannot adopt while a change is active"); - if (metadata.revision !== this.#revision || this.#value !== prepared.base) - throw new Error("Prepared change is stale"); - metadata.status = "consumed"; - this.#value = prepared.value; - this.#revision += 1; - } - - prepareDraft(transaction: DraftTransaction): PreparedImpl { - try { - return this.#prepare(transaction.finish()); - } finally { - this.#changing = false; - } - } - - releaseChange(): void { - this.#changing = false; - } - - #reserveChange(): void { - if (this.#changing) throw new Error("Tracker already has an active change"); - this.#changing = true; - } - - #prepare(produced: ProduceMetadata, replacement = false): PreparedImpl { - const base = this.#value; - const operations = diffRevisions( - base as unknown as JsonValue, - produced.value as unknown as JsonValue, - produced.hints, - ); - if (operations.length === 0) { - return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, base, []); - } - // Trusted ownership rule: value and public ops are independent immutable - // transfers. Draft revisions are reconstructed from a private detached batch: - // differ payloads may reference retained base objects, so applying them directly - // could introduce aliases. Replacements are already caller-transferred roots. - // No freeze or validation runs. - const value = replacement ? produced.value : applyImmutable(base, detachOperations(operations)); - return new PreparedImpl( - { owner: this.#owner, revision: this.#revision, status: "prepared" }, - base, - value, - detachOperations(operations), - ); - } -} - -/** Take O(1) ownership of an alias-free mutable strict-JSON root. */ -export function track(initial: T): Tracker { - return new TrackerImpl(initial); -} - -function detachOperations(operations: readonly Op[]): Op[] { - return operations.map((operation): Op => { - switch (operation[0]) { - case "r": - return ["r", cloneJson(operation[1])]; - case "s": - return ["s", operation[1], cloneJson(operation[2])]; - case "p": - return ["p", operation[1], operation[2], operation[3], operation[4].map((value) => cloneJson(value))]; - case "m": - return ["m", operation[1], operation[2].slice()]; - case "d": - return operation; - case "a": - return operation; - case "t": - return operation; - default: - throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`); - } - }); -} diff --git a/packages/chord/src/delta/draft.ts b/packages/chord/src/delta/draft.ts index 995b3a00c..cb711495f 100644 --- a/packages/chord/src/delta/draft.ts +++ b/packages/chord/src/delta/draft.ts @@ -8,614 +8,3 @@ export type Draft = Depth["length"] ex : T extends object ? { -readonly [Key in keyof T]: Draft } : T; - -export type ProduceMetadata = { - value: T; - owned: WeakSet; -}; - -type Container = Record | unknown[]; - -type DraftState = { - base: Container; - context: DraftContext; - copy: Container | undefined; - methods: Map | undefined; - proxy: object; - target: unknown[] | undefined; -}; - -type DraftContext = { - active: boolean; - created: DraftState[]; - owned: WeakSet; - proxies: WeakMap; - states: WeakMap; -}; - -const RELEASED_BASE: Container = Object.freeze({}); -const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); -const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000; - -export type DraftTransaction = { - readonly state: Draft; - finish(): ProduceMetadata; - abort(): void; -}; - -/** Create a revocable copy-on-write draft whose lifetime is controlled by its owner. */ -export function createDraftTransaction(base: T): DraftTransaction { - const context: DraftContext = { - active: true, - created: [], - owned: new WeakSet(), - proxies: new WeakMap(), - states: new WeakMap(), - }; - const root = getState(context, base as Container); - const release = (): void => { - assertActive(context); - context.active = false; - for (const state of context.created) { - state.base = RELEASED_BASE; - state.copy = undefined; - state.methods = undefined; - state.proxy = RELEASED_BASE; - state.target = undefined; - } - context.created.length = 0; - context.proxies = new WeakMap(); - context.states = new WeakMap(); - }; - return { - state: root.proxy as Draft, - finish() { - assertActive(context); - try { - return { - value: finalize(root, new Set(), new WeakMap()) as T, - owned: context.owned, - }; - } finally { - release(); - } - }, - abort: release, - }; -} - -/** @internal Compatibility helper for the existing replicated-state service. */ -export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { - const transaction = createDraftTransaction(base); - try { - const outcome = (recipe as (draft: Draft) => unknown)(transaction.state); - if (isPromiseLike(outcome)) { - void Promise.resolve(outcome).catch(() => undefined); - throw new TypeError("Replicated state change callbacks must be synchronous"); - } - return transaction.finish(); - } catch (error) { - try { - transaction.abort(); - } catch { - // finish() already released the transaction before propagating an error. - } - throw error; - } -} - -/** @internal Compatibility helper for the existing replicated-state service. */ -export function produce(base: T, recipe: (draft: Draft) => void): T { - return produceWithMetadata(base, recipe).value; -} - -function getState(context: DraftContext, base: Container): DraftState { - const existing = context.states.get(base); - if (existing !== undefined) return existing; - - const target = Array.isArray(base) ? [] : undefined; - if (target !== undefined) Reflect.set(target, "length", base.length); - const state: DraftState = { - base, - context, - copy: undefined, - methods: undefined, - proxy: target ?? {}, - target, - }; - state.proxy = new Proxy(target ?? state, createHandler(state)); - context.states.set(base, state); - context.proxies.set(state.proxy, state); - context.created.push(state); - return state; -} - -type DraftHandler = ProxyHandler & { state: DraftState }; - -const handlerPrototype: ProxyHandler = { - deleteProperty(this: DraftHandler, _target, property): boolean { - const state = this.state; - assertStringWrite(property); - const current = currentValue(state); - if (Array.isArray(current)) throw new TypeError("Draft arrays cannot contain holes"); - if (!Object.hasOwn(current, property)) return true; - const deleted = Reflect.deleteProperty(ensureCopy(state), property); - syncArrayTarget(state); - return deleted; - }, - defineProperty(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Defining draft properties is not supported"); - }, - get(this: DraftHandler, _target, property): unknown { - const state = this.state; - const current = currentValue(state); - const value = Reflect.get(current, property, state.proxy); - if (Array.isArray(current) && typeof property === "string" && ARRAY_MUTATORS.has(property)) { - return arrayMutator(state, property, value); - } - return draftValue(state.context, value); - }, - getOwnPropertyDescriptor(this: DraftHandler, _target, property): PropertyDescriptor | undefined { - const state = this.state; - const current = currentValue(state); - const descriptor = Reflect.getOwnPropertyDescriptor(current, property); - if (descriptor === undefined) return undefined; - if (Array.isArray(current) && property === "length") { - syncArrayTarget(state); - return Reflect.getOwnPropertyDescriptor(state.target!, property); - } - return { - configurable: true, - enumerable: descriptor.enumerable, - value: "value" in descriptor ? draftValue(state.context, descriptor.value) : undefined, - writable: true, - }; - }, - getPrototypeOf(this: DraftHandler): object | null { - assertActive(this.state.context); - return Object.getPrototypeOf(this.state.base); - }, - has(this: DraftHandler, _target, property): boolean { - return Reflect.has(currentValue(this.state), property); - }, - isExtensible(this: DraftHandler): boolean { - assertActive(this.state.context); - return true; - }, - ownKeys(this: DraftHandler): ArrayLike { - return Reflect.ownKeys(currentValue(this.state)); - }, - preventExtensions(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Drafts cannot be made non-extensible"); - }, - set(this: DraftHandler, _target, property, value): boolean { - const state = this.state; - assertStringWrite(property); - const current = currentValue(state); - let stored: unknown; - if (isContainer(value)) { - stored = cloneAssigned(value, state.context, new Set()); - } else { - assertJsonPrimitive(value); - stored = value; - } - if (Object.hasOwn(current, property) && Object.is(Reflect.get(current, property), stored)) return true; - const copy = ensureCopy(state); - if (Array.isArray(copy) && property === "length") { - const written = Reflect.set(copy, property, stored); - syncArrayTarget(state); - return written; - } - Object.defineProperty(copy, property, { - value: stored, - writable: true, - enumerable: true, - configurable: true, - }); - syncArrayTarget(state); - return true; - }, - setPrototypeOf(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Changing a draft prototype is not supported"); - }, -}; - -function createHandler(state: DraftState): ProxyHandler { - const handler = Object.create(handlerPrototype) as DraftHandler; - handler.state = state; - return handler; -} - -function arrayMutator(state: DraftState, property: string, value: unknown): unknown { - assertActive(state.context); - const cached = state.methods?.get(property); - if (cached !== undefined) return cached; - if (typeof value !== "function") return draftValue(state.context, value); - const wrapped = function (this: unknown, ...args: unknown[]): unknown { - assertActive(state.context); - const receiver = isContainer(this) ? state.context.proxies.get(this) : undefined; - if (receiver === undefined) return Reflect.apply(value, this, args); - const target = ensureCopy(receiver); - if (!Array.isArray(target)) throw new TypeError("Array mutator receiver is not an array draft"); - try { - return mutateArray(receiver, target, property, args); - } finally { - syncArrayTarget(receiver); - } - }; - if (state.methods === undefined) state.methods = new Map(); - state.methods.set(property, wrapped); - return wrapped; -} - -function mutateArray(state: DraftState, target: unknown[], property: string, args: unknown[]): unknown { - const { context } = state; - switch (property) { - case "push": { - const items = cloneArrayItems(args, context); - for (const item of items) defineArrayValue(target, target.length, item); - return target.length; - } - case "pop": { - if (target.length === 0) return undefined; - const index = target.length - 1; - const removed = target[index]; - Reflect.deleteProperty(target, String(index)); - target.length = index; - return draftValue(context, removed); - } - case "shift": - return draftValue(context, Reflect.apply(Array.prototype.shift, target, [])); - case "unshift": { - const items = cloneArrayItems(args, context); - if ( - items.length <= MAX_NATIVE_ARRAY_INSERT_ITEMS && - !hasInheritedGrowthIndex(target, target.length + items.length) - ) { - return Reflect.apply(Array.prototype.unshift, target, items); - } - spliceArray(target, items, 0, 0, context); - return target.length; - } - case "splice": { - const length = target.length; - const start = args.length === 0 ? 0 : clampArrayIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const items = cloneArrayItems(args.slice(2), context); - if ( - items.length <= MAX_NATIVE_ARRAY_INSERT_ITEMS && - !mayRunCoercionCode(args[0]) && - !mayRunCoercionCode(args[1]) && - !hasInheritedGrowthIndex(target, length - remove + items.length) - ) { - return Reflect.apply(Array.prototype.splice, target, [start, remove, ...items]).map((item) => - draftValue(context, item), - ); - } - return spliceArray(target, items, start, remove, context, length); - } - case "sort": { - const comparator = args[0]; - if (comparator !== undefined && typeof comparator !== "function") { - Reflect.apply(Array.prototype.sort, target, args); - } else { - Reflect.apply(Array.prototype.sort, target, [ - (left: unknown, right: unknown): number => { - const draftedLeft = draftValue(context, left); - const draftedRight = draftValue(context, right); - if (typeof comparator === "function") { - return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number; - } - const leftString = String(draftedLeft); - const rightString = String(draftedRight); - return leftString < rightString ? -1 : leftString > rightString ? 1 : 0; - }, - ]); - } - return state.proxy; - } - case "reverse": - Reflect.apply(Array.prototype.reverse, target, []); - return state.proxy; - case "fill": { - const length = target.length; - const start = args.length > 1 ? clampArrayIndex(toIntegerOrInfinity(args[1]), length) : 0; - const end = - args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length; - if (end <= start) return state.proxy; - const supplied = args[0]; - for (let index = start; index < end; index++) { - const item = isContainer(supplied) - ? cloneAssigned(supplied, context, new Set()) - : assertJsonPrimitive(supplied); - defineArrayValue(target, index, item); - } - return state.proxy; - } - case "copyWithin": { - const length = target.length; - const to = clampArrayIndex(toIntegerOrInfinity(args[0]), length); - const from = clampArrayIndex(toIntegerOrInfinity(args[1]), length); - const end = - args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length; - const count = Math.min(Math.max(end - from, 0), length - to); - const source = target.slice(from, from + count); - for (let offset = 0; offset < count; offset++) { - const item = source[offset]; - const itemState = isContainer(item) ? context.states.get(item) : undefined; - const current = (itemState?.proxy as Container | undefined) ?? item; - const copy = isContainer(current) - ? cloneAssigned(current, context, new Set()) - : assertJsonPrimitive(current); - defineArrayValue(target, to + offset, copy); - } - return state.proxy; - } - } - throw new TypeError(`Unsupported array mutator: ${property}`); -} - -function cloneArrayItems(items: readonly unknown[], context: DraftContext): unknown[] { - return items.map((item) => { - if (isContainer(item)) return cloneAssigned(item, context, new Set()); - assertJsonPrimitive(item); - return item; - }); -} - -function hasInheritedGrowthIndex(target: unknown[], nextLength: number): boolean { - for (let index = target.length; index < nextLength; index++) { - if (index in target) return true; - } - return false; -} - -function mayRunCoercionCode(value: unknown): boolean { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - -function spliceArray( - target: unknown[], - items: readonly unknown[], - index: number, - remove: number, - context: DraftContext, - length = target.length, -): unknown[] { - const removed = new Array(remove); - for (let offset = 0; offset < remove; offset++) { - const source = index + offset; - if (!(source in target)) continue; - defineArrayValue(removed, offset, draftValue(context, target[source])); - } - if (items.length < remove) { - for (let source = index + remove; source < length; source++) { - defineArrayValue(target, source - remove + items.length, target[source]); - } - } else if (items.length > remove) { - for (let source = length - 1; source >= index + remove; source--) { - defineArrayValue(target, source - remove + items.length, target[source]); - } - } - for (let offset = 0; offset < items.length; offset++) defineArrayValue(target, index + offset, items[offset]); - target.length = length - remove + items.length; - return removed; -} - -function defineArrayValue(target: unknown[], index: number, value: unknown): void { - Object.defineProperty(target, index, { - value, - writable: true, - enumerable: true, - configurable: true, - }); -} - -function toIntegerOrInfinity(value: unknown): number { - const number = +(value as unknown as number); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampArrayIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function currentValue(state: DraftState): Container { - assertActive(state.context); - return state.copy ?? state.base; -} - -function ensureCopy(state: DraftState): Container { - if (state.copy !== undefined) return state.copy; - if (state.context.owned.has(state.base)) { - state.copy = state.base; - } else { - state.copy = shallowOwnedCopy(state.base, state.context); - } - return state.copy; -} - -function shallowOwnedCopy(value: Container, context: DraftContext): Container { - if (Array.isArray(value)) { - const copy = value.slice(); - context.owned.add(copy); - return copy; - } - const copy = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) { - Object.defineProperty(copy, key, { - value: value[key], - writable: true, - enumerable: true, - configurable: true, - }); - } - context.owned.add(copy); - return copy; -} - -function cloneAssigned(value: Container, context: DraftContext, ancestors: Set): Container { - if (ancestors.has(value)) throw new TypeError("Assigned JSON values cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - const clone: unknown[] = []; - context.owned.add(clone); - for (let index = 0; index < value.length; index++) { - clone.push(cloneAssignedValue(Reflect.get(value, String(index)), context, ancestors)); - } - return clone; - } - - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError("Assigned JSON containers must be plain objects or arrays"); - } - const clone = Object.create(prototype) as Record; - context.owned.add(clone); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Assigned JSON objects cannot have symbol properties"); - const descriptor = Reflect.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Assigned JSON objects must contain enumerable data properties"); - } - Object.defineProperty(clone, key, { - value: cloneAssignedValue(descriptor.value, context, ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - return clone; - } finally { - ancestors.delete(value); - } -} - -function cloneAssignedValue(value: unknown, context: DraftContext, ancestors: Set): unknown { - if (isContainer(value)) return cloneAssigned(value, context, ancestors); - assertJsonPrimitive(value); - return value; -} - -function assertJsonPrimitive(value: unknown): null | boolean | number | string { - if (value === undefined) throw new TypeError("Assigned values cannot be undefined"); - if ( - value !== null && - typeof value !== "string" && - typeof value !== "boolean" && - !(typeof value === "number" && Number.isFinite(value)) - ) { - throw new TypeError("Assigned values must be strict JSON values"); - } - return value; -} - -function draftValue(context: DraftContext, value: unknown): unknown { - if (!isContainer(value)) return value; - return getState(context, value).proxy; -} - -function isContainer(value: unknown): value is Container { - return typeof value === "object" && value !== null; -} - -function isPromiseLike(value: unknown): value is PromiseLike { - return ( - ((typeof value === "object" && value !== null) || typeof value === "function") && - typeof (value as { then?: unknown }).then === "function" - ); -} - -function assertActive(context: DraftContext): void { - if (!context.active) throw new TypeError("Cannot use a draft outside its change callback"); -} - -function assertStringWrite(property: string | symbol): asserts property is string { - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); -} - -function syncArrayTarget(state: DraftState): void { - const current = currentValue(state); - if (Array.isArray(current)) Reflect.set(state.target!, "length", current.length); -} - -function finalize( - state: DraftState, - finalizing: Set, - finalized: WeakMap, -): Container { - const cached = finalized.get(state); - if (cached !== undefined) return cached; - if (finalizing.has(state)) throw new TypeError("Cyclic draft state is not supported"); - finalizing.add(state); - try { - if (state.copy !== undefined && shallowEqual(state.base, state.copy)) state.copy = undefined; - const current = currentValue(state); - let result = current; - for (const key of Object.keys(current)) { - const value = Reflect.get(current, key); - if (!isContainer(value)) continue; - const child = state.context.states.get(value); - if (child === undefined) continue; - const finalizedChild = finalize(child, finalizing, finalized); - if (finalizedChild === value) continue; - if (result === state.base) result = shallowOwnedCopy(state.base, state.context); - Object.defineProperty(result, key, { - value: finalizedChild, - writable: true, - enumerable: true, - configurable: true, - }); - } - if (state.copy !== undefined && Array.isArray(result)) assertDenseArray(result); - finalized.set(state, result); - return result; - } finally { - finalizing.delete(state); - } -} - -function shallowEqual(left: Container, right: Container): boolean { - if (Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - const leftRecord = left as Record; - const rightRecord = right as Record; - for (const key of leftKeys) { - if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false; - } - return true; -} - -function assertDenseArray(value: unknown[]): void { - const keys = Reflect.ownKeys(value); - if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) { - throw new TypeError("Draft arrays must be dense and contain only indexed entries"); - } - for (let index = 0; index < value.length; index++) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if ( - descriptor === undefined || - !descriptor.enumerable || - !("value" in descriptor) || - descriptor.value === undefined - ) { - throw new TypeError("Draft arrays must contain enumerable indexed data properties with defined values"); - } - } -} diff --git a/packages/chord/src/delta/revision-validator.ts b/packages/chord/src/delta/revision-validator.ts new file mode 100644 index 000000000..d78373e7a --- /dev/null +++ b/packages/chord/src/delta/revision-validator.ts @@ -0,0 +1,75 @@ +import type { JsonValue } from "../types.ts"; + +type JsonContainer = JsonValue[] | Record; + +const isContainer = (value: JsonValue): value is JsonContainer => typeof value === "object" && value !== null; + +/** Validates immutable replica revisions while skipping containers validated in earlier revisions. */ +export class JsonRevisionValidator { + readonly #validated = new WeakSet(); + + validate(value: T): T { + return this.#validate(value, new Set(), new WeakSet()) as T; + } + + #validate(value: JsonValue, ancestors: Set, finished: WeakSet): JsonValue { + if (!isContainer(value) || this.#validated.has(value)) return isContainer(value) ? value : assertPrimitive(value); + if (finished.has(value)) return value; + if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + assertDenseArray(value); + for (let index = 0; index < value.length; index++) this.#validate(value[index]!, ancestors, finished); + } else { + assertPlainObject(value); + for (const key of Reflect.ownKeys(value)) { + if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Replicated state objects must contain enumerable data properties"); + } + this.#validate(descriptor.value as JsonValue, ancestors, finished); + } + } + finished.add(value); + this.#validated.add(value); + return value; + } finally { + ancestors.delete(value); + } + } +} + +function assertPrimitive(value: unknown): null | boolean | number | string { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + throw new TypeError("Replicated state values must be strict JSON"); +} + +function assertPlainObject(value: object): void { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Replicated state containers must be plain objects or arrays"); + } +} + +function assertDenseArray(value: readonly unknown[]): void { + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) { + throw new TypeError("Replicated state arrays must be dense and contain only indexed entries"); + } + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) || + descriptor.value === undefined + ) { + throw new TypeError( + "Replicated state arrays must contain enumerable indexed data properties with defined values", + ); + } + } +} diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index d473fedec..703721031 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -1,13 +1,129 @@ import type { JsonValue } from "../types.ts"; -import { diffRevisions } from "./diff.ts"; -import { createDraftTransaction, type Draft, type DraftTransaction } from "./draft.ts"; -import type { Op } from "./index.ts"; -import { JsonRevisionStore } from "./value.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"; + +export type { Draft, JsonValue, Op }; + +type Primitive = null | boolean | number | string; +type Container = JsonValue[] | Record; +type Stored = JsonValue; +type StoredRef = Primitive | { index: number }; +type Status = "open" | "prepared" | "consumed" | "aborted" | "stale"; +type StatusCell = { value: Status }; + +type InsertSource = { refs: StoredRef[] }; +type ParentKind = 0 | 1 | 2; // object, base-array entry, inserted-array entry + +type BasePiece = { kind: "base"; start: number; length: number; step: 1 | -1 }; +type InsertPiece = { kind: "insert"; source: InsertSource; start: number; length: number; step: 1 | -1 }; +type Piece = BasePiece | InsertPiece; + +type PieceNode = { + piece: Piece; + left: PieceNode | undefined; + right: PieceNode | undefined; + priority: number; + elements: number; +}; + +type PieceLocation = { piece: Piece; logicalStart: number; minimum: number; maximum: number }; + +type DenseRegion = { start: number; length: number }; +type DenseCandidates = { indices: number[]; bits: Uint8Array | undefined; length: number }; + +type ArrayPlan = { + removeRuns: number[]; + permutation: number[] | undefined; + insertRuns: number[]; +}; + +type ArrayOverlay = { + root: PieceNode | undefined; + pieces: Piece[] | undefined; + baseOverrides: Map | undefined; + insertOverrides: Map> | undefined; + structural: boolean; + generation: number; + plan: ArrayPlan | undefined; + seed: number; + locatedOffset: number; + baseLocations: PieceLocation[] | undefined; + insertLocations: Map | undefined; +}; + +type OverlayNode = { + context: OverlayContext; + baseIndex: number; + parentIndex: number; + parentKind: ParentKind; + parentKey: string | number; + parentSource?: InsertSource; + parentPlacement: boolean; + target: object; + proxy: object; + writeKey?: string; + writeValue?: StoredRef; + writes?: Map; + deleteKey?: string; + deletes?: Set; + readded?: Set; + array?: ArrayOverlay; + dirty?: boolean; + subtreeDirty?: boolean; + preparedPath?: Path; +}; + +type OverlayContext = { + owner: object; + tracker: WeakRef>; + baseRevision: number; + status: StatusCell; + root: OverlayNode | undefined; + bases: Container[] | undefined; + stored: Container[] | undefined; + dirty: OverlayNode[]; + nodes: OverlayNode[]; + rawNodes: WeakMap | undefined; + ops: Op[] | undefined; + replacement: boolean; + replacementNoop: boolean; + baseValue: object | undefined; + overlayReleased: boolean; + simpleObjectMaterialization: boolean; + registryRef: WeakRef | undefined; +}; + +const NODE = Symbol("chord.delta.overlay.node"); +const PREPARED = new WeakMap(); +const RELEASED: Record = {}; +const ARRAY_MUTATORS = new Set([ + "push", + "pop", + "shift", + "unshift", + "splice", + "reverse", + "sort", + "fill", + "copyWithin", +]); +const MAX_DELTA_OPERATIONS = 4_096; +const MAX_SIMPLE_OBJECT_NODES = 128; +const DATA_DESCRIPTOR: PropertyDescriptor = { + value: undefined, + writable: true, + enumerable: true, + configurable: true, +}; export interface Prepared { readonly base: T; readonly value: T; readonly ops: readonly Op[]; + readonly baseRevision: number; + abort(): void; } export interface Change { @@ -18,172 +134,2111 @@ export interface Change { export interface Tracker { readonly value: T; + readonly revision: number; beginChange(): Change; prepareReplace(value: T): Prepared; adopt(prepared: Prepared): void; } -type PreparedMetadata = { - readonly owner: object; - readonly revision: number; - status: "prepared" | "consumed" | "aborted"; -}; - -const preparedMetadata = new WeakMap(); - class PreparedImpl implements Prepared { - readonly base: T; + readonly #context: OverlayContext; readonly value: T; + readonly base: T; readonly ops: readonly Op[]; - constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) { - this.base = base; + constructor(context: OverlayContext, value: T, operations: readonly Op[]) { + this.#context = context; this.value = value; - this.ops = ops; - preparedMetadata.set(this, metadata); - Object.freeze(this); + this.base = context.baseValue as T; + this.ops = operations; + PREPARED.set(this, context); + } + + get baseRevision(): number { + return this.#context.baseRevision; + } + + abort(): void { + abortContext(this.#context); } } class ChangeImpl implements Change { - readonly state: Draft; - #tracker: TrackerImpl | undefined; - #transaction: DraftTransaction | undefined; - #preparedMetadata: PreparedMetadata | undefined; - #status: "open" | "prepared" | "aborted" = "open"; + #context: OverlayContext | undefined; + #preparedStatus: StatusCell | undefined; + #settled = false; - constructor(tracker: TrackerImpl, transaction: DraftTransaction) { - this.#tracker = tracker; - this.#transaction = transaction; - this.state = transaction.state; + constructor(context: OverlayContext) { + this.#context = context; + } + + get state(): Draft { + const state = this.#context?.root?.proxy; + if (state === undefined) throw new TypeError("Cannot use a settled overlay"); + return state as Draft; } prepare(): Prepared { - if (this.#status !== "open") throw new Error("Change has already been settled"); - const tracker = this.#tracker!; - const transaction = this.#transaction!; + if (this.#settled) throw new Error("Change has already been settled"); + const context = this.#context!; + assertWritable(context); + context.status.value = "prepared"; try { - const prepared = tracker.prepareDraft(transaction); - this.#preparedMetadata = preparedMetadata.get(prepared)!; - this.#status = "prepared"; + if (!context.replacement) context.ops = context.dirty.length === 0 ? [] : emitOperations(context); + const prepared = materializePrepared(context); + this.#preparedStatus = context.status; + this.#settled = true; return prepared; } catch (error) { - this.#status = "aborted"; + context.status.value = "aborted"; + clearContext(context); + this.#settled = true; throw error; } finally { - this.#tracker = undefined; - this.#transaction = undefined; + this.#context = undefined; } } abort(): void { - if (this.#status === "aborted") return; - if (this.#status === "prepared") { - if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted"; - this.#preparedMetadata = undefined; - this.#status = "aborted"; + if (this.#settled) { + if (this.#preparedStatus?.value === "prepared") this.#preparedStatus.value = "aborted"; + this.#preparedStatus = undefined; return; } - this.#status = "aborted"; - const tracker = this.#tracker!; - const transaction = this.#transaction!; - this.#tracker = undefined; - this.#transaction = undefined; - try { - transaction.abort(); - } finally { - tracker.releaseChange(); - } + this.#settled = true; + abortContext(this.#context!); + this.#context = undefined; } } class TrackerImpl implements Tracker { - readonly #store = new JsonRevisionStore(); readonly #owner = {}; + readonly #contexts = new Set>(); + readonly #selfRef: WeakRef>; #value: T; #revision = 0; - #changing = false; + #pruneBudget = 256; constructor(initial: T) { - this.#value = this.#store.import(initial); + this.#value = initial; + this.#selfRef = new WeakRef(this as unknown as TrackerImpl); } get value(): T { return this.#value; } + get revision(): number { + return this.#revision; + } + beginChange(): Change { - this.#reserveChange(); + const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value); + this.#register(context); + return new ChangeImpl(context); + } + + prepareReplace(value: T): Prepared { + const context = createContext(this.#selfRef, this.#owner, this.#revision, value, true, this.#value); + context.status.value = "prepared"; + this.#register(context); try { - return new ChangeImpl(this, createDraftTransaction(this.#value)); + return materializePrepared(context); } catch (error) { - this.#changing = false; + context.status.value = "aborted"; + clearContext(context); throw error; } } - prepareReplace(value: T): Prepared { - this.#reserveChange(); - try { - return this.#prepare(this.#store.import(value)); - } finally { - this.#changing = false; - } - } - adopt(prepared: Prepared): void { - const metadata = prepared instanceof PreparedImpl ? preparedMetadata.get(prepared) : undefined; - if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (metadata.status === "consumed") throw new Error("Prepared change has already been used"); - if (metadata.status === "aborted") throw new Error("Prepared change has been aborted"); - if (this.#changing) throw new Error("Cannot adopt while a change is active"); - if (metadata.revision !== this.#revision || this.#value !== prepared.base) { + const context = PREPARED.get(prepared as object); + if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); + if (context.status.value === "consumed") throw new Error("Prepared change has already been used"); + if (context.status.value === "aborted") throw new Error("Prepared change has been aborted"); + if (context.status.value === "stale") throw new Error("Prepared change is stale"); + if (context.status.value !== "prepared") throw new Error("Prepared change is not ready"); + if (context.baseRevision !== this.#revision) { + context.status.value = "stale"; + clearContext(context); throw new Error("Prepared change is stale"); } - metadata.status = "consumed"; + + if (this.#value !== prepared.base) { + context.status.value = "stale"; + throw new Error("Prepared change is stale"); + } + // Materialization happened during prepare. Adoption is an infallible pointer + // swap so storage failure can discard the candidate without touching authority. this.#value = prepared.value; + context.status.value = "consumed"; this.#revision += 1; + this.#invalidate(context); } - prepareDraft(transaction: DraftTransaction): PreparedImpl { - try { - const produced = transaction.finish(); - return this.#prepare(this.#store.commit(produced.value, produced.owned)); - } finally { - this.#changing = false; + releaseContext(context: OverlayContext): void { + if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef); + context.registryRef = undefined; + } + + #register(context: OverlayContext): void { + const reference = new WeakRef(context); + context.registryRef = reference; + this.#contexts.add(reference); + this.#pruneBudget -= 1; + if (this.#pruneBudget === 0) { + this.#prune(); + this.#pruneBudget = Math.max(256, this.#contexts.size); } } - releaseChange(): void { - this.#changing = false; + #prune(): void { + for (const reference of this.#contexts) { + if (reference.deref() === undefined) this.#contexts.delete(reference); + } } - #reserveChange(): void { - if (this.#changing) throw new Error("Tracker already has an active change"); - this.#changing = true; + #invalidate(winner: OverlayContext): void { + for (const reference of this.#contexts) { + const context = reference.deref(); + if (context === undefined || context === winner) continue; + if (context.status.value === "open" || context.status.value === "prepared") context.status.value = "stale"; + // Adoption must not walk every node touched by a competing draft. Drop the + // context's strong overlay references in O(1); externally retained proxies + // still see the stale status and are reclaimed with their holders. + releaseOverlayReferences(context); + } + this.#contexts.clear(); + this.#pruneBudget = 256; } - #prepare(candidate: T): PreparedImpl { - const base = this.#value; - const operations = diffRevisions(base as unknown as JsonValue, candidate as unknown as JsonValue); - const value = operations.length === 0 ? base : candidate; - const ops = freezeOperations(operations); - return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, value, ops); + replacementBase(context: OverlayContext): T | undefined { + return context.baseRevision === this.#revision ? this.#value : undefined; } } -/** Create a tracker around an imported immutable JSON revision. */ +/** Take immutable ownership of an alias-free strict-JSON root in O(1). */ export function track(initial: T): Tracker { return new TrackerImpl(initial); } -function freezeOperations(operations: Op[]): readonly Op[] { - for (const operation of operations) freezeMetadata(operation); - return Object.freeze(operations); +function materializePrepared(context: OverlayContext): PreparedImpl { + const operations = ensureOperations(context); + const base = context.baseValue as T; + // Operation payloads are already detached from the mutable overlay. The + // resulting revision and public batch intentionally share those payloads under + // the trusted immutable-transfer contract. + const value = + operations.length === 0 + ? base + : context.simpleObjectMaterialization + ? (cloneNode(context.root!) as T) + : materializeOperations(base, operations); + const prepared = new PreparedImpl(context, value, operations); + if (context.nodes.length > 4_096) releaseOverlayReferences(context); + else clearContext(context); + return prepared; } -function freezeMetadata(value: unknown): void { - if (value === null || typeof value !== "object" || Object.isFrozen(value)) return; - for (const child of Object.values(value)) freezeMetadata(child); - Object.freeze(value); +function materializeOperations(base: T, operations: readonly Op[]): T { + for (const operation of operations) { + // Native splice/permutation remains faster for large structural batches. + if (operation[0] === "p" || operation[0] === "m") return applyImmutable(base, operations); + } + return applyImmutableTrusted(base, operations); +} + +function createContext( + tracker: WeakRef>, + owner: object, + baseRevision: number, + root: T, + replacement: boolean, + base: T, +): OverlayContext { + const context: OverlayContext = { + owner, + tracker, + baseRevision, + status: { value: "open" }, + root: undefined, + bases: [], + stored: [], + dirty: [], + nodes: [], + rawNodes: new WeakMap(), + ops: undefined, + replacement, + replacementNoop: false, + baseValue: base, + overlayReleased: false, + simpleObjectMaterialization: false, + registryRef: undefined, + }; + context.root = createNode(context, root as Container, undefined); + return context; +} + +const sharedObjectHandler: ProxyHandler = { + deleteProperty(target, property) { + return deleteProperty(nodeForTarget(target), property); + }, + defineProperty(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Defining overlay properties is not supported"); + }, + get(target, property) { + const node = nodeForTarget(target); + if (property === NODE) return node; + return getProperty(node, property); + }, + getOwnPropertyDescriptor(target, property) { + return getDescriptor(nodeForTarget(target), property); + }, + getPrototypeOf(target) { + const node = nodeForTarget(target); + assertReadable(node.context); + return Object.getPrototypeOf(nodeBase(node)); + }, + has(target, property) { + return hasProperty(nodeForTarget(target), property); + }, + isExtensible(target) { + assertReadable(nodeForTarget(target).context); + return true; + }, + ownKeys(target) { + return ownKeys(nodeForTarget(target)); + }, + preventExtensions(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Overlays cannot be made non-extensible"); + }, + set(target, property, value) { + return setProperty(nodeForTarget(target), property, value); + }, + setPrototypeOf(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Changing an overlay prototype is not supported"); + }, +}; + +const sharedArrayHandler: ProxyHandler = sharedObjectHandler; + +function createNode( + context: OverlayContext, + base: Container, + parent: OverlayNode | undefined, + parentKind: ParentKind = 0, + parentKey: string | number = "", + parentSource: InsertSource | undefined = undefined, + parentPlacement = false, +): OverlayNode { + const existing = context.rawNodes!.get(base); + if (existing !== undefined) return existing; + const target: object = Array.isArray(base) ? [] : {}; + const baseIndex = context.bases!.length; + const parentIndex = parent?.baseIndex ?? -1; + context.bases!.push(base); + const node: OverlayNode = { + context, + baseIndex, + parentIndex, + parentKind, + parentKey, + parentPlacement, + target, + proxy: target, + }; + if (parentSource !== undefined) node.parentSource = parentSource; + (target as Record)[NODE] = node; + node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); + context.rawNodes!.set(base, node); + context.nodes.push(node); + return node; +} + +function nodeBase(node: OverlayNode): Container { + return node.context.bases![node.baseIndex]!; +} + +function nodeParent(node: OverlayNode): OverlayNode | undefined { + return node.parentIndex < 0 ? undefined : node.context.nodes[node.parentIndex]; +} + +function storeValue(context: OverlayContext, value: Stored): StoredRef { + if (!isContainer(value)) return value; + const index = context.stored!.length; + context.stored!.push(value); + return { index }; +} + +function storedValue(context: OverlayContext, reference: StoredRef): Stored { + return typeof reference === "object" && reference !== null ? context.stored![reference.index]! : reference; +} + +function replaceStoredValue(context: OverlayContext, reference: StoredRef, value: Stored): StoredRef { + if (isContainer(value)) { + if (typeof reference === "object" && reference !== null) { + context.stored![reference.index] = value; + return reference; + } + return storeValue(context, value); + } + if (typeof reference === "object" && reference !== null) context.stored![reference.index] = RELEASED; + return value; +} + +function releaseStoredValue(context: OverlayContext, reference: StoredRef): void { + if (typeof reference === "object" && reference !== null) context.stored![reference.index] = RELEASED; +} + +function nodeForTarget(target: object): OverlayNode { + const node = (target as Record)[NODE] as OverlayNode | undefined; + if (node === undefined) throw new TypeError("Cannot use a settled overlay"); + return node; +} + +function assertReadable(context: OverlayContext): void { + if ( + context.overlayReleased || + context.status.value === "consumed" || + context.status.value === "aborted" || + context.status.value === "stale" + ) { + throw new TypeError("Cannot use a settled overlay"); + } +} + +function assertWritable(context: OverlayContext): void { + assertReadable(context); + if (context.status.value !== "open") throw new TypeError("Prepared overlays are read-only"); +} + +function getProperty(node: OverlayNode, property: PropertyKey): unknown { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (property === "length") return arrayLength(overlay); + if (ARRAY_MUTATORS.has(property)) return arrayMutators[property as keyof typeof arrayMutators]; + const index = arrayIndex(property); + if (index !== undefined) return getArrayIndex(node, index); + return Reflect.get(Array.prototype, property, node.proxy); + } + if (typeof property === "symbol") return Reflect.get(nodeBase(node), property, node.proxy); + const key = String(property); + if (!objectHas(node, key)) { + if (isObjectDeleted(node, key) || Object.hasOwn(nodeBase(node), key)) return undefined; + return Reflect.get(nodeBase(node), property, node.proxy); + } + const value = objectValue(node, key); + if (!isContainer(value)) return value; + return createNode(node.context, value, node, 0, key, undefined, hasObjectWrite(node, key)).proxy; +} + +function getArrayIndex(node: OverlayNode, index: number): unknown { + const overlay = arrayOverlay(node); + if (index >= arrayLength(overlay)) return undefined; + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const value = entryValueAt(node, piece, sourceIndex); + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + piece.kind === "base" ? 1 : 2, + sourceIndex, + piece.kind === "insert" ? piece.source : undefined, + piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex), + ).proxy; +} + +function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + setArrayLength(node, toArrayLength(supplied)); + return true; + } + const index = arrayIndex(property); + if (index === undefined) throw new TypeError("Only array indices and length can be written"); + if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); + setArrayIndex(node, index, clonePlacement(supplied)); + return true; + } + const key = String(property); + if (supplied === undefined) return deleteProperty(node, key); + const stored = clonePlacement(supplied); + const current = objectValue(node, key); + const wasDeleted = isObjectDeleted(node, key); + if (!wasDeleted && !isContainer(stored) && current === stored) return true; + setObjectWrite(node, key, stored); + if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { + if (node.readded === undefined) node.readded = new Set(); + node.readded.add(key); + } + deleteObjectDeletion(node, key); + markDirty(node); + return true; +} + +function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) throw new TypeError("Overlay arrays cannot contain holes"); + const key = String(property); + if (!objectHas(node, key)) return true; + deleteObjectWrite(node, key); + node.readded?.delete(key); + setObjectDeletion(node, key); + markDirty(node); + return true; +} + +function hasProperty(node: OverlayNode, property: PropertyKey): boolean { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") return true; + const index = arrayIndex(property); + if (index !== undefined) return index < arrayLength(arrayOverlay(node)); + return property in Array.prototype; + } + return typeof property === "symbol" ? property in nodeBase(node) : objectHas(node, String(property)); +} + +function ownKeys(node: OverlayNode): ArrayLike { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const length = arrayLength(arrayOverlay(node)); + const keys = new Array(length + 1); + for (let index = 0; index < length; index++) keys[index] = String(index); + keys[length] = "length"; + return keys; + } + let existingKeysOnly = + node.deleteKey === undefined && + node.deletes === undefined && + node.readded === undefined && + (node.writeKey === undefined || Object.hasOwn(nodeBase(node), node.writeKey)); + if (existingKeysOnly && node.writes !== undefined) { + for (const key of node.writes.keys()) { + if (!Object.hasOwn(nodeBase(node), key)) { + existingKeysOnly = false; + break; + } + } + } + if (existingKeysOnly) return Object.keys(nodeBase(node)); + const keys = Object.keys(nodeBase(node)).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); + const seen = new Set(keys); + if (node.writeKey !== undefined && !seen.has(node.writeKey)) { + keys.push(node.writeKey); + seen.add(node.writeKey); + } + if (node.writes !== undefined) { + for (const key of node.writes.keys()) { + if (seen.has(key)) continue; + keys.push(key); + seen.add(key); + } + } + const indices: number[] = []; + const strings: string[] = []; + for (const key of keys) { + const index = arrayIndex(key); + if (index === undefined) strings.push(key); + else indices.push(index); + } + indices.sort((left, right) => left - right); + return [...indices.map(String), ...strings]; +} + +function getDescriptor(node: OverlayNode, property: PropertyKey): PropertyDescriptor | undefined { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + const length = arrayLength(arrayOverlay(node)); + (node.target as unknown[]).length = length; + return Reflect.getOwnPropertyDescriptor(node.target, "length"); + } + const index = arrayIndex(property); + if (index === undefined || index >= arrayLength(arrayOverlay(node))) return undefined; + } else { + if (typeof property === "symbol" || !objectHas(node, String(property))) return undefined; + } + return { + configurable: true, + enumerable: true, + writable: node.context.status.value === "open", + value: getProperty(node, property), + }; +} + +function hasObjectWrite(node: OverlayNode, key: string): boolean { + return node.writeKey === key || (node.writes?.has(key) ?? false); +} + +function setObjectWrite(node: OverlayNode, key: string, value: Stored): void { + if (node.writes !== undefined) { + const reference = node.writes.get(key); + node.writes.set( + key, + reference === undefined ? storeValue(node.context, value) : replaceStoredValue(node.context, reference, value), + ); + return; + } + if (node.writeKey === undefined || node.writeKey === key) { + node.writeKey = key; + node.writeValue = + node.writeValue === undefined + ? storeValue(node.context, value) + : replaceStoredValue(node.context, node.writeValue, value); + return; + } + node.writes = new Map(); + node.writes.set(node.writeKey, node.writeValue!); + node.writes.set(key, storeValue(node.context, value)); + node.writeKey = undefined; + node.writeValue = undefined; +} + +function deleteObjectWrite(node: OverlayNode, key: string): void { + if (node.writeKey === key) { + node.writeKey = undefined; + releaseStoredValue(node.context, node.writeValue!); + node.writeValue = undefined; + } else { + const reference = node.writes?.get(key); + if (reference !== undefined) releaseStoredValue(node.context, reference); + node.writes?.delete(key); + } +} + +function isObjectDeleted(node: OverlayNode, key: string): boolean { + return node.deleteKey === key || (node.deletes?.has(key) ?? false); +} + +function setObjectDeletion(node: OverlayNode, key: string): void { + if (node.deletes !== undefined) { + node.deletes.add(key); + return; + } + if (node.deleteKey === undefined || node.deleteKey === key) { + node.deleteKey = key; + return; + } + node.deletes = new Set(); + node.deletes.add(node.deleteKey); + node.deletes.add(key); + node.deleteKey = undefined; +} + +function deleteObjectDeletion(node: OverlayNode, key: string): void { + if (node.deleteKey === key) node.deleteKey = undefined; + else node.deletes?.delete(key); +} + +function objectHas(node: OverlayNode, key: string): boolean { + if (isObjectDeleted(node, key)) return false; + return hasObjectWrite(node, key) || Object.hasOwn(nodeBase(node), key); +} + +function objectValue(node: OverlayNode, key: string): Stored { + if (node.writeKey === key) return storedValue(node.context, node.writeValue!); + const reference = node.writes?.get(key); + if (reference !== undefined) return storedValue(node.context, reference); + return (nodeBase(node) as Record)[key]!; +} + +function markDirty(node: OverlayNode): void { + if (node.dirty) return; + node.dirty = true; + node.context.dirty.push(node); + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) parent.subtreeDirty = true; +} + +function arrayOverlay(node: OverlayNode): ArrayOverlay { + if (node.array !== undefined) return node.array; + const base = nodeBase(node) as JsonValue[]; + const overlay: ArrayOverlay = { + root: undefined, + pieces: undefined, + baseOverrides: undefined, + insertOverrides: undefined, + structural: false, + generation: 0, + plan: undefined, + seed: 0x9e3779b9, + locatedOffset: 0, + baseLocations: undefined, + insertLocations: undefined, + }; + if (base.length > 0) + overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); + node.array = overlay; + return overlay; +} + +function baseOverridesForWrite(overlay: ArrayOverlay): Map { + if (overlay.baseOverrides === undefined) overlay.baseOverrides = new Map(); + return overlay.baseOverrides; +} + +function insertOverridesForWrite(overlay: ArrayOverlay): Map> { + if (overlay.insertOverrides === undefined) overlay.insertOverrides = new Map(); + return overlay.insertOverrides; +} + +function nextPiecePriority(overlay: ArrayOverlay): number { + let value = overlay.seed; + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + overlay.seed = value >>> 0; + return overlay.seed; +} + +function treeElements(node: PieceNode | undefined): number { + return node?.elements ?? 0; +} + +function updatePieceNode(node: PieceNode): void { + node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); +} + +function createPieceNode(overlay: ArrayOverlay, piece: Piece): PieceNode { + return { piece, left: undefined, right: undefined, priority: nextPiecePriority(overlay), elements: piece.length }; +} + +function mergePieceTrees(left: PieceNode | undefined, right: PieceNode | undefined): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + if (left.priority >= right.priority) { + left.right = mergePieceTrees(left.right, right); + updatePieceNode(left); + return left; + } + right.left = mergePieceTrees(left, right.left); + updatePieceNode(right); + return right; +} + +function splitPieceTree( + overlay: ArrayOverlay, + root: PieceNode | undefined, + index: number, +): [PieceNode | undefined, PieceNode | undefined] { + if (root === undefined) return [undefined, undefined]; + const leftLength = treeElements(root.left); + if (index < leftLength) { + const [left, right] = splitPieceTree(overlay, root.left, index); + root.left = right; + updatePieceNode(root); + return [left, root]; + } + const pieceEnd = leftLength + root.piece.length; + if (index > pieceEnd) { + const [left, right] = splitPieceTree(overlay, root.right, index - pieceEnd); + root.right = left; + updatePieceNode(root); + return [root, right]; + } + if (index === leftLength) { + const left = root.left; + root.left = undefined; + updatePieceNode(root); + return [left, root]; + } + if (index === pieceEnd) { + const right = root.right; + root.right = undefined; + updatePieceNode(root); + return [root, right]; + } + const offset = index - leftLength; + const first = { ...root.piece, length: offset }; + const second = { + ...root.piece, + start: root.piece.start + root.piece.step * offset, + length: root.piece.length - offset, + }; + return [ + mergePieceTrees(root.left, createPieceNode(overlay, first)), + mergePieceTrees(createPieceNode(overlay, second), root.right), + ]; +} + +function leftmostPieceNode(node: PieceNode): PieceNode { + while (node.left !== undefined) node = node.left; + return node; +} + +function rightmostPieceNode(node: PieceNode): PieceNode { + while (node.right !== undefined) node = node.right; + return node; +} + +function mergeablePieces(left: Piece, right: Piece): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "insert" && left.source !== (right as InsertPiece).source) return false; + if (left.length === 1 && right.length === 1) return Math.abs(right.start - left.start) === 1; + return left.step === right.step && left.start + left.step * left.length === right.start; +} + +function joinNormalized( + overlay: ArrayOverlay, + left: PieceNode | undefined, + right: PieceNode | undefined, +): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + const leftPiece = rightmostPieceNode(left).piece; + const rightPiece = leftmostPieceNode(right).piece; + if (!mergeablePieces(leftPiece, rightPiece)) return mergePieceTrees(left, right); + const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); + const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); + const step = leftPiece.length === 1 ? ((rightPiece.start - leftPiece.start) as 1 | -1) : leftPiece.step; + const combined: Piece = + leftPiece.kind === "base" + ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } + : { + kind: "insert", + source: leftPiece.source, + start: leftPiece.start, + length: leftPiece.length + rightPiece.length, + step, + }; + return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); +} + +function flattenPieceTree(node: PieceNode | undefined, output: Piece[]): void { + if (node === undefined) return; + flattenPieceTree(node.left, output); + output.push(node.piece); + flattenPieceTree(node.right, output); +} + +function piecesOf(overlay: ArrayOverlay): readonly Piece[] { + if (overlay.pieces === undefined) { + overlay.pieces = []; + flattenPieceTree(overlay.root, overlay.pieces); + } + return overlay.pieces; +} + +function treeFromPieces(overlay: ArrayOverlay, pieces: Piece[]): PieceNode | undefined { + mergePieces(pieces); + let root: PieceNode | undefined; + for (const piece of pieces) root = mergePieceTrees(root, createPieceNode(overlay, piece)); + return root; +} + +function replaceAllPieces(overlay: ArrayOverlay, pieces: Piece[]): void { + overlay.root = treeFromPieces(overlay, pieces); + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; +} + +function arrayLength(overlay: ArrayOverlay): number { + return treeElements(overlay.root); +} + +function locatePiece(overlay: ArrayOverlay, index: number): Piece { + let node = overlay.root; + while (node !== undefined) { + const leftLength = treeElements(node.left); + if (index < leftLength) node = node.left; + else if (index >= leftLength + node.piece.length) { + index -= leftLength + node.piece.length; + node = node.right; + } else { + overlay.locatedOffset = index - leftLength; + return node.piece; + } + } + throw new RangeError("Array overlay index is out of range"); +} + +function arrayIndex(property: PropertyKey): number | undefined { + if (typeof property !== "string" || property.length === 0 || property.length > 10) return undefined; + if (property === "0") return 0; + const first = property.charCodeAt(0); + if (first < 49 || first > 57) return undefined; + let index = first - 48; + for (let offset = 1; offset < property.length; offset++) { + const digit = property.charCodeAt(offset) - 48; + if (digit < 0 || digit > 9) return undefined; + index = index * 10 + digit; + if (index >= 4_294_967_295) return undefined; + } + return index; +} + +function entryValueAt(node: OverlayNode, piece: Piece, sourceIndex: number): Stored { + const overlay = arrayOverlay(node); + if (piece.kind === "base") { + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + return valueIndex === undefined + ? (nodeBase(node) as JsonValue[])[sourceIndex]! + : storedValue(node.context, valueIndex); + } + const overrides = overlay.insertOverrides?.get(piece.source); + const reference = overrides?.has(sourceIndex) ? overrides.get(sourceIndex)! : piece.source.refs[sourceIndex]!; + return storedValue(node.context, reference); +} + +function hasEntryOverrideAt(overlay: ArrayOverlay, piece: Piece, sourceIndex: number): boolean { + return piece.kind === "base" + ? (overlay.baseOverrides?.has(sourceIndex) ?? false) + : (overlay.insertOverrides?.get(piece.source)?.has(sourceIndex) ?? false); +} + +function extendRightmostPiece(node: PieceNode, amount: number): void { + if (node.right !== undefined) extendRightmostPiece(node.right, amount); + else node.piece.length += amount; + updatePieceNode(node); +} + +function invalidatePieceCaches(overlay: ArrayOverlay): void { + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; + overlay.plan = undefined; +} + +function replacePieceRange(node: OverlayNode, index: number, remove: number, inserted: Piece[]): void { + if (remove === 0 && inserted.length === 0) return; + const overlay = arrayOverlay(node); + if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== undefined) { + const addition = inserted[0]!; + const tail = rightmostPieceNode(overlay.root).piece; + if ( + addition.kind === "insert" && + tail.kind === "insert" && + tail.step === 1 && + tail.start + tail.length === tail.source.refs.length + ) { + for (let offset = 0; offset < addition.length; offset++) { + tail.source.refs.push(addition.source.refs[addition.start + offset]!); + } + extendRightmostPiece(overlay.root, addition.length); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); + return; + } + } + const [left, rest] = splitPieceTree(overlay, overlay.root, index); + const [, right] = splitPieceTree(overlay, rest, remove); + const middle = treeFromPieces(overlay, inserted); + overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); +} + +function mergePieces(pieces: Piece[]): void { + for (let index = 1; index < pieces.length; ) { + const left = pieces[index - 1]!; + const right = pieces[index]!; + const sameSource = + left.kind === right.kind && (left.kind === "base" || left.source === (right as InsertPiece).source); + if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { + left.step = (right.start - left.start) as 1 | -1; + left.length = 2; + pieces.splice(index, 1); + } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { + left.length += right.length; + pieces.splice(index, 1); + } else index += 1; + } +} + +function insertPiece(node: OverlayNode, items: Stored[], start = 0): InsertPiece[] { + if (items.length === start) return []; + const refs: StoredRef[] = []; + for (let index = start; index < items.length; index++) refs.push(storeValue(node.context, items[index]!)); + return [{ kind: "insert", source: { refs }, start: 0, length: refs.length, step: 1 }]; +} + +function insertPlacementPiece(node: OverlayNode, values: unknown[], start = 0): InsertPiece[] { + if (values.length === start) return []; + const storedLength = node.context.stored!.length; + try { + for (let index = start; index < values.length; index++) + values[index] = storeValue(node.context, clonePlacement(values[index])); + } catch (error) { + node.context.stored!.length = storedLength; + throw error; + } + return [ + { + kind: "insert", + source: { refs: values as StoredRef[] }, + start, + length: values.length - start, + step: 1, + }, + ]; +} + +function setArrayIndex(node: OverlayNode, index: number, stored: Stored): void { + const overlay = arrayOverlay(node); + const length = arrayLength(overlay); + if (index === length) { + replacePieceRange(node, length, 0, insertPiece(node, [stored])); + return; + } + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const current = entryValueAt(node, piece, sourceIndex); + if (!isContainer(stored) && current === stored) return; + if (piece.kind === "base") { + const existingIndex = overlay.baseOverrides?.get(sourceIndex); + if (!isContainer(stored) && stored === (nodeBase(node) as JsonValue[])[sourceIndex]) { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overlay.baseOverrides?.delete(sourceIndex); + } else if (existingIndex === undefined) { + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, stored)); + } else overlay.baseOverrides!.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } else { + let overrides = overlay.insertOverrides?.get(piece.source); + const existingIndex = overrides?.get(sourceIndex); + if (!isContainer(stored) && stored === storedValue(node.context, piece.source.refs[sourceIndex]!)) { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides?.delete(piece.source); + } else { + if (overrides === undefined) { + overrides = new Map(); + insertOverridesForWrite(overlay).set(piece.source, overrides); + } + if (existingIndex === undefined) overrides.set(sourceIndex, storeValue(node.context, stored)); + else overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } + } + markDirty(node); +} + +function setArrayLength(node: OverlayNode, next: number): void { + const current = arrayLength(arrayOverlay(node)); + if (next === current) return; + if (next < current) replacePieceRange(node, next, current - next, []); + else + replacePieceRange( + node, + current, + 0, + insertPiece( + node, + Array.from({ length: next - current }, () => null), + ), + ); +} + +function toArrayLength(value: unknown): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 0 || number >= 4_294_967_296) throw new RangeError("Invalid array length"); + return number; +} + +function toIntegerOrInfinity(value: unknown): number { + const number = Number(value); + if (Number.isNaN(number) || number === 0) return 0; + return Number.isFinite(number) ? Math.trunc(number) : number; +} + +function clampIndex(value: number, length: number): number { + if (value === Number.NEGATIVE_INFINITY) return 0; + if (value < 0) return Math.max(length + value, 0); + return Math.min(value, length); +} + +function mutatorNode(receiver: unknown): OverlayNode | undefined { + if (!isContainer(receiver)) return undefined; + const node = Reflect.get(receiver, NODE) as OverlayNode | undefined; + if (node === undefined) return undefined; + if (!Array.isArray(nodeBase(node))) throw new TypeError("Array mutator called on incompatible receiver"); + assertWritable(node.context); + return node; +} + +const arrayMutators = { + push(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.push, this, items) as number; + const length = arrayLength(arrayOverlay(node)); + replacePieceRange(node, length, 0, insertPlacementPiece(node, items)); + return length + items.length; + }, + pop(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.pop, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getArrayIndex(node, length - 1); + replacePieceRange(node, length - 1, 1, []); + return value; + }, + shift(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.shift, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getArrayIndex(node, 0); + replacePieceRange(node, 0, 1, []); + return value; + }, + unshift(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.unshift, this, items) as number; + replacePieceRange(node, 0, 0, insertPlacementPiece(node, items)); + return arrayLength(arrayOverlay(node)); + }, + splice(this: unknown, ...args: unknown[]): unknown[] { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; + const length = arrayLength(arrayOverlay(node)); + const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); + const remove = + args.length === 0 + ? 0 + : args.length === 1 + ? length - start + : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); + const removed = Array.from({ length: remove }, (_, offset) => getArrayIndex(node, start + offset)); + const itemStart = Math.min(2, args.length); + const itemCount = Math.max(args.length - 2, 0); + replacePieceRange(node, start, remove, insertPlacementPiece(node, args, itemStart)); + setArrayLength(node, length - remove + itemCount); + return removed; + }, + reverse(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.reverse, this, []); + const overlay = arrayOverlay(node); + if (arrayLength(overlay) < 2) return node.proxy; + const pieces = [...piecesOf(overlay)].reverse(); + for (const piece of pieces) { + piece.start += piece.step * (piece.length - 1); + piece.step = piece.step === 1 ? -1 : 1; + } + replaceAllPieces(overlay, pieces); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); + return node.proxy; + }, + sort(this: unknown, comparator?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); + if (comparator !== undefined && typeof comparator !== "function") + throw new TypeError("Comparator must be a function"); + const overlay = arrayOverlay(node); + const insertedSources: InsertSource[] = []; + const insertedIndices: number[] = []; + const insertedValues: unknown[] = []; + const baseValues: unknown[] = new Array((nodeBase(node) as JsonValue[]).length); + const order: number[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + if (piece.kind === "base") { + order.push(sourceIndex); + baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); + } else { + insertedSources.push(piece.source); + insertedIndices.push(sourceIndex); + const token = -insertedSources.length; + order.push(token); + insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); + } + } + } + const baseSnapshot = new Map(); + for (const [index, valueIndex] of overlay.baseOverrides ?? []) + baseSnapshot.set(index, storedValue(node.context, valueIndex)); + const insertSnapshots = new Map>(); + for (const [source, overrides] of overlay.insertOverrides ?? []) { + const snapshot = new Map(); + for (const [index, valueIndex] of overrides) snapshot.set(index, storedValue(node.context, valueIndex)); + insertSnapshots.set(source, snapshot); + } + const generation = overlay.generation; + order.sort((left, right) => { + const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; + const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; + if (typeof comparator === "function") return Number(comparator(leftValue, rightValue)); + const a = String(leftValue); + const b = String(rightValue); + return a < b ? -1 : a > b ? 1 : 0; + }); + if ( + baseSnapshot.size > 0 || + insertSnapshots.size > 0 || + (overlay.baseOverrides?.size ?? 0) > 0 || + (overlay.insertOverrides?.size ?? 0) > 0 + ) { + for (const token of order) { + restoreSortOverride(node, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); + } + } + const comparatorWasStructural = overlay.generation !== generation; + const currentLength = arrayLength(overlay); + const samePrefix = + currentLength >= order.length && + order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); + if (!samePrefix) { + replacePieceRange( + node, + 0, + Math.min(order.length, currentLength), + piecesFromSortOrder(order, insertedSources, insertedIndices), + ); + } + if (comparatorWasStructural) deduplicateArrayEntries(node); + return node.proxy; + }, + fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + if (end <= start) return node.proxy; + const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); + replacePieceRange(node, start, end - start, insertPiece(node, items)); + return node.proxy; + }, + copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const target = clampIndex(toIntegerOrInfinity(targetArg), length); + const start = clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + const count = Math.min(Math.max(end - start, 0), length - target); + const values = Array.from({ length: count }, (_, offset) => clonePlacement(getArrayIndex(node, start + offset))); + replacePieceRange(node, target, count, insertPiece(node, values)); + return node.proxy; + }, +}; + +function sortTokenSource(token: number, sources: readonly InsertSource[]): InsertSource | undefined { + return token < 0 ? sources[-token - 1] : undefined; +} + +function sortTokenIndex(token: number, indices: readonly number[]): number { + return token < 0 ? indices[-token - 1]! : token; +} + +function publicSortValue( + node: OverlayNode, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): unknown { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + const overrideIndex = + source === undefined + ? overlay.baseOverrides?.get(sourceIndex) + : overlay.insertOverrides?.get(source)?.get(sourceIndex); + const value = + overrideIndex !== undefined + ? storedValue(node.context, overrideIndex) + : source === undefined + ? (nodeBase(node) as JsonValue[])[sourceIndex]! + : storedValue(node.context, source.refs[sourceIndex]!); + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + source === undefined ? 1 : 2, + sourceIndex, + source, + source !== undefined || (overlay.baseOverrides?.has(sourceIndex) ?? false), + ).proxy; +} + +function restoreSortOverride( + node: OverlayNode, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], + baseSnapshot: ReadonlyMap, + insertSnapshots: ReadonlyMap>, +): void { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + if (source === undefined) { + const existingIndex = overlay.baseOverrides?.get(sourceIndex); + const snapshotValue = baseSnapshot.get(sourceIndex); + if (snapshotValue !== undefined) { + if (existingIndex === undefined) + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, snapshotValue)); + else overlay.baseOverrides!.set(sourceIndex, replaceStoredValue(node.context, existingIndex, snapshotValue)); + } else { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overlay.baseOverrides?.delete(sourceIndex); + } + return; + } + let overrides = overlay.insertOverrides?.get(source); + const existingIndex = overrides?.get(sourceIndex); + const snapshotValue = insertSnapshots.get(source)?.get(sourceIndex); + if (snapshotValue !== undefined) { + if (overrides === undefined) { + overrides = new Map(); + insertOverridesForWrite(overlay).set(source, overrides); + } + if (existingIndex === undefined) overrides.set(sourceIndex, storeValue(node.context, snapshotValue)); + else overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, snapshotValue)); + } else { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides?.delete(source); + } +} + +function sameSortTokenAt( + overlay: ArrayOverlay, + logicalIndex: number, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): boolean { + const piece = locatePiece(overlay, logicalIndex); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const tokenSource = sortTokenSource(token, sources); + return ( + sourceIndex === sortTokenIndex(token, indices) && + ((piece.kind === "base" && tokenSource === undefined) || + (piece.kind === "insert" && piece.source === tokenSource)) + ); +} + +function deduplicateArrayEntries(node: OverlayNode): void { + const overlay = arrayOverlay(node); + const seenBase = new Set(); + const seenInsert = new Map>(); + const next: Piece[] = []; + let duplicated = false; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + let seen: boolean; + if (piece.kind === "base") { + seen = seenBase.has(sourceIndex); + seenBase.add(sourceIndex); + } else { + let indices = seenInsert.get(piece.source); + if (indices === undefined) { + indices = new Set(); + seenInsert.set(piece.source, indices); + } + seen = indices.has(sourceIndex); + indices.add(sourceIndex); + } + if (seen) { + duplicated = true; + appendMergedPiece( + next, + insertPiece(node, [clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]!, + ); + } else appendMergedPiece(next, singletonPiece(piece, sourceIndex)); + } + } + if (!duplicated) return; + replaceAllPieces(overlay, next); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); +} + +function piecesFromSortOrder( + order: readonly number[], + sources: readonly InsertSource[], + indices: readonly number[], +): Piece[] { + const pieces: Piece[] = []; + for (const token of order) { + const source = token < 0 ? sources[-token - 1] : undefined; + const sourceIndex = token < 0 ? indices[-token - 1]! : token; + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + ((source === undefined && previous.kind === "base") || + (source !== undefined && previous.kind === "insert" && previous.source === source)); + if (previous !== undefined && sameSource) { + if (previous.length === 1) { + const step = sourceIndex - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + continue; + } + } else if (previous.start + previous.step * previous.length === sourceIndex) { + previous.length += 1; + continue; + } + } + pieces.push( + source === undefined + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }, + ); + } + return pieces; +} + +function appendMergedPiece(pieces: Piece[], piece: Piece): void { + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + previous.kind === piece.kind && + (previous.kind === "base" || previous.source === (piece as InsertPiece).source); + if (previous !== undefined && sameSource && previous.length === 1 && piece.length === 1) { + const step = piece.start - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + return; + } + } + if ( + previous !== undefined && + sameSource && + previous.step === piece.step && + previous.start + previous.step * previous.length === piece.start + ) { + previous.length += piece.length; + } else pieces.push(piece); +} + +function singletonPiece(piece: Piece, sourceIndex: number): Piece { + return piece.kind === "base" + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; +} + +function clonePlacement(value: unknown): Stored { + const proxyNode = isContainer(value) ? (Reflect.get(value, NODE) as OverlayNode | undefined) : undefined; + return proxyNode === undefined ? cloneJson(value) : clonePlacementNode(proxyNode); +} + +function cloneJson(value: unknown, ancestors?: Set): Stored { + if (!isContainer(value)) return strictPrimitive(value); + const active = ancestors ?? new Set(); + if (active.has(value)) throw new TypeError("Draft placements cannot contain cycles"); + active.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) { + throw new TypeError("Draft placements must contain dense plain arrays"); + } + const result = new Array(value.length); + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Draft placements must contain enumerable indexed data properties"); + } + defineData(result, String(index), cloneJson(descriptor.value, active)); + } + return result; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Draft placements must contain plain objects or arrays"); + } + const result = Object.create(prototype) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key === "symbol") throw new TypeError("Draft placements cannot contain symbol properties"); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Draft placements must contain enumerable data properties"); + } + defineData(result, key, cloneJson(descriptor.value, active)); + } + return result; + } finally { + active.delete(value); + } +} + +function strictPrimitive(value: unknown): Primitive { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + throw new TypeError("Draft placements must contain strict JSON values"); +} + +function cloneNode(node: OverlayNode): Container { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))) as Record; + for (const key of ownKeys(node) as string[]) + defineData(result, key, cloneStored(objectValue(node, key), node.context)); + return result; +} + +function cloneStored(value: Stored, context: OverlayContext): Stored { + if (!isContainer(value)) return value; + const node = context.rawNodes!.get(value); + return node === undefined || (!node.dirty && !node.subtreeDirty) ? value : cloneNode(node); +} + +function clonePlacementNode(node: OverlayNode): Container { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result.push(clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))) as Record; + for (const key of ownKeys(node) as string[]) + defineData(result, key, clonePlacementStored(objectValue(node, key), node.context)); + return result; +} + +function clonePlacementStored(value: Stored, context: OverlayContext): Stored { + if (!isContainer(value)) return strictPrimitive(value); + const node = context.rawNodes!.get(value); + return node === undefined ? cloneJson(value) : clonePlacementNode(node); +} + +function defineData(target: object, key: PropertyKey, value: JsonValue): void { + DATA_DESCRIPTOR.value = value; + Object.defineProperty(target, key, DATA_DESCRIPTOR); + DATA_DESCRIPTOR.value = undefined; +} + +function emitOperations(context: OverlayContext): Op[] { + const simple = emitSimpleObjectOperations(context); + if (simple !== undefined) return simple; + const operations: Op[] = []; + const forcedFolds = new Map(); + const emissionPaths = new Map(); + const denseIndices = new Map(); + for (const node of context.dirty) { + recordDenseArrayPosition(node, denseIndices); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (!overlay.structural && (overlay.baseOverrides?.size ?? 0) > 0) { + let candidates = denseIndices.get(node); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (nodeBase(node) as JsonValue[]).length }; + denseIndices.set(node, candidates); + } + for (const index of overlay.baseOverrides!.keys()) addDenseCandidate(candidates, index); + } + } + } + const denseRegions = new Map(); + for (const [array, candidates] of denseIndices) { + const path = resolvePath(array); + if (path === undefined || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { + continue; + } + const regions = buildDenseRegions(candidates); + if (regions.length > 0) denseRegions.set(array, regions); + } + for (const node of context.dirty) { + const path = resolvePath(node); + if (path === undefined) continue; + if (hasCoveringDenseRegion(node, path, denseRegions)) continue; + emissionPaths.set(node, path); + if (!Array.isArray(nodeBase(node)) && hasReservedMutation(node)) forcedFolds.set(node, path); + const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); + if (reservedAt >= 0) { + let ancestor = node; + for (let depth = path.length; depth > reservedAt; depth--) ancestor = nodeParent(ancestor)!; + forcedFolds.set(ancestor, path.slice(0, reservedAt)); + } + } + for (const [node, path] of forcedFolds) emissionPaths.set(node, path); + for (const node of denseRegions.keys()) { + const path = resolvePath(node); + if (path !== undefined && !hasCoveringDenseRegion(node, path, denseRegions)) emissionPaths.set(node, path); + } + let maxDepth = 0; + for (const path of emissionPaths.values()) maxDepth = Math.max(maxDepth, path.length); + const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); + for (const [node, path] of emissionPaths) buckets[path.length]!.push(node); + const folded = new Set(); + for (const bucket of buckets) { + for (const node of bucket) { + const path = emissionPaths.get(node)!; + if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; + if (forcedFolds.has(node)) { + emitSet(operations, path, cloneNode(node)); + folded.add(node); + continue; + } + if (Array.isArray(nodeBase(node))) emitArrayOperations(node, path, operations, denseRegions.get(node)); + else emitObjectOperations(node, path, operations); + if (operations.length > MAX_DELTA_OPERATIONS) { + locatedDenseArray = undefined; + return [["r", cloneNode(context.root!)]]; + } + } + } + locatedDenseArray = undefined; + return operations; +} + +function emitSimpleObjectOperations(context: OverlayContext): Op[] | undefined { + const nodes: OverlayNode[] = []; + for (const node of context.dirty) { + if (Array.isArray(nodeBase(node)) || hasReservedMutation(node) || hasPlacementAncestor(node)) return undefined; + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) { + if (Array.isArray(nodeBase(parent))) return undefined; + } + const path = resolvePath(node); + if (path === undefined) continue; + for (const segment of path) { + if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) return undefined; + } + nodes.push(node); + if (nodes.length > MAX_SIMPLE_OBJECT_NODES) return undefined; + } + // Stable insertion sort avoids comparator/bucket allocations for the usual + // handful of dirty object nodes. + for (let index = 1; index < nodes.length; index++) { + const node = nodes[index]!; + const depth = node.preparedPath!.length; + let at = index; + while (at > 0 && nodes[at - 1]!.preparedPath!.length > depth) { + nodes[at] = nodes[at - 1]!; + at -= 1; + } + nodes[at] = node; + } + const operations: Op[] = []; + let canMaterializeDirectly = true; + for (const node of nodes) { + if (emitObjectOperations(node, node.preparedPath!, operations)) canMaterializeDirectly = false; + if (operations.length > MAX_DELTA_OPERATIONS) return [["r", cloneNode(context.root!)]]; + } + context.simpleObjectMaterialization = canMaterializeDirectly; + return operations; +} + +let locatedDenseArray: OverlayNode | undefined; +let locatedDenseIndex = 0; + +function locateDenseArrayPosition(node: OverlayNode): boolean { + let child = node; + for (let parent = nodeParent(child); parent !== undefined; parent = nodeParent(child)) { + if (Array.isArray(nodeBase(parent))) { + const overlay = arrayOverlay(parent); + if (child.parentKind !== 1 || overlay.structural) return false; + const sourceIndex = child.parentKey as number; + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + const current = + valueIndex === undefined + ? (nodeBase(parent) as JsonValue[])[sourceIndex] + : storedValue(parent.context, valueIndex); + if (current !== nodeBase(child)) return false; + locatedDenseArray = parent; + locatedDenseIndex = sourceIndex; + return true; + } + child = parent; + } + return false; +} + +function hasCoveringDenseRegion( + node: OverlayNode, + path: Path, + denseRegions: ReadonlyMap, +): boolean { + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) { + const regions = denseRegions.get(parent); + if (regions === undefined) continue; + const parentPath = resolvePath(parent); + if (parentPath === undefined || path.length <= parentPath.length) continue; + let matches = true; + for (let index = 0; index < parentPath.length; index++) { + if (path[index] !== parentPath[index]) { + matches = false; + break; + } + } + if (!matches) continue; + const index = path[parentPath.length]; + if (typeof index === "number" && regionContaining(regions, index)) return true; + } + return false; +} + +function addDenseCandidate(candidates: DenseCandidates, index: number): void { + if (candidates.bits !== undefined) { + candidates.bits[index] = 1; + return; + } + candidates.indices.push(index); + if (candidates.indices.length < 256) return; + candidates.bits = new Uint8Array(candidates.length); + for (const existing of candidates.indices) candidates.bits[existing] = 1; + candidates.indices.length = 0; +} + +function recordDenseArrayPosition(node: OverlayNode, groups: Map): void { + if (!locateDenseArrayPosition(node)) return; + let candidates = groups.get(locatedDenseArray!); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (nodeBase(locatedDenseArray!) as JsonValue[]).length }; + groups.set(locatedDenseArray!, candidates); + } + addDenseCandidate(candidates, locatedDenseIndex); +} + +function buildDenseRegions(candidates: DenseCandidates): DenseRegion[] { + if (candidates.bits === undefined) return []; + const regions: DenseRegion[] = []; + const bits = candidates.bits; + for (let at = 0; at < bits.length; ) { + while (at < bits.length && bits[at] === 0) at += 1; + if (at === bits.length) break; + const start = at; + let end = at; + let count = 0; + let gap = 0; + while (at < bits.length) { + if (bits[at] !== 0) { + count += 1; + end = at; + gap = 0; + } else if (++gap > 1) break; + at += 1; + } + const length = end - start + 1; + if (count >= 256 && count * 2 >= length) regions.push({ start, length }); + } + return regions; +} + +function regionContaining(regions: readonly DenseRegion[], index: number): boolean { + for (const region of regions) if (index >= region.start && index < region.start + region.length) return true; + return false; +} + +function hasReservedMutation(node: OverlayNode): boolean { + if (node.writeKey !== undefined && RESERVED_SEGMENTS.has(node.writeKey)) return true; + if (node.deleteKey !== undefined && RESERVED_SEGMENTS.has(node.deleteKey)) return true; + if (node.writes !== undefined) { + for (const key of node.writes.keys()) if (RESERVED_SEGMENTS.has(key)) return true; + } + if (node.deletes !== undefined) { + for (const key of node.deletes) if (RESERVED_SEGMENTS.has(key)) return true; + } + return false; +} + +function hasFoldedAncestor(node: OverlayNode, folded: ReadonlySet): boolean { + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) + if (folded.has(parent)) return true; + return false; +} + +function hasPlacementAncestor(node: OverlayNode): boolean { + for (let current: OverlayNode | undefined = node; current !== undefined; current = nodeParent(current)) { + if (nodeParent(current) !== undefined && current.parentPlacement) return true; + } + return false; +} + +function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): boolean { + const nextPath = [...path, key] as unknown as NonEmptyPath; + const before = + node.readded?.has(key) || !Object.hasOwn(nodeBase(node), key) + ? undefined + : (nodeBase(node) as Record)[key]; + const after = cloneStored(value, node.context); + const emitted = emitChangedValue(operations, nextPath, before, after); + return isContainer(before) && isContainer(after) && !emitted; +} + +function emitObjectOperations(node: OverlayNode, path: Path, operations: Op[]): boolean { + // Immutable replay must encode delete-and-readd explicitly so string-key + // insertion order matches the draft. + if (node.readded !== undefined) { + for (const key of node.readded) { + if (Object.hasOwn(nodeBase(node), key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); + } + } + let normalizedContainerWrite = false; + if (node.writeKey !== undefined) + normalizedContainerWrite = emitObjectWrite( + node, + path, + operations, + node.writeKey, + objectValue(node, node.writeKey), + ); + if (node.writes !== undefined) { + for (const [key, valueIndex] of node.writes) { + if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; + if (emitObjectWrite(node, path, operations, key, storedValue(node.context, valueIndex))) + normalizedContainerWrite = true; + } + } + if (node.deleteKey !== undefined && Object.hasOwn(nodeBase(node), node.deleteKey)) { + operations.push(["d", [...path, node.deleteKey] as unknown as NonEmptyPath]); + } + if (node.deletes !== undefined) { + for (const key of node.deletes) { + if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; + if (Object.hasOwn(nodeBase(node), key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); + } + } + return normalizedContainerWrite; +} + +function buildArrayPlan(node: OverlayNode): ArrayPlan { + const overlay = arrayOverlay(node); + if (overlay.plan !== undefined) return overlay.plan; + const base = nodeBase(node) as JsonValue[]; + const pieces = piecesOf(overlay); + const retained = new Uint8Array(base.length); + const targetBase: number[] = []; + for (const piece of pieces) { + if (piece.kind !== "base") continue; + for (let offset = 0; offset < piece.length; offset++) { + const index = piece.start + piece.step * offset; + retained[index] = 1; + targetBase.push(index); + } + } + const removeRuns: number[] = []; + for (let end = base.length; end > 0; ) { + if (retained[end - 1] !== 0) { + end -= 1; + continue; + } + let start = end - 1; + while (start > 0 && retained[start - 1] === 0) start -= 1; + removeRuns.push(start, end - start); + end = start; + } + const retainedBase: number[] = []; + for (let index = 0; index < retained.length; index++) if (retained[index] !== 0) retainedBase.push(index); + let permutation: number[] | undefined; + if (targetBase.some((value, index) => value !== retainedBase[index])) { + const positions = new Map(retainedBase.map((value, index) => [value, index])); + permutation = targetBase.map((value) => positions.get(value)!); + } + const insertRuns: number[] = []; + let logicalIndex = 0; + for (let pieceIndex = 0; pieceIndex < pieces.length; ) { + const piece = pieces[pieceIndex]!; + if (piece.kind === "base") { + logicalIndex += piece.length; + pieceIndex += 1; + continue; + } + const startPiece = pieceIndex; + while (pieceIndex < pieces.length && pieces[pieceIndex]!.kind === "insert") { + logicalIndex += pieces[pieceIndex]!.length; + pieceIndex += 1; + } + insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); + } + overlay.plan = { removeRuns, permutation, insertRuns }; + return overlay.plan; +} + +function rangeLength(pieces: readonly Piece[], start: number, end: number): number { + let length = 0; + for (let index = start; index < end; index++) length += pieces[index]!.length; + return length; +} + +function emitArrayOperations( + node: OverlayNode, + path: Path, + operations: Op[], + denseRegions: readonly DenseRegion[] | undefined, +): void { + const overlay = arrayOverlay(node); + const base = nodeBase(node) as JsonValue[]; + if (denseRegions !== undefined) { + for (const region of denseRegions) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", path, region.start, region.length, cloneArrayRegion(node, region)]); + } + } + if (overlay.structural) { + const plan = buildArrayPlan(node); + for (let index = 0; index < plan.removeRuns.length; index += 2) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", path, plan.removeRuns[index]!, plan.removeRuns[index + 1]!, []]); + } + if (plan.permutation !== undefined) operations.push(["m", path, plan.permutation]); + const pieces = piecesOf(overlay); + for (let run = 0; run < plan.insertRuns.length; run += 3) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const logicalIndex = plan.insertRuns[run]!; + const items: JsonValue[] = []; + for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { + const piece = pieces[pieceIndex]! as InsertPiece; + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + operations.push(["p", path, logicalIndex, 0, items]); + } + } + for (const [baseIndex, valueIndex] of overlay.baseOverrides ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const index = findEntryIndex(overlay, 1, baseIndex, undefined); + if (index === undefined || (denseRegions !== undefined && regionContaining(denseRegions, index))) continue; + emitChangedValue( + operations, + [...path, index] as unknown as NonEmptyPath, + base[baseIndex], + cloneStored(storedValue(node.context, valueIndex), node.context), + ); + } +} + +function cloneArrayRegion(node: OverlayNode, region: DenseRegion): JsonValue[] { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (let index = region.start; index < region.start + region.length; index++) { + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + return result; +} + +function emitChangedValue( + operations: Op[], + path: NonEmptyPath, + before: JsonValue | undefined, + after: JsonValue, +): boolean { + if (!isContainer(after) && before === after) return false; + if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; + if (typeof before === "string" && typeof after === "string") { + // Slice/equality stays on V8's rope fast path; startsWith scans the entire + // accumulated prefix on every append. + if (after.length > before.length && after.slice(0, before.length) === before) { + operations.push(["a", path, after.slice(before.length)]); + return true; + } + const shared = overlap(before, after, 65_536); + if (shared > 0) { + operations.push(["t", path, before.length - shared]); + if (after.length > shared) operations.push(["a", path, after.slice(shared)]); + return true; + } + } + operations.push(["s", path, after]); + return true; +} + +function emitSet(operations: Op[], path: Path, value: JsonValue): void { + if (path.length === 0) operations.push(["r", value]); + else operations.push(["s", path as NonEmptyPath, value]); +} + +function resolvePath(node: OverlayNode): Path | undefined { + if (node.preparedPath !== undefined) return node.preparedPath; + const parent = nodeParent(node); + if (parent === undefined) { + node.preparedPath = []; + return node.preparedPath; + } + const parentPath = resolvePath(parent); + if (parentPath === undefined) return undefined; + if (node.parentKind === 0) { + const key = node.parentKey as string; + if (!objectHas(parent, key) || objectValue(parent, key) !== nodeBase(node)) return undefined; + node.preparedPath = [...parentPath, key]; + return node.preparedPath; + } + const overlay = arrayOverlay(parent); + const index = findEntryIndex(overlay, node.parentKind, node.parentKey as number, node.parentSource); + if (index === undefined) return undefined; + const piece = locatePiece(overlay, index); + if (entryValueAt(parent, piece, node.parentKey as number) !== nodeBase(node)) return undefined; + node.preparedPath = [...parentPath, index]; + return node.preparedPath; +} + +function ensurePieceLocations(overlay: ArrayOverlay): void { + if (overlay.baseLocations !== undefined) return; + overlay.baseLocations = []; + overlay.insertLocations = new Map(); + let logicalStart = 0; + for (const piece of piecesOf(overlay)) { + const last = piece.start + piece.step * (piece.length - 1); + const location = { + piece, + logicalStart, + minimum: Math.min(piece.start, last), + maximum: Math.max(piece.start, last), + }; + if (piece.kind === "base") overlay.baseLocations.push(location); + else { + let locations = overlay.insertLocations.get(piece.source); + if (locations === undefined) { + locations = []; + overlay.insertLocations.set(piece.source, locations); + } + locations.push(location); + } + logicalStart += piece.length; + } + const byMinimum = (left: PieceLocation, right: PieceLocation): number => left.minimum - right.minimum; + overlay.baseLocations.sort(byMinimum); + for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); +} + +function findEntryIndex( + overlay: ArrayOverlay, + kind: ParentKind, + sourceIndex: number, + source: InsertSource | undefined, +): number | undefined { + if (kind === 1 && !overlay.structural) return sourceIndex; + ensurePieceLocations(overlay); + const locations = kind === 1 ? overlay.baseLocations! : overlay.insertLocations!.get(source!); + if (locations === undefined) return undefined; + let low = 0; + let high = locations.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (locations[middle]!.minimum <= sourceIndex) low = middle + 1; + else high = middle; + } + const location = locations[low - 1]; + if (location === undefined || sourceIndex > location.maximum) return undefined; + const offset = (sourceIndex - location.piece.start) / location.piece.step; + return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : undefined; +} + +function ensureOperations(context: OverlayContext): Op[] { + if (context.ops !== undefined) return context.ops; + assertReadable(context); + if (context.replacement) { + const base = context.tracker.deref()?.replacementBase(context); + context.replacementNoop = + base !== undefined && equalTrustedJson(base as unknown as JsonValue, nodeBase(context.root!) as JsonValue); + context.ops = context.replacementNoop ? [] : [["r", nodeBase(context.root!)]]; + } else context.ops = emitOperations(context); + return context.ops; +} + +function equalTrustedJson(left: JsonValue, right: JsonValue): boolean { + if (left === right) return true; + if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; + if (Array.isArray(left)) { + const other = right as JsonValue[]; + if (left.length !== other.length) return false; + for (let index = 0; index < left.length; index++) + if (!equalTrustedJson(left[index]!, other[index]!)) return false; + return true; + } + const other = right as Record; + const keys = Object.keys(left); + if (keys.length !== Object.keys(other).length) return false; + for (const key of keys) if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key]!, other[key]!)) return false; + return true; +} + +function abortContext(context: OverlayContext): void { + if (context.status.value === "aborted" || context.status.value === "consumed" || context.status.value === "stale") + return; + context.status.value = "aborted"; + clearContext(context); +} + +function releaseOverlayReferences(context: OverlayContext): void { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) locatedDenseArray = undefined; + context.overlayReleased = true; + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = undefined; + context.ops = undefined; + context.root = undefined; + context.bases = undefined; + context.stored = undefined; + context.baseValue = undefined; +} + +function clearContext(context: OverlayContext): void { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) locatedDenseArray = undefined; + for (const node of context.nodes) { + if (Array.isArray(node.target)) node.target.length = 0; + Reflect.deleteProperty(node.target, NODE); + node.writes?.clear(); + node.deletes?.clear(); + node.readded?.clear(); + if (node.array?.pieces !== undefined) node.array.pieces.length = 0; + if (node.array !== undefined) { + node.array.root = undefined; + node.array.baseLocations = undefined; + node.array.insertLocations = undefined; + } + node.array?.baseOverrides?.clear(); + node.array?.insertOverrides?.clear(); + node.parentSource = undefined; + node.target = RELEASED; + node.proxy = RELEASED; + node.writeKey = undefined; + node.writeValue = undefined; + node.writes = undefined; + node.deleteKey = undefined; + node.deletes = undefined; + node.readded = undefined; + node.array = undefined; + node.subtreeDirty = undefined; + node.preparedPath = undefined; + } + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = undefined; + context.ops = undefined; + context.root = undefined; + context.bases = undefined; + context.stored = undefined; + context.baseValue = undefined; + context.overlayReleased = true; +} + +function isContainer(value: unknown): value is Container { + return value !== null && typeof value === "object"; } diff --git a/packages/chord/src/delta/value.ts b/packages/chord/src/delta/value.ts deleted file mode 100644 index 1746e61c2..000000000 --- a/packages/chord/src/delta/value.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { JsonValue } from "../types.ts"; - -type JsonContainer = JsonValue[] | Record; - -const isContainer = (value: unknown): value is JsonContainer => typeof value === "object" && value !== null; - -/** Owns and freezes immutable JSON revisions while reusing already-owned subtrees. */ -export class JsonRevisionStore { - readonly #owned = new WeakSet(); - - import(value: T): T { - return this.#clone(value, new Set()) as T; - } - - commit(value: T, transactionOwned: WeakSet): T { - return this.#finish(value as JsonContainer, transactionOwned, new Set(), new WeakSet()) as T; - } - - adopt(value: T): T { - return this.#adopt(value, new Set(), new WeakSet()) as T; - } - - #clone(value: unknown, ancestors: Set): JsonValue { - if (!isContainer(value)) return assertPrimitive(value); - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - const result = new Array(value.length); - for (let index = 0; index < value.length; index++) { - Object.defineProperty(result, index, { - value: this.#clone(value[index], ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - this.#owned.add(result); - return Object.freeze(result) as unknown as JsonValue; - } - assertPlainObject(value); - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - Object.defineProperty(result, key, { - value: this.#clone(descriptor.value, ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - this.#owned.add(result); - return Object.freeze(result) as unknown as JsonValue; - } finally { - ancestors.delete(value); - } - } - - #adopt(value: JsonValue, ancestors: Set, finished: WeakSet): JsonValue { - if (!isContainer(value) || this.#owned.has(value)) return isContainer(value) ? value : assertPrimitive(value); - if (finished.has(value)) return value; - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - for (let index = 0; index < value.length; index++) { - const child = this.#adopt(value[index]!, ancestors, finished); - if (child !== value[index]) defineValue(value, String(index), child as JsonContainer); - } - } else { - assertPlainObject(value); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - const child = this.#adopt(descriptor.value as JsonValue, ancestors, finished); - if (child !== descriptor.value && isContainer(child)) defineValue(value, key, child); - } - } - finished.add(value); - this.#owned.add(value); - return Object.freeze(value) as unknown as JsonValue; - } finally { - ancestors.delete(value); - } - } - - #finish( - value: JsonContainer, - transactionOwned: WeakSet, - ancestors: Set, - placements: WeakSet, - ): JsonContainer { - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - if (placements.has(value)) return this.#clone(value, new Set()) as JsonContainer; - placements.add(value); - if (this.#owned.has(value)) return value; - if (!transactionOwned.has(value)) return this.#clone(value, ancestors) as JsonContainer; - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - for (let index = 0; index < value.length; index++) { - const child = value[index]; - if (!isContainer(child)) { - assertPrimitive(child); - continue; - } - const next = this.#finish(child, transactionOwned, ancestors, placements); - if (next !== child) defineValue(value, String(index), next); - } - } else { - assertPlainObject(value); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - if (!isContainer(descriptor.value)) { - assertPrimitive(descriptor.value); - continue; - } - const next = this.#finish(descriptor.value, transactionOwned, ancestors, placements); - if (next !== descriptor.value) defineValue(value, key, next); - } - } - this.#owned.add(value); - return Object.freeze(value) as unknown as JsonContainer; - } finally { - ancestors.delete(value); - } - } -} - -function defineValue(target: object, key: string, value: JsonContainer): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function assertPrimitive(value: unknown): null | boolean | number | string { - if (value === null || typeof value === "string" || typeof value === "boolean") return value; - if (typeof value === "number" && Number.isFinite(value)) return value; - throw new TypeError("Replicated state values must be strict JSON"); -} - -function assertPlainObject(value: object): void { - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError("Replicated state containers must be plain objects or arrays"); - } -} - -function assertDenseArray(value: readonly unknown[]): void { - const keys = Reflect.ownKeys(value); - if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) { - throw new TypeError("Replicated state arrays must be dense and contain only indexed entries"); - } - for (let index = 0; index < value.length; index++) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if ( - descriptor === undefined || - !descriptor.enumerable || - !("value" in descriptor) || - descriptor.value === undefined - ) { - throw new TypeError( - "Replicated state arrays must contain enumerable indexed data properties with defined values", - ); - } - } -} diff --git a/packages/chord/src/services/state.ts b/packages/chord/src/services/state.ts index 37ee7c77f..395250f79 100644 --- a/packages/chord/src/services/state.ts +++ b/packages/chord/src/services/state.ts @@ -1,6 +1,6 @@ import { BACKGROUND_CONTEXT } from "../context/index.ts"; import { applyImmutable, isBase, type Op, type Prepared, type Tracker, track } from "../delta/index.ts"; -import { JsonRevisionStore } from "../delta/value.ts"; +import { JsonRevisionValidator } from "../delta/revision-validator.ts"; import type { AttachedReplicatedState, Context, @@ -265,7 +265,7 @@ export function attachReplicatedStateSource( export class ReplicatedStateReplica implements ReplicatedState { readonly #listeners = new Set>(); readonly #reportError: (error: Error) => void; - readonly #store = new JsonRevisionStore(); + readonly #validator = new JsonRevisionValidator(); #value: T | undefined; #sequence: number | undefined; @@ -292,7 +292,7 @@ export class ReplicatedStateReplica implements let next: T; try { if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch"); - next = this.#store.adopt(applyImmutable(undefined, ops)); + next = this.#validator.validate(applyImmutable(undefined, ops)); } catch (error) { this.clear(); throw error; @@ -312,7 +312,7 @@ export class ReplicatedStateReplica implements } let next: T; try { - next = this.#store.adopt(applyImmutable(this.#value, ops)); + next = this.#validator.validate(applyImmutable(this.#value, ops)); } catch (error) { this.clear(); throw error; diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts index 70810a8d5..cd887b5da 100644 --- a/packages/chord/src/types.ts +++ b/packages/chord/src/types.ts @@ -41,20 +41,26 @@ export interface ReplicatedStateDelivery { } export interface ReplicatedState { - /** Immutable value, or undefined until hydration. Later updates do not mutate previously returned values. */ + /** + * Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an + * in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values. + */ readonly value: T | undefined; - /** Listener values are immutable and may structurally share unchanged data with other revisions. */ + /** Listener values follow the same immutable ownership contract and may share unchanged revision data. */ subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void; } export interface MutableReplicatedState extends ReplicatedState { readonly value: T; /** - * Atomically publish one synchronous copy-on-write mutation. Draft handles are revoked when the callback returns. - * Async callbacks and non-JSON assignments, including `undefined`, throw without changing the value. + * Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns. + * Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it. */ change(context: Context, mutate: (draft: Draft) => void): void; - /** Atomically replace the complete value with a detached immutable JSON snapshot. */ + /** + * Atomically take immutable ownership of an alias-free strict-JSON replacement. + * The caller must not mutate the transferred root after this call. + */ replace(context: Context, value: T): void; } @@ -263,7 +269,10 @@ export interface FacetEnvironment { provide(service: Service, implementation: NoInfer): void; /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ provideMany(service: Service): ServiceSpawner; - /** Create initialized mutable state suitable for exposing through a service implementation. */ + /** + * Create initialized mutable state by taking immutable ownership of an alias-free strict-JSON root. + * The caller must not mutate `initial` after this call. + */ replicatedState(initial: T): MutableReplicatedState; /** Give the facet ownership of a resource cleanup function. */ own(disposal: () => void | Promise): void; diff --git a/packages/chord/test/delta-astra/benchmark-head.worker.ts b/packages/chord/test/delta-astra/benchmark-head.worker.ts deleted file mode 100644 index f5c06b0ca..000000000 --- a/packages/chord/test/delta-astra/benchmark-head.worker.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { diffRevisions } from "../../src/delta/diff.ts"; -import { type Draft, produceWithMetadata } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { JsonRevisionStore } from "../../src/delta/value.ts"; -import type { JsonValue } from "../../src/types.ts"; - -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; - -const MiB = 1024 * 1024; -const scenario = process.argv[2] as Scenario; -const size = Number(process.argv[3] ?? 100_000); -const trial = Number(process.argv[4] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const store = new JsonRevisionStore(); -const importStart = performance.now(); -let value = store.import(input); -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - const draftStart = performance.now(); - const produced = produceWithMetadata(value, (draft) => { - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(draft); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) draft.rows[index]!.value = -index; - checksum = draft.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - draft.rows.shift(); - draft.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = draft.rows.length; - break; - case "sort": - draft.rows.sort((left, right) => left.id - right.id); - checksum = draft.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) draft.rows[index]!.value = -index; - checksum = draft.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(draft.rows.unshift, draft.rows, inserted); - checksum = draft.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - }); - const draftFinalizeMs = performance.now() - draftStart - mutateMs; - const prepareStart = performance.now(); - const next = produced.value === value ? value : store.commit(produced.value, produced.owned); - const operations: readonly Op[] = - next === value ? [] : diffRevisions(value as unknown as JsonValue, next as unknown as JsonValue); - prepareMs = draftFinalizeMs + performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(operations); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = operations.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - value = next; - adoptMs = performance.now() - adoptStart; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode: "head", - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-astra/benchmark.ts b/packages/chord/test/delta-astra/benchmark.ts deleted file mode 100644 index c6cf9af44..000000000 --- a/packages/chord/test/delta-astra/benchmark.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -type Mode = "astra" | "delta" | "head"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Result = { - mode: Mode; - scenario: Scenario; - size: number; - trial: number; - queueOperations: number; - importMs: number; - mutateMs: number; - prepareMs: number; - serializeMs: number; - adoptMs: number; - opBytes: number; - operationCount: number; - checksum: number; - readyMiB: number; - transientMiB: number; - retainedMiB: number; - maxRssMiB: number; -}; -type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; - -const scenarios: readonly Scenario[] = [ - "import", - "committed-read", - "draft-read", - "sparse", - "queue", - "sort", - "dense", - "unshift", -]; -const modes: readonly Mode[] = ["astra", "delta", "head"]; -const quick = process.argv.includes("--quick"); -const size = quick ? 10_000 : 100_000; -const trials = quick ? 1 : 3; -const root = fileURLToPath(new URL("../../../..", import.meta.url)); -const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); -const headRoot = join(tmpdir(), `chord-astra-head-${process.pid}`); -const headFiles = [ - "packages/chord/src/state/diff.ts", - "packages/chord/src/state/draft.ts", - "packages/chord/src/state/value.ts", - "packages/chord/src/delta/index.ts", - "packages/chord/src/types.ts", -] as const; - -function writeHeadFixture(): string { - for (const path of headFiles) { - const destination = join(headRoot, path); - mkdirSync(dirname(destination), { recursive: true }); - writeFileSync(destination, execFileSync("git", ["show", `HEAD:${path}`], { cwd: root })); - } - const worker = join(headRoot, "packages/chord/test/delta-astra/benchmark-head.worker.ts"); - mkdirSync(dirname(worker), { recursive: true }); - const source = readFileSync(new URL("./benchmark-head.worker.ts", import.meta.url), "utf8") - .replaceAll("../../src/delta/diff.ts", "../../src/state/diff.ts") - .replaceAll("../../src/delta/draft.ts", "../../src/state/draft.ts") - .replaceAll("../../src/delta/value.ts", "../../src/state/value.ts"); - writeFileSync(worker, source); - writeFileSync(join(headRoot, "package.json"), JSON.stringify({ type: "module" })); - return worker; -} - -function average(values: readonly number[]): number | null { - return values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; -} - -const headWorker = writeHeadFixture(); -const currentWorker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); -const results: Result[] = []; -const failures: Failure[] = []; -try { - for (let trial = 0; trial < trials; trial++) { - for (const scenario of scenarios) { - for (const mode of modes) { - const worker = mode === "head" ? headWorker : currentWorker; - const args = - mode === "head" - ? [scenario, String(size), String(trial)] - : [mode, scenario, String(size), String(trial)]; - const child = spawnSync(process.execPath, ["--expose-gc", "--max-old-space-size=4096", worker, ...args], { - cwd: mode === "head" ? headRoot : root, - encoding: "utf8", - timeout: 300_000, - maxBuffer: 10 * 1024 * 1024, - }); - if (child.status !== 0) { - const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; - failures.push(failure); - console.log(JSON.stringify(failure)); - continue; - } - const result = JSON.parse(child.stdout) as Result; - results.push(result); - console.log(JSON.stringify(result)); - } - } - } -} finally { - rmSync(headRoot, { recursive: true, force: true }); -} - -const summary = scenarios.flatMap((scenario) => - modes.map((mode) => { - const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); - return { - mode, - scenario, - importMs: average(selected.map((result) => result.importMs)), - mutateMs: average(selected.map((result) => result.mutateMs)), - prepareMs: average(selected.map((result) => result.prepareMs)), - serializeMs: average(selected.map((result) => result.serializeMs)), - adoptMs: average(selected.map((result) => result.adoptMs)), - opBytes: average(selected.map((result) => result.opBytes)), - readyMiB: average(selected.map((result) => result.readyMiB)), - transientMiB: average(selected.map((result) => result.transientMiB)), - retainedMiB: average(selected.map((result) => result.retainedMiB)), - maxRssMiB: average(selected.map((result) => result.maxRssMiB)), - failures: failures.filter((failure) => failure.mode === mode && failure.scenario === scenario).length, - }; - }), -); -const report = { node: process.version, head, size, trials, results, failures, summary }; -const output = join(tmpdir(), "chord-astra-benchmark.json"); -writeFileSync(output, JSON.stringify(report, null, 2)); -console.log(JSON.stringify({ output, node: process.version, head, size, trials, summary })); diff --git a/packages/chord/test/delta-astra/benchmark.worker.ts b/packages/chord/test/delta-astra/benchmark.worker.ts deleted file mode 100644 index 03692966f..000000000 --- a/packages/chord/test/delta-astra/benchmark.worker.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { track as trackAstra } from "../../src/delta/astra/index.ts"; -import type { Draft } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { track as trackDelta } from "../../src/delta/tracker.ts"; - -type Mode = "astra" | "delta"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; -type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; -type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; -type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; - -const MiB = 1024 * 1024; -const mode = process.argv[2] as Mode; -const scenario = process.argv[3] as Scenario; -const size = Number(process.argv[4] ?? 100_000); -const trial = Number(process.argv[5] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const importStart = performance.now(); -const tracker = (mode === "astra" ? trackAstra(input) : trackDelta(input)) as TrackerLike; -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(tracker.value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - let change: ChangeLike | undefined = tracker.beginChange(); - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(change.state); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - change.state.rows.shift(); - change.state.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = change.state.rows.length; - break; - case "sort": - change.state.rows.sort((left, right) => left.id - right.id); - checksum = change.state.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); - checksum = change.state.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const prepareStart = performance.now(); - let prepared: PreparedLike | undefined = change.prepare(); - prepareMs = performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(prepared.ops); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = prepared.ops.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - tracker.adopt(prepared); - adoptMs = performance.now() - adoptStart; - change = undefined; - prepared = undefined; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode, - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-astra/retention.test.ts b/packages/chord/test/delta-astra/retention.test.ts deleted file mode 100644 index 9e9dd3e98..000000000 --- a/packages/chord/test/delta-astra/retention.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -describe("astra overlay retention", () => { - it.each([ - "aborted", - "dropped-prepared", - "settled-change", - "stale-unprepared", - "stale-held", - "held-revoked", - "registry-churn", - ])( - "releases or invalidates %s", - (scenario) => { - const child = spawnSync( - process.execPath, - ["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario], - { encoding: "utf8", timeout: 60_000 }, - ); - expect(child.error, child.stderr).toBeUndefined(); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - }, - 65_000, - ); -}); diff --git a/packages/chord/test/delta-astra/retention.worker.ts b/packages/chord/test/delta-astra/retention.worker.ts deleted file mode 100644 index 54bc91567..000000000 --- a/packages/chord/test/delta-astra/retention.worker.ts +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import { setImmediate } from "node:timers/promises"; -import { track } from "../../src/delta/astra/index.ts"; - -async function collect(reference: WeakRef): Promise { - assert.ok(global.gc, "worker requires --expose-gc"); - for (let attempt = 0; attempt < 40; attempt++) { - await setImmediate(); - global.gc(); - if (reference.deref() === undefined) return; - } - assert.fail("overlay data is still retained"); -} - -const retainedRevoked: object[] = []; -const retainedSettledChanges: object[] = []; -const retainedStaleChanges: object[] = []; -const scenario = process.argv[2]; -if (scenario === "aborted") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const reference = new WeakRef(change.state.payload.rows); - change.abort(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "dropped-prepared") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const makeReference = (): WeakRef => { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - return new WeakRef(prepared.value.payload!.rows); - }; - const reference = makeReference(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "settled-change") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const makeReference = (): WeakRef => { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - const reference = new WeakRef(prepared.value.payload!.rows); - retainedSettledChanges.push(change); - return reference; - }; - const reference = makeReference(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "stale-unprepared") { - const setup = (): { - tracker: ReturnType>; - reference: WeakRef; - } => { - const initial = { payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }; - const tracker = track(initial); - const reference = new WeakRef(initial.payload.rows); - retainedStaleChanges.push(tracker.beginChange()); - const winner = tracker.prepareReplace({ payload: { rows: [{ value: -1 }] } }); - tracker.adopt(winner); - return { tracker, reference }; - }; - const { tracker, reference } = setup(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: { rows: [{ value: -1 }] } }); - assert.throws(() => (retainedStaleChanges[0] as { state: { payload: unknown } }).state.payload, TypeError); -} else if (scenario === "stale-held") { - const tracker = track({ child: { value: 0 } }); - let prepared = (() => { - const change = tracker.beginChange(); - change.state.child.value = 1; - return change.prepare(); - })(); - const held = prepared.value; - const wrapper = new WeakRef(prepared); - prepared = undefined as never; - await collect(wrapper); - const winner = tracker.prepareReplace({ child: { value: 2 } }); - tracker.adopt(winner); - assert.throws(() => held.child.value, TypeError); -} else if (scenario === "held-revoked") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const held = change.state.payload; - const child = new WeakRef(change.state.payload.rows); - retainedRevoked.push(held); - change.abort(); - await collect(child); - assert.throws(() => held.rows, TypeError); -} else if (scenario === "registry-churn") { - const tracker = track({ value: 0 }); - for (let index = 0; index < 100_000; index++) { - const change = tracker.beginChange(); - change.state.value = index; - change.abort(); - } - const winner = tracker.prepareReplace({ value: 1 }); - tracker.adopt(winner); - assert.deepEqual(tracker.value, { value: 1 }); -} else { - throw new Error(`unknown scenario: ${scenario}`); -} - -console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-benchmark/benchmark.ts b/packages/chord/test/delta-benchmark/benchmark.ts new file mode 100644 index 000000000..d63b5e116 --- /dev/null +++ b/packages/chord/test/delta-benchmark/benchmark.ts @@ -0,0 +1,210 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "tracker"; +type Scenario = + | "import" + | "committed-read" + | "draft-read" + | "sparse" + | "queue" + | "sort" + | "dense" + | "unshift" + | "watch-authority-control" + | "watch-live-borrowed" + | "watch-acquire-shared" + | "watch-acquire-detached" + | "watch-acquire-staggered" + | "watch-lag" + | "watch-catch-up" + | "watch-catch-up-coalesced" + | "watch-live-immutable"; +type Result = { + mode: Mode; + scenario: Scenario; + size: number; + trial: number; + queueOperations: number; + watchProfile: string | null; + watchCount: number; + watchUpdates: number; + queuedBatchReferences: number; + importMs: number; + mutateMs: number; + prepareMs: number; + serializeMs: number; + adoptMs: number; + acquireMs: number; + enqueueMs: number; + deliveryMs: number; + scenarioWorkMs: number; + opBytes: number; + operationCount: number; + checksum: number; + readyMiB: number; + sampledAcquireMiB: number; + sampledTransientMiB: number; + retainedMiB: number; + retainedOverReadyMiB: number; + postAcquireRetainedMiB: number; + postLagRetainedMiB: number; + postDeliveryRetainedMiB: number; + releasedRetainedMiB: number; + maxRssMiB: number; +}; + +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const ordinaryScenarios: readonly Scenario[] = [ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", +]; +const watchScenarios: readonly Scenario[] = [ + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]; +const modes: readonly Mode[] = ["tracker"]; +const quick = process.argv.includes("--quick"); +const watchOnly = process.argv.includes("--watch-only"); +const ordinaryOnly = process.argv.includes("--ordinary-only"); +if (watchOnly && ordinaryOnly) throw new Error("Choose at most one scenario filter"); +const scenarios = watchOnly + ? watchScenarios + : ordinaryOnly + ? ordinaryScenarios + : [...ordinaryScenarios, ...watchScenarios]; +const size = quick ? 10_000 : 100_000; +const trials = quick ? 1 : 3; +const watchCount = quick ? 4 : 8; +const watchUpdates = quick ? 20 : 100; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + for (const mode of modes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(size), + String(trial), + String(watchCount), + String(watchUpdates), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + } else { + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } + } +} + +const average = (values: readonly number[]): number | null => + values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; +const median = (values: readonly number[]): number | null => { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +}; +const range = (values: readonly number[]): readonly [number, number] | null => + values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +const metricNames = [ + "importMs", + "mutateMs", + "prepareMs", + "serializeMs", + "adoptMs", + "acquireMs", + "enqueueMs", + "deliveryMs", + "scenarioWorkMs", + "opBytes", + "operationCount", + "readyMiB", + "sampledAcquireMiB", + "sampledTransientMiB", + "retainedMiB", + "retainedOverReadyMiB", + "postAcquireRetainedMiB", + "postLagRetainedMiB", + "postDeliveryRetainedMiB", + "releasedRetainedMiB", + "maxRssMiB", +] as const satisfies readonly (keyof Result)[]; + +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); + const averages: Record = {}; + const medians: Record = {}; + const ranges: Record = {}; + for (const metric of metricNames) { + const values = selected.map((result) => result[metric]); + averages[metric] = average(values); + medians[metric] = median(values); + ranges[metric] = range(values); + } + return { + mode, + scenario, + watchProfile: selected[0]?.watchProfile ?? null, + watchCount: selected[0]?.watchCount ?? 0, + watchUpdates: selected[0]?.watchUpdates ?? 0, + averages, + medians, + ranges, + }; + }), +); +const output = join(tmpdir(), "chord-delta-benchmark.json"); +writeFileSync( + output, + JSON.stringify( + { node: process.version, size, trials, watchCount, watchUpdates, results, failures, summary }, + null, + 2, + ), +); +console.log( + JSON.stringify({ + output, + node: process.version, + size, + trials, + watchCount, + watchUpdates, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/benchmark.worker.ts b/packages/chord/test/delta-benchmark/benchmark.worker.ts new file mode 100644 index 000000000..e9f88bd5c --- /dev/null +++ b/packages/chord/test/delta-benchmark/benchmark.worker.ts @@ -0,0 +1,436 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { applyImmutable, type Op, track } from "../../src/delta/index.ts"; + +type Mode = "tracker"; +type Scenario = + | "import" + | "committed-read" + | "draft-read" + | "sparse" + | "queue" + | "sort" + | "dense" + | "unshift" + | "watch-authority-control" + | "watch-live-borrowed" + | "watch-acquire-shared" + | "watch-acquire-detached" + | "watch-acquire-staggered" + | "watch-lag" + | "watch-catch-up" + | "watch-catch-up-coalesced" + | "watch-live-immutable"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; +type Frame = { readonly sequence: number; readonly ops: readonly Op[] }; +type WatchReplica = { + value: Document; + readonly baseSequence: number; + sequence: number; + readonly queue: Frame[]; +}; + +const MiB = 1024 * 1024; +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set([ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]); +const WATCH_SCENARIOS = new Set([ + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +if (!MODES.has(mode)) throw new Error(`Unknown benchmark mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown benchmark scenario ${scenario}`); +const size = Number(process.argv[4] ?? 100_000); +const trial = Number(process.argv[5] ?? 0); +const requestedWatchCount = Number(process.argv[6] ?? 8); +const requestedWatchUpdates = Number(process.argv[7] ?? 100); +if (!Number.isSafeInteger(requestedWatchCount) || requestedWatchCount < 1) throw new RangeError("Invalid watch count"); +if (!Number.isSafeInteger(requestedWatchUpdates) || requestedWatchUpdates < 0) + throw new RangeError("Invalid watch update count"); + +function fixture(count: number): Document { + return { + rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), + }; +} + +function cloneTrusted(value: T): T { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T; + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) { + Object.defineProperty(result, key, { + value: cloneTrusted((value as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); + } + return result as T; +} + +function sum(document: Document | Draft): number { + let checksum = 0; + for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; + return checksum; +} + +async function gc(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let index = 0; index < 3; index++) { + await setImmediate(); + global.gc(); + } +} + +await gc(); +const baselineHeap = process.memoryUsage().heapUsed; +let input: Document | undefined = fixture(size); +if (scenario === "sort") input.rows.reverse(); +const importStart = performance.now(); +const tracker = track(input) as TrackerLike; +const importMs = performance.now() - importStart; +input = undefined; +await gc(); +const readyHeap = process.memoryUsage().heapUsed; +let peakHeap = readyHeap; +let mutateMs = 0; +let prepareMs = 0; +let serializeMs = 0; +let adoptMs = 0; +let acquireMs = 0; +let enqueueMs = 0; +let deliveryMs = 0; +let opBytes = 0; +let operationCount = 0; +let checksum = 0; +let authoritySequence = 0; +let postAcquireRetainedMiB = 0; +let postLagRetainedMiB = 0; +let postDeliveryRetainedMiB = 0; +let sampledAcquireMiB = 0; +let watchers: WatchReplica[] = []; +let borrowedListeners: Array<(value: Document, sequence: number) => number> = []; +const queueOperations = Math.min(size, 1_000); +const watchCount = WATCH_SCENARIOS.has(scenario) ? requestedWatchCount : 0; +const watchUpdateTarget = WATCH_SCENARIOS.has(scenario) ? requestedWatchUpdates : 0; + +const samplePeak = (): void => { + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +}; + +const retainedFromBaseline = async (): Promise => { + await gc(); + return (process.memoryUsage().heapUsed - baselineHeap) / MiB; +}; + +const createWatch = (value: Document): WatchReplica => ({ + value, + baseSequence: authoritySequence, + sequence: authoritySequence, + queue: [], +}); + +const acquireSharedWatches = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + const base = tracker.value; + watchers = Array.from({ length: watchCount }, () => createWatch(base)); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + for (const watcher of watchers) assert.equal(watcher.value, tracker.value); + samplePeak(); +}; + +const acquireDetachedWatches = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + watchers = Array.from({ length: watchCount }, () => createWatch(cloneTrusted(tracker.value))); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + for (let left = 0; left < watchers.length; left++) { + assert.notEqual(watchers[left]!.value, tracker.value); + for (let right = left + 1; right < watchers.length; right++) { + assert.notEqual(watchers[left]!.value, watchers[right]!.value); + } + } + samplePeak(); +}; + +const acquireOneWatch = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + watchers.push(createWatch(tracker.value)); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + samplePeak(); +}; + +const commitWatchUpdate = (update: number): Frame => { + let change: ChangeLike | undefined = tracker.beginChange(); + const row = ((update + 1) * 997) % size; + const mutateStart = performance.now(); + change.state.rows[row]!.value = -update - 1; + mutateMs += performance.now() - mutateStart; + samplePeak(); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs += performance.now() - prepareStart; + samplePeak(); + const operations = prepared.ops; + operationCount += operations.length; + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs += performance.now() - adoptStart; + samplePeak(); + change = undefined; + prepared = undefined; + authoritySequence += 1; + return { sequence: authoritySequence, ops: operations }; +}; + +const enqueueFrame = (frame: Frame): void => { + const start = performance.now(); + for (const watcher of watchers) { + assert.equal(frame.sequence, watcher.sequence + watcher.queue.length + 1); + watcher.queue.push(frame); + } + enqueueMs += performance.now() - start; +}; + +const applyFrame = (watcher: WatchReplica, frame: Frame): void => { + assert.equal(frame.sequence, watcher.sequence + 1); + watcher.value = applyImmutable(watcher.value, frame.ops); + watcher.sequence = frame.sequence; +}; + +const drainWatchQueues = (measure: boolean): void => { + const start = performance.now(); + for (const watcher of watchers) { + for (const frame of watcher.queue) applyFrame(watcher, frame); + watcher.queue.length = 0; + samplePeak(); + } + if (measure) deliveryMs += performance.now() - start; +}; + +const drainWatchQueuesCoalesced = (): void => { + const start = performance.now(); + for (const watcher of watchers) { + const operations = watcher.queue.flatMap((frame) => frame.ops); + watcher.value = applyImmutable(watcher.value, operations); + watcher.sequence = watcher.queue.at(-1)?.sequence ?? watcher.sequence; + watcher.queue.length = 0; + samplePeak(); + } + deliveryMs += performance.now() - start; +}; + +const assertWatchConvergence = (): void => { + for (const watcher of watchers) { + assert.equal(watcher.sequence, authoritySequence); + assert.deepEqual(watcher.value, tracker.value); + } +}; + +if (scenario === "committed-read") { + const start = performance.now(); + checksum = sum(tracker.value); + mutateMs = performance.now() - start; + samplePeak(); +} else if (scenario === "watch-authority-control") { + for (let update = 0; update < watchUpdateTarget; update++) commitWatchUpdate(update); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(tracker.value); +} else if (scenario === "watch-live-borrowed") { + const start = performance.now(); + borrowedListeners = Array.from({ length: watchCount }, () => (value, sequence) => value.rows[0]!.value + sequence); + for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence); + acquireMs = performance.now() - start; + postAcquireRetainedMiB = await retainedFromBaseline(); + for (let update = 0; update < watchUpdateTarget; update++) { + commitWatchUpdate(update); + const deliveryStart = performance.now(); + for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence); + deliveryMs += performance.now() - deliveryStart; + samplePeak(); + } + postDeliveryRetainedMiB = await retainedFromBaseline(); +} else if (scenario === "watch-acquire-shared" || scenario === "watch-acquire-detached") { + if (scenario === "watch-acquire-shared") acquireSharedWatches(); + else acquireDetachedWatches(); + postAcquireRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + watchers.length; +} else if (scenario === "watch-acquire-staggered") { + for (let watcher = 0; watcher < watchCount; watcher++) { + acquireOneWatch(); + if (watcher + 1 === watchCount) break; + const frame = commitWatchUpdate(watcher); + enqueueFrame(frame); + } + for (let watcher = 0; watcher < watchers.length; watcher++) { + assert.equal(watchers[watcher]!.baseSequence, watcher); + assert.equal(watchers[watcher]!.queue.length, watchCount - watcher - 1); + } + postAcquireRetainedMiB = await retainedFromBaseline(); + drainWatchQueues(false); + assertWatchConvergence(); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + sum(watchers.at(-1)!.value); +} else if ( + scenario === "watch-lag" || + scenario === "watch-catch-up" || + scenario === "watch-catch-up-coalesced" || + scenario === "watch-live-immutable" +) { + acquireSharedWatches(); + postAcquireRetainedMiB = await retainedFromBaseline(); + for (let update = 0; update < watchUpdateTarget; update++) { + const frame = commitWatchUpdate(update); + if (scenario === "watch-live-immutable") { + const start = performance.now(); + for (const watcher of watchers) applyFrame(watcher, frame); + deliveryMs += performance.now() - start; + samplePeak(); + } else enqueueFrame(frame); + } + postLagRetainedMiB = await retainedFromBaseline(); + if (scenario === "watch-catch-up-coalesced") drainWatchQueuesCoalesced(); + else if (scenario !== "watch-live-immutable") drainWatchQueues(scenario === "watch-catch-up"); + assertWatchConvergence(); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + sum(tracker.value); +} else if (scenario !== "import") { + let change: ChangeLike | undefined = tracker.beginChange(); + const start = performance.now(); + switch (scenario) { + case "draft-read": + checksum = sum(change.state); + break; + case "sparse": + for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "queue": + for (let index = 0; index < queueOperations; index++) { + change.state.rows.shift(); + change.state.rows.push({ id: size + index, value: index, payload: "queue" }); + } + checksum = change.state.rows.length; + break; + case "sort": + change.state.rows.sort((left, right) => left.id - right.id); + checksum = change.state.rows[0]!.id; + break; + case "dense": + for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "unshift": { + const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); + Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); + checksum = change.state.rows.length; + break; + } + default: + throw new Error(`Unhandled benchmark scenario ${scenario}`); + } + mutateMs = performance.now() - start; + samplePeak(); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs = performance.now() - prepareStart; + samplePeak(); + const serializeStart = performance.now(); + const serialized = JSON.stringify(prepared.ops); + serializeMs = performance.now() - serializeStart; + opBytes = Buffer.byteLength(serialized); + operationCount = prepared.ops.length; + samplePeak(); + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs = performance.now() - adoptStart; + change = undefined; + prepared = undefined; +} + +const scenarioWorkMs = mutateMs + prepareMs + serializeMs + adoptMs + acquireMs + enqueueMs + deliveryMs; +samplePeak(); +await gc(); +const retainedHeap = process.memoryUsage().heapUsed; +const retainedMiB = (retainedHeap - baselineHeap) / MiB; +const retainedOverReadyMiB = (retainedHeap - readyHeap) / MiB; +const watcherRoots = watchers.length; +const queuedBatchReferences = watchers.reduce((total, watcher) => total + watcher.queue.length, 0); +const listenerCount = borrowedListeners.length; +watchers = []; +borrowedListeners = []; +await gc(); +const releasedRetainedMiB = (process.memoryUsage().heapUsed - baselineHeap) / MiB; +console.log( + JSON.stringify({ + mode, + scenario, + size, + trial, + queueOperations, + watchProfile: WATCH_SCENARIOS.has(scenario) ? "local-sparse-array-leaf" : null, + watchCount: watcherRoots + listenerCount, + watchUpdates: authoritySequence, + queuedBatchReferences, + importMs, + mutateMs, + prepareMs, + serializeMs, + adoptMs, + acquireMs, + enqueueMs, + deliveryMs, + scenarioWorkMs, + opBytes, + operationCount, + checksum, + readyMiB: (readyHeap - baselineHeap) / MiB, + sampledAcquireMiB, + sampledTransientMiB: (peakHeap - readyHeap) / MiB, + retainedMiB, + retainedOverReadyMiB, + postAcquireRetainedMiB, + postLagRetainedMiB, + postDeliveryRetainedMiB, + releasedRetainedMiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + }), +); diff --git a/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts b/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts new file mode 100644 index 000000000..017b6540a --- /dev/null +++ b/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts @@ -0,0 +1,337 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Provisional Pico5 benchmark schemas. Results are architecture experiments, +// not compatibility commitments for public or persisted ConversationView data. +const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0"; + +type Mode = "tracker"; +type Scenario = + | "projection-control" + | "normal" + | "fanout" + | "delayed-start-lag" + | "churn-reconnect" + | "overflow-reset" + | "branch-head-replacement"; +type Distribution = { + count: number; + totalMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + minMs: number; + maxMs: number; +}; +type PhaseName = + | "mutation" + | "prepare" + | "adopt" + | "synthesize" + | "projectApply" + | "project" + | "enqueue" + | "sessionLine" + | "delivery" + | "opSerialize" + | "resetSerialize" + | "conversationAcquire" + | "documentAcquire" + | "conversationSerialize" + | "documentSerialize" + | "resetClone"; +type Result = { + mode: Mode; + scenario: Scenario; + trial: number; + provisionalSchema: string; + headlineEntries: number; + commitTarget: number; + initialAggregateBytes: number; + conversationWatchCount: number; + documentWatchCount: number; + queueCapacity: number | null; + queueCapacityUnit: "pending-operations"; + deliveryModel: "async-serialized-ops-only-no-consumer-apply"; + conversationSequence: number; + documentSequences: Record; + publishedAggregateBatches: number; + publishedDocumentBatches: number; + conversationRegistrations: number; + documentRegistrations: number; + conversationBaseBytes: number; + documentBaseBytes: number; + noOpCommitCount: number; + unmountedCommitCount: number; + resetCount: number; + resetBytes: number; + deliveryFrameReferences: number; + deliveryOperationReferences: number; + aggregateOperationCount: number; + aggregateOperationBytes: number; + documentOperationCount: number; + documentOperationBytes: number; + peakQueueReferences: number; + peakQueuedOperationReferences: number; + peakPendingOperationsPerWatch: number; + peakUniqueQueuedBatches: number; + finalQueueReferences: number; + finalQueuedOperationReferences: number; + finalUniqueQueuedBatches: number; + phaseDistributions: Record; + authorityReadyMiB: number; + postAcquireRetainedMiB: number; + postWorkRetainedMiB: number; + postVerificationRetainedMiB: number; + releaseHeapMiB: number; + releaseTotalMiB: number; + peakHeapMiB: number; + peakTotalHeapMiB: number; + maxRssMiB: number; + checksum: number; +}; +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; +type NumericResultKey = + | "initialAggregateBytes" + | "conversationSequence" + | "publishedAggregateBatches" + | "publishedDocumentBatches" + | "conversationRegistrations" + | "documentRegistrations" + | "conversationBaseBytes" + | "documentBaseBytes" + | "noOpCommitCount" + | "unmountedCommitCount" + | "resetCount" + | "resetBytes" + | "deliveryFrameReferences" + | "deliveryOperationReferences" + | "aggregateOperationCount" + | "aggregateOperationBytes" + | "documentOperationCount" + | "documentOperationBytes" + | "peakQueueReferences" + | "peakQueuedOperationReferences" + | "peakPendingOperationsPerWatch" + | "peakUniqueQueuedBatches" + | "finalQueueReferences" + | "finalQueuedOperationReferences" + | "finalUniqueQueuedBatches" + | "authorityReadyMiB" + | "postAcquireRetainedMiB" + | "postWorkRetainedMiB" + | "postVerificationRetainedMiB" + | "releaseHeapMiB" + | "releaseTotalMiB" + | "peakHeapMiB" + | "peakTotalHeapMiB" + | "maxRssMiB"; + +type SummaryValue = { median: number | null; range: readonly [number, number] | null }; + +const modes: readonly Mode[] = ["tracker"]; +const scenarios: readonly Scenario[] = [ + "projection-control", + "normal", + "fanout", + "delayed-start-lag", + "churn-reconnect", + "overflow-reset", + "branch-head-replacement", +]; +const phaseNames: readonly PhaseName[] = [ + "mutation", + "prepare", + "adopt", + "synthesize", + "projectApply", + "project", + "enqueue", + "sessionLine", + "delivery", + "opSerialize", + "resetSerialize", + "conversationAcquire", + "documentAcquire", + "conversationSerialize", + "documentSerialize", + "resetClone", +]; +const numericMetrics: readonly NumericResultKey[] = [ + "initialAggregateBytes", + "conversationSequence", + "publishedAggregateBatches", + "publishedDocumentBatches", + "conversationRegistrations", + "documentRegistrations", + "conversationBaseBytes", + "documentBaseBytes", + "noOpCommitCount", + "unmountedCommitCount", + "resetCount", + "resetBytes", + "deliveryFrameReferences", + "deliveryOperationReferences", + "aggregateOperationCount", + "aggregateOperationBytes", + "documentOperationCount", + "documentOperationBytes", + "peakQueueReferences", + "peakQueuedOperationReferences", + "peakPendingOperationsPerWatch", + "peakUniqueQueuedBatches", + "finalQueueReferences", + "finalQueuedOperationReferences", + "finalUniqueQueuedBatches", + "authorityReadyMiB", + "postAcquireRetainedMiB", + "postWorkRetainedMiB", + "postVerificationRetainedMiB", + "releaseHeapMiB", + "releaseTotalMiB", + "peakHeapMiB", + "peakTotalHeapMiB", + "maxRssMiB", +]; + +function optionNumber(name: string, fallback: number): number { + const prefix = `--${name}=`; + const raw = process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`Invalid --${name}`); + return value; +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +} + +function range(values: readonly number[]): readonly [number, number] | null { + return values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +} + +function summarize(values: readonly number[]): SummaryValue { + return { median: median(values), range: range(values) }; +} + +const quick = process.argv.includes("--quick"); +const selectedScenario = process.argv + .find((argument) => argument.startsWith("--scenario=")) + ?.slice("--scenario=".length); +if (selectedScenario !== undefined && !(scenarios as readonly string[]).includes(selectedScenario)) { + throw new Error(`Unknown scenario ${selectedScenario}`); +} +const selectedScenarios = selectedScenario === undefined ? scenarios : [selectedScenario as Scenario]; +const headlineEntries = optionNumber("entries", quick ? 128 : 512); +const commits = optionNumber("commits", quick ? 120 : 1_000); +const trials = optionNumber("trials", quick ? 1 : 3); +const queueOperationCapacity = quick ? 64 : 256; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./conversation-view-benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of selectedScenarios) { + const offset = trial % modes.length; + const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)]; + for (const mode of trialModes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(headlineEntries), + String(commits), + String(trial), + String(queueOperationCapacity), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 20 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + continue; + } + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } +} + +const summary = selectedScenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.scenario === scenario && result.mode === mode); + const metrics: Record = {}; + for (const metric of numericMetrics) metrics[metric] = summarize(selected.map((result) => result[metric])); + const phases: Record< + string, + { medianMs: SummaryValue; p95Ms: SummaryValue; p99Ms: SummaryValue; totalMs: SummaryValue } + > = {}; + for (const phase of phaseNames) { + phases[phase] = { + medianMs: summarize(selected.map((result) => result.phaseDistributions[phase].medianMs)), + p95Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p95Ms)), + p99Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p99Ms)), + totalMs: summarize(selected.map((result) => result.phaseDistributions[phase].totalMs)), + }; + } + return { + mode, + scenario, + provisionalSchema: PROVISIONAL_SCHEMA, + conversationWatchCount: selected[0]?.conversationWatchCount ?? 0, + documentWatchCount: selected[0]?.documentWatchCount ?? 0, + queueCapacity: selected[0]?.queueCapacity ?? null, + queueCapacityUnit: "pending-operations", + deliveryModel: "async-serialized-ops-only-no-consumer-apply", + metrics, + phases, + }; + }), +); + +const output = join(tmpdir(), "chord-conversation-view-benchmark.json"); +writeFileSync( + output, + JSON.stringify( + { + node: process.version, + provisionalSchema: PROVISIONAL_SCHEMA, + profile: "local-pico5-provisional-conversation-view", + headlineEntries, + commits, + trials, + queueOperationCapacity, + results, + failures, + summary, + }, + null, + 2, + ), +); +console.log( + JSON.stringify({ + output, + node: process.version, + provisionalSchema: PROVISIONAL_SCHEMA, + headlineEntries, + commits, + trials, + queueOperationCapacity, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts b/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts new file mode 100644 index 000000000..265428321 --- /dev/null +++ b/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts @@ -0,0 +1,1140 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { applyImmutable, type JsonValue, type NonEmptyPath, type Op, type Path, track } from "../../src/delta/index.ts"; + +// Provisional Pico5 benchmark schemas. They intentionally model architecture and +// workload shape, not a committed public API or persisted wire format. +const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0"; +const MiB = 1024 * 1024; +const MOUNTED_KEYS = ["config", "inbox", "turn", "preferences", "live"] as const; +const DOCUMENT_WATCH_KEYS = ["config", "inbox", "preferences", "live"] as const; + +type Mode = "tracker"; +type Scenario = + | "projection-control" + | "normal" + | "fanout" + | "delayed-start-lag" + | "churn-reconnect" + | "overflow-reset" + | "branch-head-replacement"; +type MountedKey = (typeof MOUNTED_KEYS)[number]; +type DocumentKey = MountedKey | "audit"; +type JsonObject = Record; +type ConfigDoc = { + schema: string; + model: { provider: string; modelId: string }; + thinkingLevel: string; + selectedTools: string[]; + sections: Array<{ id: string; title: string; instructions: string }>; + tools: Array<{ name: string; description: string; inputSchema: JsonObject }>; + temperature: number; + revision: number; +}; +type InboxItem = { id: number; mode: string; text: string }; +type InboxDoc = { schema: string; items: InboxItem[]; revision: number }; +type TurnDoc = { + schema: string; + stage: string; + message: string; + toolSlots: Array<{ callId: string; name: string; status: string; preview: string }>; + revision: number; +}; +type PreferencesDoc = { + schema: string; + theme: string; + keymap: string; + compactTools: boolean; + fontScale: number; + editorProfile: string; + shortcuts: Record; + revision: number; +}; +type LiveDoc = { + schema: string; + status: string; + tokens: number; + cost: number; + heartbeat: number; + generation: { stage: string; attempt: number; partial: string; retryAt?: number }; + tools: Array<{ callId: string; name: string; status: string; progress: number; output: string }>; +}; +type AuditDoc = { schema: string; commits: number; note: string }; +type TranscriptEntry = { + id: number; + parent: number | null; + kind: "user" | "assistant" | "tool-result" | "application"; + data: JsonObject; +}; +type ConversationView = { + schema: string; + conversation: { id: string; head: number | null }; + docs: Record; + entries: TranscriptEntry[]; +}; +type PreparedLike = { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; + abort(): void; +}; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike; abort(): void }; +type TrackerLike = { + readonly value: T; + beginChange(): ChangeLike; + adopt(prepared: PreparedLike): void; +}; +type DeltaFrame = { readonly kind: "delta"; readonly sequence: number; readonly ops: readonly Op[] }; +type WatchType = "conversation" | "document"; +type Watch = { + readonly snapshot: T; + readonly baseSequence: number; + readonly baseHash: string; + deliveredSequence: number; + pendingOperationCount: number; + readonly queue: DeltaFrame[]; + readonly started: boolean; + readonly watchType: WatchType; +}; +type DocumentWatch = Watch & { readonly key: DocumentKey }; +type Distribution = { + count: number; + totalMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + minMs: number; + maxMs: number; +}; +type PhaseName = + | "mutation" + | "prepare" + | "adopt" + | "synthesize" + | "projectApply" + | "project" + | "enqueue" + | "sessionLine" + | "delivery" + | "opSerialize" + | "resetSerialize" + | "conversationAcquire" + | "documentAcquire" + | "conversationSerialize" + | "documentSerialize" + | "resetClone"; +type PhaseSamples = Record; +type ScenarioConfig = { + conversationWatchCount: 0 | 1 | 8; + documentWatchCount: 0 | 4 | 16; + started: boolean; + queueCapacity: number | undefined; +}; +type DocMutation = { readonly key: DocumentKey; readonly mutate: (draft: Draft) => void }; +type CommitPlan = { + readonly docs: readonly DocMutation[]; + readonly entryKind?: TranscriptEntry["kind"]; + readonly branch?: boolean; + readonly unmountedOnly: boolean; + readonly expectedConversationNoop: boolean; +}; + +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set([ + "projection-control", + "normal", + "fanout", + "delayed-start-lag", + "churn-reconnect", + "overflow-reset", + "branch-head-replacement", +]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const headlineEntries = Number(process.argv[4] ?? 512); +const commitTarget = Number(process.argv[5] ?? 1_000); +const trial = Number(process.argv[6] ?? 0); +const overflowQueueOperationCapacity = Number(process.argv[7] ?? 256); +if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`); +if (!Number.isSafeInteger(headlineEntries) || headlineEntries < 1) throw new RangeError("Invalid entry count"); +if (!Number.isSafeInteger(commitTarget) || commitTarget < 1) throw new RangeError("Invalid commit count"); +if (!Number.isSafeInteger(overflowQueueOperationCapacity) || overflowQueueOperationCapacity < 1) { + throw new RangeError("Invalid queue operation capacity"); +} + +const scenarioConfig = (name: Scenario): ScenarioConfig => { + switch (name) { + case "projection-control": + return { conversationWatchCount: 0, documentWatchCount: 0, started: true, queueCapacity: undefined }; + case "normal": + return { conversationWatchCount: 1, documentWatchCount: 4, started: true, queueCapacity: undefined }; + case "fanout": + return { conversationWatchCount: 8, documentWatchCount: 16, started: true, queueCapacity: undefined }; + case "delayed-start-lag": + return { conversationWatchCount: 1, documentWatchCount: 4, started: false, queueCapacity: undefined }; + case "churn-reconnect": + return { conversationWatchCount: 1, documentWatchCount: 4, started: true, queueCapacity: undefined }; + case "overflow-reset": + return { + conversationWatchCount: 1, + documentWatchCount: 4, + started: false, + queueCapacity: overflowQueueOperationCapacity, + }; + case "branch-head-replacement": + return { conversationWatchCount: 1, documentWatchCount: 4, started: false, queueCapacity: undefined }; + } +}; +const config = scenarioConfig(scenario); + +function cloneTrusted(value: T): T { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T; + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) { + Object.defineProperty(result, key, { + value: cloneTrusted((value as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); + } + return result as T; +} + +function hashJson(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function payloadText(length: number, seed: number): string { + const bytes = Buffer.allocUnsafe(length); + let state = (seed ^ 0x9e3779b9) >>> 0; + for (let index = 0; index < length; index++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + bytes[index] = 32 + ((state >>> 0) % 95); + } + return bytes.toString("ascii"); +} + +function makeEntry(id: number, parent: number | null, kind: TranscriptEntry["kind"]): TranscriptEntry { + switch (kind) { + case "user": + return { + id, + parent, + kind, + data: { role: "user", content: payloadText(2_400 + (id % 600), id), timestamp: id * 10 }, + }; + case "assistant": + return { + id, + parent, + kind, + data: { + role: "assistant", + content: [{ type: "text", text: payloadText(5_500 + (id % 2_000), id) }], + model: "claude-sonnet-provisional", + usage: { input: 2_048 + id, output: 512 + (id % 256), cached: id % 1_024 }, + }, + }; + case "tool-result": + return { + id, + parent, + kind, + data: { + callId: `call-${id}`, + tool: id % 2 === 0 ? "read" : "bash", + status: "completed", + output: payloadText(11_000 + (id % 5_000), id), + }, + }; + case "application": + return { + id, + parent, + kind, + data: { + namespace: "pico5.provisional", + event: "artifact.updated", + payload: payloadText(3_200 + (id % 1_000), id), + }, + }; + } +} + +function entryKindAt(index: number): TranscriptEntry["kind"] { + const cycle: readonly TranscriptEntry["kind"][] = [ + "user", + "assistant", + "assistant", + "tool-result", + "application", + "assistant", + "tool-result", + ]; + return cycle[index % cycle.length]!; +} + +function resolveActiveLineage(records: ReadonlyMap, head: number | null): TranscriptEntry[] { + const reversed: TranscriptEntry[] = []; + const seen = new Set(); + let current = head; + while (current !== null) { + if (seen.has(current)) throw new Error(`Transcript cycle at ${current}`); + seen.add(current); + const entry = records.get(current); + if (entry === undefined) throw new Error(`Missing transcript entry ${current}`); + reversed.push(entry); + current = entry.parent; + } + reversed.reverse(); + return reversed; +} + +function createTracker(value: T): TrackerLike { + return track(value) as unknown as TrackerLike; +} + +function prefixDocumentOperations(key: MountedKey, operations: readonly Op[]): Op[] { + const prefix: Path = ["docs", key]; + return operations.map((operation): Op => { + if (operation[0] === "r") return ["s", prefix as NonEmptyPath, operation[1]]; + const path = [...prefix, ...operation[1]]; + switch (operation[0]) { + case "s": + return ["s", path as unknown as NonEmptyPath, operation[2]]; + case "d": + return ["d", path as unknown as NonEmptyPath]; + case "a": + return ["a", path as unknown as NonEmptyPath, operation[2]]; + case "t": + return ["t", path as unknown as NonEmptyPath, operation[2]]; + case "p": + return ["p", path, operation[2], operation[3], operation[4]]; + case "m": + return ["m", path, operation[2]]; + default: + throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`); + } + }); +} + +function commonEntryPrefix(left: readonly TranscriptEntry[], right: readonly TranscriptEntry[]): number { + let index = 0; + while (index < left.length && index < right.length && left[index]!.id === right[index]!.id) index += 1; + return index; +} + +function initialDocuments(): Record { + const sections = Array.from({ length: 32 }, (_, index) => ({ + id: `section-${index}`, + title: `Configuration section ${index}`, + instructions: payloadText(1_850 + (index % 5) * 80, 10_000 + index), + })); + const tools = Array.from({ length: 24 }, (_, index) => ({ + name: `tool_${index}`, + description: payloadText(320 + (index % 4) * 40, 20_000 + index), + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: payloadText(120, 30_000 + index) }, + query: { type: "string", description: payloadText(120, 31_000 + index) }, + }, + required: ["path"], + }, + })); + const inboxItems = Array.from( + { length: 16 }, + (_, index): InboxItem => ({ + id: index + 1, + mode: index % 3 === 0 ? "steer" : "followUp", + text: payloadText(960 + (index % 4) * 48, 40_000 + index), + }), + ); + const liveTools = Array.from({ length: 8 }, (_, index) => ({ + callId: `initial-call-${index}`, + name: index % 2 === 0 ? "read" : "bash", + status: index < 2 ? "running" : "waiting", + progress: index < 2 ? 0.25 : 0, + output: payloadText(640 + index * 96, 50_000 + index), + })); + return { + config: { + schema: "pico5.doc.config.provisional.v0", + model: { provider: "anthropic", modelId: "claude-sonnet-provisional" }, + thinkingLevel: "medium", + selectedTools: tools.slice(0, 12).map((tool) => tool.name), + sections, + tools, + temperature: 0.2, + revision: 0, + } as unknown as JsonObject, + inbox: { schema: "pico5.doc.inbox.provisional.v0", items: inboxItems, revision: 0 } as unknown as JsonObject, + turn: { + schema: "pico5.doc.turn.provisional.v0", + stage: "streaming", + message: payloadText(640, 60_000), + toolSlots: [{ callId: "initial-call-0", name: "read", status: "running", preview: "reading" }], + revision: 0, + } as unknown as JsonObject, + preferences: { + schema: "pico5.doc.preferences.provisional.v0", + theme: "dark", + keymap: "default", + compactTools: false, + fontScale: 1, + editorProfile: payloadText(2_400, 70_000), + shortcuts: Object.fromEntries( + Array.from({ length: 24 }, (_, index) => [`command.${index}`, `ctrl+${String.fromCharCode(97 + index)}`]), + ), + revision: 0, + } as unknown as JsonObject, + live: { + schema: "pico5.doc.live.provisional.v0", + status: "streaming", + tokens: 8_192, + cost: 0.024, + heartbeat: 0, + generation: { stage: "streaming", attempt: 1, partial: payloadText(8_192, 80_000) }, + tools: liveTools, + } as unknown as JsonObject, + audit: { schema: "pico5.doc.audit.unmounted.provisional.v0", commits: 0, note: "" } as unknown as JsonObject, + }; +} + +await collect(); +const processBaselineHeap = process.memoryUsage().heapUsed; + +const phaseSamples: PhaseSamples = { + mutation: [], + prepare: [], + adopt: [], + synthesize: [], + projectApply: [], + project: [], + enqueue: [], + sessionLine: [], + delivery: [], + opSerialize: [], + resetSerialize: [], + conversationAcquire: [], + documentAcquire: [], + conversationSerialize: [], + documentSerialize: [], + resetClone: [], +}; +const records = new Map(); +let head: number | null = null; +let nextEntryId = 1; +for (let index = 0; index < headlineEntries; index++) { + const entry = makeEntry(nextEntryId++, head, entryKindAt(index)); + records.set(entry.id, entry); + head = entry.id; +} +const documentTrackers = {} as Record>; +for (const [key, value] of Object.entries(initialDocuments()) as Array<[DocumentKey, JsonObject]>) { + documentTrackers[key] = createTracker(value); +} + +function rebuildExpectedView(): ConversationView { + const docs = {} as Record; + for (const key of MOUNTED_KEYS) docs[key] = cloneTrusted(documentTrackers[key].value); + return { + schema: PROVISIONAL_SCHEMA, + conversation: { id: "conversation-1", head }, + docs, + entries: cloneTrusted(resolveActiveLineage(records, head)), + }; +} + +let aggregate = rebuildExpectedView(); +const initialAggregateBytes = Buffer.byteLength(JSON.stringify(aggregate)); +let conversationSequence = 0; +const documentSequences = Object.fromEntries( + (Object.keys(documentTrackers) as DocumentKey[]).map((key) => [key, 0]), +) as Record; +let conversationWatches: Array> = []; +let documentWatches: DocumentWatch[] = []; +let queuedFrameReferences = 0; +let queuedOperationReferences = 0; +const queuedFrameRefCounts = new Map(); +let peakQueueReferences = 0; +let peakQueuedOperationReferences = 0; +let peakPendingOperationsPerWatch = 0; +let peakUniqueQueuedBatches = 0; +let deliveryFrameReferences = 0; +let deliveryOperationReferences = 0; +let aggregateOperationCount = 0; +let aggregateOperationBytes = 0; +let documentOperationCount = 0; +let documentOperationBytes = 0; +let publishedAggregateBatches = 0; +let publishedDocumentBatches = 0; +let conversationRegistrations = 0; +let documentRegistrations = 0; +let conversationBaseBytes = 0; +let documentBaseBytes = 0; +let resetCount = 0; +let resetBytes = 0; +let noOpCommitCount = 0; +let unmountedCommitCount = 0; +let unmountedPublicationViolations = 0; +let sessionCommitCount = 0; +let peakHeap = process.memoryUsage().heapUsed; +const deferredResetFrames: DeltaFrame[] = []; + +function samplePeak(): void { + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +} + +async function collect(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + global.gc(); + } +} + +function distribution(values: readonly number[]): Distribution { + if (values.length === 0) return { count: 0, totalMs: 0, medianMs: 0, p95Ms: 0, p99Ms: 0, minMs: 0, maxMs: 0 }; + const ordered = [...values].sort((left, right) => left - right); + return { + count: ordered.length, + totalMs: ordered.reduce((total, value) => total + value, 0), + medianMs: ordered[Math.floor(ordered.length / 2)]!, + p95Ms: ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * 0.95))]!, + p99Ms: ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * 0.99))]!, + minMs: ordered[0]!, + maxMs: ordered.at(-1)!, + }; +} + +function snapshotHash(value: object): string { + return hashJson(value); +} + +function serializeBaseSnapshot(watchType: WatchType, snapshot: object): string { + const phase = watchType === "conversation" ? "conversationSerialize" : "documentSerialize"; + const start = performance.now(); + const encoded = JSON.stringify(snapshot); + phaseSamples[phase].push(performance.now() - start); + const bytes = Buffer.byteLength(encoded); + if (watchType === "conversation") conversationBaseBytes += bytes; + else documentBaseBytes += bytes; + return createHash("sha256").update(encoded).digest("hex"); +} + +function captureSnapshot(value: T, phase: "conversationAcquire" | "documentAcquire"): T { + const start = performance.now(); + const snapshot = value; + phaseSamples[phase].push(performance.now() - start); + samplePeak(); + return snapshot; +} + +function createWatch(value: T, sequence: number, started: boolean, watchType: WatchType): Watch { + const acquirePhase = watchType === "conversation" ? "conversationAcquire" : "documentAcquire"; + const snapshot = captureSnapshot(value, acquirePhase); + if (watchType === "conversation") conversationRegistrations += 1; + else documentRegistrations += 1; + return { + snapshot, + baseSequence: sequence, + baseHash: serializeBaseSnapshot(watchType, snapshot), + deliveredSequence: sequence, + pendingOperationCount: 0, + queue: [], + started, + watchType, + }; +} + +function verifySnapshotUnchanged(watch: Watch): void { + assert.equal(snapshotHash(watch.snapshot), watch.baseHash); +} + +function clearWatchQueue(watch: Watch): void { + queuedFrameReferences -= watch.queue.length; + queuedOperationReferences -= watch.pendingOperationCount; + for (const frame of watch.queue) { + const references = queuedFrameRefCounts.get(frame)! - 1; + if (references === 0) queuedFrameRefCounts.delete(frame); + else queuedFrameRefCounts.set(frame, references); + } + watch.queue.length = 0; + watch.pendingOperationCount = 0; +} + +function retainQueuedFrame(watch: Watch, frame: DeltaFrame): void { + watch.queue.push(frame); + watch.pendingOperationCount += frame.ops.length; + queuedFrameReferences += 1; + queuedOperationReferences += frame.ops.length; + queuedFrameRefCounts.set(frame, (queuedFrameRefCounts.get(frame) ?? 0) + 1); +} + +function resetWatch(watch: Watch, value: T, sequence: number): void { + clearWatchQueue(watch as unknown as Watch); + const cloneStart = performance.now(); + const resetValue = value; + phaseSamples.resetClone.push(performance.now() - cloneStart); + const frame: DeltaFrame = { + kind: "delta", + sequence, + ops: [["r", resetValue as unknown as JsonValue]], + }; + retainQueuedFrame(watch as unknown as Watch, frame); + deferredResetFrames.push(frame); + resetCount += 1; +} + +function enqueueWatch(watch: Watch, frame: DeltaFrame, current: T): void { + const previous = watch.queue.at(-1)?.sequence ?? watch.deliveredSequence; + assert.equal(frame.sequence, previous + 1); + if (config.queueCapacity !== undefined && watch.pendingOperationCount + frame.ops.length > config.queueCapacity) { + resetWatch(watch, current, frame.sequence); + return; + } + retainQueuedFrame(watch as unknown as Watch, frame); +} + +function sampleQueueMetrics(): void { + peakQueueReferences = Math.max(peakQueueReferences, queuedFrameReferences); + peakQueuedOperationReferences = Math.max(peakQueuedOperationReferences, queuedOperationReferences); + peakUniqueQueuedBatches = Math.max(peakUniqueQueuedBatches, queuedFrameRefCounts.size); + for (const watch of conversationWatches) { + peakPendingOperationsPerWatch = Math.max(peakPendingOperationsPerWatch, watch.pendingOperationCount); + } + for (const watch of documentWatches) { + peakPendingOperationsPerWatch = Math.max(peakPendingOperationsPerWatch, watch.pendingOperationCount); + } +} + +function acquireInitialWatches(): void { + for (let index = 0; index < config.conversationWatchCount; index++) { + conversationWatches.push(createWatch(aggregate, conversationSequence, config.started, "conversation")); + } + for (let index = 0; index < config.documentWatchCount; index++) { + const key = DOCUMENT_WATCH_KEYS[index % DOCUMENT_WATCH_KEYS.length]!; + documentWatches.push({ + ...createWatch(documentTrackers[key].value, documentSequences[key], config.started, "document"), + key, + }); + } +} + +function planCommit(index: number): CommitPlan { + const docs: DocMutation[] = []; + const cycle = index % 100; + if (cycle < 70) { + docs.push({ + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + const limit = 4_096 + (index % 8) * 4_096; + const chunk = payloadText(384 + (index % 5) * 64, 100_000 + index); + live.generation.partial = `${live.generation.partial}${chunk}`.slice(-limit); + live.generation.stage = "streaming"; + live.status = "streaming"; + live.tokens += 24 + (index % 17); + live.cost += 0.0002; + live.heartbeat = index; + }, + }); + } else if (cycle < 90) { + docs.push({ + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + const slot = live.tools[index % live.tools.length]!; + slot.status = cycle % 4 === 0 ? "completed" : "running"; + slot.progress = Math.min(1, slot.progress + 0.125); + slot.output = `${slot.output.slice(-2_048)}${payloadText(512, 110_000 + index)}`; + live.heartbeat = index; + }, + }); + } else if (cycle < 94) { + docs.push({ + key: "inbox", + mutate: (draft) => { + const inbox = draft as unknown as Draft; + inbox.items.push({ + id: 1_000_000 + index, + mode: cycle % 2 === 0 ? "steer" : "followUp", + text: payloadText(1_024, 120_000 + index), + }); + if (inbox.items.length > 20) inbox.items.shift(); + inbox.revision += 1; + }, + }); + } else if (cycle < 96) { + docs.push( + { + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + live.status = cycle === 94 ? "admitting" : "ready"; + live.generation.stage = cycle === 94 ? "requesting" : "completed"; + live.heartbeat = index; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = cycle === 94 ? "requesting" : "idle"; + turn.message = cycle === 94 ? payloadText(720, 130_000 + index) : ""; + turn.revision += 1; + }, + }, + ); + } else if (cycle === 96) { + docs.push({ + key: "config", + mutate: (draft) => { + const configDoc = draft as unknown as Draft; + configDoc.thinkingLevel = index % 200 === 96 ? "high" : "medium"; + configDoc.temperature = 0.15 + (Math.floor(index / 100) % 4) * 0.05; + const section = configDoc.sections[Math.floor(index / 100) % configDoc.sections.length]!; + section.instructions = payloadText(section.instructions.length, 140_000 + index); + configDoc.revision += 1; + }, + }); + } else if (cycle === 97) { + docs.push( + { + key: "preferences", + mutate: (draft) => { + const preferences = draft as unknown as Draft; + preferences.theme = index % 200 === 97 ? "dim" : "dark"; + preferences.compactTools = !preferences.compactTools; + preferences.editorProfile = payloadText(2_400 + (index % 4) * 256, 150_000 + index); + preferences.revision += 1; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = "configured"; + turn.revision += 1; + }, + }, + ); + } else if (cycle === 98) { + docs.push( + { + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + live.status = "retrying"; + live.generation.stage = "retrying"; + live.generation.attempt += 1; + live.generation.retryAt = 1_700_000_000_000 + index * 100; + live.heartbeat = index; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = "retrying"; + turn.revision += 1; + }, + }, + ); + } else { + docs.push( + { + key: "audit", + mutate: (draft) => { + const audit = draft as unknown as Draft; + audit.commits += 1; + audit.note = `unmounted-${index}`; + }, + }, + { + key: "config", + mutate: (draft) => { + const configDoc = draft as unknown as Draft; + configDoc.model = { + provider: configDoc.model.provider, + modelId: configDoc.model.modelId, + }; + }, + }, + ); + return { + docs, + unmountedOnly: true, + expectedConversationNoop: true, + }; + } + const settlement = cycle === 94 || cycle === 95; + const branch = scenario === "branch-head-replacement" && cycle === 95 && index >= 100; + return { + docs, + entryKind: settlement ? (cycle === 94 ? "user" : "assistant") : undefined, + branch, + unmountedOnly: false, + expectedConversationNoop: false, + }; +} + +function applyProjectedBatch(publicBatch: readonly Op[]): void { + aggregate = applyImmutable(aggregate, publicBatch); +} + +function enqueuePublishedBatches( + aggregateFrame: DeltaFrame | undefined, + documentFrames: readonly DocumentWatchFrame[], +): void { + const start = performance.now(); + if (aggregateFrame !== undefined) { + for (const watch of conversationWatches) enqueueWatch(watch, aggregateFrame, aggregate); + } + for (const documentFrame of documentFrames) { + for (const watch of documentWatches) { + if (watch.key === documentFrame.key) + enqueueWatch(watch, documentFrame.frame, documentTrackers[watch.key].value); + } + } + sampleQueueMetrics(); + phaseSamples.enqueue.push(performance.now() - start); +} + +type DocumentWatchFrame = { readonly key: DocumentKey; readonly frame: DeltaFrame }; + +function serializePublishedBatches( + aggregateFrame: DeltaFrame | undefined, + documentFrames: readonly DocumentWatchFrame[], +): void { + const start = performance.now(); + if (aggregateFrame !== undefined) { + aggregateOperationBytes += Buffer.byteLength(JSON.stringify(aggregateFrame.ops)); + } + for (const { frame } of documentFrames) documentOperationBytes += Buffer.byteLength(JSON.stringify(frame.ops)); + phaseSamples.opSerialize.push(performance.now() - start); +} + +function finishResetSerialization(): void { + if (deferredResetFrames.length === 0) return; + const start = performance.now(); + for (const frame of deferredResetFrames) resetBytes += Buffer.byteLength(JSON.stringify(frame.ops)); + phaseSamples.resetSerialize.push(performance.now() - start); + deferredResetFrames.length = 0; +} + +function isResetJump(frame: DeltaFrame, previousSequence: number): boolean { + return frame.sequence > previousSequence + 1 && frame.ops.length === 1 && frame.ops[0]![0] === "r"; +} + +async function forwardOpsOnly(frame: DeltaFrame): Promise { + // Models WatchHandle.start's serialized async listener. It forwards operations + // only; it intentionally does not construct or update a consumer-side replica. + await Promise.resolve(); + deliveryFrameReferences += 1; + deliveryOperationReferences += frame.ops.length; +} + +async function drainStartedWatches(): Promise { + const start = performance.now(); + for (const watch of conversationWatches) { + if (!watch.started) continue; + for (const frame of watch.queue) { + assert.ok(frame.sequence === watch.deliveredSequence + 1 || isResetJump(frame, watch.deliveredSequence)); + await forwardOpsOnly(frame); + watch.deliveredSequence = frame.sequence; + } + clearWatchQueue(watch as unknown as Watch); + assert.equal(watch.deliveredSequence, conversationSequence); + } + for (const watch of documentWatches) { + if (!watch.started) continue; + for (const frame of watch.queue) { + assert.ok(frame.sequence === watch.deliveredSequence + 1 || isResetJump(frame, watch.deliveredSequence)); + await forwardOpsOnly(frame); + watch.deliveredSequence = frame.sequence; + } + clearWatchQueue(watch); + assert.equal(watch.deliveredSequence, documentSequences[watch.key]); + } + phaseSamples.delivery.push(performance.now() - start); +} + +async function commit(index: number): Promise { + const plan = planCommit(index); + const conversationBefore = conversationSequence; + const queuedBefore = conversationWatches.reduce((total, watch) => total + watch.queue.length, 0); + const sessionStart = performance.now(); + const mutationStart = performance.now(); + const pending: Array<{ key: DocumentKey; change: ChangeLike }> = []; + for (const mutation of plan.docs) { + const change = documentTrackers[mutation.key].beginChange(); + mutation.mutate(change.state); + pending.push({ key: mutation.key, change }); + } + if (plan.entryKind !== undefined) { + let parent = head; + if (plan.branch) { + const active = resolveActiveLineage(records, head); + parent = active[Math.max(0, active.length - 48)]?.id ?? null; + } + const entry = makeEntry(nextEntryId++, parent, plan.entryKind); + records.set(entry.id, entry); + head = entry.id; + } + phaseSamples.mutation.push(performance.now() - mutationStart); + + const prepareStart = performance.now(); + const prepared = pending.map(({ key, change }) => ({ key, change, prepared: change.prepare() })); + phaseSamples.prepare.push(performance.now() - prepareStart); + + const adoptStart = performance.now(); + for (const item of prepared) documentTrackers[item.key].adopt(item.prepared); + phaseSamples.adopt.push(performance.now() - adoptStart); + + const projectStart = performance.now(); + const synthesizeStart = performance.now(); + const rawAggregateOperations: Op[] = []; + const documentFrames: DocumentWatchFrame[] = []; + for (const item of prepared) { + if (item.prepared.ops.length === 0) continue; + documentSequences[item.key] += 1; + const documentOps = item.prepared.ops; + documentOperationCount += documentOps.length; + publishedDocumentBatches += 1; + documentFrames.push({ + key: item.key, + frame: { kind: "delta", sequence: documentSequences[item.key], ops: documentOps }, + }); + if (item.key !== "audit") rawAggregateOperations.push(...prefixDocumentOperations(item.key, documentOps)); + } + if (plan.entryKind !== undefined) { + const previousEntries = aggregate.entries; + const nextEntries = resolveActiveLineage(records, head); + const common = commonEntryPrefix(previousEntries, nextEntries); + if (common !== previousEntries.length || common !== nextEntries.length) { + rawAggregateOperations.push([ + "p", + ["entries"], + common, + previousEntries.length - common, + cloneTrusted(nextEntries.slice(common)) as unknown as JsonValue[], + ]); + rawAggregateOperations.push(["s", ["conversation", "head"], head]); + } + } + let aggregateFrame: DeltaFrame | undefined; + if (rawAggregateOperations.length > 0) { + conversationSequence += 1; + aggregateOperationCount += rawAggregateOperations.length; + publishedAggregateBatches += 1; + aggregateFrame = { kind: "delta", sequence: conversationSequence, ops: rawAggregateOperations }; + } + phaseSamples.synthesize.push(performance.now() - synthesizeStart); + + const projectApplyStart = performance.now(); + if (aggregateFrame !== undefined) { + // Immutable application may safely share public placements with the projection. + applyProjectedBatch(aggregateFrame.ops); + } + phaseSamples.projectApply.push(performance.now() - projectApplyStart); + phaseSamples.project.push(performance.now() - projectStart); + + enqueuePublishedBatches(aggregateFrame, documentFrames); + phaseSamples.sessionLine.push(performance.now() - sessionStart); + sessionCommitCount += 1; + samplePeak(); + serializePublishedBatches(aggregateFrame, documentFrames); + finishResetSerialization(); + await drainStartedWatches(); + samplePeak(); + + if (plan.expectedConversationNoop) { + noOpCommitCount += 1; + assert.equal(aggregateFrame, undefined); + } + if (plan.unmountedOnly) { + unmountedCommitCount += 1; + const queuedAfter = conversationWatches.reduce((total, watch) => total + watch.queue.length, 0); + if (conversationSequence !== conversationBefore || queuedAfter !== queuedBefore) + unmountedPublicationViolations += 1; + } +} + +function registerConversationWatch(started: boolean): void { + conversationWatches.push(createWatch(aggregate, conversationSequence, started, "conversation")); +} + +function registerDocumentWatch(key: DocumentKey, started: boolean): void { + documentWatches.push({ + ...createWatch(documentTrackers[key].value, documentSequences[key], started, "document"), + key, + }); +} + +function verifyAndDropConversationWatch(watch: Watch): void { + verifySnapshotUnchanged(watch); + if (watch.started) assert.equal(watch.deliveredSequence, conversationSequence); + conversationWatches = conversationWatches.filter((candidate) => candidate !== watch); +} + +function verifyAndDropDocumentWatch(watch: DocumentWatch): void { + verifySnapshotUnchanged(watch); + if (watch.started) assert.equal(watch.deliveredSequence, documentSequences[watch.key]); + documentWatches = documentWatches.filter((candidate) => candidate !== watch); +} + +await collect(); +const baselineHeap = process.memoryUsage().heapUsed; +acquireInitialWatches(); +await collect(); +const readyHeap = process.memoryUsage().heapUsed; +const postAcquireRetainedMiB = (readyHeap - baselineHeap) / MiB; +samplePeak(); + +const churnInterval = Math.max(8, Math.floor(commitTarget / 10)); +for (let index = 0; index < commitTarget; index++) { + await commit(index); + if (scenario === "churn-reconnect" && (index + 1) % churnInterval === 0) { + const current = conversationWatches[0]; + if (current !== undefined) verifyAndDropConversationWatch(current); + registerConversationWatch(true); + const key = DOCUMENT_WATCH_KEYS[Math.floor((index + 1) / churnInterval) % DOCUMENT_WATCH_KEYS.length]!; + const currentDocument = documentWatches.find((watch) => watch.key === key); + if (currentDocument !== undefined) verifyAndDropDocumentWatch(currentDocument); + registerDocumentWatch(key, true); + } +} + +await collect(); +const queuedHeap = process.memoryUsage().heapUsed; +const postWorkRetainedMiB = (queuedHeap - baselineHeap) / MiB; +samplePeak(); + +function replayWatch(watch: Watch, expected: T, expectedSequence: number): void { + verifySnapshotUnchanged(watch); + if (watch.started) { + assert.equal(watch.deliveredSequence, expectedSequence); + return; + } + let value = cloneTrusted(watch.snapshot); + let sequence = watch.baseSequence; + for (const frame of watch.queue) { + assert.ok(frame.sequence === sequence + 1 || isResetJump(frame, sequence)); + value = applyImmutable(value, frame.ops); + sequence = frame.sequence; + } + assert.equal(sequence, expectedSequence); + assert.deepEqual(value, expected); +} + +let expectedView: ConversationView | undefined = rebuildExpectedView(); +assert.deepEqual(aggregate, expectedView); +expectedView = undefined; +for (const watch of conversationWatches) replayWatch(watch, aggregate, conversationSequence); +for (const watch of documentWatches) { + replayWatch(watch, documentTrackers[watch.key].value, documentSequences[watch.key]); +} +assert.equal(unmountedPublicationViolations, 0); +assert.equal(noOpCommitCount, Math.floor(commitTarget / 100)); +assert.equal(unmountedCommitCount, Math.floor(commitTarget / 100)); +assert.equal(sessionCommitCount, commitTarget); + +await collect(); +const verifiedHeap = process.memoryUsage().heapUsed; +const postVerificationRetainedMiB = (verifiedHeap - baselineHeap) / MiB; +const finalQueueReferences = + conversationWatches.reduce((total, watch) => total + watch.queue.length, 0) + + documentWatches.reduce((total, watch) => total + watch.queue.length, 0); +const finalQueuedOperationReferences = + conversationWatches.reduce((total, watch) => total + watch.pendingOperationCount, 0) + + documentWatches.reduce((total, watch) => total + watch.pendingOperationCount, 0); +const finalUniqueQueuedBatches = new Set([ + ...conversationWatches.flatMap((watch) => watch.queue), + ...documentWatches.flatMap((watch) => watch.queue), +]).size; +assert.equal(finalQueueReferences, queuedFrameReferences); +assert.equal(finalQueuedOperationReferences, queuedOperationReferences); +assert.equal(finalUniqueQueuedBatches, queuedFrameRefCounts.size); +assert.equal(deferredResetFrames.length, 0); +for (const watch of conversationWatches) clearWatchQueue(watch as unknown as Watch); +for (const watch of documentWatches) clearWatchQueue(watch); +conversationWatches = []; +documentWatches = []; +assert.equal(queuedFrameReferences, 0); +assert.equal(queuedOperationReferences, 0); +assert.equal(queuedFrameRefCounts.size, 0); +await collect(); +const releasedHeap = process.memoryUsage().heapUsed; +const releaseHeapMiB = (releasedHeap - baselineHeap) / MiB; + +const phaseDistributions = Object.fromEntries( + (Object.keys(phaseSamples) as PhaseName[]).map((name) => [name, distribution(phaseSamples[name])]), +) as Record; +const checksum = + aggregate.entries.length + + Number(aggregate.conversation.head ?? 0) + + conversationSequence + + MOUNTED_KEYS.reduce((total, key) => total + JSON.stringify(aggregate.docs[key]).length, 0); + +console.log( + JSON.stringify({ + mode, + scenario, + trial, + provisionalSchema: PROVISIONAL_SCHEMA, + headlineEntries, + commitTarget, + initialAggregateBytes, + conversationWatchCount: config.conversationWatchCount, + documentWatchCount: config.documentWatchCount, + queueCapacity: config.queueCapacity ?? null, + queueCapacityUnit: "pending-operations", + deliveryModel: "async-serialized-ops-only-no-consumer-apply", + conversationSequence, + documentSequences, + publishedAggregateBatches, + publishedDocumentBatches, + conversationRegistrations, + documentRegistrations, + conversationBaseBytes, + documentBaseBytes, + noOpCommitCount, + unmountedCommitCount, + resetCount, + resetBytes, + deliveryFrameReferences, + deliveryOperationReferences, + aggregateOperationCount, + aggregateOperationBytes, + documentOperationCount, + documentOperationBytes, + peakQueueReferences, + peakQueuedOperationReferences, + peakPendingOperationsPerWatch, + peakUniqueQueuedBatches, + finalQueueReferences, + finalQueuedOperationReferences, + finalUniqueQueuedBatches, + phaseDistributions, + authorityReadyMiB: (baselineHeap - processBaselineHeap) / MiB, + postAcquireRetainedMiB, + postWorkRetainedMiB, + postVerificationRetainedMiB, + releaseHeapMiB, + releaseTotalMiB: (releasedHeap - processBaselineHeap) / MiB, + peakHeapMiB: (peakHeap - baselineHeap) / MiB, + peakTotalHeapMiB: (peakHeap - processBaselineHeap) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + checksum, + }), +); diff --git a/packages/chord/test/delta-benchmark/memory-benchmark.ts b/packages/chord/test/delta-benchmark/memory-benchmark.ts new file mode 100644 index 000000000..4ae74b2da --- /dev/null +++ b/packages/chord/test/delta-benchmark/memory-benchmark.ts @@ -0,0 +1,142 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "tracker"; +type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches"; +type Result = { + mode: Mode; + scenario: Scenario; + trial: number; + size: number; + updates: number; + watchCount: number; + workMs: number; + acquisitionMs: number; + gcCount: number; + gcMs: number; + readyHeapMiB: number; + readyRssMiB: number; + peakOverReadyHeapMiB: number; + peakOverProcessBaselineHeapMiB: number; + peakOverReadyRssMiB: number; + retainedOverReadyHeapMiB: number; + retainedOverProcessBaselineHeapMiB: number; + retainedOverReadyRssMiB: number; + snapshotRetainedHeapMiB: number; + afterSnapshotReleaseOverReadyHeapMiB: number; + afterSnapshotReleaseOverProcessBaselineHeapMiB: number; + releasedOverProcessBaselineHeapMiB: number; + releasedOverProcessBaselineRssMiB: number; + maxRssMiB: number; + checksum: number; +}; +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const modes: readonly Mode[] = ["tracker"]; +const scenarios: readonly Scenario[] = ["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]; +const metrics = [ + "workMs", + "acquisitionMs", + "gcCount", + "gcMs", + "readyHeapMiB", + "readyRssMiB", + "peakOverReadyHeapMiB", + "peakOverProcessBaselineHeapMiB", + "peakOverReadyRssMiB", + "retainedOverReadyHeapMiB", + "retainedOverProcessBaselineHeapMiB", + "retainedOverReadyRssMiB", + "snapshotRetainedHeapMiB", + "afterSnapshotReleaseOverReadyHeapMiB", + "afterSnapshotReleaseOverProcessBaselineHeapMiB", + "releasedOverProcessBaselineHeapMiB", + "releasedOverProcessBaselineRssMiB", + "maxRssMiB", +] as const satisfies readonly (keyof Result)[]; + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +} + +function range(values: readonly number[]): readonly [number, number] | null { + return values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +} + +const quick = process.argv.includes("--quick"); +const size = quick ? 10_000 : 100_000; +const updates = quick ? 20 : 100; +const watchCount = quick ? 4 : 8; +const trials = quick ? 1 : 3; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./memory-benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + const offset = trial % modes.length; + const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)]; + for (const mode of trialModes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-flush-bytecode", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(size), + String(updates), + String(watchCount), + String(trial), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + continue; + } + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } +} + +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.scenario === scenario && result.mode === mode); + const values: Record = {}; + for (const metric of metrics) { + const samples = selected.map((result) => result[metric]); + values[metric] = { median: median(samples), range: range(samples) }; + } + return { scenario, mode, metrics: values }; + }), +); +const output = join(tmpdir(), "chord-delta-memory-benchmark.json"); +writeFileSync( + output, + JSON.stringify({ node: process.version, size, updates, watchCount, trials, results, failures, summary }, null, 2), +); +console.log( + JSON.stringify({ + output, + node: process.version, + size, + updates, + watchCount, + trials, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts b/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts new file mode 100644 index 000000000..df1c64e31 --- /dev/null +++ b/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { PerformanceObserver, performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { type Op, track } from "../../src/delta/index.ts"; + +const MiB = 1024 * 1024; + +type Mode = "tracker"; +type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Document; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; + +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set(["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const size = Number(process.argv[4] ?? 100_000); +const updates = Number(process.argv[5] ?? 100); +const watchCount = Number(process.argv[6] ?? 8); +const trial = Number(process.argv[7] ?? 0); +if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`); +if (!Number.isSafeInteger(size) || size < 1) throw new RangeError("Invalid size"); +if (!Number.isSafeInteger(updates) || updates < 1) throw new RangeError("Invalid update count"); +if (!Number.isSafeInteger(watchCount) || watchCount < 1) throw new RangeError("Invalid watch count"); + +function fixture(count: number): Document { + return { rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })) }; +} + +async function collect(): Promise { + assert.ok(global.gc, "memory benchmark requires --expose-gc"); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + global.gc(); + } +} + +function createTracker(value: Document): TrackerLike { + return track(value) as TrackerLike; +} + +function commitUpdate(tracker: TrackerLike, update: number): void { + const change = tracker.beginChange(); + const row = ((update + 1) * 997) % size; + change.state.rows[row]!.value = -update - 1; + const prepared = change.prepare(); + tracker.adopt(prepared); +} + +function acquireSnapshot(tracker: TrackerLike): Document { + return tracker.value; +} + +let warmup: TrackerLike | undefined = createTracker(fixture(16)); +const warmupChange = warmup.beginChange(); +warmupChange.state.rows[0]!.value = -1; +const warmupPrepared = warmupChange.prepare(); +warmup.adopt(warmupPrepared); +warmup = undefined; +await collect(); +const processBaseline = process.memoryUsage(); +let input: Document | undefined = fixture(size); +let tracker: TrackerLike | undefined = createTracker(input); +input = undefined; +await collect(); +const ready = process.memoryUsage(); +let peakHeap = ready.heapUsed; +let peakRss = ready.rss; +let acquisitionMs = 0; +let snapshots: Document[] = []; +let gcCount = 0; +let gcMs = 0; +const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + gcCount += 1; + gcMs += entry.duration; + } +}); +observer.observe({ entryTypes: ["gc"] }); + +const samplePeak = (): void => { + const memory = process.memoryUsage(); + peakHeap = Math.max(peakHeap, memory.heapUsed); + peakRss = Math.max(peakRss, memory.rss); +}; + +const acquire = (): void => { + const start = performance.now(); + snapshots.push(acquireSnapshot(tracker!)); + acquisitionMs += performance.now() - start; + samplePeak(); +}; + +const workStart = performance.now(); +if (scenario === "idle-control") { + for (let update = 0; update < updates; update++) samplePeak(); +} else if (scenario === "sparse-burst") { + for (let update = 0; update < updates; update++) { + commitUpdate(tracker, update); + samplePeak(); + } +} else if (scenario === "staggered-revisions") { + for (let watcher = 0; watcher < watchCount; watcher++) { + acquire(); + if (watcher + 1 < watchCount) commitUpdate(tracker, watcher); + samplePeak(); + } +} else { + for (let watcher = 0; watcher < watchCount; watcher++) acquire(); + for (let update = 0; update < updates; update++) { + commitUpdate(tracker, update); + samplePeak(); + } +} +const workMs = performance.now() - workStart; +for (let attempt = 0; attempt < 3; attempt++) await setImmediate(); +observer.disconnect(); +samplePeak(); +await collect(); +const retained = process.memoryUsage(); +const checksum = tracker.value.rows[0]!.value + tracker.value.rows.at(-1)!.value + snapshots.length; + +snapshots = []; +await collect(); +const afterSnapshotRelease = process.memoryUsage(); +tracker = undefined; +await collect(); +const released = process.memoryUsage(); + +console.log( + JSON.stringify({ + mode, + scenario, + trial, + size, + updates: scenario === "staggered-revisions" ? watchCount - 1 : scenario === "idle-control" ? 0 : updates, + watchCount: scenario === "staggered-revisions" || scenario === "lagging-watches" ? watchCount : 0, + workMs, + acquisitionMs, + gcCount, + gcMs, + readyHeapMiB: (ready.heapUsed - processBaseline.heapUsed) / MiB, + readyRssMiB: (ready.rss - processBaseline.rss) / MiB, + peakOverReadyHeapMiB: (peakHeap - ready.heapUsed) / MiB, + peakOverProcessBaselineHeapMiB: (peakHeap - processBaseline.heapUsed) / MiB, + peakOverReadyRssMiB: (peakRss - ready.rss) / MiB, + retainedOverReadyHeapMiB: (retained.heapUsed - ready.heapUsed) / MiB, + retainedOverProcessBaselineHeapMiB: (retained.heapUsed - processBaseline.heapUsed) / MiB, + retainedOverReadyRssMiB: (retained.rss - ready.rss) / MiB, + snapshotRetainedHeapMiB: (retained.heapUsed - afterSnapshotRelease.heapUsed) / MiB, + afterSnapshotReleaseOverReadyHeapMiB: (afterSnapshotRelease.heapUsed - ready.heapUsed) / MiB, + afterSnapshotReleaseOverProcessBaselineHeapMiB: (afterSnapshotRelease.heapUsed - processBaseline.heapUsed) / MiB, + releasedOverProcessBaselineHeapMiB: (released.heapUsed - processBaseline.heapUsed) / MiB, + releasedOverProcessBaselineRssMiB: (released.rss - processBaseline.rss) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + checksum, + }), +); diff --git a/packages/chord/test/delta-clone.test.ts b/packages/chord/test/delta-clone.test.ts index eed45ee77..f0b8b83d0 100644 --- a/packages/chord/test/delta-clone.test.ts +++ b/packages/chord/test/delta-clone.test.ts @@ -2,32 +2,42 @@ import { describe, expect, it } from "vitest"; import { apply, track } from "../src/delta/index.ts"; describe("tracker ownership", () => { - it("deeply detaches and freezes the imported revision", () => { + it("takes immutable ownership of the imported revision in O(1)", () => { const input = { point: { x: 3, y: 7, pressure: 0.1 }, rows: [{ values: [0, false, null, "text", { n: 1 }] }], }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); - expect(tracker.value.point).not.toBe(input.point); - expect(tracker.value.rows[0]!.values[4]).not.toBe(input.rows[0]!.values[4]); - expect(Object.isFrozen(tracker.value.rows[0]!.values)).toBe(true); - input.point.x = 99; - expect(tracker.value.point.x).toBe(3); + expect(tracker.value).toBe(input); + expect(tracker.value.point).toBe(input.point); + expect(tracker.value.rows[0]!.values[4]).toBe(input.rows[0]!.values[4]); + expect(Object.isFrozen(tracker.value.rows[0]!.values)).toBe(false); }); - it("preserves null prototypes and expands aliases", () => { + it("preserves null prototypes in an alias-free owned root", () => { type Dictionary = { enabled: boolean; child: { n: number } }; const dictionary = Object.assign(Object.create(null) as Dictionary, { enabled: true, child: { n: 1 } }); - const shared = { nested: [{ n: 1 }] }; - const tracker = track({ dictionary, left: shared, right: shared }); + const tracker = track({ dictionary, left: { nested: [{ n: 1 }] }, right: { nested: [{ n: 1 }] } }); expect(Object.getPrototypeOf(tracker.value.dictionary)).toBeNull(); - expect(tracker.value.dictionary).not.toBe(dictionary); + expect(tracker.value.dictionary).toBe(dictionary); expect(tracker.value.left).not.toBe(tracker.value.right); expect(tracker.value.left.nested[0]).not.toBe(tracker.value.right.nested[0]); }); + it("does not traverse a trusted root while taking ownership", () => { + let reads = 0; + const input = Object.defineProperty({}, "untrustedAccessor", { + enumerable: true, + get() { + reads += 1; + throw new Error("root was traversed"); + }, + }); + const tracker = track(input); + expect(tracker.value).toBe(input); + expect(reads).toBe(0); + }); + it("copies assigned and inserted values immediately", () => { const tracker = track({ rows: [] as { nested: { value: number } }[] }); const assigned = { nested: { value: 1 } }; @@ -40,41 +50,4 @@ describe("tracker ownership", () => { const replica = apply(structuredClone(prepared.base), prepared.ops); expect(replica).toEqual(prepared.value); }); - - it("rejects cycles, accessors, classes, and non-finite numbers", () => { - const cyclic: { self?: object } = {}; - cyclic.self = cyclic; - expect(() => track(cyclic)).toThrow(/cycles/); - expect(() => track({ value: Number.NaN })).toThrow(/strict JSON/); - expect(() => track({ value: new Date() })).toThrow(/plain objects/); - const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => 1 }); - expect(() => track(accessor)).toThrow(/data properties/); - }); - - it("rejects indexed accessors and non-enumerable array entries without invoking getters", () => { - let reads = 0; - const accessor: number[] = []; - Object.defineProperty(accessor, "0", { - enumerable: true, - configurable: true, - get() { - reads += 1; - return 1; - }, - }); - expect(() => track({ values: accessor })).toThrow(/data properties/); - expect(reads).toBe(0); - - const hidden: number[] = []; - Object.defineProperty(hidden, "0", { value: 1, enumerable: false, configurable: true, writable: true }); - expect(() => track({ values: hidden })).toThrow(/enumerable/); - - const tracker = track({ values: [] as number[] }); - const change = tracker.beginChange(); - expect(() => { - change.state.values = accessor; - }).toThrow(/data properties/); - expect(reads).toBe(0); - change.abort(); - }); }); diff --git a/packages/chord/test/delta-cow/benchmark.ts b/packages/chord/test/delta-cow/benchmark.ts deleted file mode 100644 index 60ad76322..000000000 --- a/packages/chord/test/delta-cow/benchmark.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -type Mode = "cow" | "delta" | "astra"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Result = { - mode: Mode; - scenario: Scenario; - size: number; - trial: number; - queueOperations: number; - importMs: number; - mutateMs: number; - prepareMs: number; - serializeMs: number; - adoptMs: number; - opBytes: number; - operationCount: number; - checksum: number; - readyMiB: number; - transientMiB: number; - retainedMiB: number; - maxRssMiB: number; -}; - -type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; - -const scenarios: readonly Scenario[] = [ - "import", - "committed-read", - "draft-read", - "sparse", - "queue", - "sort", - "dense", - "unshift", -]; -const modes: readonly Mode[] = ["cow", "delta", "astra"]; -const quick = process.argv.includes("--quick"); -const size = quick ? 10_000 : 100_000; -const trials = quick ? 1 : 3; -const root = fileURLToPath(new URL("../../../..", import.meta.url)); -const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); -const results: Result[] = []; -const failures: Failure[] = []; - -for (let trial = 0; trial < trials; trial++) { - for (const scenario of scenarios) { - for (const mode of modes) { - const child = spawnSync( - process.execPath, - ["--expose-gc", "--max-old-space-size=4096", worker, mode, scenario, String(size), String(trial)], - { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, - ); - if (child.status !== 0) { - const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; - failures.push(failure); - console.log(JSON.stringify(failure)); - } else { - const result = JSON.parse(child.stdout) as Result; - results.push(result); - console.log(JSON.stringify(result)); - } - } - } -} - -const average = (values: readonly number[]): number | null => - values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; -const summary = scenarios.flatMap((scenario) => - modes.map((mode) => { - const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); - return { - mode, - scenario, - importMs: average(selected.map((result) => result.importMs)), - mutateMs: average(selected.map((result) => result.mutateMs)), - prepareMs: average(selected.map((result) => result.prepareMs)), - serializeMs: average(selected.map((result) => result.serializeMs)), - adoptMs: average(selected.map((result) => result.adoptMs)), - opBytes: average(selected.map((result) => result.opBytes)), - operationCount: average(selected.map((result) => result.operationCount)), - readyMiB: average(selected.map((result) => result.readyMiB)), - transientMiB: average(selected.map((result) => result.transientMiB)), - retainedMiB: average(selected.map((result) => result.retainedMiB)), - maxRssMiB: average(selected.map((result) => result.maxRssMiB)), - }; - }), -); -const output = join(tmpdir(), "chord-cow-benchmark.json"); -writeFileSync(output, JSON.stringify({ node: process.version, size, trials, results, failures, summary }, null, 2)); -console.log(JSON.stringify({ output, node: process.version, size, trials, failures: failures.length, summary })); diff --git a/packages/chord/test/delta-cow/benchmark.worker.ts b/packages/chord/test/delta-cow/benchmark.worker.ts deleted file mode 100644 index 6af1e16bf..000000000 --- a/packages/chord/test/delta-cow/benchmark.worker.ts +++ /dev/null @@ -1,145 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { track as trackAstra } from "../../src/delta/astra/index.ts"; -import { track as trackCow } from "../../src/delta/cow/index.ts"; -import type { Draft } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { track as trackDelta } from "../../src/delta/tracker.ts"; - -type Mode = "cow" | "astra" | "delta"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; -type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; -type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; -type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; - -const MiB = 1024 * 1024; -const mode = process.argv[2] as Mode; -const scenario = process.argv[3] as Scenario; -const size = Number(process.argv[4] ?? 100_000); -const trial = Number(process.argv[5] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const importStart = performance.now(); -const tracker = ( - mode === "cow" ? trackCow(input) : mode === "astra" ? trackAstra(input) : trackDelta(input) -) as TrackerLike; -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(tracker.value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - let change: ChangeLike | undefined = tracker.beginChange(); - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(change.state); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - change.state.rows.shift(); - change.state.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = change.state.rows.length; - break; - case "sort": - change.state.rows.sort((left, right) => left.id - right.id); - checksum = change.state.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); - checksum = change.state.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const prepareStart = performance.now(); - let prepared: PreparedLike | undefined = change.prepare(); - prepareMs = performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(prepared.ops); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = prepared.ops.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - tracker.adopt(prepared); - adoptMs = performance.now() - adoptStart; - change = undefined; - prepared = undefined; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode, - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-cow/core.test.ts b/packages/chord/test/delta-cow/core.test.ts deleted file mode 100644 index 7edb31242..000000000 --- a/packages/chord/test/delta-cow/core.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - apply, - applyImmutable, - assertValidOp, - assertValidWireOp, - decoder, - encoder, - isBase, - type JsonValue, - type Op, - overlap, - track, - UnsafePathError, - type WireOp, -} from "../../src/delta/cow/index.ts"; - -describe("immutable tracker lifecycle", () => { - it("keeps a draft alive across await and adopts only a prepared change", async () => { - const input = { count: 1, nested: { text: "a" }, values: [1] }; - const tracker = track(input); - expect(tracker.value).toBe(input); - - const change = tracker.beginChange(); - change.state.count = 2; - await Promise.resolve(); - change.state.nested.text += "b"; - change.state.values.push(2); - expect(tracker.value).toEqual(input); - - const prepared = change.prepare(); - expect(prepared.base).toBe(tracker.value); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.ops).toEqual([ - ["s", ["count"], 2], - ["a", ["nested", "text"], "b"], - ["p", ["values"], 1, 0, [2]], - ]); - expect(() => change.state.count).toThrow(TypeError); - expect(tracker.value).toEqual(input); - - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - }); - - it("aborts and revokes without changing the committed value", () => { - const tracker = track({ child: { value: 1 } }); - const change = tracker.beginChange(); - const child = change.state.child; - child.value = 2; - change.abort(); - expect(tracker.value.child.value).toBe(1); - expect(() => child.value).toThrow(TypeError); - expect(() => change.abort()).not.toThrow(); - expect(() => change.prepare()).toThrow(/settled/); - const next = tracker.beginChange(); - next.abort(); - }); - - it("grows arrays with explicit nulls and revokes after preparation", () => { - const tracker = track({ values: [1, 2] as Array }); - const change = tracker.beginChange(); - change.state.values.length = 4; - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([1, 2, null, null]); - expect(() => change.state.values).toThrow(TypeError); - tracker.adopt(prepared); - }); - - it("normalizes a deep no-op to exact previous identity", () => { - const tracker = track({ value: { nested: [1, 2] } }); - const change = tracker.beginChange(); - change.state.value = { nested: [1, 2] }; - const prepared = change.prepare(); - expect(prepared.value).toBe(prepared.base); - expect(prepared.ops).toEqual([]); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.base); - }); - - it("takes O(1) replacement ownership and applies no-op normalization", () => { - const tracker = track({ nested: { value: 1 } }); - const replacement = { nested: { value: 2 } }; - const prepared = tracker.prepareReplace(replacement); - expect(prepared.value).toBe(replacement); - tracker.adopt(prepared); - - const noOp = tracker.prepareReplace({ nested: { value: 2 } }); - expect(noOp.value).toBe(tracker.value); - expect(noOp.ops).toEqual([]); - }); - - it("detaches operation payloads from candidate placements", () => { - const tracker = track({ rows: [] as { id: number }[] }); - const change = tracker.beginChange(); - change.state.rows.push({ id: 1 }); - const prepared = change.prepare(); - const splice = prepared.ops[0]!; - if (splice[0] !== "p") throw new Error("expected splice"); - expect(splice[4][0]).not.toBe(prepared.value.rows[0]); - tracker.adopt(prepared); - tracker.value.rows[0]!.id = 2; - expect(splice[4]).toEqual([{ id: 1 }]); - }); - - it("rejects foreign, stale, repeated, and overlapping changes", () => { - const first = track({ value: 0 }); - const second = track({ value: 0 }); - const change = first.beginChange(); - expect(() => first.beginChange()).toThrow(/active/); - change.state.value = 1; - const prepared = change.prepare(); - expect(() => second.adopt(prepared)).toThrow(/different tracker/); - first.adopt(prepared); - expect(() => first.adopt(prepared)).toThrow(/already been used/); - - const stale = first.prepareReplace({ value: 2 }); - const winner = first.prepareReplace({ value: 3 }); - first.adopt(winner); - expect(() => first.adopt(stale)).toThrow(/stale/); - expect(() => first.adopt(stale)).toThrow(/stale/); - }); - - it("invalidates a prepared result when its change is aborted", () => { - const tracker = track({ value: 0 }); - const change = tracker.beginChange(); - change.state.value = 1; - const prepared = change.prepare(); - change.abort(); - expect(() => tracker.adopt(prepared)).toThrow(/aborted/); - expect(() => change.abort()).not.toThrow(); - }); - - it("makes competing same-base preparations stale after adopting a no-op", () => { - const tracker = track({ value: { count: 1 } }); - const first = tracker.prepareReplace({ value: { count: 1 } }); - const competing = tracker.prepareReplace({ value: { count: 1 } }); - expect(first.value).toBe(first.base); - expect(competing.value).toBe(competing.base); - tracker.adopt(first); - expect(() => tracker.adopt(first)).toThrow(/already been used/); - expect(() => tracker.adopt(competing)).toThrow(/stale/); - }); - - it("keeps large prepareReplace array edits narrow", () => { - const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } })); - const tracker = track({ rows }); - const replacement = tracker.value.rows.slice(); - replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable }; - const prepared = tracker.prepareReplace({ rows: replacement }); - expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]); - expect(JSON.stringify(prepared.ops).length).toBeLessThan(100); - expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); - }); -}); - -describe("canonical strings", () => { - it("emits append and rolling-window operations", () => { - const tracker = track({ text: "abcdefgh" }); - let change = tracker.beginChange(); - change.state.text += "ij"; - let prepared = change.prepare(); - expect(prepared.ops).toEqual([["a", ["text"], "ij"]]); - tracker.adopt(prepared); - - change = tracker.beginChange(); - change.state.text = `${change.state.text.slice(3)}xyz`; - prepared = change.prepare(); - expect(prepared.ops).toEqual([ - ["t", ["text"], 3], - ["a", ["text"], "xyz"], - ]); - }); - - it("finds bounded overlaps", () => { - expect(overlap("abcdefgh", "defghxyz", 65_536)).toBe(5); - expect(overlap("abcdef", "defghi", 0)).toBe(0); - }); -}); - -describe("operation application and validation", () => { - it("applies mutable and immutable operations", () => { - const operations: Op[] = [ - ["a", ["text"], "b"], - ["p", ["values"], 1, 1, [3, 4]], - ["s", ["nested", "value"], 2], - ]; - const base = { text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }; - const immutable = applyImmutable(base, operations); - expect(immutable).toEqual({ text: "ab", values: [1, 3, 4], nested: { value: 2 }, stable: { value: 9 } }); - expect(base).toEqual({ text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }); - expect(immutable.stable).toBe(base.stable); - expect(apply(structuredClone(base), operations)).toEqual(immutable); - }); - - it("supports root replacement, root splice, and permutations", () => { - const base: Op[] = [["r", [1, 2, 3]]]; - expect(isBase(base)).toBe(true); - let value = apply(undefined, base); - value = apply(value, [["p", [], 1, 1, [4]]]); - value = apply(value, [["m", [], [2, 0, 1]]]); - expect(value).toEqual([3, 1, 4]); - }); - - it("rejects unsafe and malformed paths", () => { - expect(() => apply({}, [["s", ["constructor", "prototype", "x"], true]])).toThrow(UnsafePathError); - expect(() => apply({ values: [1] }, [["s", ["values", 3], 2]])).toThrow(UnsafePathError); - expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); - expect(() => assertValidOp(["s", "value", 1] as never)).toThrow(); - expect(() => assertValidOp(["m", [], [0, 0]] as never)).toThrow(/bijection/); - }); - - it("validates immutable operations before traversing the target", () => { - let reads = 0; - const target = Object.defineProperty({}, "trap", { - enumerable: true, - get() { - reads += 1; - return {}; - }, - }); - expect(() => applyImmutable(target, [["s", "bad-path", 1] as never])).toThrow(/path/); - expect(reads).toBe(0); - }); - - it("validates decoded and wire vocabularies separately", () => { - expect(() => assertValidOp(["s", ["value"], 1])).not.toThrow(); - expect(() => assertValidOp(["s", 1] as never)).toThrow(); - expect(() => assertValidWireOp(["s", 1])).not.toThrow(); - expect(() => assertValidWireOp(["#", 0, ["value"]])).not.toThrow(); - }); -}); - -describe("codec", () => { - it("interns paths, omits adjacent paths, and round-trips", () => { - const path = ["nested", "text"] as const; - const enc = encoder(); - const dec = decoder(); - const first: Op[] = [ - ["t", path, 1], - ["a", path, "x"], - ]; - expect(dec.decode(enc.encode(first))).toEqual(first); - const second: Op[] = [["a", path, "y"]]; - const wire = enc.encode(second); - expect(wire).toEqual([ - ["#", 0, path], - ["a", 0, "y"], - ]); - expect(dec.decode(wire)).toEqual(second); - }); - - it("resets path dictionaries on a base", () => { - const enc = encoder(); - const path = ["value"] as const; - enc.encode([["s", path, 1]]); - enc.encode([["s", path, 2]]); - expect(enc.encode([["r", { value: 3 }]])).toEqual([["r", { value: 3 }]]); - expect(enc.encode([["s", path, 4]])).toEqual([["s", path, 4]]); - }); - - it("rejects unresolved short forms and unsafe interned paths", () => { - expect(() => decoder().decode([["a", "x"]])).toThrow(); - const wire = [ - ["#", 0, ["__proto__"]], - ["s", 0, true], - ] as unknown as WireOp[]; - expect(() => decoder().decode(wire)).toThrow(UnsafePathError); - }); - - it("omits an adjacent repeated path", () => { - const enc = encoder(); - const path = ["value"] as const; - expect( - enc.encode([ - ["s", path, 1], - ["s", path, 2], - ]), - ).toEqual([ - ["s", path, 1], - ["s", 2], - ]); - }); - - it("interns on second use rather than first", () => { - const enc = encoder(); - const path = ["a", "deep"] as const; - expect(enc.encode([["a", path, "1"]])).toEqual([["a", path, "1"]]); - expect(enc.encode([["a", path, "2"]])).toEqual([ - ["#", 0, path], - ["a", 0, "2"], - ]); - }); - - it("does not collide paths containing null characters", () => { - const operations: Op[] = [ - ["s", ["a\u0000b"], 1], - ["s", ["a", "b"], 2], - ]; - expect(decoder().decode(encoder().encode(operations))).toEqual(operations); - }); - - it("clears decoder ids on a base batch", () => { - const dec = decoder(); - dec.decode([ - ["#", 0, ["a"]], - ["a", 0, "1"], - ]); - dec.decode([["r", { a: "" }]]); - expect(() => dec.decode([["a", 0, "2"]])).toThrow(); - }); - - it("makes batches after a base self-contained", () => { - const enc = encoder(); - const path = ["a", "deep"] as const; - enc.encode([["a", path, "1"]]); - enc.encode([["a", path, "2"]]); - const base = enc.encode([["r", { a: { deep: "x" } }]]); - const after = enc.encode([["a", path, "3"]]); - expect(after).toEqual([["a", path, "3"]]); - const dec = decoder(); - expect(dec.decode(base)).toEqual([["r", { a: { deep: "x" } }]]); - expect(dec.decode(after)).toEqual([["a", path, "3"]]); - }); - - it("round-trips deterministic mixed operation streams", () => { - const batches: Op[][] = Array.from({ length: 100 }, (_, index) => [ - ["s", ["rows", index, "value"], index], - ["a", ["output"], String(index)], - ["p", ["tail"], index, 0, [index]], - ]); - const enc = encoder(); - const dec = decoder(); - expect(batches.map((batch) => dec.decode(enc.encode(batch)))).toEqual(batches); - }); -}); - -describe("immutable application ownership", () => { - it("does not mutate a replacement payload targeted by a later operation", () => { - const replacement = { nested: { value: 1 } }; - const next = applyImmutable<{ nested: { value: number } }>(undefined, [ - ["r", replacement], - ["s", ["nested", "value"], 2], - ]); - expect(replacement.nested.value).toBe(1); - expect(next.nested.value).toBe(2); - }); - - it("adopts a mutable replacement payload rather than copying it", () => { - const batch: Op[] = [["r", { value: 0 }]]; - const first = apply<{ value: number }>(undefined, batch); - const second = apply<{ value: number }>(undefined, batch); - first.value = 1; - expect(second.value).toBe(1); - }); -}); - -describe("path and prototype safety", () => { - it("rejects constructor walks and forbidden interned paths", () => { - expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow(); - expect(({} as Record).gadget).toBeUndefined(); - const wire = [ - ["#", 0, ["__proto__", "w"]], - ["s", 0, true], - ] as unknown as WireOp[]; - expect(() => decoder().decode(wire)).toThrow(); - }); - - it("does not run inherited setters", () => { - Object.defineProperty(Object.prototype, "trap", { - set() { - throw new Error("inherited setter ran"); - }, - configurable: true, - }); - try { - expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow(); - expect(apply({}, [["s", ["trap"], 1]])).toEqual({ trap: 1 }); - } finally { - delete (Object.prototype as Record).trap; - } - }); - - it("allows reserved names inside values without prototype pollution", () => { - const value = JSON.parse('{"__proto__":{"z":1}}') as JsonValue; - const out = apply({}, [["s", ["value"], value]]); - expect(Object.hasOwn((out as { value: object }).value, "__proto__")).toBe(true); - expect(({} as Record).z).toBeUndefined(); - }); - - it("imports own properties without invoking inherited setters", () => { - const root = {} as Record; - Object.defineProperty(root, "trap", { value: 1, writable: true, enumerable: true, configurable: true }); - Object.defineProperty(Object.prototype, "trap", { - set() { - throw new Error("inherited setter ran"); - }, - configurable: true, - }); - try { - expect(() => track(root)).not.toThrow(); - } finally { - delete (Object.prototype as Record).trap; - } - - let value: { values: number[] } | undefined; - Object.defineProperty(Array.prototype, "0", { - set() { - throw new Error("inherited array setter ran"); - }, - configurable: true, - }); - try { - value = track({ values: [1, 2] }).value; - } finally { - delete (Array.prototype as unknown as Record)[0]; - } - expect(value).toEqual({ values: [1, 2] }); - }); -}); - -describe("array index safety", () => { - it("writes an existing index and appends exactly one past the end", () => { - expect(apply({ values: [1, 2, 3] }, [["s", ["values", 1], 9]])).toEqual({ values: [1, 9, 3] }); - expect(apply({ values: [1, 2, 3] }, [["s", ["values", 3], 9]])).toEqual({ values: [1, 2, 3, 9] }); - }); - - it("rejects gaps, huge indices, and string-spelled indices", () => { - expect(() => apply({ values: [1, 2, 3] }, [["s", ["values", 5], 9]])).toThrow(); - expect(() => apply({ values: [] }, [["s", ["values", 4_294_967_290], 1]])).toThrow(); - expect(() => apply({ values: [1] }, [["s", ["values", "0"], 9]])).toThrow(); - expect(() => apply({ values: ["a"] }, [["a", ["values", "0"], "b"]])).toThrow(); - }); - - it("allows explicit growth values and rejects deletion past the end", () => { - expect(apply({ values: [1] }, [["p", ["values"], 1, 0, [null, null, 9]]])).toEqual({ - values: [1, null, null, 9], - }); - expect(() => apply({ values: [1] }, [["d", ["values", 1]]])).toThrow(); - }); - - it("applies large splice payloads without spreading them at once", () => { - const items = Array(300_000).fill(null); - const result = apply({ values: [] as JsonValue[] }, [["p", ["values"], 0, 0, items]]); - expect(result.values).toHaveLength(items.length); - }); -}); - -describe("operation structure safety", () => { - it("rejects unknown verbs, malformed tuples, paths, and splice payloads", () => { - expect(() => apply({ value: 1 }, [["ZZZ", ["value"], 9] as never])).toThrow(); - expect(() => apply({ values: [1] }, [["p", ["values"], 0, 0, "not-an-array"] as never])).toThrow(); - expect(() => apply({ value: 1 }, [["s", "value", 9] as never])).toThrow(); - expect(() => apply({ value: 1 }, [{ op: "s" } as never])).toThrow(); - expect(() => apply({ value: 1 }, [null as never])).toThrow(); - }); - - it("rejects invalid append and truncation operations", () => { - expect(() => apply({ value: 1 }, [["a", ["missing"], "x"]])).toThrow(); - expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); - expect(() => apply({ value: "abc" }, [["t", ["value"], -1]])).toThrow(); - expect(() => decoder().decode([["t", ["value"], -1]])).toThrow(); - }); - - it("clamps splice removal past the end", () => { - expect(apply({ values: [1, 2] }, [["p", ["values"], 0, 1e9, []]])).toEqual({ values: [] }); - }); -}); - -describe("operation assertions", () => { - it("accepts decoded operations and rejects wire-only forms", () => { - for (const operation of [ - ["r", { value: 1 }], - ["s", ["value"], 1], - ["d", ["value"]], - ["a", ["value"], "x"], - ["t", ["value"], 2], - ["p", ["value"], 0, 0, []], - ["m", ["value"], [0]], - ] as never[]) { - expect(() => assertValidOp(operation)).not.toThrow(); - } - for (const wireOnly of [ - ["s", 1], - ["d"], - ["a", "x"], - ["t", 2], - ["p", 0, 0, []], - ["#", 0, ["value"]], - ["s", 0, 1], - ] as never[]) { - expect(() => assertValidOp(wireOnly)).toThrow(); - expect(() => assertValidWireOp(wireOnly)).not.toThrow(); - } - }); - - it("does not recursively inspect operation payloads", () => { - expect(() => assertValidOp(["s", ["value"], new Map()] as never)).not.toThrow(); - expect(() => assertValidWireOp(["r", new Date()] as never)).not.toThrow(); - }); -}); diff --git a/packages/chord/test/delta-cow/cow.test.ts b/packages/chord/test/delta-cow/cow.test.ts deleted file mode 100644 index 13638ee24..000000000 --- a/packages/chord/test/delta-cow/cow.test.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { apply, applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -describe("optimized COW transaction invariants", () => { - it("matches candidate, detached replay, and adopted value", () => { - const tracker = track({ rows: [{ value: 1 }], text: "a" }); - const base = clone(tracker.value); - const change = tracker.beginChange(); - change.state.rows[0]!.value = 2; - change.state.rows.push({ value: 3 }); - change.state.text += "b"; - const prepared = change.prepare(); - const candidate = clone(prepared.value); - const operations = clone(prepared.ops); - expect(apply(base, operations)).toEqual(candidate); - tracker.adopt(prepared); - expect(tracker.value).toEqual(candidate); - }); - - it("clones external and draft placements independently", () => { - const tracker = track({ - source: { value: 1 }, - left: null as { value: number } | null, - right: null as { value: number } | null, - rows: [] as { value: number }[], - }); - const external = { value: 2 }; - const change = tracker.beginChange(); - change.state.left = external; - change.state.right = change.state.source; - change.state.rows.push(external, external, change.state.source); - external.value = 9; - change.state.source.value = 7; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ - source: { value: 7 }, - left: { value: 2 }, - right: { value: 1 }, - rows: [{ value: 2 }, { value: 2 }, { value: 1 }], - }); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - }); - - it("distinguishes raw committed and draft-subtree assignments", () => { - const tracker = track({ - source: { value: 1 }, - rawCopy: null as { value: number } | null, - draftCopy: null as { value: number } | null, - }); - const raw = tracker.value.source; - const change = tracker.beginChange(); - change.state.source.value = 5; - change.state.rawCopy = raw; - change.state.draftCopy = change.state.source; - expect(change.prepare().value).toEqual({ source: { value: 5 }, rawCopy: { value: 1 }, draftCopy: { value: 5 } }); - }); - - it("keeps Prepared.value authoritative and independent from public operations", () => { - const tracker = track({ rows: [] as { value: number }[] }); - const change = tracker.beginChange(); - change.state.rows.push({ value: 1 }); - const prepared = change.prepare(); - const splice = prepared.ops[0]; - if (splice?.[0] !== "p") throw new Error("expected splice"); - expect(splice[4][0]).not.toBe(prepared.value.rows[0]); - (splice[4][0] as { value: number }).value = 99; - expect(prepared.value.rows[0]!.value).toBe(1); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - expect(tracker.value.rows[0]!.value).toBe(1); - }); - - it("supports a MemoryStorage-style recursive clone", () => { - const storageClone = (value: T): T => { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(storageClone) as T; - const output: Record = {}; - for (const key of Object.keys(value)) output[key] = storageClone((value as Record)[key]); - return output as T; - }; - const tracker = track({ rows: [{ value: 1 }] }); - const change = tracker.beginChange(); - change.state.rows[0]!.value = 2; - change.state.rows.push({ value: 3 }); - expect(storageClone(change.prepare().value)).toEqual({ rows: [{ value: 2 }, { value: 3 }] }); - }); - - it("folds reserved-key changes without prototype pollution", () => { - const initial = JSON.parse('{"safe":{"__proto__":{"value":1}}}') as { - safe: { __proto__: { value: number } }; - }; - const tracker = track(initial); - const change = tracker.beginChange(); - change.state.safe.__proto__.value = 2; - const prepared = change.prepare(); - expect(applyImmutable(tracker.value, prepared.ops)).toEqual(prepared.value); - tracker.adopt(prepared); - expect(({} as Record).value).toBeUndefined(); - }); -}); - -describe("logical placement cloning", () => { - it("does not let a detached dirty object handle overwrite its replacement", () => { - const tracker = track({ child: { value: 1 }, stable: { value: 9 } }); - const change = tracker.beginChange(); - const held = change.state.child; - held.value = 2; - change.state.child = { value: 3 }; - held.value = 4; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ child: { value: 3 }, stable: { value: 9 } }); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - expect(tracker.value.child.value).toBe(3); - }); - - it("does not let a detached dirty array handle overwrite its replacement", () => { - const tracker = track({ rows: [{ value: 1 }, { value: 9 }] }); - const change = tracker.beginChange(); - const held = change.state.rows[0]!; - held.value = 2; - change.state.rows[0] = { value: 3 }; - held.value = 4; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ rows: [{ value: 3 }, { value: 9 }] }); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - expect(tracker.value.rows[0]!.value).toBe(3); - }); - - it("copyWithin clones pending dirty descendants with primitive indices", () => { - const tracker = track({ rows: [{ nested: { value: 1 } }, { nested: { value: 2 } }] }); - const change = tracker.beginChange(); - change.state.rows[0]!.nested.value = 9; - change.state.rows.copyWithin(1, 0, 1); - const prepared = change.prepare(); - expect(prepared.value).toEqual({ rows: [{ nested: { value: 9 } }, { nested: { value: 9 } }] }); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("keeps diff-aligned moved placements independent in the authoritative candidate", () => { - const tracker = track({ - rows: [ - { id: 0, score: 0, label: "r-0" }, - { id: 1, score: 1 }, - { id: 2, score: 2 }, - { id: 2, score: 2 }, - { id: 200106, score: 16, label: "r-200106" }, - { id: 4, score: 200103 }, - { id: 5, score: 200002 }, - { id: 6, score: 6, label: "r-6" }, - { id: 7, score: 7 }, - { id: 200001, score: 13, label: "r-200001" }, - ], - text: "start-200100-200107", - meta: { count: 0 }, - }); - const base = clone(tracker.value); - const change = tracker.beginChange(); - let held = change.state.rows[5]!; - change.state.rows.unshift({ id: 200200, score: 13 }); - held.score += 1_000; - change.state.meta.count -= 2; - held = change.state.rows[3]!; - change.state.rows.unshift({ id: 200202, score: 15, label: "r-200202" }); - held.score += 1_000; - change.state.text += "-200203"; - change.state.rows[0] = { id: 200204, score: 12 }; - change.state.rows[1] = change.state.rows[2]!; - change.state.text += "-200206"; - change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); - const prepared = change.prepare(); - expect(prepared.value.rows[0]!.id).toBe(0); - expect(prepared.value.rows[1]!.id).toBe(0); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - expect(apply(base, clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - - const next = tracker.beginChange(); - const secondScore = next.state.rows[1]!.score; - next.state.rows[0]!.score = 99; - const nextPrepared = next.prepare(); - expect(nextPrepared.value.rows[0]!.score).toBe(99); - expect(nextPrepared.value.rows[1]!.score).toBe(secondScore); - expect(nextPrepared.value.rows[0]).not.toBe(nextPrepared.value.rows[1]); - nextPrepared.abort(); - }); - - it("fuzzes deeply-equal duplicate placements through movement and stable sort", () => { - for (let seed = 1; seed <= 60; seed++) { - const initial = { - rows: Array.from({ length: 20 }, (_, index) => ({ - id: Math.floor(index / 2), - score: Math.floor(index / 2), - label: `row-${Math.floor(index / 2)}`, - })), - }; - const tracker = track(initial); - const expected = clone(initial); - const change = tracker.beginChange(); - const heldIndex = seed % expected.rows.length; - const expectedHeld = expected.rows[heldIndex]!; - const draftHeld = change.state.rows[heldIndex]!; - expected.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); - change.state.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); - expectedHeld.score += 1_000; - draftHeld.score += 1_000; - expected.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); - change.state.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); - const source = (seed * 7) % expected.rows.length; - let target = (seed * 11 + 3) % expected.rows.length; - if (target === source) target = (target + 1) % expected.rows.length; - expected.rows[target] = clone(expected.rows[source]!); - change.state.rows[target] = change.state.rows[source]!; - expected.rows.sort((left, right) => left.id - right.id || left.score - right.score); - change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); - const prepared = change.prepare(); - expect(prepared.value, `candidate seed ${seed}`).toEqual(expected); - expect(new Set(prepared.value.rows).size, `identity seed ${seed}`).toBe(prepared.value.rows.length); - expect(apply(clone(initial), clone(prepared.ops)), `replay seed ${seed}`).toEqual(expected); - prepared.abort(); - } - }); - - it("clones a dirty draft subtree from its source tracker context", () => { - const source = track({ subtree: { nested: { value: 1 }, rows: [{ value: 2 }] } }); - const destination = track({ copy: null as { nested: { value: number }; rows: { value: number }[] } | null }); - const sourceChange = source.beginChange(); - sourceChange.state.subtree.nested.value = 7; - sourceChange.state.subtree.rows[0]!.value = 8; - const destinationChange = destination.beginChange(); - destinationChange.state.copy = sourceChange.state.subtree; - const prepared = destinationChange.prepare(); - expect(prepared.value.copy).toEqual({ nested: { value: 7 }, rows: [{ value: 8 }] }); - expect(apply(clone(destination.value), clone(prepared.ops))).toEqual(prepared.value); - destination.adopt(prepared); - sourceChange.abort(); - }); -}); - -describe("native mutator parity", () => { - const compareNumbers = ( - nativeMutation: (values: number[]) => void, - draftMutation: (values: number[]) => void, - ): void => { - const native = [1, 2, 3]; - nativeMutation(native); - const tracker = track({ values: [1, 2, 3] }); - const change = tracker.beginChange(); - draftMutation(change.state.values); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual(native); - tracker.adopt(prepared); - }; - - it("matches captured-length splice coercion when coercion mutates the array", () => { - compareNumbers( - (values) => { - values.splice( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 1, - 9, - ); - }, - (values) => { - values.splice( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 1, - 9, - ); - }, - ); - }); - - it("keeps reentrant splice shrink results dense strict JSON", () => { - const tracker = track({ values: [1, 2, 3] as Array }); - const change = tracker.beginChange(); - change.state.values.splice( - { - valueOf() { - change.state.values.length = 1; - return 0; - }, - } as unknown as number, - 0, - 9, - ); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([9, 1, null, null]); - expect(Object.keys(prepared.value.values)).toEqual(["0", "1", "2", "3"]); - expect(prepared.value.values.every((value) => value !== undefined)).toBe(true); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("matches fill and copyWithin coercion side effects", () => { - compareNumbers( - (values) => { - values.fill(7, { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number); - }, - (values) => { - values.fill(7, { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number); - }, - ); - compareNumbers( - (values) => { - values.copyWithin( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 0, - 2, - ); - }, - (values) => { - values.copyWithin( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 0, - 2, - ); - }, - ); - }); - - it("matches comparator direct-index and structural writes", () => { - type Row = { rank: number; touches: number }; - const mutate = (values: Row[]): void => { - let changed = false; - values.sort((left, right) => { - left.touches += 1; - right.touches += 1; - if (!changed) { - changed = true; - values[0] = { rank: 9, touches: 0 }; - values.push({ rank: 3, touches: 0 }); - } - return left.rank - right.rank; - }); - }; - const native: Row[] = [ - { rank: 2, touches: 0 }, - { rank: 1, touches: 0 }, - ]; - mutate(native); - const tracker = track({ - values: [ - { rank: 2, touches: 0 }, - { rank: 1, touches: 0 }, - ], - }); - const change = tracker.beginChange(); - mutate(change.state.values); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual(native); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("rejects bigint comparator results like native sort", () => { - expect(() => [2, 1].sort(() => 1n as unknown as number)).toThrow(TypeError); - const tracker = track({ values: [2, 1] }); - const change = tracker.beginChange(); - expect(() => change.state.values.sort(() => 1n as unknown as number)).toThrow(TypeError); - change.abort(); - expect(tracker.value.values).toEqual([2, 1]); - }); - - it("forwards borrowed mutators to ordinary generic array receivers", () => { - const tracker = track({ values: [1, 2, 3] }); - const change = tracker.beginChange(); - const receiver = [3, 1, 2]; - expect(Reflect.apply(change.state.values.push, receiver, [4])).toBe(4); - expect(Reflect.apply(change.state.values.splice, receiver, [1, 1, 5])).toEqual([1]); - expect(Reflect.apply(change.state.values.sort, receiver, [(left: number, right: number) => left - right])).toBe( - receiver, - ); - expect(Reflect.apply(change.state.values.fill, receiver, [7, 1, 2])).toBe(receiver); - expect(Reflect.apply(change.state.values.copyWithin, receiver, [2, 0, 1])).toBe(receiver); - expect(receiver).toEqual([2, 7, 2, 5]); - expect(change.state.values).toEqual([1, 2, 3]); - change.abort(); - }); -}); - -describe("batched immutable application", () => { - it("copies shared ancestors once while preserving untouched subtrees", () => { - const base = { - rows: [ - { a: 1, b: 2 }, - { a: 3, b: 4 }, - ], - stable: { value: 9 }, - }; - const operations: Op[] = [ - ["s", ["rows", 0, "a"], 10], - ["s", ["rows", 0, "b"], 20], - ["s", ["rows", 1, "a"], 30], - ]; - const result = applyImmutable(base, operations); - expect(result).toEqual({ - rows: [ - { a: 10, b: 20 }, - { a: 30, b: 4 }, - ], - stable: { value: 9 }, - }); - expect(base).toEqual({ - rows: [ - { a: 1, b: 2 }, - { a: 3, b: 4 }, - ], - stable: { value: 9 }, - }); - expect(result.stable).toBe(base.stable); - }); - - it("does not mutate replacement or insertion payloads targeted later in the batch", () => { - const replacement = { rows: [{ value: 1 }] }; - const result = applyImmutable(undefined, [ - ["r", replacement], - ["s", ["rows", 0, "value"], 2], - ]); - expect(result.rows[0]!.value).toBe(2); - expect(replacement.rows[0]!.value).toBe(1); - }); -}); - -type Item = { id: number; score: number }; -type Document = { rows: Item[]; text: string; meta: { revision: number; label?: string } }; -type MutableDocument = Document | Draft; - -function mutate(document: MutableDocument, choice: number, value: number): void { - const item = (): Item => ({ id: value, score: value % 7 }); - switch (choice) { - case 0: - document.text += `-${value}`; - break; - case 1: - document.rows.push(item()); - break; - case 2: - document.rows.unshift(item()); - break; - case 3: - if (document.rows.length > 0) document.rows.shift(); - break; - case 4: - if (document.rows.length > 0) document.rows.pop(); - break; - case 5: { - const index = document.rows.length === 0 ? 0 : value % (document.rows.length + 1); - document.rows.splice(index, document.rows.length === 0 ? 0 : value % 2, item()); - break; - } - case 6: - document.rows.reverse(); - break; - case 7: - document.rows.sort((left, right) => left.id - right.id); - break; - case 8: - if (document.rows.length > 0) document.rows[value % document.rows.length]!.score = value; - break; - case 9: - document.meta.revision += 1; - document.meta.label = `r-${value}`; - break; - default: - delete document.meta.label; - } -} - -it("converges through randomized multi-operation transactions", () => { - for (let seed = 1; seed <= 30; seed++) { - let randomState = seed; - const random = (): number => { - randomState = (randomState + 0x6d2b79f5) | 0; - let value = Math.imul(randomState ^ (randomState >>> 15), 1 | randomState); - value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; - return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; - }; - const initial: Document = { - rows: Array.from({ length: 4 }, (_, id) => ({ id, score: 0 })), - text: "start", - meta: { revision: 0 }, - }; - const tracker = track(initial); - const expected = clone(initial); - for (let transaction = 0; transaction < 20; transaction++) { - const base = clone(tracker.value); - const change = tracker.beginChange(); - for (let operation = 0; operation < 5; operation++) { - const choice = Math.floor(random() * 11); - const value = seed * 10_000 + transaction * 10 + operation; - mutate(expected, choice, value); - mutate(change.state, choice, value); - } - const prepared = change.prepare(); - const candidate = clone(prepared.value); - expect(candidate, `candidate seed ${seed} transaction ${transaction}`).toEqual(expected); - expect(apply(base, clone(prepared.ops)), `replay seed ${seed} transaction ${transaction}`).toEqual(expected); - tracker.adopt(prepared); - expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(expected); - } - } -}, 30_000); diff --git a/packages/chord/test/delta-cow/draft.test.ts b/packages/chord/test/delta-cow/draft.test.ts deleted file mode 100644 index bbe1584b5..000000000 --- a/packages/chord/test/delta-cow/draft.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyImmutable, type Draft, track } from "../../src/delta/cow/index.ts"; - -describe("copy-on-write change drafts", () => { - it("copies only changed branches", () => { - const tracker = track({ - changed: { count: 1, sibling: { value: "kept" } }, - untouched: { value: 2 }, - }); - const base = tracker.value; - const change = tracker.beginChange(); - const first = change.state.changed; - expect(change.state.changed).toBe(first); - change.state.changed.count = 3; - const prepared = change.prepare(); - expect(prepared.value).not.toBe(base); - expect(prepared.value.changed).not.toBe(base.changed); - expect(prepared.value.changed.sibling).toBe(base.changed.sibling); - expect(prepared.value.untouched).toBe(base.untouched); - }); - - it("supports frozen bases, deletion, and native array mutators", () => { - const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] }); - const change = tracker.beginChange(); - delete change.state.optional; - change.state.values.push(4); - expect(change.state.values.pop()).toBe(4); - change.state.values.unshift(0); - expect(change.state.values.shift()).toBe(0); - expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]); - change.state.values.sort((left, right) => left - right); - change.state.values.reverse(); - change.state.values.fill(9, 1, 3); - change.state.values.copyWithin(1, 0, 2); - const prepared = change.prepare(); - expect(prepared.value).toEqual({ values: [5, 5, 9, 2] }); - }); - - it("copies inserted draft values without aliasing their original handle", () => { - const tracker = track({ values: [{ value: 1 }, { value: 2 }] }); - const change = tracker.beginChange(); - const held = change.state.values[0]!; - change.state.values.unshift(held); - held.value = 9; - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]); - expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); - }); - - it("keeps comparator edits when sorting object drafts", () => { - const tracker = track({ - rows: [ - { rank: 2, comparisons: 0 }, - { rank: 1, comparisons: 0 }, - ], - }); - const change = tracker.beginChange(); - change.state.rows.sort((left, right) => { - left.comparisons++; - right.comparisons++; - return left.rank - right.rank; - }); - const prepared = change.prepare(); - expect(prepared.value.rows.map((row) => row.rank)).toEqual([1, 2]); - expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]); - }); - - it("drops writes through detached child handles", () => { - const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] }); - const change = tracker.beginChange(); - const child = change.state.child; - const shifted = change.state.items.shift(); - delete (change.state as { child?: Draft<{ value: string }>; items: Draft<{ value: string }[]> }).child; - child.value = "detached child"; - if (shifted !== undefined) shifted.value = "detached item"; - expect(change.prepare().value).toEqual({ items: [{ value: "kept" }] }); - }); - - it.each(["unshift", "splice"] as const)("inserts 100,000 items with %s without argument overflow", (method) => { - const tracker = track({ values: [-1] }); - const change = tracker.beginChange(); - const items = Array.from({ length: 100_000 }, (_, value) => value); - if (method === "unshift") Reflect.apply(change.state.values.unshift, change.state.values, items); - else Reflect.apply(change.state.values.splice, change.state.values, [1, 0, ...items]); - const prepared = change.prepare(); - expect(prepared.value.values).toHaveLength(100_001); - expect(prepared.value.values[method === "unshift" ? 0 : 1]).toBe(0); - expect(prepared.value.values[method === "unshift" ? 99_999 : 100_000]).toBe(99_999); - expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); - }); - - it("rejects array holes without mutating the committed base", () => { - const tracker = track({ values: [1, 2] }); - const change = tracker.beginChange(); - expect(() => delete change.state.values[0]).toThrow(/holes/); - change.abort(); - expect(tracker.value.values).toEqual([1, 2]); - }); -}); diff --git a/packages/chord/test/delta-cow/fuzz.test.ts b/packages/chord/test/delta-cow/fuzz.test.ts deleted file mode 100644 index 0020e8976..000000000 --- a/packages/chord/test/delta-cow/fuzz.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, it } from "vitest"; -import { applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; - -type Item = { id: number; text: string; score: number }; -type Document = { - items: Item[]; - text: string; - meta: { revision: number; label?: string }; -}; - -type MutableDocument = Document | Draft; - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -const random = - (seed: number): (() => number) => - () => { - seed = (seed + 0x6d2b79f5) | 0; - let value = Math.imul(seed ^ (seed >>> 15), 1 | seed); - value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; - return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; - }; - -const mutate = (document: MutableDocument, choice: number, value: number): void => { - const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 }); - switch (choice) { - case 0: - document.text += `-${value}`; - break; - case 1: - document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`; - break; - case 2: - document.items.push(item()); - break; - case 3: - document.items.unshift(item()); - break; - case 4: - if (document.items.length > 0) document.items.shift(); - break; - case 5: - if (document.items.length > 0) document.items.pop(); - break; - case 6: { - const index = document.items.length === 0 ? 0 : value % (document.items.length + 1); - document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item()); - break; - } - case 7: - document.items.reverse(); - break; - case 8: - document.items.sort((left, right) => left.id - right.id); - break; - case 9: - if (document.items.length > 0) document.items[value % document.items.length]!.score = value; - break; - case 10: - document.meta.revision += 1; - document.meta.label = `revision-${value}`; - break; - case 11: - delete document.meta.label; - break; - case 12: - if (document.items.length > 1) document.items[1] = clone(document.items[0]!); - break; - default: - for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item(); - } -}; - -it("converges across randomized prepared revisions", () => { - for (let seed = 1; seed <= 100; seed++) { - const rng = random(seed); - const initial: Document = { - items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })), - text: "start", - meta: { revision: 0 }, - }; - const tracker = track(initial); - const expected = clone(initial); - let replica = clone(tracker.value); - for (let step = 0; step < 100; step++) { - const choice = Math.floor(rng() * 14); - const value = seed * 1_000 + step; - mutate(expected, choice, value); - const change = tracker.beginChange(); - mutate(change.state, choice, value); - const prepared = change.prepare(); - const operations: readonly Op[] = prepared.ops; - replica = applyImmutable(replica, operations); - tracker.adopt(prepared); - expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - } - } -}); diff --git a/packages/chord/test/delta-cow/value.test.ts b/packages/chord/test/delta-cow/value.test.ts deleted file mode 100644 index a896f5476..000000000 --- a/packages/chord/test/delta-cow/value.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { track } from "../../src/delta/cow/index.ts"; - -describe("trusted immutable revision ownership", () => { - it("takes O(1) ownership of an alias-free strict-JSON root", () => { - const input = { left: { value: 1 }, right: { value: 2 } }; - const tracker = track(input); - expect(tracker.value).toBe(input); - expect(tracker.value.left).toBe(input.left); - }); - - it("copies changed branches while sharing untouched branches", () => { - const tracker = track({ changed: { value: 1 }, retained: { value: 2 } }); - const base = tracker.value; - const change = tracker.beginChange(); - change.state.changed.value = 3; - const prepared = change.prepare(); - expect(prepared.value).not.toBe(base); - expect(prepared.value.changed).not.toBe(base.changed); - expect(prepared.value.retained).toBe(base.retained); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - }); - - it("makes repeated placements independent", () => { - const tracker = track({ values: [] as { value: number }[] }); - const change = tracker.beginChange(); - const shared = { value: 1 }; - change.state.values.push(shared, shared); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([{ value: 1 }, { value: 1 }]); - expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); - }); -}); diff --git a/packages/chord/test/delta-cow/diff.test.ts b/packages/chord/test/delta-diff.test.ts similarity index 97% rename from packages/chord/test/delta-cow/diff.test.ts rename to packages/chord/test/delta-diff.test.ts index 75d3005e6..495d525a2 100644 --- a/packages/chord/test/delta-cow/diff.test.ts +++ b/packages/chord/test/delta-diff.test.ts @@ -7,8 +7,8 @@ import { diffRevisions, encoder, type Op, -} from "../../src/delta/cow/index.ts"; -import type { JsonValue } from "../../src/types.ts"; +} from "../src/delta/index.ts"; +import type { JsonValue } from "../src/types.ts"; const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => { const operations = diffRevisions(before, after); @@ -257,11 +257,11 @@ describe("immutable revision diff", () => { expect(diffRevisions(before, after)).toEqual([["r", after]]); }); - it("uses normalized array fallback without payload-cost policy", () => { + it("bounds a wide normalized array fallback with a root replacement", () => { const before = { values: Array.from({ length: 40_000 }, () => 0) }; const after = { values: Array.from({ length: 40_000 }, () => 1) }; const operations = diffRevisions(before, after); - expect(operations).toEqual([["p", ["values"], 0, 40_000, after.values]]); + expect(operations).toEqual([["r", after]]); expect(applyImmutable(before, operations)).toEqual(after); }); }); diff --git a/packages/chord/test/delta-retention.test.ts b/packages/chord/test/delta-retention.test.ts deleted file mode 100644 index 695cf36ac..000000000 --- a/packages/chord/test/delta-retention.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -describe("delta change retention across GC jobs", () => { - it.each(["aborted-payload", "unadopted-prepared", "draft-proxies", "settled-lifecycle"])( - "releases %s", - (scenario) => { - const child = spawnSync( - process.execPath, - ["--expose-gc", fileURLToPath(new URL("./delta-retention.worker.ts", import.meta.url)), scenario], - { encoding: "utf8", timeout: 60_000 }, - ); - expect(child.error, child.stderr).toBeUndefined(); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - }, - 65_000, - ); -}); diff --git a/packages/chord/test/delta-retention.worker.ts b/packages/chord/test/delta-retention.worker.ts deleted file mode 100644 index 084e9a254..000000000 --- a/packages/chord/test/delta-retention.worker.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from "node:assert/strict"; -import { setImmediate } from "node:timers/promises"; -import { type Tracker, track } from "../src/delta/index.ts"; - -const retainedSettledObjects: object[] = []; - -async function collect(ref: WeakRef): Promise { - assert.ok(global.gc, "worker requires --expose-gc"); - for (let attempt = 0; attempt < 30; attempt++) { - await setImmediate(); - global.gc(); - if (ref.deref() === undefined) return; - } - assert.fail("released change data is still retained"); -} - -function abortedPayload(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const reference = new WeakRef(change.state.payload.rows); - change.abort(); - return reference; -} - -function unadoptedPrepared(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - return new WeakRef(prepared.value.payload!.rows); -} - -function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: [{ value: 1 }] }; - const reference = new WeakRef(change.state.payload.rows[0]!); - change.abort(); - return reference; -} - -function settledLifecycle(): { tracker: WeakRef; future: WeakRef } { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: [{ value: 1 }] }; - const retainedPrepared = change.prepare(); - tracker.adopt(retainedPrepared); - retainedSettledObjects.push(change, retainedPrepared); - - const futurePrepared = tracker.prepareReplace({ - payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }, - }); - tracker.adopt(futurePrepared); - return { - tracker: new WeakRef(tracker), - future: new WeakRef(futurePrepared.value.payload!.rows), - }; -} - -const scenario = process.argv[2]; -if (scenario === "settled-lifecycle") { - const references = settledLifecycle(); - await collect(references.tracker); - await collect(references.future); -} else { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - let reference: WeakRef; - switch (scenario) { - case "aborted-payload": - reference = abortedPayload(tracker); - break; - case "unadopted-prepared": - reference = unadoptedPrepared(tracker); - break; - case "draft-proxies": - reference = draftProxies(tracker); - break; - default: - throw new Error(`unknown retention scenario: ${scenario}`); - } - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} -console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-cow/retention.test.ts b/packages/chord/test/delta-tracker/retention.test.ts similarity index 75% rename from packages/chord/test/delta-cow/retention.test.ts rename to packages/chord/test/delta-tracker/retention.test.ts index 9ae8fd9de..29543857d 100644 --- a/packages/chord/test/delta-cow/retention.test.ts +++ b/packages/chord/test/delta-tracker/retention.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -describe("delta change retention across GC jobs", () => { +describe("delta tracker retention across GC jobs", () => { it.each([ "aborted-payload", "unadopted-prepared", @@ -11,6 +11,12 @@ describe("delta change retention across GC jobs", () => { "obsolete-revisions", "retained-settled-prepared", "retained-settled-change", + "retained-settled-proxy", + "retained-large-settled-proxy", + "retained-large-placement-proxies", + "stale-unprepared-change", + "same-job-fast-cleanup", + "same-job-folded-ops-cleanup", "lifecycle-churn", ])( "validates %s retention semantics", diff --git a/packages/chord/test/delta-cow/retention.worker.ts b/packages/chord/test/delta-tracker/retention.worker.ts similarity index 50% rename from packages/chord/test/delta-cow/retention.worker.ts rename to packages/chord/test/delta-tracker/retention.worker.ts index 772377000..a0a80d8d4 100644 --- a/packages/chord/test/delta-cow/retention.worker.ts +++ b/packages/chord/test/delta-tracker/retention.worker.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { setImmediate } from "node:timers/promises"; -import { type Tracker, track } from "../../src/delta/cow/index.ts"; +import { type Tracker, track } from "../../src/delta/index.ts"; const retainedSettledObjects: object[] = []; const retainedLargePrepared: object[] = []; @@ -119,6 +119,147 @@ if (scenario === "settled-lifecycle") { await collect(references.base); await collect(references.value); assert.equal(retainedSettledChanges.length, 1); +} else if (scenario === "retained-settled-proxy") { + const tracker = track({ + child: { value: 0 }, + payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }, + }); + const obsolete = new WeakRef(tracker.value.payload.rows); + const commit = (): object => { + const change = tracker.beginChange(); + const held = change.state.child; + held.value = 1; + const prepared = change.prepare(); + tracker.adopt(prepared); + return held; + }; + retainedSettledChanges.push(commit()); + const replace = (): void => { + const replacement = tracker.prepareReplace({ child: { value: 2 }, payload: { rows: [{ value: 1 }] } }); + tracker.adopt(replacement); + }; + replace(); + await collect(obsolete); + assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/); +} else if (scenario === "retained-large-settled-proxy") { + const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) }); + const obsolete = new WeakRef(tracker.value.rows); + const commit = (): object => { + const change = tracker.beginChange(); + const held = change.state.rows[0]!; + for (let index = 1; index < 5_000; index++) assert.equal(change.state.rows[index]!.value, index); + held.value = -1; + const prepared = change.prepare(); + tracker.adopt(prepared); + return held; + }; + retainedSettledChanges.push(commit()); + const replace = (): void => { + const replacement = tracker.prepareReplace({ rows: [{ value: 1 }] }); + tracker.adopt(replacement); + }; + replace(); + await collect(obsolete); + assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/); +} else if (scenario === "retained-large-placement-proxies") { + type Root = { + child: { value: number }; + visited: { value: number }[]; + first: number[] | null; + second: number[] | null; + rows: Array; + }; + const run = (kind: "write" | "writes" | "insert" | "override"): WeakRef => { + const tracker = track({ + child: { value: 0 }, + visited: Array.from({ length: 5_000 }, (_, value) => ({ value })), + first: null, + second: null, + rows: kind === "override" ? [null] : [], + }); + const change = tracker.beginChange(); + for (const value of change.state.visited) assert.equal(value.value >= 0, true); + let held: object; + if (kind === "write" || kind === "writes") { + held = change.state.child; + change.state.first = Array(50_000).fill(1); + if (kind === "writes") change.state.second = Array(50_000).fill(2); + } else { + held = change.state.rows; + if (kind === "insert") change.state.rows.push(Array(50_000).fill(1)); + else change.state.rows[0] = Array(50_000).fill(1); + } + const prepared = change.prepare(); + const payload = kind === "write" || kind === "writes" ? prepared.value.first : prepared.value.rows[0]; + assert.ok(payload); + const reference = new WeakRef(payload); + tracker.adopt(prepared); + const replacement = tracker.prepareReplace({ + child: { value: 1 }, + visited: [], + first: null, + second: null, + rows: [], + }); + tracker.adopt(replacement); + retainedSettledChanges.push(held); + return reference; + }; + const references = [run("write"), run("writes"), run("insert"), run("override")]; + for (const reference of references) await collect(reference); + for (const held of retainedSettledChanges) assert.throws(() => Reflect.get(held, "length"), /settled/); +} else if (scenario === "stale-unprepared-change") { + const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }); + const stale = tracker.beginChange(); + stale.state.payload.rows[0]!.value = -1; + const obsolete = new WeakRef(tracker.value.payload.rows); + const replace = (): void => { + const winner = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } }); + tracker.adopt(winner); + }; + replace(); + assert.throws(() => stale.prepare(), /settled/); + retainedSettledChanges.push(stale); + await collect(obsolete); + assert.equal(retainedSettledChanges.length, 1); +} else if (scenario === "same-job-fast-cleanup") { + assert.ok(global.gc, "worker requires --expose-gc"); + const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) }); + global.gc(); + const readyHeap = process.memoryUsage().heapUsed; + const commit = (value: number): void => { + const change = tracker.beginChange(); + const draft = change.state; + for (let index = 0; index < 4_094; index++) assert.equal(draft.rows[index]!.value, index); + draft.rows.at(-1)!.value = value; + const prepared = change.prepare(); + tracker.adopt(prepared); + }; + for (let value = 0; value < 100; value++) commit(-value - 1); + global.gc(); + const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024); + assert.ok(retainedMiB < 16, `same-job cleanup retained ${retainedMiB.toFixed(2)} MiB`); + assert.equal(tracker.value.rows.at(-1)!.value, -100); +} else if (scenario === "same-job-folded-ops-cleanup") { + assert.ok(global.gc, "worker requires --expose-gc"); + const tracker = track( + Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, 0])) as Record, + ); + global.gc(); + const readyHeap = process.memoryUsage().heapUsed; + const commit = (value: number): void => { + const change = tracker.beginChange(); + for (let index = 0; index < 5_000; index++) change.state[`field${index}`] = value; + const prepared = change.prepare(); + assert.equal(prepared.ops.length, 1); + assert.equal(prepared.ops[0]![0], "r"); + tracker.adopt(prepared); + }; + for (let value = 1; value <= 100; value++) commit(value); + global.gc(); + const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024); + assert.ok(retainedMiB < 8, `same-job folded operations retained ${retainedMiB.toFixed(2)} MiB`); + assert.equal(tracker.value.field0, 100); } else if (scenario === "lifecycle-churn") { const tracker = track({ value: 0 }); for (let index = 0; index < 100_000; index++) { diff --git a/packages/chord/test/delta-astra/astra.test.ts b/packages/chord/test/delta-tracker/tracker.test.ts similarity index 75% rename from packages/chord/test/delta-astra/astra.test.ts rename to packages/chord/test/delta-tracker/tracker.test.ts index 72f6eb855..7b4d7808d 100644 --- a/packages/chord/test/delta-astra/astra.test.ts +++ b/packages/chord/test/delta-tracker/tracker.test.ts @@ -1,7 +1,6 @@ import { isProxy } from "node:util/types"; import { describe, expect, it } from "vitest"; -import { type Draft, type Op, track } from "../../src/delta/astra/index.ts"; -import { apply, decoder, encoder, type JsonValue } from "../../src/delta/index.ts"; +import { apply, type Draft, decoder, encoder, type JsonValue, type Op, track } from "../../src/delta/index.ts"; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; @@ -9,20 +8,41 @@ function replay(base: T, operations: readonly Op[]): T { return apply(clone(base), clone(operations)); } +function expectAliasFree(value: unknown): void { + const seen = new WeakMap(); + const visit = (current: unknown, path: string): void => { + if (current === null || typeof current !== "object") return; + const previous = seen.get(current); + if (previous !== undefined) throw new Error(`container at ${path} aliases ${previous}`); + seen.set(current, path); + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index++) visit(current[index], `${path}[${index}]`); + return; + } + for (const key of Object.keys(current)) visit((current as Record)[key], `${path}.${key}`); + }; + visit(value, "$root"); +} + function settle(tracker: ReturnType>, mutate: (draft: Draft) => void): T { - const base = clone(tracker.value); + const baseRoot = tracker.value; + const base = clone(baseRoot); const change = tracker.beginChange(); mutate(change.state); const prepared = change.prepare(); const candidate = clone(prepared.value); + expect(isProxy(prepared.value)).toBe(false); expect(replay(base, prepared.ops)).toEqual(candidate); + expect(baseRoot).toEqual(base); tracker.adopt(prepared); + expect(baseRoot).toEqual(base); + expect(tracker.value).toBe(prepared.value); expect(tracker.value).toEqual(candidate); return candidate; } -describe("astra transactional overlay lifecycle", () => { - it("borrows the committed root and leaves it untouched until adopt", () => { +describe("delta tracker transactional overlay lifecycle", () => { + it("materializes an immutable next revision and adopts it by pointer swap", () => { const initial = { count: 1, nested: { text: "a" }, values: [1] }; const tracker = track(initial); expect(tracker.value).toBe(initial); @@ -32,28 +52,24 @@ describe("astra transactional overlay lifecycle", () => { change.state.values.push(2); expect(initial).toEqual({ count: 1, nested: { text: "a" }, values: [1] }); const prepared = change.prepare(); + const next = prepared.value; expect(prepared.baseRevision).toBe(0); expect(prepared.base).toBe(initial); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); + expect(next).not.toBe(initial); + expect(isProxy(next)).toBe(false); + expect(next).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); expect(prepared.ops).toEqual([ ["s", ["count"], 2], ["a", ["nested", "text"], "b"], ["p", ["values"], 1, 0, [2]], ]); - expect(() => { - (prepared.value as { count: number }).count = 9; - }).toThrow(/read-only/); + expect(tracker.value).toBe(initial); tracker.adopt(prepared); - expect(initial).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.value).toBe(tracker.value); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.base).toBe(prepared.value); + expect(initial).toEqual({ count: 1, nested: { text: "a" }, values: [1] }); + expect(tracker.value).toBe(next); + expect(prepared.value).toBe(next); + expect(prepared.base).toBe(initial); expect(() => change.state.count).toThrow(TypeError); - expect(prepared.ops).toEqual([ - ["s", ["count"], 2], - ["a", ["nested", "text"], "b"], - ["p", ["values"], 1, 0, [2]], - ]); }); it("keeps a transaction live across await and seals it only at prepare", async () => { @@ -66,7 +82,7 @@ describe("astra transactional overlay lifecycle", () => { expect(prepared.value).toEqual({ left: 1, nested: { right: 2 } }); expect(() => { change.state.left = 3; - }).toThrow(/read-only/); + }).toThrow(/settled/); tracker.adopt(prepared); }); @@ -93,12 +109,31 @@ describe("astra transactional overlay lifecycle", () => { const held = secondPrepared.value; tracker.adopt(firstPrepared); expect(tracker.value.value).toBe(1); - expect(() => held.value).toThrow(TypeError); + expect(held.value).toBe(2); expect(() => tracker.adopt(secondPrepared)).toThrow(/stale/); expect(() => tracker.adopt(firstPrepared)).toThrow(/already been used/); }); - it("adopts object edits in place with ordinary fast-layout descriptors", () => { + it("invalidates competing open overlays without changing their base revision", () => { + const initial = { rows: [{ value: 0 }, { value: 1 }] }; + const tracker = track(initial); + const staleChange = tracker.beginChange(); + const held = staleChange.state.rows[1]!; + held.value = 2; + + const winner = tracker.beginChange(); + winner.state.rows[0]!.value = 3; + const prepared = winner.prepare(); + tracker.adopt(prepared); + + expect(initial).toEqual({ rows: [{ value: 0 }, { value: 1 }] }); + expect(tracker.value).toEqual({ rows: [{ value: 3 }, { value: 1 }] }); + expect(() => held.value).toThrow(/settled/); + expect(() => staleChange.prepare()).toThrow(/settled/); + staleChange.abort(); + }); + + it("adopts object edits as ordinary fast-layout immutable revisions", () => { const initial = { first: 1, second: 2 } as { first: number; second?: number; third?: number }; const tracker = track(initial); const change = tracker.beginChange(); @@ -107,9 +142,10 @@ describe("astra transactional overlay lifecycle", () => { change.state.third = 4; const prepared = change.prepare(); tracker.adopt(prepared); - expect(tracker.value).toBe(initial); + expect(tracker.value).not.toBe(initial); + expect(initial).toEqual({ first: 1, second: 2 }); expect(tracker.value).toEqual({ first: 3, third: 4 }); - expect(Object.getOwnPropertyDescriptor(initial, "third")).toEqual({ + expect(Object.getOwnPropertyDescriptor(tracker.value, "third")).toEqual({ value: 4, writable: true, enumerable: true, @@ -137,9 +173,10 @@ describe("astra transactional overlay lifecycle", () => { next.state.value = 2; const nextPrepared = next.prepare(); tracker.adopt(nextPrepared); - // In-place adoption intentionally advances retained references to the mutable committed root. - expect(published).toBe(tracker.value); - expect(prepared.value).toEqual({ value: 2, nested: { count: 1 } }); + // Retained immutable publications remain at their original revision. + expect(published).not.toBe(tracker.value); + expect(prepared.value).toEqual({ value: 1, nested: { count: 1 } }); + expect(tracker.value).toEqual({ value: 2, nested: { count: 1 } }); }); it("keeps replacement base/value structurally compatible and readable after adoption", () => { @@ -148,22 +185,22 @@ describe("astra transactional overlay lifecycle", () => { const tracker = track(original); const prepared = tracker.prepareReplace(replacement); expect(prepared.base).toBe(original); - expect(prepared.value).not.toBe(replacement); + expect(prepared.value).toBe(replacement); tracker.adopt(prepared); expect(prepared.base).toBe(original); expect(prepared.value).toBe(replacement); expect(prepared.value).toBe(tracker.value); }); - it("revokes aborted and stale prepared candidate views", () => { + it("keeps aborted and stale materialized candidates readable", () => { const tracker = track({ value: 0 }); const abortedChange = tracker.beginChange(); abortedChange.state.value = 1; const aborted = abortedChange.prepare(); const abortedView = aborted.value; aborted.abort(); - expect(() => abortedView.value).toThrow(TypeError); - expect(() => aborted.value.value).toThrow(TypeError); + expect(abortedView.value).toBe(1); + expect(aborted.value.value).toBe(1); const loserChange = tracker.beginChange(); loserChange.state.value = 2; @@ -171,8 +208,21 @@ describe("astra transactional overlay lifecycle", () => { const loserView = loser.value; const winner = tracker.prepareReplace({ value: 3 }); tracker.adopt(winner); - expect(() => loserView.value).toThrow(TypeError); - expect(() => loser.value.value).toThrow(TypeError); + expect(loserView.value).toBe(2); + expect(loser.value.value).toBe(2); + }); + + it("lets a settled Change abort its prepared result without retaining its context", () => { + const tracker = track({ value: 0, nested: { value: 1 } }); + const change = tracker.beginChange(); + change.state.value = 1; + const prepared = change.prepare(); + const operations = prepared.ops; + change.abort(); + change.abort(); + expect(prepared.ops).toBe(operations); + expect(prepared.value).toEqual({ value: 1, nested: { value: 1 } }); + expect(() => tracker.adopt(prepared)).toThrow(/aborted/); }); it("rejects foreign and aborted prepared values", () => { @@ -186,26 +236,28 @@ describe("astra transactional overlay lifecycle", () => { }); it("reads deleted own properties as absent", () => { - Object.defineProperty(Object.prototype, "astraInherited", { + Object.defineProperty(Object.prototype, "trackerInherited", { value: 7, writable: true, configurable: true, }); try { - const tracker = track({ value: 1 } as { value?: number; astraInherited?: number }); + const tracker = track({ value: 1 } as { value?: number; trackerInherited?: number }); const change = tracker.beginChange(); delete change.state.value; - change.state.astraInherited = 1; - delete change.state.astraInherited; + change.state.trackerInherited = 1; + delete change.state.trackerInherited; expect(change.state.value).toBeUndefined(); - expect(change.state.astraInherited).toBeUndefined(); + expect(change.state.trackerInherited).toBeUndefined(); const prepared = change.prepare(); expect(prepared.value.value).toBeUndefined(); - expect(prepared.value.astraInherited).toBeUndefined(); + // Plain committed values expose inherited properties through ordinary + // JavaScript reads; strict-JSON membership remains own-properties only. + expect(prepared.value.trackerInherited).toBe(7); tracker.adopt(prepared); expect(Object.keys(tracker.value)).toEqual([]); } finally { - delete (Object.prototype as Record).astraInherited; + delete (Object.prototype as Record).trackerInherited; } }); @@ -252,24 +304,21 @@ describe("astra transactional overlay lifecycle", () => { expect(tracker.value).toBe(initial); }); - it("takes O(1) ownership for replacement and detaches its operation payload", () => { + it("takes O(1) immutable ownership for replacement and its operation payload", () => { const tracker = track({ value: 0, rows: [] as { value: number }[] }); const replacement = { value: 1, rows: [{ value: 2 }] }; const prepared = tracker.prepareReplace(replacement); expect(prepared.base).toBe(tracker.value); - expect(prepared.value.rows).not.toBe(replacement.rows); + expect(prepared.value).toBe(replacement); const operations = prepared.ops; expect(operations).toEqual([["r", replacement]]); - expect((operations[0] as readonly ["r", typeof replacement])[1]).not.toBe(replacement); + expect((operations[0] as readonly ["r", typeof replacement])[1]).toBe(replacement); tracker.adopt(prepared); expect(tracker.value).toBe(replacement); - expect(prepared.value).toBe(replacement); - replacement.rows[0]!.value = 9; - expect(operations).toEqual([["r", { value: 1, rows: [{ value: 2 }] }]]); }); }); -describe("astra policy view", () => { +describe("delta tracker policy view", () => { it("supports native reads, descriptors, keys, iteration, map, and JSON", () => { const tracker = track({ values: [1, 2, 3], object: { a: 1 } as { a: number; b?: number } }); const change = tracker.beginChange(); @@ -286,6 +335,26 @@ describe("astra policy view", () => { change.abort(); }); + it("keeps document keys distinct from object proxy target fields", () => { + const tracker = track({ + object: { + context: 1, + base: 2, + parent: 3, + dirty: 4, + target: 5, + proxy: 6, + }, + }); + const change = tracker.beginChange(); + change.state.object.context = 7; + change.state.object.proxy = 8; + expect(Object.keys(change.state.object)).toEqual(["context", "base", "parent", "dirty", "target", "proxy"]); + const prepared = change.prepare(); + expect(prepared.value.object).toEqual({ context: 7, base: 2, parent: 3, dirty: 4, target: 5, proxy: 8 }); + tracker.adopt(prepared); + }); + it("keeps native coercion side effects and string operation forms", () => { const tracker = track({ text: "abcdefgh", values: [1, 2, 3], marker: 0 }); const change = tracker.beginChange(); @@ -351,6 +420,21 @@ describe("astra policy view", () => { expect(Object.keys(tracker.value.object)).toEqual(["1", "2", "label"]); }); + it("normalizes deeply equal container assignments before direct candidate materialization", () => { + const initial = { child: { a: 1, b: 2 }, count: 0 }; + const tracker = track(initial); + const change = tracker.beginChange(); + change.state.child = { b: 2, a: 1 }; + change.state.count = 1; + const prepared = change.prepare(); + const replayed = replay(initial, prepared.ops); + expect(prepared.ops).toEqual([["s", ["count"], 1]]); + expect(Object.keys(prepared.value.child)).toEqual(["a", "b"]); + expect(Object.keys(replayed.child)).toEqual(["a", "b"]); + expect(prepared.value).toEqual(replayed); + tracker.adopt(prepared); + }); + it("keeps native integer and string ordering across deletion and re-addition", () => { const tracker = track({ object: { 1: "one", 2: "two", first: "a", second: "b" } as Record, @@ -568,7 +652,7 @@ describe("astra policy view", () => { }); }); -describe("astra by-value placements", () => { +describe("delta tracker by-value placements", () => { it("clones property, index, push, unshift, splice, fill, and copyWithin placements", () => { const tracker = track({ property: null as { value: number } | null, @@ -597,6 +681,81 @@ describe("astra by-value placements", () => { tracker.adopt(prepared); }); + it("rejects non-strict JSON placements before changing the draft", () => { + const initial = { + optional: "remove" as string | undefined, + payload: null as JsonValue, + number: 0, + values: [1, 2] as JsonValue[], + }; + const tracker = track(initial); + const change = tracker.beginChange(); + const undefinedValue = undefined as unknown as JsonValue; + + expect(() => { + change.state.values[0] = undefinedValue; + }).toThrow(/strict JSON/); + expect(() => change.state.values.push({ staged: true }, undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.unshift(undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.splice(1, 0, undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.fill(undefinedValue)).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: undefined } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: () => 1 } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: Symbol("invalid") } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: 1n } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.NaN; + }).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.POSITIVE_INFINITY; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = new Date() as unknown as JsonValue; + }).toThrow(/plain objects/); + + const sparse: JsonValue[] = new Array(1); + expect(() => { + change.state.payload = sparse; + }).toThrow(/dense plain arrays/); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => { + change.state.payload = cyclic as JsonValue; + }).toThrow(/cycles/); + let accessorReads = 0; + const accessor = Object.defineProperty({}, "value", { + enumerable: true, + get() { + accessorReads += 1; + return 1; + }, + }); + expect(() => { + change.state.payload = accessor as JsonValue; + }).toThrow(/data properties/); + expect(accessorReads).toBe(0); + + expect(change.state).toEqual(initial); + change.state.optional = undefined; + const dictionary = Object.create(null) as Record; + dictionary.valid = true; + change.state.payload = dictionary; + change.state.values.push({ valid: true }); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ payload: { valid: true }, number: 0, values: [1, 2, { valid: true }] }); + expect(Object.getPrototypeOf(prepared.value.payload)).toBeNull(); + expect(replay(initial, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + it("expands repeated source aliases into independent placements", () => { const tracker = track({ left: null as { nested: { value: number } } | null, @@ -622,6 +781,36 @@ describe("astra by-value placements", () => { tracker.adopt(prepared); }); + it("deep-clones draft-sourced placements within and across revisions", () => { + const tracker = track({ + a: { child: { value: 1 } }, + b: null as { child: { value: number } } | null, + rows: [{ child: { value: 1 } }, { child: { value: 2 } }], + }); + const first = tracker.beginChange(); + first.state.b = first.state.a; + first.state.b.child.value = 3; + first.state.rows[1] = first.state.rows[0]!; + const firstPrepared = first.prepare(); + expect(firstPrepared.value.a.child.value).toBe(1); + expect(firstPrepared.value.b?.child.value).toBe(3); + expect(firstPrepared.value.a).not.toBe(firstPrepared.value.b); + expect(firstPrepared.value.a.child).not.toBe(firstPrepared.value.b?.child); + expect(firstPrepared.value.rows[0]).not.toBe(firstPrepared.value.rows[1]); + expect(firstPrepared.value.rows[0]!.child).not.toBe(firstPrepared.value.rows[1]!.child); + expectAliasFree(firstPrepared.value); + tracker.adopt(firstPrepared); + + const base = clone(tracker.value); + const second = tracker.beginChange(); + second.state.rows[1]!.child.value = 4; + const secondPrepared = second.prepare(); + expect(secondPrepared.value.rows.map((row) => row.child.value)).toEqual([1, 4]); + expect(replay(base, secondPrepared.ops)).toEqual(secondPrepared.value); + expectAliasFree(secondPrepared.value); + tracker.adopt(secondPrepared); + }); + it("distinguishes raw committed references from draft references", () => { const tracker = track({ source: { value: 1 }, @@ -684,7 +873,7 @@ describe("astra by-value placements", () => { sparseTracker.adopt(sparsePrepared); }, 30_000); - it("keeps operation payloads detached from candidate and adopted placement mutation", () => { + it("shares immutable operation placements with the materialized candidate", () => { const tracker = track({ rows: [] as { value: number }[] }); const change = tracker.beginChange(); change.state.rows.push({ value: 1 }); @@ -697,13 +886,15 @@ describe("astra by-value placements", () => { expect(isProxy(value)).toBe(false); pending.push(...Object.values(value)); } + const splice = operations[0]; + if (splice?.[0] !== "p") throw new Error("expected splice"); + expect(splice[4][0]).toBe(prepared.value.rows[0]); tracker.adopt(prepared); - tracker.value.rows[0]!.value = 9; - expect(operations).toEqual([["p", ["rows"], 0, 0, [{ value: 1 }]]]); + expect(tracker.value).toBe(prepared.value); }); }); -describe("astra piece arrays", () => { +describe("delta tracker piece arrays", () => { it("keeps adoption independent from mutable detached permutation metadata", () => { const tracker = track({ values: [3, 1, 2] }); const change = tracker.beginChange(); @@ -716,6 +907,31 @@ describe("astra piece arrays", () => { expect(tracker.value.values).toEqual([1, 2, 3]); }); + it("matches native splice with zero or one argument", () => { + const noArguments = track({ values: [1, 2, 3] }); + const noArgumentsChange = noArguments.beginChange(); + expect(Reflect.apply(noArgumentsChange.state.values.splice, noArgumentsChange.state.values, [])).toEqual([]); + const noArgumentsPrepared = noArgumentsChange.prepare(); + expect(noArgumentsPrepared.value.values).toEqual([1, 2, 3]); + expect(noArgumentsPrepared.ops).toEqual([]); + noArguments.adopt(noArgumentsPrepared); + + const oneArgument = track({ values: [1, 2, 3, 4] }); + const oneArgumentChange = oneArgument.beginChange(); + expect(oneArgumentChange.state.values.splice(1)).toEqual([2, 3, 4]); + const oneArgumentPrepared = oneArgumentChange.prepare(); + expect(oneArgumentPrepared.value.values).toEqual([1]); + expect(replay(oneArgument.value, oneArgumentPrepared.ops)).toEqual(oneArgumentPrepared.value); + oneArgument.adopt(oneArgumentPrepared); + + const pastEnd = track({ values: [1, 2, 3] }); + const pastEndChange = pastEnd.beginChange(); + expect(pastEndChange.state.values.splice(3)).toEqual([]); + const pastEndPrepared = pastEndChange.prepare(); + expect(pastEndPrepared.value.values).toEqual([1, 2, 3]); + pastEnd.adopt(pastEndPrepared); + }); + it("supports all structural mutators and native return values", () => { const tracker = track({ values: [3, 1, 2] }); const base = clone(tracker.value); @@ -818,21 +1034,34 @@ describe("astra piece arrays", () => { }); it("normalizes duplicate entries created by structurally reentrant sort callbacks", () => { - const tracker = track({ values: [{ rank: 2 }, { rank: 1 }] }); + const tracker = track({ + values: [ + { rank: 2, edited: 0, nested: { value: 0 } }, + { rank: 1, edited: 0, nested: { value: 0 } }, + ], + }); const base = clone(tracker.value); const change = tracker.beginChange(); let inserted = false; change.state.values.sort((left, right) => { + left.edited = left.rank * 10; + left.nested.value = left.rank * 100; + right.edited = right.rank * 10; + right.nested.value = right.rank * 100; if (!inserted) { inserted = true; - change.state.values.unshift({ rank: 4 }); + change.state.values.unshift({ rank: 4, edited: 0, nested: { value: 0 } }); } return left.rank - right.rank; }); const prepared = change.prepare(); expect(prepared.value.values.map((value) => value.rank)).toEqual([1, 2, 1]); + expect(prepared.value.values.map((value) => value.edited)).toEqual([10, 20, 10]); + expect(prepared.value.values.map((value) => value.nested.value)).toEqual([100, 200, 100]); expect(prepared.value.values[0]).not.toBe(prepared.value.values[2]); + expect(prepared.value.values[0]!.nested).not.toBe(prepared.value.values[2]!.nested); expect(replay(base, prepared.ops)).toEqual(prepared.value); + expectAliasFree(prepared.value); tracker.adopt(prepared); }); @@ -1025,7 +1254,7 @@ describe("astra piece arrays", () => { ); }); -describe("astra randomized transactions", () => { +describe("delta tracker randomized transactions", () => { type Document = { values: { id: number; score: number }[]; text: string; @@ -1091,7 +1320,8 @@ describe("astra randomized transactions", () => { const tracker = track(initial); const expected = clone(initial); for (let transaction = 0; transaction < 25; transaction++) { - const base = clone(tracker.value); + const baseRoot = tracker.value; + const base = clone(baseRoot); const change = tracker.beginChange(); for (let operation = 0; operation < 5; operation++) { const choice = Math.floor(rng() * 11); @@ -1101,16 +1331,43 @@ describe("astra randomized transactions", () => { } const prepared = change.prepare(); const policy = clone(prepared.value); + expect(isProxy(prepared.value)).toBe(false); + expect(baseRoot, `prepare changed base seed ${seed} transaction ${transaction}`).toEqual(base); expect(policy, `policy seed ${seed} transaction ${transaction}`).toEqual(expected); expect(replay(base, prepared.ops), `replay seed ${seed} transaction ${transaction}`).toEqual(policy); tracker.adopt(prepared); + expect(baseRoot, `adopt changed base seed ${seed} transaction ${transaction}`).toEqual(base); + expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toBe(prepared.value); expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(policy); + expectAliasFree(tracker.value); } } }, 30_000); }); -describe("astra security and storage-style cloning", () => { +describe("delta tracker object emission scaling", () => { + it("orders many reverse-depth object edits through the bounded fallback", () => { + type DeepNode = { value: number; next?: DeepNode }; + let initial: DeepNode = { value: 0 }; + for (let depth = 0; depth < 512; depth++) initial = { value: 0, next: initial }; + const tracker = track(initial); + const change = tracker.beginChange(); + const nodes: Array> = []; + let node: Draft | undefined = change.state; + while (node !== undefined) { + nodes.push(node); + node = node.next; + } + for (let index = nodes.length - 1; index >= 0; index--) nodes[index]!.value = index + 1; + const prepared = change.prepare(); + expect(prepared.ops).toHaveLength(nodes.length); + expect(replay(initial, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expectAliasFree(tracker.value); + }); +}); + +describe("delta tracker security and storage-style cloning", () => { it("defines own properties without invoking inherited setters", () => { const tracker = track({} as Record); Object.defineProperty(Object.prototype, "trap", { diff --git a/packages/chord/test/delta-traversal.bench.ts b/packages/chord/test/delta-traversal.bench.ts index f50c4971b..04cbb5647 100644 --- a/packages/chord/test/delta-traversal.bench.ts +++ b/packages/chord/test/delta-traversal.bench.ts @@ -94,7 +94,9 @@ function sources() { "../src/delta/tracker.ts", "../src/delta/draft.ts", "../src/delta/diff.ts", - "../src/delta/value.ts", + "../src/delta/revision-validator.ts", + "../src/delta/apply-immutable-batch.ts", + "../src/delta/apply-immutable-trusted.ts", "./delta-traversal.bench.ts", ].map((path) => [ path, diff --git a/packages/chord/test/delta.test.ts b/packages/chord/test/delta.test.ts index 3a2bb32c2..93cf95de6 100644 --- a/packages/chord/test/delta.test.ts +++ b/packages/chord/test/delta.test.ts @@ -19,10 +19,9 @@ describe("immutable tracker lifecycle", () => { it("keeps a draft alive across await and adopts only a prepared change", async () => { const input = { count: 1, nested: { text: "a" }, values: [1] }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); - expect(Object.isFrozen(tracker.value)).toBe(true); - expect(Object.isFrozen(tracker.value.nested)).toBe(true); + expect(tracker.value).toBe(input); + expect(Object.isFrozen(tracker.value)).toBe(false); + expect(Object.isFrozen(tracker.value.nested)).toBe(false); const change = tracker.beginChange(); change.state.count = 2; @@ -60,16 +59,14 @@ describe("immutable tracker lifecycle", () => { next.abort(); }); - it("releases the tracker and revokes drafts when preparation fails", () => { - const tracker = track({ values: [1, 2] }); + it("grows arrays with explicit nulls and revokes drafts after preparation", () => { + const tracker = track({ values: [1, 2] as Array }); const change = tracker.beginChange(); change.state.values.length = 4; - expect(() => change.prepare()).toThrow(/dense|holes/); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([1, 2, null, null]); expect(() => change.state.values).toThrow(TypeError); - expect(() => change.abort()).not.toThrow(); - expect(() => change.prepare()).toThrow(/settled/); - const next = tracker.beginChange(); - next.abort(); + tracker.adopt(prepared); }); it("normalizes a deep no-op to exact previous identity", () => { @@ -83,13 +80,12 @@ describe("immutable tracker lifecycle", () => { expect(tracker.value).toBe(prepared.base); }); - it("copies replacement input and applies the same no-op normalization", () => { + it("takes immutable ownership of replacement input and applies no-op normalization", () => { const tracker = track({ nested: { value: 1 } }); const replacement = { nested: { value: 2 } }; const prepared = tracker.prepareReplace(replacement); - replacement.nested.value = 9; - expect(prepared.value.nested.value).toBe(2); - expect(Object.isFrozen(prepared.value.nested)).toBe(true); + expect(prepared.value).toBe(replacement); + expect(prepared.ops).toEqual([["r", replacement]]); tracker.adopt(prepared); const noOp = tracker.prepareReplace({ nested: { value: 2 } }); @@ -97,31 +93,31 @@ describe("immutable tracker lifecycle", () => { expect(noOp.ops).toEqual([]); }); - it("deeply freezes prepared metadata", () => { + it("shares immutable operation placements with the prepared candidate", () => { const tracker = track({ rows: [] as { id: number }[] }); const change = tracker.beginChange(); change.state.rows.push({ id: 1 }); const prepared = change.prepare(); - expect(Object.isFrozen(prepared)).toBe(true); - expect(Object.isFrozen(prepared.ops)).toBe(true); const splice = prepared.ops[0]!; - expect(Object.isFrozen(splice)).toBe(true); if (splice[0] !== "p") throw new Error("expected splice"); - expect(Object.isFrozen(splice[1])).toBe(true); - expect(Object.isFrozen(splice[4])).toBe(true); - expect(Object.isFrozen(splice[4][0])).toBe(true); + expect(splice[4][0]).toBe(prepared.value.rows[0]); + expect(Object.isFrozen(prepared)).toBe(false); + expect(Object.isFrozen(prepared.ops)).toBe(false); }); - it("rejects foreign, stale, repeated, and overlapping changes", () => { + it("rejects foreign, stale, and repeated preparations while allowing competing changes", () => { const first = track({ value: 0 }); const second = track({ value: 0 }); const change = first.beginChange(); - expect(() => first.beginChange()).toThrow(/active/); + const competing = first.beginChange(); change.state.value = 1; + competing.state.value = 2; const prepared = change.prepare(); + const competingPrepared = competing.prepare(); expect(() => second.adopt(prepared)).toThrow(/different tracker/); first.adopt(prepared); expect(() => first.adopt(prepared)).toThrow(/already been used/); + expect(() => first.adopt(competingPrepared)).toThrow(/stale/); const stale = first.prepareReplace({ value: 2 }); const winner = first.prepareReplace({ value: 3 }); @@ -151,14 +147,14 @@ describe("immutable tracker lifecycle", () => { expect(() => tracker.adopt(competing)).toThrow(/stale/); }); - it("keeps large prepareReplace array edits narrow", () => { + it("emits an owned root replacement without traversing large replacement input", () => { const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } })); const tracker = track({ rows }); - const replacement = tracker.value.rows.slice(); - replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable }; - const prepared = tracker.prepareReplace({ rows: replacement }); - expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]); - expect(JSON.stringify(prepared.ops).length).toBeLessThan(100); + const replacement = { rows: tracker.value.rows.slice() }; + replacement.rows[5_000] = { value: -1, stable: replacement.rows[5_000]!.stable }; + const prepared = tracker.prepareReplace(replacement); + expect(prepared.value).toBe(replacement); + expect(prepared.ops).toEqual([["r", replacement]]); expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); }); }); diff --git a/packages/chord/test/services.test.ts b/packages/chord/test/services.test.ts index 46c9331ea..51bc258f7 100644 --- a/packages/chord/test/services.test.ts +++ b/packages/chord/test/services.test.ts @@ -99,8 +99,7 @@ describe("remote services", () => { delivered = value; deliveries.push(delivery.kind); }); - expect(state.value).toEqual(initial); - expect(state.value).not.toBe(initial); + expect(state.value).toBe(initial); expect(delivered).toBe(state.value); const hydrated = delivered; diff --git a/packages/chord/test/state-draft.test.ts b/packages/chord/test/state-draft.test.ts index e8e3fc69c..87075fd79 100644 --- a/packages/chord/test/state-draft.test.ts +++ b/packages/chord/test/state-draft.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { applyImmutable, type Draft, track } from "../src/delta/index.ts"; +import { applyImmutable, type Draft, type JsonValue, track } from "../src/delta/index.ts"; -describe("copy-on-write change drafts", () => { +describe("transactional overlay drafts", () => { it("copies only changed branches", () => { const tracker = track({ changed: { count: 1, sibling: { value: "kept" } }, @@ -89,15 +89,28 @@ describe("copy-on-write change drafts", () => { expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); }); - it("rejects invalid writes without mutating the committed base", () => { - const tracker = track({ values: [1, 2] }); - let change = tracker.beginChange(); - expect(() => delete change.state.values[0]).toThrow(/dense|holes/); - change.abort(); - change = tracker.beginChange(); + it("rejects non-strict JSON placements without mutating the draft or base", () => { + const tracker = track({ number: 0, payload: null as JsonValue, values: [1, 2] as JsonValue[] }); + const change = tracker.beginChange(); expect(() => { - change.state.values[0] = undefined as unknown as number; - }).toThrow(/undefined/); + change.state.values[0] = undefined as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => change.state.values.push({ nested: undefined } as unknown as JsonValue)).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.NaN; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = new Date() as unknown as JsonValue; + }).toThrow(/plain objects/); + expect(change.state).toEqual(tracker.value); + change.abort(); + expect(tracker.value).toEqual({ number: 0, payload: null, values: [1, 2] }); + }); + + it("rejects array holes without mutating the committed base", () => { + const tracker = track({ values: [1, 2] }); + const change = tracker.beginChange(); + expect(() => delete change.state.values[0]).toThrow(/dense|holes/); change.abort(); expect(tracker.value.values).toEqual([1, 2]); }); diff --git a/packages/chord/test/state-fuzz.test.ts b/packages/chord/test/state-fuzz.test.ts index 46aea678d..7b6540154 100644 --- a/packages/chord/test/state-fuzz.test.ts +++ b/packages/chord/test/state-fuzz.test.ts @@ -12,6 +12,22 @@ type MutableDocument = Document | Draft; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +function expectAliasFree(value: unknown): void { + const seen = new WeakMap(); + const visit = (current: unknown, path: string): void => { + if (current === null || typeof current !== "object") return; + const previous = seen.get(current); + if (previous !== undefined) throw new Error(`container at ${path} aliases ${previous}`); + seen.set(current, path); + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index++) visit(current[index], `${path}[${index}]`); + return; + } + for (const key of Object.keys(current)) visit((current as Record)[key], `${path}.${key}`); + }; + visit(value, "$root"); +} + const random = (seed: number): (() => number) => () => { @@ -85,16 +101,23 @@ it("converges across randomized prepared revisions", () => { for (let step = 0; step < 100; step++) { const choice = Math.floor(rng() * 14); const value = seed * 1_000 + step; + const baseRoot = tracker.value; + const base = clone(baseRoot); mutate(expected, choice, value); const change = tracker.beginChange(); mutate(change.state, choice, value); const prepared = change.prepare(); - const operations: readonly Op[] = prepared.ops; + const operations = clone(prepared.ops) as readonly Op[]; + expect(baseRoot, `prepare base seed ${seed} step ${step} choice ${choice}`).toEqual(base); + expect(prepared.base).toBe(baseRoot); replica = applyImmutable(replica, operations); + expect(replica, `replay seed ${seed} step ${step} choice ${choice}`).toEqual(prepared.value); tracker.adopt(prepared); + expect(baseRoot, `adopt base seed ${seed} step ${step} choice ${choice}`).toEqual(base); + expect(tracker.value).toBe(prepared.value); expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - expect(Object.isFrozen(tracker.value)).toBe(true); + expectAliasFree(tracker.value); } } }); diff --git a/packages/chord/test/state-value.test.ts b/packages/chord/test/state-value.test.ts index 202deb590..50b10301f 100644 --- a/packages/chord/test/state-value.test.ts +++ b/packages/chord/test/state-value.test.ts @@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest"; import { track } from "../src/delta/index.ts"; describe("immutable revision ownership", () => { - it("imports detached frozen JSON and expands aliases", () => { - const shared = { value: 1 }; - const input = { left: shared, right: shared }; + it("takes ownership of an alias-free mutable JSON root without freezing", () => { + const input = { left: { value: 1 }, right: { value: 1 } }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); + expect(tracker.value).toBe(input); expect(tracker.value.left).not.toBe(tracker.value.right); - expect(Object.isFrozen(tracker.value)).toBe(true); - expect(Object.isFrozen(tracker.value.left)).toBe(true); + expect(Object.isFrozen(tracker.value)).toBe(false); + expect(Object.isFrozen(tracker.value.left)).toBe(false); }); it("commits transaction copies in place while sharing unchanged branches", () => { const tracker = track({ changed: { value: 1 }, retained: { value: 2 } }); const base = tracker.value; + const baseSnapshot = structuredClone(base); const change = tracker.beginChange(); change.state.changed.value = 3; const prepared = change.prepare(); expect(prepared.value.changed).not.toBe(base.changed); expect(prepared.value.retained).toBe(base.retained); - expect(Object.isFrozen(prepared.value.changed)).toBe(true); + expect(Object.isFrozen(prepared.value.changed)).toBe(false); tracker.adopt(prepared); + expect(base).toEqual(baseSnapshot); expect(tracker.value).toBe(prepared.value); }); diff --git a/packages/chord/test/state.test.ts b/packages/chord/test/state.test.ts index a604083c2..d175db524 100644 --- a/packages/chord/test/state.test.ts +++ b/packages/chord/test/state.test.ts @@ -27,7 +27,7 @@ describe("transactional replicated state", () => { expect(state.value).not.toBe(previous); expect(state.value.changed).not.toBe(previous.changed); expect(state.value.retained).toBe(previous.retained); - expect(Object.isFrozen(state.value)).toBe(true); + expect(Object.isFrozen(state.value)).toBe(false); expect(deliveries).toEqual([0, 1]); }); @@ -140,25 +140,16 @@ describe("transactional replicated state", () => { expect(state.value.left).not.toBe(state.value.right); }); - it("expands reused owned subtrees into independent placements", () => { + it("takes immutable ownership of alias-free replacements", () => { const state = replicatedState({ left: { value: 1 }, right: { value: 2 } }); - state.replace(BACKGROUND_CONTEXT, { left: state.value.left, right: state.value.left }); + const replacement = { left: { ...state.value.left }, right: { ...state.value.left } }; + state.replace(BACKGROUND_CONTEXT, replacement); + expect(state.value).toBe(replacement); expect(state.value.left).not.toBe(state.value.right); state.change(BACKGROUND_CONTEXT, (draft) => { draft.left.value = 9; }); expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } }); - - const overlapping = replicatedState({ parent: { child: { value: 1 } }, other: { value: 2 } }); - overlapping.replace(BACKGROUND_CONTEXT, { - parent: overlapping.value.parent, - other: overlapping.value.parent.child, - }); - expect(overlapping.value.parent.child).not.toBe(overlapping.value.other); - overlapping.change(BACKGROUND_CONTEXT, (draft) => { - draft.other.value = 9; - }); - expect(overlapping.value).toEqual({ parent: { child: { value: 1 } }, other: { value: 9 } }); }); it("preserves compact string, splice, and permutation operations", () => { @@ -185,6 +176,24 @@ describe("transactional replicated state", () => { ]); }); + it("validates replica revisions without freezing shared immutable payloads", () => { + const replica = new ReplicatedStateReplica<{ rows: { value: number }[] }>(() => {}); + const initial = { rows: [{ value: 1 }] }; + replica.hydrate(0, [["r", initial]], BACKGROUND_CONTEXT); + expect(replica.value).toBe(initial); + expect(Object.isFrozen(initial)).toBe(false); + expect(Object.isFrozen(initial.rows[0])).toBe(false); + + const inserted = { value: 2 }; + replica.update(1, [["p", ["rows"], 1, 0, [inserted]]], BACKGROUND_CONTEXT); + expect(replica.value).not.toBe(initial); + expect(replica.value?.rows).toEqual([{ value: 1 }, { value: 2 }]); + expect(replica.value?.rows[0]).toBe(initial.rows[0]); + expect(replica.value?.rows[1]).toBe(inserted); + expect(initial.rows).toHaveLength(1); + expect(Object.isFrozen(inserted)).toBe(false); + }); + it("clears a replica when an adopted update is invalid", () => { const errors: Error[] = []; const replica = new ReplicatedStateReplica<{ value: number }>((error) => errors.push(error)); @@ -207,7 +216,7 @@ describe("transactional replicated state", () => { expect(state.value).toBe(previous); state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } }); expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } }); - expect(state.value.retained).not.toBe(previous.retained); + expect(state.value.retained).toBe(previous.retained); }); }); diff --git a/packages/durable/docs/chord-delta-findings.md b/packages/durable/docs/chord-delta-findings.md index 3654a2b51..3f6a88558 100644 --- a/packages/durable/docs/chord-delta-findings.md +++ b/packages/durable/docs/chord-delta-findings.md @@ -323,7 +323,7 @@ implementation or reproduces the historical graph/mutation comparison. Targeted tests from `packages/chord`: ```sh -node ../../node_modules/vitest/dist/cli.js --run test/delta.test.ts test/delta-clone.test.ts test/delta-retention.test.ts test/services.test.ts test/service-wire.test.ts test/facets.test.ts +node ../../node_modules/vitest/dist/cli.js --run test/delta.test.ts test/delta-clone.test.ts test/delta-tracker/retention.test.ts test/services.test.ts test/service-wire.test.ts test/facets.test.ts ``` Historical source identifiers: