diff --git a/packages/agent/src/harness/pico3/legacy-tracker.ts b/packages/agent/src/harness/pico3/legacy-tracker.ts new file mode 100644 index 000000000..8bd952d12 --- /dev/null +++ b/packages/agent/src/harness/pico3/legacy-tracker.ts @@ -0,0 +1,90 @@ +import { + type Change, + type Tracker as ChordTracker, + type JsonValue, + type Op, + type Prepared, + track as trackChord, +} from "@earendil-works/chord/delta"; + +/** Temporary compatibility surface for Pico3's flush-based document handling. */ +export interface Tracker { + readonly state: T; + readonly target: T; + readonly dirty: boolean; + flush(): Op[]; + rebase(): void; +} + +class LegacyTracker implements Tracker { + private readonly tracker: ChordTracker; + private change: Change | undefined; + private wrappers = new WeakMap(); + private forceBase = true; + + constructor(initial: T) { + this.tracker = trackChord(initial); + } + + get state(): T { + this.change ??= this.tracker.beginChange(); + return this.wrap(this.change.state as T); + } + + get target(): T { + return this.tracker.value; + } + + get dirty(): boolean { + return this.forceBase || this.change !== undefined; + } + + flush(): Op[] { + let prepared: Prepared | undefined; + 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); + } + if (this.forceBase) { + this.forceBase = false; + return [["r", this.tracker.value as unknown as JsonValue]]; + } + return (prepared?.ops ?? []) as Op[]; + } + + 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 { + return new LegacyTracker(initial); +} diff --git a/packages/agent/src/harness/pico3/session.ts b/packages/agent/src/harness/pico3/session.ts index 9057b481c..d776ca637 100644 --- a/packages/agent/src/harness/pico3/session.ts +++ b/packages/agent/src/harness/pico3/session.ts @@ -1,7 +1,8 @@ import { type Context, isJsonValue } from "@earendil-works/chord"; import { createContextKey, withContextValue, withoutAbortSignal } from "@earendil-works/chord/context"; -import { isBase, type Op, type Tracker, track } from "@earendil-works/chord/delta"; +import { isBase, type Op } from "@earendil-works/chord/delta"; import { deriveContext } from "./context.ts"; +import { type Tracker, track } from "./legacy-tracker.ts"; import { Membrane } from "./membrane.ts"; import { type AnyKind, @@ -509,7 +510,7 @@ class TxImpl implements CoreTx { snapshot(ref: { doc: "session" }): SessionState; snapshot(ref: DocRef): object { if (ref.doc !== "session") this.assertScope(ref.conversationId, "snapshot"); - const value = structuredClone(this.doc(ref).target) as JsonObject; + const value = plain(this.doc(ref).state) as JsonObject; if (ref.doc !== "session") { this.defaults.fill(ref.doc, value); if (this.invoker.type === "task") { @@ -637,8 +638,8 @@ class TxImpl implements CoreTx { get: (key: string) => { this.assertSurfaceActive(); const { doc, fallback } = definition(key); - const document = this.doc({ doc, conversationId }).target as JsonObject; - return key in document ? structuredClone(document[key]) : structuredClone(fallback); + const document = this.doc({ doc, conversationId }).state as JsonObject; + return key in document ? plain(document[key]) : fallback === undefined ? undefined : plain(fallback); }, set: (key: string, value: JsonValue) => { this.assertSurfaceActive(); diff --git a/packages/agent/src/harness/pico3/view.ts b/packages/agent/src/harness/pico3/view.ts index 435590aef..7c65b9427 100644 --- a/packages/agent/src/harness/pico3/view.ts +++ b/packages/agent/src/harness/pico3/view.ts @@ -1,4 +1,5 @@ -import { applyImmutable, type Op, type Tracker, track } from "@earendil-works/chord/delta"; +import { applyImmutable, type Op } from "@earendil-works/chord/delta"; +import { type Tracker, track } from "./legacy-tracker.ts"; import type { CommitResult, Session } from "./session.ts"; import type { AnyKind, @@ -86,8 +87,8 @@ export class ViewManager { .map((scoped) => scoped.event); if (appended.length === 0 && documentChanges.length === 0 && !taskChanged && events.length === 0) continue; - const entryOps = applyEntries(record.tracker, appended); const state = record.tracker.state; + applyEntries(state.entries, appended); const rewindableChanged = documentChanges.some( (change) => change.ref.doc === "rewindable" && change.ref.conversationId === record.conversationId, ); @@ -117,7 +118,7 @@ export class ViewManager { syncRecord(state.plugins, this.plugins(rewindable!, sticky!, session)); } - const ops = [...entryOps, ...record.tracker.flush()]; + const ops = record.tracker.flush(); if (ops.length === 0 && events.length === 0) continue; const envelope = Object.freeze({ revision: ++record.revision, @@ -334,23 +335,15 @@ class WatchImpl implements Watch { } } -function applyEntries(tracker: Tracker, appended: EntryList): Op[] { - const ops: Op[] = []; - const entries = tracker.state.entries; +function applyEntries(entries: EntryList, appended: EntryList): void { for (const entry of appended) { if (entry.head !== undefined) { const retained = entries.findIndex((candidate) => candidate.id >= entry.head!); const remove = retained < 0 ? entries.length : retained; - if (remove > 0) { - ops.push(...tracker.flush()); - entries.splice(0, remove); - tracker.flush(); - ops.push(["p", ["entries"], 0, remove, []]); - } + if (remove > 0) entries.splice(0, remove); } entries.push(structuredClone(entry)); } - return ops; } function inputIds(task: Task): Id[] { 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 f75c1ff82..4a2ed4f43 100644 --- a/packages/agent/test/harness/pico3/spec-view-events.test.ts +++ b/packages/agent/test/harness/pico3/spec-view-events.test.ts @@ -170,7 +170,7 @@ test("late joiner reconstructs streaming generation state without replayed event watch.stop(); }); -test("a head commit is represented by transcript splice ops and matching entry/head events", async () => { +test("a self-head commit rewrites one transcript entry with matching entry/head events", async () => { const env = await open({}); onTestFinished(() => env.close()); await env.root.write({ kind: "before" }, ctx); @@ -181,10 +181,17 @@ test("a head commit is represented by transcript splice ops and matching entry/h }); await env.root.reset("fresh", ctx); assert.ok(envelope); - const entryOps = envelope.ops.filter((op) => op[0] === "p" && op[1]?.[0] === "entries"); - assert.equal(entryOps.length, 2); - assert.equal(entryOps[0]?.[2], 0); - assert.ok((entryOps[0]?.[3] as number) > 0); + 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"], + ); assert.deepEqual( envelope.events.map((event) => event.type), ["head.moved", "entry.added"], diff --git a/packages/chord/src/api.ts b/packages/chord/src/api.ts index b82a5abf3..d7961eed8 100644 --- a/packages/chord/src/api.ts +++ b/packages/chord/src/api.ts @@ -1,8 +1,9 @@ import { FacetKernel } from "./facets/host.ts"; import { disposeLoadedFacets } from "./facets/loader.ts"; import { RemoteServiceBindingImpl } from "./services/consumer.ts"; -import { MutableReplicatedStateImpl } from "./services/state.ts"; +import { attachReplicatedStateSource, MutableReplicatedStateImpl } from "./services/state.ts"; import type { + AttachedReplicatedState, Facet, FacetHost, FacetLoader, @@ -12,6 +13,8 @@ import type { RemoteServiceBinding, RemoteServiceBindingOptions, RemoteServiceContract, + ReplicatedStateSource, + ReplicatedStateSourceOptions, Service, } from "./types.ts"; @@ -85,6 +88,19 @@ export function createRemoteServiceBinding(options: RemoteServiceBindingOptions) return new RemoteServiceBindingImpl(options); } -export function replicatedState(initial: T): MutableReplicatedState { - return new MutableReplicatedStateImpl(initial); +export function replicatedState( + source: ReplicatedStateSource, + options?: ReplicatedStateSourceOptions, +): AttachedReplicatedState; +export function replicatedState(initial: T): MutableReplicatedState; +export function replicatedState( + initialOrSource: object | ReplicatedStateSource, + options?: ReplicatedStateSourceOptions, +): MutableReplicatedState | AttachedReplicatedState { + if (isReplicatedStateSource(initialOrSource)) return attachReplicatedStateSource(initialOrSource, options); + return new MutableReplicatedStateImpl(initialOrSource); +} + +function isReplicatedStateSource(value: object): value is ReplicatedStateSource { + return typeof (value as { readonly attach?: unknown }).attach === "function"; } diff --git a/packages/chord/src/delta/diff.ts b/packages/chord/src/delta/diff.ts new file mode 100644 index 000000000..b022d142a --- /dev/null +++ b/packages/chord/src/delta/diff.ts @@ -0,0 +1,523 @@ +import type { JsonValue } from "../types.ts"; +import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "./index.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; + 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, { indices: [index], used: 0 }); + else entry.indices.push(index); + } + const result = new Array(after.length); + for (let index = 0; index < after.length; index++) { + const entry = positions.get(after[index]!); + if (entry === undefined || entry.used === entry.indices.length) return undefined; + result[index] = entry.indices[entry.used++]!; + } + 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 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[], + 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, beforeAt, beforeMatch, afterAt, afterMatch, outputAt); + outputAt += afterMatch - afterAt; + if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) { + diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations); + } + outputAt += 1; + beforeAt = beforeMatch + 1; + afterAt = afterMatch + 1; + } + if (!overflowedBatches.has(operations)) { + diffArrayRegion(before, after, path, operations, beforeAt, beforeEnd, afterAt, afterEnd, outputAt); + } +}; + +function diffArrayRegion( + before: JsonValue[], + after: JsonValue[], + path: Path, + operations: Op[], + 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) { + const positional: ArrayMatch[] = []; + for (let offset = 0; offset < beforeCount; offset++) { + if (sameValue(before[beforeStart + offset]!, after[afterStart + offset]!)) { + positional.push([beforeStart + offset, afterStart + offset]); + } + } + if (positional.length > 0) { + processArrayMatches( + before, + after, + path, + operations, + beforeStart, + beforeEnd, + afterStart, + afterEnd, + outputStart, + positional, + ); + return; + } + } + + const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd); + if (subsequence !== undefined && subsequence.length > 0) { + processArrayMatches( + before, + after, + path, + operations, + 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, + 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, + beforeStart, + beforeEnd, + afterStart, + afterEnd, + outputStart, + absolute, + ); + return; + } + + if (beforeCount === 1 && afterCount === 1) { + diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations); + return; + } + emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); +} + +const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => { + if (before === after || 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, 0, before.length, 0, after.length, 0); +}; + +const diffObject = ( + before: Record, + after: Record, + path: Path, + operations: Op[], +): 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); + 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[]): 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); + return; + } + if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) { + diffObject(before, after, path, operations); + return; + } + emitSet(path, after, operations); +}; + +const jsonCost = (value: JsonValue): number => { + if (value === null) return 4; + if (typeof value === "string") return value.length + 2; + if (typeof value === "number") return String(value).length; + if (typeof value === "boolean") return value ? 4 : 5; + if (Array.isArray(value)) { + let cost = 2; + for (let index = 0; index < value.length; index++) cost += jsonCost(value[index]!) + (index === 0 ? 0 : 1); + return cost; + } + let cost = 2; + let index = 0; + for (const key of Object.keys(value)) { + cost += key.length + 3 + jsonCost(value[key]!) + (index++ === 0 ? 0 : 1); + } + return cost; +}; + +const pathCost = (path: Path): number => { + let cost = 2; + for (let index = 0; index < path.length; index++) { + const segment: Seg = path[index]!; + cost += (typeof segment === "string" ? segment.length + 2 : String(segment).length) + (index === 0 ? 0 : 1); + } + return cost; +}; + +const operationCost = (operation: Op): number => { + switch (operation[0]) { + case "r": + return 6 + jsonCost(operation[1]); + case "s": + return 7 + pathCost(operation[1]) + jsonCost(operation[2]); + case "d": + return 6 + pathCost(operation[1]); + case "a": + return 7 + pathCost(operation[1]) + operation[2].length + 2; + case "t": + return 7 + pathCost(operation[1]) + String(operation[2]).length; + case "p": + return ( + 10 + + pathCost(operation[1]) + + String(operation[2]).length + + String(operation[3]).length + + jsonCost(operation[4]) + ); + case "m": + return 7 + pathCost(operation[1]) + jsonCost(operation[2]); + } +}; + +/** Compute a compact operation batch from two immutable JSON revisions. */ +export function diffRevisions(before: JsonValue, after: JsonValue): Op[] { + const operations: Op[] = []; + diffValue(before, after, [], operations); + if (overflowedBatches.has(operations)) return [["r", after]]; + if (operations.length === 0 || operations[0]?.[0] === "r") return operations; + let deltaCost = 2; + for (const operation of operations) deltaCost += operationCost(operation) + 1; + if (deltaCost < 65_536) return operations; + const snapshotCost = jsonCost(after) + 6; + return deltaCost >= snapshotCost ? [["r", after]] : operations; +} diff --git a/packages/chord/src/state/draft.ts b/packages/chord/src/delta/draft.ts similarity index 90% rename from packages/chord/src/state/draft.ts rename to packages/chord/src/delta/draft.ts index 30d05414b..995b3a00c 100644 --- a/packages/chord/src/state/draft.ts +++ b/packages/chord/src/delta/draft.ts @@ -35,9 +35,16 @@ type DraftContext = { 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; -/** Run a mutation recipe and return its value with transaction ownership metadata. */ -export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { +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: [], @@ -46,17 +53,8 @@ export function produceWithMetadata(base: T, recipe: (draft: D states: new WeakMap(), }; const root = getState(context, base as Container); - try { - const outcome = (recipe as (draft: Draft) => unknown)(root.proxy as Draft); - if (isPromiseLike(outcome)) { - void Promise.resolve(outcome).catch(() => undefined); - throw new TypeError("Replicated state change callbacks must be synchronous"); - } - return { - value: finalize(root, new Set(), new WeakMap()) as T, - owned: context.owned, - }; - } finally { + const release = (): void => { + assertActive(context); context.active = false; for (const state of context.created) { state.base = RELEASED_BASE; @@ -68,10 +66,45 @@ export function produceWithMetadata(base: T, recipe: (draft: D 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; } } -/** Run a mutation recipe against a copy-on-write draft of `base`. */ +/** @internal Compatibility helper for the existing replicated-state service. */ export function produce(base: T, recipe: (draft: Draft) => void): T { return produceWithMetadata(base, recipe).value; } @@ -238,11 +271,14 @@ function mutateArray(state: DraftState, target: unknown[], property: string, arg return draftValue(context, Reflect.apply(Array.prototype.shift, target, [])); case "unshift": { const items = cloneArrayItems(args, context); - if (hasInheritedGrowthIndex(target, target.length + items.length)) { - spliceArray(target, items, 0, 0, context); - return target.length; + if ( + items.length <= MAX_NATIVE_ARRAY_INSERT_ITEMS && + !hasInheritedGrowthIndex(target, target.length + items.length) + ) { + return Reflect.apply(Array.prototype.unshift, target, items); } - return Reflect.apply(Array.prototype.unshift, target, items); + spliceArray(target, items, 0, 0, context); + return target.length; } case "splice": { const length = target.length; @@ -255,12 +291,14 @@ function mutateArray(state: DraftState, target: unknown[], property: string, arg : 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) ) { - const removed = Reflect.apply(Array.prototype.splice, target, [start, remove, ...items]) as unknown[]; - return removed.map((item) => draftValue(context, item)); + return Reflect.apply(Array.prototype.splice, target, [start, remove, ...items]).map((item) => + draftValue(context, item), + ); } return spliceArray(target, items, start, remove, context, length); } @@ -570,8 +608,14 @@ function assertDenseArray(value: unknown[]): void { throw new TypeError("Draft arrays must be dense and contain only indexed entries"); } for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || value[index] === undefined) { - throw new TypeError("Draft arrays cannot contain holes or undefined entries"); + 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/index.ts b/packages/chord/src/delta/index.ts index 7cac8db29..1d4aa89fc 100644 --- a/packages/chord/src/delta/index.ts +++ b/packages/chord/src/delta/index.ts @@ -2,8 +2,10 @@ import type { JsonValue } from "../types.ts"; export type { JsonValue } from "../types.ts"; +const isObj = (value: unknown): value is object => value !== null && typeof value === "object"; + // ───────────────────────────────────────────────────────────────────────────── -// chord/delta — operation-log change tracking over plain JSON. +// chord/delta — immutable revision tracking and operations over plain JSON. // // Depends on nothing else in the harness. Session storage, the runtime and the // facet host consume it; keep the arrows pointing that way. @@ -107,1331 +109,11 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi return 0; } -// ─── Tracker ───────────────────────────────────────────────────────────────── +// ─── Immutable revision tracking ───────────────────────────────────────────── -export interface TrackerOptions { - maxOverlapScan?: number; -} - -export interface Tracker { - /** - * The tracked value. Mutate and read state only through this proxy. Values - * inserted into it are adopted: callers may retain read-only references, but - * must not mutate them outside this proxy. - */ - state: T; - /** The untracked current value. Mutating it bypasses change tracking. */ - readonly target: T; - flush(): Op[]; - /** Make the next flush a complete base batch without changing the value. */ - rebase(): void; - /** Accept pending mutations locally without emitting them. */ - discard(): void; - readonly dirty: boolean; -} - -const isObj = (value: unknown): value is object => value !== null && typeof value === "object"; -const cloneJson = (value: T): T => { - if (!isObj(value)) return value; - if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T; - // Spread preserves compact object layouts instead of reserving extra slots - // while growing an empty object. Both branches create own writable properties. - const result = ( - Object.getPrototypeOf(value) === null ? Object.assign(Object.create(null), value) : { ...value } - ) as Record; - for (const key of Object.keys(result)) { - const child = result[key]!; - if (isObj(child)) result[key] = cloneJson(child); - } - return result as T; -}; - -const INDEX = /^(?:0|[1-9]\d*)$/; -const norm = (target: object, key: string | symbol): Seg | symbol => - typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key; -const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); -const MISSING = Symbol("missing"); -type MaybeJson = JsonValue | typeof MISSING; - -const spliceItems = (target: unknown[], index: number, remove: number, items: JsonValue[]): JsonValue[] => { - const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]) as JsonValue[]; - const chunkSize = 10_000; - for (let offset = 0; offset < items.length; offset += chunkSize) { - Reflect.apply(Array.prototype.splice, target, [index + offset, 0, ...items.slice(offset, offset + chunkSize)]); - } - return removed; -}; - -const jsonEqual = (left: JsonValue, right: JsonValue): boolean => { - if (left === right) return true; - if (!isObj(left) || !isObj(right) || Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index++) { - if (!jsonEqual(left[index]!, right[index]!)) return false; - } - return true; - } - const leftObject = left as Record; - const rightObject = right as Record; - const leftKeys = Object.keys(leftObject); - const rightKeys = Object.keys(rightObject); - if (leftKeys.length !== rightKeys.length) return false; - for (const key of leftKeys) { - if (!Object.hasOwn(rightObject, key) || !jsonEqual(leftObject[key]!, rightObject[key]!)) return false; - } - return true; -}; - -const emitSet = (path: Path, value: JsonValue, out: Op[]): void => { - const snapshot = cloneJson(value); - if (path.length === 0) out.push(["r", snapshot]); - else out.push(["s", [...path] as unknown as NonEmptyPath, snapshot]); -}; - -const emitDelete = (path: Path, out: Op[]): void => { - if (path.length === 0) throw new TypeError("the tracked root cannot be deleted"); - out.push(["d", [...path] as unknown as NonEmptyPath]); -}; - -const diffString = (before: string, after: string, path: Path, scan: number, out: Op[]): void => { - if (before === after) return; - if (path.length === 0) { - emitSet(path, after, out); - return; - } - const at = [...path] as unknown as NonEmptyPath; - // NOT `after.startsWith(before)`. `after` is usually a cons string — the - // producer just did `s += chunk` — and V8's startsWith walks a cons char by - // char. `slice(...) === before` flattens once and compares with memcmp. - // Measured on a 200 KB string growing by 8 bytes per flush: 845 us -> 42 us. - if (after.length > before.length && after.slice(0, before.length) === before) { - out.push(["a", at, after.slice(before.length)]); - return; - } - const shared = overlap(before, after, scan); - if (shared === 0) { - out.push(["s", at, after]); - return; - } - out.push(["t", at, before.length - shared]); - if (after.length > shared) out.push(["a", at, after.slice(shared)]); -}; - -const diffValue = (before: MaybeJson, after: MaybeJson, path: Path, scan: number, out: Op[]): void => { - if (before === MISSING) { - if (after !== MISSING) emitSet(path, after, out); - return; - } - if (after === MISSING) { - emitDelete(path, out); - return; - } - if (before === after) return; - if (typeof before === "string" && typeof after === "string") { - diffString(before, after, path, scan, out); - return; - } - if (Array.isArray(before) && Array.isArray(after)) { - diffArray(before, after, path, scan, out); - return; - } - if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) { - diffObject(before as Record, after as Record, path, scan, out); - return; - } - emitSet(path, after, out); -}; - -function diffObject( - before: Record, - after: Record, - path: Path, - scan: number, - out: Op[], -): void { - if ([...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) { - emitSet(path, after, out); - return; - } - for (const key of Object.keys(after)) { - diffValue(Object.hasOwn(before, key) ? before[key]! : MISSING, after[key]!, [...path, key], scan, out); - } - for (const key of Object.keys(before)) { - if (!Object.hasOwn(after, key)) emitDelete([...path, key], out); - } -} - -function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: number, out: Op[]): void { - if (before.length === after.length) { - for (let index = 0; index < after.length; index++) { - diffValue(before[index]!, after[index]!, [...path, index], scan, out); - } - return; - } - - let prefix = 0; - while (prefix < before.length && prefix < after.length && jsonEqual(before[prefix]!, after[prefix]!)) prefix++; - let suffix = 0; - while ( - suffix < before.length - prefix && - suffix < after.length - prefix && - jsonEqual(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!) - ) { - suffix++; - } - const shorter = Math.min(before.length, after.length); - if (prefix + suffix === shorter) { - const remove = before.length - prefix - suffix; - const items = after.slice(prefix, after.length - suffix); - if (prefix === 0 && remove === before.length) emitSet(path, after, out); - else out.push(["p", [...path], prefix, remove, cloneJson(items)]); - return; - } - - // Structural movement combined with retained-index edits has no unique - // alignment. Preserve the retained index deltas and express only the tail - // length change structurally. It may be broader than the producer's intent, - // but never degrades those edits to a whole-array replacement. - for (let index = 0; index < shorter; index++) { - diffValue(before[index]!, after[index]!, [...path, index], scan, out); - } - if (after.length > before.length) { - out.push(["p", [...path], before.length, 0, cloneJson(after.slice(before.length))]); - } else if (before.length > after.length) { - if (after.length === 0) emitSet(path, after, out); - else out.push(["p", [...path], after.length, before.length - after.length, []]); - } -} - -export function track(root: T, options: TrackerOptions = {}): Tracker { - const scan = options.maxOverlapScan ?? 65_536; - // Mutations are recorded directly. The active trie supports coalescing while - // retired child generations preserve dominance across array reindexing. - type Slot = { - op: Op; - dead: boolean; - order: number; - index: number; - str?: { anchor: string; value: string }; - }; - type LogNode = { - slots?: Slot[]; - kids?: Map; - retiredKids?: Map[]; - lastOrder?: number; - }; - - let log: (Slot | undefined)[] = []; - let trie: LogNode = {}; - let nextOrder = 0; - let tombstones = 0; - let liveSlots = 0; - let nodeCount = 1; - let lastAddedSlot: Slot | undefined; - let hasPending = false; - let forceBase = true; - - const clearPending = (): void => { - log = []; - trie = {}; - nextOrder = 0; - tombstones = 0; - liveSlots = 0; - nodeCount = 1; - lastAddedSlot = undefined; - hasPending = false; - }; - - const logNode = (path: Path): LogNode => { - let at = trie; - for (const segment of path) { - if (!at.kids) at.kids = new Map(); - let next = at.kids.get(segment); - if (next === undefined) { - next = {}; - at.kids.set(segment, next); - nodeCount++; - } - at = next; - } - return at; - }; - - const findLogNode = (path: Path): LogNode | undefined => { - let at = trie; - for (const segment of path) { - const next = at.kids?.get(segment); - if (next === undefined) return undefined; - at = next; - } - return at; - }; - - const compactLog = (): void => { - if (tombstones < 1_024 || tombstones * 2 < log.length) return; - const compacted: Slot[] = []; - for (const slot of log) { - if (slot === undefined) continue; - slot.index = compacted.length; - compacted.push(slot); - } - log = compacted; - tombstones = 0; - }; - - const killSlot = (slot: Slot): void => { - if (slot.dead) return; - slot.dead = true; - liveSlots--; - if (log[slot.index] === slot) { - log[slot.index] = undefined; - tombstones++; - } - }; - - const liveSlot = (at: LogNode): Slot | undefined => { - const slots = at.slots; - if (slots === undefined) return undefined; - while (slots.length > 0 && slots[slots.length - 1]!.dead) slots.pop(); - if (slots.length === 0) { - at.slots = undefined; - return undefined; - } - return slots[slots.length - 1]; - }; - - const killHere = (at: LogNode): void => { - if (at.slots === undefined) return; - for (const slot of at.slots) killSlot(slot); - at.slots = undefined; - }; - - const addSlot = (at: LogNode, slot: Slot): void => { - compactLog(); - slot.order = nextOrder++; - slot.index = log.length; - at.lastOrder = slot.order; - if (at.slots === undefined) at.slots = []; - at.slots.push(slot); - log.push(slot); - liveSlots++; - lastAddedSlot = slot; - }; - - const collapsePending = (): void => { - // Long mutation windows can otherwise retain operations and retired path - // generations indefinitely. Fall back to a complete snapshot once either - // history exceeds a bounded coalescing window. Payload bytes and transient - // allocation remain workload-dependent. - if (forceBase || (liveSlots <= 4_096 && nodeCount <= 4_096)) return; - const value = cloneJson(root as unknown as JsonValue); - clearPending(); - hasPending = true; - addSlot(trie, { op: ["r", value], dead: false, order: 0, index: 0 }); - }; - - const killSubtree = (at: LogNode): void => { - killHere(at); - if (at.kids !== undefined) { - for (const child of at.kids.values()) killSubtree(child); - at.kids = undefined; - } - if (at.retiredKids !== undefined) { - for (const generation of at.retiredKids) { - for (const child of generation.values()) killSubtree(child); - } - at.retiredKids = undefined; - } - }; - - const retireKids = (at: LogNode): void => { - if (at.kids === undefined) return; - if (at.retiredKids === undefined) at.retiredKids = []; - at.retiredKids.push(at.kids); - at.kids = undefined; - }; - - // Deepest live ancestor op carrying a payload we can fold a later write into. - // `s`/`r` carry the whole subtree; `p` carries the items it inserted, so a - // write to one of those indices belongs inside the payload rather than after it. - const foldTarget = (path: Path): { slot: Slot; rest: Path; item?: number } | undefined => { - let at = trie; - let found: { slot: Slot; depth: number; item?: number } | undefined; - let ancestorMax = -1; - for (let depth = 0; depth < path.length; depth++) { - if (found !== undefined && at.lastOrder !== undefined && at.lastOrder > found.slot.order) { - found = undefined; - } - const slot = liveSlot(at); - // A fold is sound only if nothing has been recorded at this path or above it - // since: a later op there (a splice on the same array, a replacement of an - // ancestor) would have to apply after this write, not before it. Writes to - // other branches are irrelevant, which is why this is not "the most recent op". - if (slot !== undefined && slot.order >= ancestorMax && at.lastOrder === slot.order) { - if (slot.op[0] === "s" || slot.op[0] === "r") found = { slot, depth }; - else if (slot.op[0] === "p") { - const index = path[depth]; - const items = slot.op[4] as JsonValue[]; - if (typeof index === "number" && index >= slot.op[2] && index < slot.op[2] + items.length) { - found = { slot, depth: depth + 1, item: index - slot.op[2] }; - } - } - } - if (at.lastOrder !== undefined && at.lastOrder > ancestorMax) ancestorMax = at.lastOrder; - const next = at.kids?.get(path[depth]!); - if (next === undefined) break; - at = next; - } - if (found === undefined) return undefined; - return { slot: found.slot, rest: path.slice(found.depth), item: found.item }; - }; - - const foldInto = (container: JsonValue, rest: Path, op: Op): boolean => { - if (rest.length === 0) return false; - let target: JsonValue = container; - for (let index = 0; index < rest.length - 1; index++) { - if (!isObj(target)) return false; - target = (target as Record)[rest[index]!]!; - } - if (!isObj(target)) return false; - const key = rest[rest.length - 1]!; - const holder = target as Record; - const write = (value: JsonValue): void => { - Object.defineProperty(holder, key, { value, writable: true, enumerable: true, configurable: true }); - }; - switch (op[0]) { - case "s": - if (key === "__proto__") return false; - write(cloneJson(op[2])); - return true; - case "d": - delete holder[key]; - return true; - case "a": { - const value = holder[key]; - if (typeof value !== "string") return false; - write(value + op[2]); - return true; - } - case "t": { - const value = holder[key]; - if (typeof value !== "string") return false; - write(value.slice(op[2])); - return true; - } - case "p": { - const value = holder[key]; - if (!Array.isArray(value)) return false; - spliceItems(value, op[2], op[3], cloneJson(op[4] as JsonValue[])); - return true; - } - default: - return false; - } - }; - - const recordString = (path: Path, previous: string, value: string): void => { - if (forceBase) return; - hasPending = true; - // a string inside a pending payload belongs in that payload, as for any write - const fold = foldTarget(path); - if (fold !== undefined) { - const op: Op = ["s", path as unknown as NonEmptyPath, value]; - if (fold.item !== undefined) { - const items = fold.slot.op[4] as JsonValue[]; - if (fold.rest.length === 0) { - items[fold.item] = value; - return; - } - if (foldInto(items[fold.item]!, fold.rest, op)) return; - } else { - const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2]; - if (foldInto(payload as JsonValue, fold.rest, op)) return; - } - } - const at = logNode(path); - const live = liveSlot(at); - if (live?.str !== undefined) { - live.str.value = value; - return; - } - if (live !== undefined) { - // a pending set or delete at this path already replaced the value; keep that - // op and carry the new value in it rather than anchoring to it - if (live.op[0] === "s" || live.op[0] === "r") { - live.op = live.op[0] === "r" ? ["r", value] : ["s", live.op[1], value]; - return; - } - if (live.op[0] === "d") { - killHere(at); - addSlot(at, { - op: ["s", path as unknown as NonEmptyPath, value], - dead: false, - order: 0, - index: 0, - }); - return; - } - // a truncate/append pair from an earlier string diff: both must go - killHere(at); - } - killSubtree(at); - addSlot(at, { - op: ["s", path as unknown as NonEmptyPath, value], - dead: false, - order: 0, - index: 0, - str: { anchor: previous, value }, - }); - }; - - const record = (op: Op): void => { - if (forceBase) return; - hasPending = true; - const path = (op[0] === "r" ? [] : (op[1] as Path)) as Path; - const existing = findLogNode(path); - const anchored = existing === undefined ? undefined : liveSlot(existing); - if (anchored?.str !== undefined) { - switch (op[0]) { - case "a": - anchored.str.value += op[2]; - return; - case "t": - anchored.str.value = anchored.str.value.slice(op[2]); - return; - case "s": - if (typeof op[2] === "string") { - anchored.str.value = op[2]; - return; - } - } - if (existing !== undefined) killSubtree(existing); - } else if (existing !== undefined && (op[0] === "s" || op[0] === "d" || op[0] === "r")) { - // A replacement absorbed into an ancestor payload must still invalidate - // operations already recorded at and below its destination. - killSubtree(existing); - } - - if (path.length > 0) { - const fold = foldTarget(path); - if (fold !== undefined) { - if (fold.item !== undefined) { - const items = fold.slot.op[4] as JsonValue[]; - if (fold.rest.length === 0) { - if (op[0] === "s") { - items[fold.item] = cloneJson(op[2]); - return; - } - } else if (foldInto(items[fold.item]!, fold.rest, op)) return; - } else { - const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2]; - if (foldInto(payload as JsonValue, fold.rest, op)) return; - } - } - } - - const at = logNode(path); - const live = liveSlot(at); - if (live !== undefined) { - const previous = live.op; - if (op[0] === "a" && previous[0] === "a") { - live.op = ["a", previous[1], previous[2] + op[2]]; - return; - } - if (op[0] === "a" && (previous[0] === "s" || previous[0] === "r")) { - const value = previous[0] === "r" ? previous[1] : previous[2]; - if (typeof value === "string") { - live.op = previous[0] === "r" ? ["r", value + op[2]] : ["s", previous[1], value + op[2]]; - return; - } - } - if (op[0] === "t" && (previous[0] === "s" || previous[0] === "r")) { - const value = previous[0] === "r" ? previous[1] : previous[2]; - if (typeof value === "string") { - const cut = value.slice(op[2]); - live.op = previous[0] === "r" ? ["r", cut] : ["s", previous[1], cut]; - return; - } - } - if (op[0] === "p" && previous[0] === "p") { - const previousItems = previous[4] as JsonValue[]; - // These rewrites are sound only for adjacent recorded operations. A - // tombstoned operation remains a barrier through lastAddedSlot. - if ( - previous[3] === 0 && - op[3] === 0 && - previous[2] + previousItems.length === op[2] && - lastAddedSlot === live - ) { - const items = op[4] as JsonValue[]; - for (let index = 0; index < items.length; index++) previousItems.push(items[index]!); - return; - } - if ( - previous[3] === 0 && - lastAddedSlot === live && - op[2] >= previous[2] && - op[2] + op[3] <= previous[2] + previousItems.length - ) { - spliceItems(previousItems, op[2] - previous[2], op[3], op[4] as JsonValue[]); - if (previousItems.length === 0 && previous[3] === 0) killSlot(live); - return; - } - if ( - op[3] > 0 && - (op[4] as JsonValue[]).length === 0 && - previousItems.length > 0 && - lastAddedSlot === live - ) { - const from = op[2] - previous[2]; - if (from >= 0 && from + op[3] === previousItems.length) { - previousItems.length = from; - if (previousItems.length === 0 && previous[3] === 0) killSlot(live); - return; - } - } - } - if (op[0] === "s" || op[0] === "d" || op[0] === "r") killHere(at); - } - if (op[0] === "s" || op[0] === "r" || op[0] === "d") { - // Replacements dominate all earlier descendants, including generations - // detached by array splices. - killSubtree(at); - } else if (op[0] === "p") { - // Splices preserve earlier writes but form a barrier for later folding. - retireKids(at); - } - addSlot(at, { op, dead: false, order: 0, index: 0 }); - }; - - // Local diff, used when a whole container is assigned: keeps op quality - // without a baseline by comparing the outgoing value with the incoming one at - // the moment of the write. String leaves go through recordString so every - // string path keeps a single anchored slot; otherwise a later write to the - // same string would have to supersede ops whose starting value it no longer - // knows. - const diffInto = (before: JsonValue, after: JsonValue, at: Seg[]): void => { - if (forceBase) return; - hasPending = true; - if (before === after) return; - if (typeof before === "string" && typeof after === "string") { - recordString(at.slice() as Path, before, after); - return; - } - if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) { - for (let index = 0; index < after.length; index++) { - at.push(index); - diffInto(before[index]!, after[index]!, at); - at.pop(); - } - return; - } - if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) { - const beforeObject = before as Record; - const afterObject = after as Record; - const beforeKeys = Object.keys(beforeObject); - const afterKeys = Object.keys(afterObject); - if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) { - record(["s", at.slice() as unknown as NonEmptyPath, cloneJson(after)]); - return; - } - for (const key of afterKeys) { - at.push(key); - if (Object.hasOwn(beforeObject, key)) diffInto(beforeObject[key]!, afterObject[key]!, at); - else record(["s", at.slice() as unknown as NonEmptyPath, cloneJson(afterObject[key]!)]); - at.pop(); - } - for (const key of beforeKeys) { - if (!Object.hasOwn(afterObject, key)) record(["d", [...at, key] as unknown as NonEmptyPath]); - } - return; - } - // arrays of differing length, and everything else: chord's own diff - const out: Op[] = []; - diffValue(before, after, at as Path, scan, out); - for (const op of out) record(op); - }; - - const guard = (segment: Seg | symbol): Seg => { - if (typeof segment === "symbol") throw new UnsafePathError(String(segment)); - if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) throw new UnsafePathError(segment); - return segment; - }; - - const integer = (value: unknown): number => { - const number = Number(value); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; - }; - - const spliceRange = (length: number, args: readonly unknown[]): { index: number; remove: number } => { - const rawStart = args.length === 0 ? 0 : integer(args[0]); - const index = rawStart < 0 ? Math.max(0, length + rawStart) : Math.min(rawStart, length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - index - : Math.max(0, Math.min(integer(args[1]), length - index)); - return { index, remove }; - }; - - // Paths are walked from placement cells rather than baked into proxies. A held - // descendant keeps its cells and their owner entries alive, so an ancestor can - // be re-created after GC without losing array-renumbering metadata. Entries keep - // only weak references to public proxies; parent caches keep only weak cells. - let shape = 0; - type Entry = { - target: object; - cells: Set; - fallback: Cell; - blocked: Seg | undefined; - proxy: WeakRef | undefined; - childProxies: Map> | undefined; - childCells: Set> | undefined; - }; - type Cell = { - parent: Cell | undefined; - owner: Entry | undefined; - entry: Entry | undefined; - target: object | undefined; - cleanup?: CellCleanup; - seg: Seg; - dead: boolean; - at?: number; - cached?: Path; - }; - type EntrySlot = { ref: WeakRef; token: object }; - type EntryCleanup = { target: WeakRef; token: object }; - type CellCleanup = { owner: WeakRef; key: PropertyKey | undefined; ref: WeakRef }; - - // Ordinary tree entries are weak values. Explicit aliases are sparse and stay - // strong while their raw weak key is alive so alias locations survive a period - // in which no public proxy exists. - const entries = new WeakMap(); - const aliases = new WeakMap(); - // A live public proxy retains its entry through this ephemeron. Raw proxy RHS - // values can therefore be unwrapped before they are written into plain targets. - const proxyEntries = new WeakMap(); - // WeakRef targets already stay alive through the current JavaScript job. These - // strong job-local caches avoid repeated deref bookkeeping without extending - // that lifetime; one microtask drops both maps before the next job. - let jobCells = new WeakMap, Cell>(); - let jobProxies = new WeakMap(); - let jobCleanupScheduled = false; - const scheduleJobCleanup = (): void => { - if (jobCleanupScheduled) return; - jobCleanupScheduled = true; - queueMicrotask(() => { - jobCells = new WeakMap(); - jobProxies = new WeakMap(); - jobCleanupScheduled = false; - }); - }; - const keepCellForJob = (ref: WeakRef, cell: Cell): void => { - jobCells.set(ref, cell); - scheduleJobCleanup(); - }; - const derefCell = (ref: WeakRef): Cell | undefined => { - const cached = jobCells.get(ref); - if (cached !== undefined) return cached; - const cell = ref.deref(); - if (cell !== undefined) keepCellForJob(ref, cell); - return cell; - }; - const entryFinalizer = new FinalizationRegistry(({ target, token }) => { - const raw = target.deref(); - if (raw !== undefined && entries.get(raw)?.token === token) entries.delete(raw); - }); - const cellFinalizer = new FinalizationRegistry(({ owner, key, ref }) => { - const entry = owner.deref(); - if (entry === undefined) return; - if (key !== undefined && entry.childProxies?.get(key) === ref) entry.childProxies.delete(key); - entry.childCells?.delete(ref); - }); - - // Almost no document ever puts one object at two positions. Until one does, - // every write has exactly one path, so the per-write cell walk is skipped. - let aliased = false; - const isDetached = (cell: Cell): boolean => { - for (let c: Cell | undefined = cell; c !== undefined; c = c.parent) if (c.dead) return true; - return false; - }; - const pathOf = (cell: Cell): Path => { - if (cell.at === shape && cell.cached !== undefined) return cell.cached; - const out: Seg[] = []; - for (let c: Cell | undefined = cell; c !== undefined && c.parent !== undefined; c = c.parent) out.push(c.seg); - out.reverse(); - cell.at = shape; - cell.cached = out as Path; - return out as Path; - }; - const liveCells = (entry: Entry): Cell[] => { - const out: Cell[] = []; - for (const cell of entry.cells) if (!isDetached(cell)) out.push(cell); - return out; - }; - const primary = (entry: Entry): Cell => (aliased ? (liveCells(entry)[0] ?? entry.fallback) : entry.fallback); - const pathNow = (entry: Entry): Path => pathOf(primary(entry)); - const findEntry = (target: object): Entry | undefined => { - const alias = aliases.get(target); - if (alias !== undefined) return alias; - const slot = entries.get(target); - const entry = slot?.ref.deref(); - if (slot !== undefined && entry === undefined) entries.delete(target); - return entry; - }; - const indexEntry = (entry: Entry): void => { - const token = {}; - entries.set(entry.target, { ref: new WeakRef(entry), token }); - entryFinalizer.register(entry, { target: new WeakRef(entry.target), token }); - }; - const addPlacement = (entry: Entry, cell: Cell): void => { - if (entry.cells.has(cell)) return; - let hasLive = false; - for (const existing of entry.cells) { - if (!isDetached(existing)) { - hasLive = true; - break; - } - } - cell.entry = entry; - cell.target = entry.target; - entry.cells.add(cell); - if (!hasLive) { - const previous = entry.fallback; - entry.fallback = cell; - if (previous !== cell) { - shape++; - // Reattaching a held container moves every known immediate child to - // its new placement. Array registrations survive cache clears. - const children = new Set(); - for (const ref of entry.childProxies?.values() ?? []) { - const child = derefCell(ref); - if (child !== undefined) children.add(child); - } - for (const ref of entry.childCells ?? []) { - const child = derefCell(ref); - if (child !== undefined) children.add(child); - } - for (const child of children) { - if (!child.dead && child.parent === previous) child.parent = cell; - } - } - } else if (entry.blocked === undefined) { - aliased = true; - aliases.set(entry.target, entry); - } - }; - const cachePlacement = (owner: Entry, key: PropertyKey, cell: Cell): void => { - let cleanup = cell.cleanup; - if (cleanup === undefined) { - cleanup = { owner: new WeakRef(owner), key: undefined, ref: new WeakRef(cell) }; - cell.cleanup = cleanup; - cellFinalizer.register(cell, cleanup); - } - keepCellForJob(cleanup.ref, cell); - if (cleanup.key !== undefined && cleanup.key !== key && owner.childProxies?.get(cleanup.key) === cleanup.ref) { - owner.childProxies.delete(cleanup.key); - } - if (owner.childProxies === undefined) owner.childProxies = new Map(); - owner.childProxies.set(key, cleanup.ref); - cleanup.key = key; - if (Array.isArray(owner.target)) { - if (owner.childCells === undefined) owner.childCells = new Set(); - owner.childCells.add(cleanup.ref); - } - }; - const findPlacement = ( - owner: Entry, - parent: Cell, - segment: Seg, - target: object, - blocked: Seg | undefined, - ): Cell | undefined => { - if (blocked === undefined) { - const known = findEntry(target); - if (known !== undefined) { - for (const cell of known.cells) { - if (!cell.dead && cell.owner === owner && cell.parent === parent && cell.seg === segment) return cell; - } - } - return undefined; - } - // Blocked views are intentionally absent from the ordinary raw-entry index. - for (const ref of owner.childCells ?? []) { - const cell = derefCell(ref); - if ( - cell !== undefined && - !cell.dead && - cell.target === target && - cell.entry?.blocked === blocked && - cell.parent === parent && - cell.seg === segment - ) { - return cell; - } - } - return undefined; - }; - const unwrap = (value: unknown): unknown => (isObj(value) ? (proxyEntries.get(value)?.target ?? value) : value); - const adoptItems = (values: readonly unknown[]): JsonValue[] => values.map(unwrap) as JsonValue[]; - - // Every operation is emitted once per live location of an explicitly aliased - // object. Alias cells are independent of public proxy lifetimes. - const emit = (entry: Entry, op: Op): void => { - record(op); - if (!aliased) return; - const live = liveCells(entry); - if (live.length <= 1) return; - const base = pathNow(entry); - const head = primary(entry); - for (const cell of live) { - if (cell === head || op[0] === "r") continue; - const rest = (op[1] as Seg[]).slice(base.length); - const cloned = [...op] as unknown as Op; - (cloned as unknown as Seg[][])[1] = [...pathOf(cell), ...rest]; - record(cloned); - } - }; - - // Structural mutation updates every still-live placement cell, including cells - // retained only by held descendants. Dead weak registrations are pruned lazily; - // finalizers keep idle live parents from accumulating them indefinitely. - const renumber = ( - entry: Entry, - key: string, - before: number, - after: number, - spliceAt: number, - spliceRemove: number, - spliceInsert: number, - ): void => { - shape++; - const childCells = entry.childCells; - if (key === "push" || !Array.isArray(entry.target) || childCells === undefined || childCells.size === 0) return; - let index: number; - let remove = 0; - let insert = 0; - if (key === "pop") { - index = before - 1; - remove = before > 0 ? 1 : 0; - } else if (key === "shift") { - index = 0; - remove = before > 0 ? 1 : 0; - } else if (key === "unshift") { - index = 0; - insert = after - before; - } else if (key === "splice") { - index = spliceAt; - remove = spliceRemove; - insert = spliceInsert; - } else { - for (const ref of [...childCells]) { - const cell = derefCell(ref); - if (cell === undefined) { - childCells.delete(ref); - continue; - } - const at = (entry.target as unknown[]).indexOf(cell.target); - if (at < 0) { - cell.dead = true; - childCells.delete(ref); - } else cell.seg = at; - } - entry.childProxies?.clear(); - return; - } - const delta = insert - remove; - for (const ref of [...childCells]) { - const cell = derefCell(ref); - if (cell === undefined) { - childCells.delete(ref); - continue; - } - const at = cell.seg; - if (typeof at !== "number") continue; - if (at >= index && at < index + remove) { - cell.dead = true; - childCells.delete(ref); - } else if (at >= index + remove) cell.seg = at + delta; - } - entry.childProxies?.clear(); - }; - - const wrap = (object: V, cell: Cell, blockedSegment?: Seg): V => { - const placed = cell.entry; - if (placed !== undefined && placed.target === object && placed.blocked === blockedSegment) { - return proxyFor(placed) as V; - } - const existing = blockedSegment === undefined ? findEntry(object) : undefined; - if (existing !== undefined) { - addPlacement(existing, cell); - return proxyFor(existing) as V; - } - const entry: Entry = { - target: object, - cells: new Set(), - fallback: cell, - blocked: blockedSegment, - proxy: undefined, - childProxies: undefined, - childCells: undefined, - }; - addPlacement(entry, cell); - if (blockedSegment === undefined) indexEntry(entry); - return proxyFor(entry) as V; - }; - - type EntryHandler = ProxyHandler & { entry: Entry }; - const handlerPrototype: ProxyHandler = { - get(this: EntryHandler, target, key, receiver) { - const entry = this.entry; - if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) { - return (...args: unknown[]) => { - if (entry.blocked !== undefined) throw new UnsafePathError(entry.blocked); - const detached = aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback); - if (detached) { - return Reflect.apply(Array.prototype[key as "push"], target, args.map(unwrap)); - } - const before = target.length; - let spliceAt = -1; - let spliceRemove = 0; - let spliceInsert = 0; - let insertAt = -1; - let insertCount = 0; - let result: unknown; - switch (key) { - case "push": { - const items = adoptItems(args); - if (items.length > 0) emit(entry, ["p", [...pathNow(entry)], before, 0, cloneJson(items)]); - insertAt = before; - insertCount = items.length; - spliceItems(target, before, 0, items); - result = target.length; - break; - } - case "unshift": { - const items = adoptItems(args); - if (items.length > 0) emit(entry, ["p", [...pathNow(entry)], 0, 0, cloneJson(items)]); - insertAt = 0; - insertCount = items.length; - spliceItems(target, 0, 0, items); - result = target.length; - break; - } - case "pop": - if (before > 0) emit(entry, ["p", [...pathNow(entry)], before - 1, 1, []]); - result = Reflect.apply(Array.prototype.pop, target, args); - break; - case "shift": - if (before > 0) emit(entry, ["p", [...pathNow(entry)], 0, 1, []]); - result = Reflect.apply(Array.prototype.shift, target, args); - break; - case "splice": { - const items = adoptItems(args.slice(2)); - const { index, remove } = spliceRange(before, args); - spliceAt = index; - spliceRemove = remove; - spliceInsert = items.length; - insertAt = index; - insertCount = items.length; - if (remove > 0 || items.length > 0) { - if (index === 0 && remove === before) { - if (pathNow(entry).length === 0) emit(entry, ["r", cloneJson(items as JsonValue)]); - else - emit(entry, [ - "s", - [...pathNow(entry)] as unknown as NonEmptyPath, - cloneJson(items as JsonValue), - ]); - } else emit(entry, ["p", [...pathNow(entry)], index, remove, cloneJson(items)]); - } - result = spliceItems(target, index, remove, items); - break; - } - default: { - result = Reflect.apply(Array.prototype[key as "sort"], target, args.map(unwrap)); - if (pathNow(entry).length === 0) emit(entry, ["r", cloneJson(target as unknown as JsonValue)]); - else - emit(entry, [ - "s", - [...pathNow(entry)] as unknown as NonEmptyPath, - cloneJson(target as unknown as JsonValue), - ]); - } - } - collapsePending(); - renumber(entry, key, before, target.length, spliceAt, spliceRemove, spliceInsert); - for (let index = insertAt; insertCount > 0 && index < insertAt + insertCount; index++) { - const item = target[index]; - if (!isObj(item)) continue; - const known = findEntry(item); - if (known === undefined || known.target === entry.target) continue; - const parent = primary(entry); - let seen = false; - for (const existingCell of known.cells) { - if (!existingCell.dead && existingCell.parent === parent && existingCell.seg === index) - seen = true; - } - if (!seen) { - const inserted: Cell = { - parent, - owner: entry, - entry: undefined, - target: item, - seg: index, - dead: false, - }; - addPlacement(known, inserted); - cachePlacement(entry, String(index), inserted); - } - } - return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? receiver : result; - }; - } - const value = Reflect.get(target, key, receiver); - if (!isObj(value)) return value; - const cachedRef = entry.childProxies?.get(key); - const cached = cachedRef === undefined ? undefined : derefCell(cachedRef); - if (cached !== undefined && cached.target === value && cached.entry !== undefined) { - return proxyFor(cached.entry); - } - if (cachedRef !== undefined && cached === undefined && entry.childProxies?.get(key) === cachedRef) { - entry.childProxies?.delete(key); - } - const rawSegment = norm(target, key); - let segment: Seg; - let childBlocked = entry.blocked; - if (entry.blocked !== undefined) { - if (typeof rawSegment === "symbol") throw new UnsafePathError(String(rawSegment)); - segment = rawSegment; - } else if (typeof rawSegment === "string" && RESERVED_SEGMENTS.has(rawSegment) && Object.hasOwn(target, key)) { - segment = rawSegment; - childBlocked = rawSegment; - } else segment = guard(rawSegment); - const parent = primary(entry); - const existingCell = findPlacement(entry, parent, segment, value, childBlocked); - if (existingCell?.entry !== undefined) { - cachePlacement(entry, key, existingCell); - return proxyFor(existingCell.entry); - } - const childCell: Cell = { - parent, - owner: entry, - entry: undefined, - target: value, - seg: segment, - dead: false, - }; - const child = wrap(value, childCell, childBlocked); - cachePlacement(entry, key, childCell); - return child; - }, - - set(this: EntryHandler, target, key, value) { - const entry = this.entry; - if (entry.blocked !== undefined) throw new UnsafePathError(entry.blocked); - const rawValue = unwrap(value); - if (aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback)) { - return Reflect.set(target, key, rawValue); - } - if (Array.isArray(target) && key === "length") { - const before = target.length; - const next = Number(rawValue); - if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) { - return Reflect.set(target, key, rawValue); - } - if (next < before) { - if (next === 0) { - if (pathNow(entry).length === 0) emit(entry, ["r", []]); - else emit(entry, ["s", [...pathNow(entry)] as unknown as NonEmptyPath, []]); - } else emit(entry, ["p", [...pathNow(entry)], next, before - next, []]); - Reflect.set(target, key, next); - renumber(entry, "splice", before, next, next, before - next, 0); - entry.childProxies?.clear(); - } else if (next > before) { - target.length = next; - target.fill(null, before); - const grown = new Array(next - before).fill(null) as JsonValue[]; - emit(entry, ["p", [...pathNow(entry)], before, 0, grown]); - } - collapsePending(); - return true; - } - - const segment = guard(norm(target, key)); - if (Array.isArray(target)) { - if (typeof segment !== "number") throw new UnsafePathError(segment); - if (segment > target.length) throw new UnsafePathError(segment); - } - const at = [...pathNow(entry), segment] as unknown as NonEmptyPath; - - if (rawValue === undefined) { - if (Array.isArray(target)) throw new TypeError("undefined would create a sparse array; use splice instead"); - if (Object.hasOwn(target, key)) emit(entry, ["d", at]); - entry.childProxies?.delete(key); - const deleted = Reflect.deleteProperty(target, key); - if (deleted) collapsePending(); - return deleted; - } - - const previous = (target as Record)[key]; - if (previous === rawValue) return true; - if (Array.isArray(target) && (segment as number) === target.length) { - emit(entry, ["p", [...pathNow(entry)], target.length, 0, [cloneJson(rawValue as JsonValue)]]); - } else if (isObj(previous) && isObj(rawValue)) { - diffInto(previous as JsonValue, rawValue as JsonValue, [...pathNow(entry), segment] as Seg[]); - } else if (typeof previous === "string" && typeof rawValue === "string") { - if (!aliased) recordString([...pathNow(entry), segment] as Path, previous, rawValue); - else - for (const cell of liveCells(entry)) - recordString([...pathOf(cell), segment] as Path, previous, rawValue); - } else { - emit(entry, ["s", at, cloneJson(rawValue as JsonValue)]); - } - entry.childProxies?.delete(key); - const updated = Reflect.set(target, key, rawValue); - if (updated) { - if (isObj(rawValue)) { - const known = findEntry(rawValue); - if (known !== undefined) { - const placement: Cell = { - parent: primary(entry), - owner: entry, - entry: undefined, - target: rawValue, - seg: segment, - dead: false, - }; - addPlacement(known, placement); - cachePlacement(entry, key, placement); - } - } - collapsePending(); - } - return updated; - }, - - deleteProperty(this: EntryHandler, target, key) { - const entry = this.entry; - if (entry.blocked !== undefined) throw new UnsafePathError(entry.blocked); - if (aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback)) { - return Reflect.deleteProperty(target, key); - } - const segment = guard(norm(target, key)); - if (Array.isArray(target)) { - if (typeof segment !== "number") throw new UnsafePathError(segment); - throw new TypeError("delete would create a sparse array; use splice instead"); - } - if (Object.hasOwn(target, key)) emit(entry, ["d", [...pathNow(entry), segment] as unknown as NonEmptyPath]); - entry.childProxies?.delete(key); - const deleted = Reflect.deleteProperty(target, key); - if (deleted) collapsePending(); - return deleted; - }, - - defineProperty() { - throw new TypeError("defineProperty is not supported on tracked state; use assignment"); - }, - setPrototypeOf() { - throw new TypeError("setPrototypeOf is not supported on tracked state"); - }, - preventExtensions() { - throw new TypeError("preventExtensions is not supported on tracked state"); - }, - }; - - function proxyFor(entry: Entry): object { - const cached = jobProxies.get(entry); - if (cached !== undefined) return cached; - const existing = entry.proxy?.deref(); - if (existing !== undefined) { - jobProxies.set(entry, existing); - scheduleJobCleanup(); - return existing; - } - const handler = Object.create(handlerPrototype) as EntryHandler; - handler.entry = entry; - const proxy = new Proxy(entry.target, handler); - entry.proxy = new WeakRef(proxy); - jobProxies.set(entry, proxy); - scheduleJobCleanup(); - proxyEntries.set(proxy, entry); - return proxy; - } - - const rootCell: Cell = { - parent: undefined, - owner: undefined, - entry: undefined, - target: root, - seg: "", - dead: false, - }; - let state = wrap(root, rootCell); - - return { - get state() { - return state; - }, - get target() { - return root; - }, - set state(next: T) { - const rawNext = unwrap(next) as T; - if (rawNext === root) { - clearPending(); - forceBase = true; - return; - } - clearPending(); - root = rawNext; - state = wrap(root, { - parent: undefined, - owner: undefined, - entry: undefined, - target: root, - seg: "", - dead: false, - }); - forceBase = true; - }, - rebase() { - clearPending(); - forceBase = true; - }, - get dirty() { - // conservative: true if anything was written since the last flush, even - // if the writes cancelled out - return forceBase || hasPending; - }, - discard() { - clearPending(); - }, - flush() { - if (forceBase) { - const value = cloneJson(root as unknown as JsonValue); - forceBase = false; - clearPending(); - return [["r", value]]; - } - if (!hasPending) return []; - const out: Op[] = []; - for (const slot of log) { - if (slot === undefined || slot.dead) continue; - if (slot.str !== undefined) { - diffValue(slot.str.anchor, slot.str.value, slot.op[1] as Path, scan, out); - continue; - } - out.push(slot.op); - } - clearPending(); - return out; - }, - }; -} +export { diffRevisions } from "./diff.ts"; +export type { Draft } from "./draft.ts"; +export { type Change, type Prepared, type Tracker, track } from "./tracker.ts"; // ─── Path safety ───────────────────────────────────────────────────────────── @@ -1727,8 +409,8 @@ function applyOps(target: T | undefined, ops: readonly Op[]): T { export function applyImmutable(target: T | undefined, ops: readonly Op[]): T { let root = target as unknown as JsonValue; for (const op of ops) { + assertValidOp(op); if (op[0] === "r") { - assertValidOp(op); root = op[1]; continue; } diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts new file mode 100644 index 000000000..d473fedec --- /dev/null +++ b/packages/chord/src/delta/tracker.ts @@ -0,0 +1,189 @@ +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"; + +export interface Prepared { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; +} + +export interface Change { + readonly state: Draft; + prepare(): Prepared; + abort(): void; +} + +export interface Tracker { + readonly value: T; + 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 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; + preparedMetadata.set(this, metadata); + Object.freeze(this); + } +} + +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 = preparedMetadata.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 #store = new JsonRevisionStore(); + readonly #owner = {}; + #value: T; + #revision = 0; + #changing = false; + + constructor(initial: T) { + this.#value = this.#store.import(initial); + } + + get value(): T { + return this.#value; + } + + 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(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) { + throw new Error("Prepared change is stale"); + } + metadata.status = "consumed"; + this.#value = prepared.value; + this.#revision += 1; + } + + prepareDraft(transaction: DraftTransaction): PreparedImpl { + try { + const produced = transaction.finish(); + return this.#prepare(this.#store.commit(produced.value, produced.owned)); + } 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(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); + } +} + +/** Create a tracker around an imported immutable JSON revision. */ +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 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); +} diff --git a/packages/chord/src/state/value.ts b/packages/chord/src/delta/value.ts similarity index 95% rename from packages/chord/src/state/value.ts rename to packages/chord/src/delta/value.ts index 1c0afd2f7..1746e61c2 100644 --- a/packages/chord/src/state/value.ts +++ b/packages/chord/src/delta/value.ts @@ -164,8 +164,16 @@ function assertDenseArray(value: readonly unknown[]): void { throw new TypeError("Replicated state arrays must be dense and contain only indexed entries"); } for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || value[index] === undefined) { - throw new TypeError("Replicated state arrays cannot contain holes or undefined entries"); + 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/index.ts b/packages/chord/src/index.ts index 511ce4245..fe156d642 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -10,6 +10,7 @@ export { defineService, replicatedState, } from "./api.ts"; +export type { Draft } from "./delta/index.ts"; export { isJsonValue } from "./json.ts"; export { isRemoteServiceErrorCode, @@ -46,8 +47,8 @@ export { type WireServiceProviderUpdate, type WireServiceSubscriptionSnapshot, } from "./services/wire.ts"; -export type { Draft } from "./state/draft.ts"; export type { + AttachedReplicatedState, Context, ContextKey, Facet, @@ -66,6 +67,10 @@ export type { RemoteServiceTransport, ReplicatedState, ReplicatedStateDelivery, + ReplicatedStateSource, + ReplicatedStateSourceAttachment, + ReplicatedStateSourceFrame, + ReplicatedStateSourceOptions, Service, ServiceCall, ServiceCatalogueEntry, diff --git a/packages/chord/src/services/provider.ts b/packages/chord/src/services/provider.ts index 705d41772..ad6ab86ca 100644 --- a/packages/chord/src/services/provider.ts +++ b/packages/chord/src/services/provider.ts @@ -437,11 +437,12 @@ export class RemoteServiceProvider { if (member.kind === "method") { members.push({ name, kind: "method" }); } else { + const snapshot = member.state.snapshot(); members.push({ name, kind: "state", - sequence: member.state.sequence, - ops: [["r", member.state.value as JsonValue]], + sequence: snapshot.sequence, + ops: [["r", snapshot.value as JsonValue]], }); } } diff --git a/packages/chord/src/services/state-internals.ts b/packages/chord/src/services/state-internals.ts index bf9bcf4c4..ed5c1ff0e 100644 --- a/packages/chord/src/services/state-internals.ts +++ b/packages/chord/src/services/state-internals.ts @@ -2,8 +2,8 @@ import type { Op } from "../delta/index.ts"; import type { Context } from "../types.ts"; export interface ReplicatedStateInternals { - readonly sequence: number; - readonly value: unknown; + /** Atomically capture the immutable value and its matching publication sequence. */ + snapshot(): { readonly value: unknown; readonly sequence: number }; subscribe(listener: (ops: readonly Op[], sequence: number, context: Context) => void): () => void; } diff --git a/packages/chord/src/services/state.ts b/packages/chord/src/services/state.ts index 3dd86b326..37ee7c77f 100644 --- a/packages/chord/src/services/state.ts +++ b/packages/chord/src/services/state.ts @@ -1,71 +1,53 @@ import { BACKGROUND_CONTEXT } from "../context/index.ts"; -import { applyImmutable, isBase, type Op } from "../delta/index.ts"; -import { diffRevisions } from "../state/diff.ts"; -import { produceWithMetadata } from "../state/draft.ts"; -import { JsonRevisionStore } from "../state/value.ts"; -import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts"; +import { applyImmutable, isBase, type Op, type Prepared, type Tracker, track } from "../delta/index.ts"; +import { JsonRevisionStore } from "../delta/value.ts"; +import type { + AttachedReplicatedState, + Context, + JsonValue, + MutableReplicatedState, + ReplicatedState, + ReplicatedStateDelivery, + ReplicatedStateSource, + ReplicatedStateSourceAttachment, + ReplicatedStateSourceFrame, + ReplicatedStateSourceOptions, +} from "../types.ts"; import { registerReplicatedStateInternals } from "./state-internals.ts"; +type StateListener = (value: T, context: Context, delivery: ReplicatedStateDelivery) => void; +type SourceListener = (ops: readonly Op[], sequence: number, context: Context) => void; + type Publication = { - value: T; - ops: readonly Op[]; - sequence: number; - context: Context; + readonly value: T; + readonly ops: readonly Op[]; + readonly sequence: number; + readonly context: Context; }; -export class MutableReplicatedStateImpl implements MutableReplicatedState { - readonly #listeners = new Map<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void, number>(); - readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>(); - readonly #store = new JsonRevisionStore(); +/** Maintains local publication order independently of how revisions are produced. */ +class ReplicatedStatePublisher { + readonly #listeners = new Map, number>(); + readonly #sourceListeners = new Set(); readonly #publications: Publication[] = []; #value: T; #sequence = 0; - #changing = false; #delivering = false; constructor(initial: T) { - this.#value = this.#store.import(initial); - const thisSource = this; - registerReplicatedStateInternals(this, { - get sequence() { - return thisSource.#sequence; - }, - get value() { - return thisSource.#value; - }, - subscribe: (listener) => { - thisSource.#sourceListeners.add(listener); - return () => thisSource.#sourceListeners.delete(listener); - }, - }); + this.#value = initial; } get value(): T { return this.#value; } - change(context: Context, mutate: Parameters["change"]>[1]): void { - if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback"); - this.#changing = true; - let next: T; - try { - const produced = produceWithMetadata(this.#value, mutate); - if (produced.value === this.#value) return; - next = this.#store.commit(produced.value, produced.owned); - } finally { - this.#changing = false; - } - this.#commit(next, context); + snapshot(): { readonly value: T; readonly sequence: number } { + return { value: this.#value, sequence: this.#sequence }; } - replace(context: Context, value: T): void { - if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback"); - this.#commit(this.#store.import(value), context); - } - - subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void { - const sequence = this.#sequence; - const value = this.#value; + subscribe(listener: StateListener): () => void { + const { value, sequence } = this.snapshot(); this.#listeners.set(listener, sequence); try { listener(value, serviceDeliveryContext(), { kind: "hydrate", sequence }); @@ -76,13 +58,18 @@ export class MutableReplicatedStateImpl implements MutableRepl return () => this.#listeners.delete(listener); } - #commit(next: T, context: Context): void { - const ops = diffRevisions(this.#value as unknown as JsonValue, next as unknown as JsonValue); - if (ops.length === 0) return; - this.#value = next; + subscribeSource(listener: SourceListener): () => void { + this.#sourceListeners.add(listener); + return () => this.#sourceListeners.delete(listener); + } + + /** Publish an already-prepared immutable revision and return isolated listener failures. */ + publish(value: T, ops: readonly Op[], context: Context): unknown[] { + this.#value = value; this.#sequence += 1; - this.#publications.push({ value: next, ops, sequence: this.#sequence, context }); - if (this.#delivering) return; + this.#publications.push({ value, ops, sequence: this.#sequence, context }); + if (this.#delivering) return []; + this.#delivering = true; const errors: unknown[] = []; try { @@ -111,14 +98,172 @@ export class MutableReplicatedStateImpl implements MutableRepl } finally { this.#delivering = false; } - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Replicated state listeners failed"); + return errors; + } +} + +export class MutableReplicatedStateImpl implements MutableReplicatedState { + readonly #tracker: Tracker; + readonly #publisher: ReplicatedStatePublisher; + #changing = false; + + constructor(initial: T) { + this.#tracker = track(initial); + this.#publisher = new ReplicatedStatePublisher(this.#tracker.value); + registerReplicatedStateInternals(this, { + snapshot: () => this.#publisher.snapshot(), + subscribe: (listener) => this.#publisher.subscribeSource(listener), + }); + } + + get value(): T { + return this.#tracker.value; + } + + change(context: Context, mutate: Parameters["change"]>[1]): void { + if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback"); + this.#changing = true; + let prepared: Prepared; + try { + const change = this.#tracker.beginChange(); + try { + const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state); + if (isPromiseLike(outcome)) { + void Promise.resolve(outcome).catch(() => undefined); + throw new TypeError("Replicated state change callbacks must be synchronous"); + } + prepared = change.prepare(); + } catch (error) { + change.abort(); + throw error; + } + } finally { + this.#changing = false; + } + + this.#tracker.adopt(prepared); + if (prepared.ops.length === 0) return; + throwCollectedErrors( + this.#publisher.publish(prepared.value, prepared.ops, context), + "Replicated state listeners failed", + ); + } + + replace(context: Context, value: T): void { + if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback"); + const prepared = this.#tracker.prepareReplace(value); + this.#tracker.adopt(prepared); + if (prepared.ops.length === 0) return; + throwCollectedErrors( + this.#publisher.publish(prepared.value, prepared.ops, context), + "Replicated state listeners failed", + ); + } + + subscribe(listener: StateListener): () => void { + return this.#publisher.subscribe(listener); + } +} + +class AttachedReplicatedStateImpl implements AttachedReplicatedState { + readonly #publisher: ReplicatedStatePublisher; + readonly #attachment: ReplicatedStateSourceAttachment; + readonly #reportError: (error: Error) => void; + #cursor: number; + #disposed = false; + + constructor(attachment: ReplicatedStateSourceAttachment, options: ReplicatedStateSourceOptions) { + const { snapshot } = attachment; + assertCursor(snapshot.cursor, "snapshot"); + this.#attachment = attachment; + this.#cursor = snapshot.cursor; + this.#publisher = new ReplicatedStatePublisher(snapshot.value); + this.#reportError = options.onError ?? reportErrorAsync; + registerReplicatedStateInternals(this, { + snapshot: () => this.#publisher.snapshot(), + subscribe: (listener) => this.#publisher.subscribeSource(listener), + }); + } + + get value(): T { + return this.#publisher.value; + } + + subscribe(listener: StateListener): () => void { + return this.#publisher.subscribe(listener); + } + + activate(): void { + this.#attachment.activate((frame) => this.#receive(frame)); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#attachment.dispose(); + } + + #receive(frame: ReplicatedStateSourceFrame): void { + if (this.#disposed) return; + try { + assertCursor(frame.cursor, "frame"); + const expected = this.#cursor + 1; + if (frame.cursor !== expected) { + throw new Error(`Replicated state source cursor has a gap: expected ${expected}, received ${frame.cursor}`); + } + this.#cursor = frame.cursor; + const errors = this.#publisher.publish(frame.value, frame.ops, frame.context); + if (errors.length === 1) this.#report(errors[0]); + else if (errors.length > 1) this.#report(new AggregateError(errors, "Replicated state listeners failed")); + } catch (error) { + this.#fail(toError(error)); + } + } + + #fail(error: Error): void { + if (this.#disposed) return; + this.#disposed = true; + try { + this.#attachment.dispose(); + } catch (disposeError) { + this.#report(new AggregateError([error, disposeError], "Replicated state source contract failed")); + return; + } + this.#report(error); + } + + #report(error: unknown): void { + try { + this.#reportError(toError(error)); + } catch (reportError) { + reportErrorAsync(toError(reportError)); + } + } +} + +/** Attach a publication-only replicated state to one authoritative immutable source stream. */ +export function attachReplicatedStateSource( + source: ReplicatedStateSource, + options: ReplicatedStateSourceOptions = {}, +): AttachedReplicatedState { + const attachment = source.attach(); + try { + const state = new AttachedReplicatedStateImpl(attachment, options); + state.activate(); + return state; + } catch (error) { + try { + attachment.dispose(); + } catch (disposeError) { + throw new AggregateError([error, disposeError], "Failed to attach replicated state source"); + } + throw error; } } /** A cold read-only state used by service consumers until a complete snapshot arrives. */ export class ReplicatedStateReplica implements ReplicatedState { - readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>(); + readonly #listeners = new Set>(); readonly #reportError: (error: Error) => void; readonly #store = new JsonRevisionStore(); #value: T | undefined; @@ -132,7 +277,7 @@ export class ReplicatedStateReplica implements return this.#value; } - subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void { + subscribe(listener: StateListener): () => void { this.#listeners.add(listener); if (this.#value !== undefined) { this.#deliver(listener, this.#value, serviceDeliveryContext(), { @@ -187,12 +332,7 @@ export class ReplicatedStateReplica implements for (const listener of this.#listeners) this.#deliver(listener, this.#value, context, delivery); } - #deliver( - listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void, - value: T, - context: Context, - delivery: ReplicatedStateDelivery, - ): void { + #deliver(listener: StateListener, value: T, context: Context, delivery: ReplicatedStateDelivery): void { try { listener(value, context, delivery); } catch (error) { @@ -207,6 +347,29 @@ export function serviceDeliveryContext(): Context { return BACKGROUND_CONTEXT; } +function assertCursor(cursor: number, kind: "snapshot" | "frame"): void { + if (!Number.isSafeInteger(cursor)) + throw new TypeError(`Replicated state source ${kind} cursor must be a safe integer`); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + ((typeof value === "object" && value !== null) || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function" + ); +} + +function throwCollectedErrors(errors: readonly unknown[], message: string): void { + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, message); +} + +function reportErrorAsync(error: Error): void { + queueMicrotask(() => { + throw error; + }); +} + function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/packages/chord/src/state/diff.ts b/packages/chord/src/state/diff.ts deleted file mode 100644 index 4cfcebd57..000000000 --- a/packages/chord/src/state/diff.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "../delta/index.ts"; -import type { JsonValue } from "../types.ts"; - -const DEFAULT_OVERLAP_SCAN = 65_536; -const MAX_DELTA_OPERATIONS = 4_096; - -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) operations.push(["r", value]); - else operations.push(["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; - 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, { indices: [index], used: 0 }); - else entry.indices.push(index); - } - const result = new Array(after.length); - for (let index = 0; index < after.length; index++) { - const entry = positions.get(after[index]!); - if (entry === undefined || entry.used === entry.indices.length) return undefined; - result[index] = entry.indices[entry.used++]!; - } - 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) { - operations.push(["a", path, after.slice(before.length)]); - return; - } - const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN); - if (shared === 0) { - operations.push(["s", path, after]); - return; - } - operations.push(["t", path, before.length - shared]); - if (after.length > shared) operations.push(["a", path, after.slice(shared)]); -}; - -const emitSameLengthShift = ( - before: readonly JsonValue[], - after: readonly JsonValue[], - path: Path, - operations: Op[], -): boolean => { - const length = before.length; - if (length < 2) return false; - let candidates = 0; - for (let removed = 1; removed < length && candidates < 16; removed++) { - if (before[removed] !== after[0]) continue; - candidates += 1; - let matches = true; - for (let index = removed; index < length; index++) { - if (before[index] !== after[index - removed]) { - matches = false; - break; - } - } - if (!matches) continue; - operations.push(["p", path, 0, removed, []]); - operations.push(["p", path, length - removed, 0, after.slice(length - removed)]); - return true; - } - candidates = 0; - for (let inserted = 1; inserted < length && candidates < 16; inserted++) { - if (after[inserted] !== before[0]) continue; - candidates += 1; - let matches = true; - for (let index = inserted; index < length; index++) { - if (after[index] !== before[index - inserted]) { - matches = false; - break; - } - } - if (!matches) continue; - operations.push(["p", path, 0, 0, after.slice(0, inserted)]); - operations.push(["p", path, length, inserted, []]); - return true; - } - return false; -}; - -const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => { - if (before.length === after.length) { - let mismatches = 0; - for (let index = 0; index < after.length; index++) { - if (before[index] !== after[index]) mismatches += 1; - } - if (mismatches === 0) return; - if (mismatches > 1) { - const order = permutation(before, after); - if (order !== undefined) { - operations.push(["m", path, order]); - return; - } - if (emitSameLengthShift(before, after, path, operations)) return; - } - for (let index = 0; index < after.length; index++) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - diffValue(before[index]!, after[index]!, [...path, index], operations); - } - return; - } - - let prefix = 0; - const shortest = Math.min(before.length, after.length); - while (prefix < shortest && equalJson(before[prefix]!, after[prefix]!)) prefix += 1; - let suffix = 0; - while ( - suffix < shortest - prefix && - equalJson(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!) - ) { - suffix += 1; - } - operations.push(["p", path, prefix, before.length - prefix - suffix, after.slice(prefix, after.length - suffix)]); -}; - -const diffObject = ( - before: Record, - after: Record, - path: Path, - operations: Op[], -): 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 (operations.length > MAX_DELTA_OPERATIONS) return; - if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations); - else emitSet([...path, key], after[key]!, operations); - } - for (const key of beforeKeys) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - if (!Object.hasOwn(after, key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); - } -}; - -const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[]): void => { - if (before === after || operations.length > MAX_DELTA_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); - return; - } - if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) { - diffObject(before, after, path, operations); - return; - } - emitSet(path, after, operations); -}; - -const jsonCost = (value: JsonValue): number => { - if (value === null) return 4; - if (typeof value === "string") return value.length + 2; - if (typeof value === "number") return String(value).length; - if (typeof value === "boolean") return value ? 4 : 5; - if (Array.isArray(value)) { - let cost = 2; - for (let index = 0; index < value.length; index++) cost += jsonCost(value[index]!) + (index === 0 ? 0 : 1); - return cost; - } - let cost = 2; - let index = 0; - for (const key of Object.keys(value)) { - cost += key.length + 3 + jsonCost(value[key]!) + (index++ === 0 ? 0 : 1); - } - return cost; -}; - -const pathCost = (path: Path): number => { - let cost = 2; - for (let index = 0; index < path.length; index++) { - const segment: Seg = path[index]!; - cost += (typeof segment === "string" ? segment.length + 2 : String(segment).length) + (index === 0 ? 0 : 1); - } - return cost; -}; - -const operationCost = (operation: Op): number => { - switch (operation[0]) { - case "r": - return 6 + jsonCost(operation[1]); - case "s": - return 7 + pathCost(operation[1]) + jsonCost(operation[2]); - case "d": - return 6 + pathCost(operation[1]); - case "a": - return 7 + pathCost(operation[1]) + operation[2].length + 2; - case "t": - return 7 + pathCost(operation[1]) + String(operation[2]).length; - case "p": - return ( - 10 + - pathCost(operation[1]) + - String(operation[2]).length + - String(operation[3]).length + - jsonCost(operation[4]) - ); - case "m": - return 7 + pathCost(operation[1]) + jsonCost(operation[2]); - } -}; - -/** Compute a compact operation batch from two immutable JSON revisions. */ -export function diffRevisions(before: JsonValue, after: JsonValue): Op[] { - const operations: Op[] = []; - diffValue(before, after, [], operations); - if (operations.length > MAX_DELTA_OPERATIONS) return [["r", after]]; - if (operations.length === 0 || operations[0]?.[0] === "r") return operations; - let deltaCost = 2; - for (const operation of operations) deltaCost += operationCost(operation) + 1; - if (deltaCost < 65_536) return operations; - const snapshotCost = jsonCost(after) + 6; - return deltaCost >= snapshotCost ? [["r", after]] : operations; -} diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts index 2f84a56eb..70810a8d5 100644 --- a/packages/chord/src/types.ts +++ b/packages/chord/src/types.ts @@ -1,6 +1,5 @@ -import type { Op } from "./delta/index.ts"; +import type { Draft, Op } from "./delta/index.ts"; import type { RemoteServiceProvider } from "./services/provider.ts"; -import type { Draft } from "./state/draft.ts"; export type { RemoteServiceError } from "./services/errors.ts"; export type { RemoteServiceProvider } from "./services/provider.ts"; @@ -59,6 +58,54 @@ export interface MutableReplicatedState extends ReplicatedStat replace(context: Context, value: T): void; } +/** One immutable authoritative revision committed after an attachment snapshot. */ +export interface ReplicatedStateSourceFrame { + /** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */ + readonly cursor: number; + /** The exact immutable value produced by this commit. */ + readonly value: T; + /** The exact immutable operation batch that produced `value` from the preceding source revision. */ + readonly ops: readonly Op[]; + readonly context: Context; +} + +export interface ReplicatedStateSourceAttachment { + /** Fixed immutable snapshot captured at the atomic attachment boundary. */ + readonly snapshot: { readonly value: T; readonly cursor: number }; + /** + * Install the sole listener and synchronously drain every buffered frame in source commit order. + * This method is single-use. After it begins, every new committed frame must also be delivered in order + * until disposal, including commits made reentrantly while a prior frame is being delivered. + */ + activate(listener: (frame: ReplicatedStateSourceFrame) => void): void; + /** Stop delivery and release source resources. Disposal must be idempotent. */ + dispose(): void; +} + +/** + * An authoritative immutable revision source. + * + * `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer + * every later committed frame. The snapshot must include every commit before that boundary; buffered frames must + * include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are + * immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them. + */ +export interface ReplicatedStateSource { + attach(): ReplicatedStateSourceAttachment; +} + +export interface ReplicatedStateSourceOptions { + /** Receives source-contract and publication-listener failures without throwing them into the source. */ + readonly onError?: (error: Error) => void; +} + +/** A synchronously hydrated publication-only state backed by one source attachment. */ +export interface AttachedReplicatedState extends ReplicatedState { + readonly value: T; + /** Idempotently release the source attachment. The last published value remains readable. */ + dispose(): void; +} + declare const SERVICE_TYPE: unique symbol; export type ServiceMode = "singleton" | "keyed"; diff --git a/packages/chord/test/delta-clone.test.ts b/packages/chord/test/delta-clone.test.ts index a6c022f6d..eed45ee77 100644 --- a/packages/chord/test/delta-clone.test.ts +++ b/packages/chord/test/delta-clone.test.ts @@ -1,76 +1,80 @@ import { describe, expect, it } from "vitest"; import { apply, track } from "../src/delta/index.ts"; -describe("delta payload cloning", () => { - it("deeply detaches mixed objects and arrays in a base snapshot", () => { +describe("tracker ownership", () => { + it("deeply detaches and freezes the imported revision", () => { const input = { point: { x: 3, y: 7, pressure: 0.1 }, rows: [{ values: [0, false, null, "text", { n: 1 }] }], }; - const producer = track(input); - const copy = apply(undefined, producer.flush()); - expect(copy).toEqual(input); - expect(copy).not.toBe(input); - expect(copy.point).not.toBe(input.point); - expect(copy.rows).not.toBe(input.rows); - expect(copy.rows[0]).not.toBe(input.rows[0]); - expect(copy.rows[0].values).not.toBe(input.rows[0].values); - expect(copy.rows[0].values[4]).not.toBe(input.rows[0].values[4]); - copy.point.pressure = 0.9; - (copy.rows[0].values[4] as { n: number }).n = 2; - expect(input.point.pressure).toBe(0.1); - expect(input.rows[0].values[4]).toEqual({ n: 1 }); - producer.state.point.x = 99; - expect(copy.point.x).toBe(3); + 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); }); - it("preserves null prototypes at the root and inside arrays", () => { + it("preserves null prototypes and expands aliases", () => { type Dictionary = { enabled: boolean; child: { n: number } }; const dictionary = Object.assign(Object.create(null) as Dictionary, { enabled: true, child: { n: 1 } }); - const rootCopy = apply(undefined, track(dictionary).flush()); - const nestedCopy = apply<{ rows: Dictionary[] }>(undefined, track({ rows: [dictionary] }).flush()); - expect(Object.getPrototypeOf(rootCopy)).toBeNull(); - expect(Object.getPrototypeOf(nestedCopy.rows[0])).toBeNull(); - expect(Object.getPrototypeOf(rootCopy.child)).toBe(Object.prototype); - expect(rootCopy.child).not.toBe(dictionary.child); - expect(nestedCopy.rows[0].child).not.toBe(dictionary.child); - rootCopy.child.n = 2; - expect(dictionary.child.n).toBe(1); - expect(nestedCopy.rows[0].child.n).toBe(1); - }); - - it("recursively clones own data properties shadowing inherited names", () => { - const input = { constructor: { n: 1 }, toString: [{ n: 2 }], hasOwnProperty: { n: 3 } }; - const copy = apply(undefined, track(input).flush()); - expect(Object.getPrototypeOf(copy)).toBe(Object.prototype); - for (const key of Object.keys(input)) expect(Object.hasOwn(copy, key)).toBe(true); - expect(copy.constructor).not.toBe(input.constructor); - expect(copy.toString[0]).not.toBe(input.toString[0]); - expect(copy.hasOwnProperty).not.toBe(input.hasOwnProperty); - copy.constructor.n = 9; - expect(input.constructor.n).toBe(1); - }); - - it("continues expanding shared input values into independent JSON payloads", () => { const shared = { nested: [{ n: 1 }] }; - const copy = apply<{ a: typeof shared; b: typeof shared }>(undefined, track({ a: shared, b: shared }).flush()); - expect(copy.a).toEqual(copy.b); - expect(copy.a).not.toBe(copy.b); - expect(copy.a.nested).not.toBe(copy.b.nested); - expect(copy.a.nested[0]).not.toBe(copy.b.nested[0]); + const tracker = track({ dictionary, left: shared, right: shared }); + expect(Object.getPrototypeOf(tracker.value.dictionary)).toBeNull(); + expect(tracker.value.dictionary).not.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("keeps published insertion payloads independent of subsequent producer edits", () => { - type Item = { point: { x: number; y: number; pressure: number }; tags: { name: string }[] }; - const producer = track({ items: [] as Item[] }); - const replica = apply(undefined, producer.flush()); - producer.state.items.push({ point: { x: 1, y: 2, pressure: 0.5 }, tags: [{ name: "original" }] }); - const batch = producer.flush(); - producer.state.items[0].point.x = 9; - producer.state.items[0].tags[0].name = "changed"; - const published = apply(replica, batch); - expect(published.items[0].point.x).toBe(1); - expect(published.items[0].tags[0].name).toBe("original"); - expect(published.items[0].point).not.toBe(producer.target.items[0].point); + it("copies assigned and inserted values immediately", () => { + const tracker = track({ rows: [] as { nested: { value: number } }[] }); + const assigned = { nested: { value: 1 } }; + const change = tracker.beginChange(); + change.state.rows.push(assigned); + assigned.nested.value = 9; + expect(change.state.rows[0]!.nested.value).toBe(1); + const prepared = change.prepare(); + expect(prepared.value.rows[0]!.nested.value).toBe(1); + 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-retention.test.ts b/packages/chord/test/delta-retention.test.ts index 92502271b..695cf36ac 100644 --- a/packages/chord/test/delta-retention.test.ts +++ b/packages/chord/test/delta-retention.test.ts @@ -2,21 +2,9 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -describe("delta proxy retention across GC jobs", () => { - it.each([ - "unheld", - "tracker-disposal", - "raw-root-rebase", - "array-subtree-reattach", - "held-descendant", - "held-identity", - "held-array-reacquire", - "alias-rewrap", - "detach-reinsert", - "root-array-windows", - "blocked-view", - ])( - "preserves semantics without retaining unused proxies: %s", +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, diff --git a/packages/chord/test/delta-retention.worker.ts b/packages/chord/test/delta-retention.worker.ts index 5691b31c2..084e9a254 100644 --- a/packages/chord/test/delta-retention.worker.ts +++ b/packages/chord/test/delta-retention.worker.ts @@ -1,223 +1,82 @@ -// Isolated --expose-gc scenarios; invoked by delta-retention.test.ts. import assert from "node:assert/strict"; import { setImmediate } from "node:timers/promises"; -import { apply, type Tracker, track } from "../src/delta/index.ts"; +import { type Tracker, track } from "../src/delta/index.ts"; -async function collect(): Promise { +const retainedSettledObjects: object[] = []; + +async function collect(ref: WeakRef): Promise { assert.ok(global.gc, "worker requires --expose-gc"); - // WeakRef keeps dereferenced/created targets alive through the current job. - await setImmediate(); - global.gc(); - await setImmediate(); - global.gc(); - await setImmediate(); -} -async function waitForCollection(ref: WeakRef): Promise { - for (let attempt = 0; attempt < 20; attempt++) { - await collect(); + for (let attempt = 0; attempt < 30; attempt++) { + await setImmediate(); + global.gc(); if (ref.deref() === undefined) return; } - assert.fail("an otherwise unreferenced child proxy is still retained"); -} -function sampleChild(t: Tracker<{ rows: { n: number }[] }>): WeakRef { - return new WeakRef(t.state.rows[0]); -} -function fillCaches(state: { n: number }[]): WeakRef { - let sum = 0; - for (let i = 0; i < state.length; i++) sum += state[i].n; - assert.ok(sum >= 0); - return new WeakRef(state[0]); -} -function select(t: Tracker<{ rows: { text: string }[]; selected: { text: string } | null }>): WeakRef { - const child = t.state.rows[0]; - t.state.selected = child; - return new WeakRef(child); + assert.fail("released change data is still retained"); } -// Separate frames prevent VM temporaries from pinning the objects under test. -function disposableTracker(): [WeakRef, WeakRef] { - const t = track({ rows: [{ n: 1 }] }); - t.flush(); - void t.state.rows[0].n; - return [new WeakRef(t.state), new WeakRef(t.target)]; +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 blockedView(t: Tracker<{ holder: { constructor: { n: number } }; safe: { n: number } }>): WeakRef { - const view = t.state.holder.constructor; - assert.equal(view.n, 1); - return new WeakRef(view); + +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]; -switch (scenario) { - case "tracker-disposal": { - const [proxy, raw] = disposableTracker(); - await waitForCollection(proxy); - await waitForCollection(raw); - break; +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}`); } - case "raw-root-rebase": { - const t = track([1]); - let replica = apply(undefined, t.flush()); - t.state = t.target; - replica = apply(replica, t.flush()); - t.state.push(2); - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - break; - } - case "array-subtree-reattach": { - const t = track({ rows: [[{ nested: { n: 1 } }, { nested: { n: 2 } }], []] }); - let replica = apply(undefined, t.flush()); - const held = t.state.rows[0]; - const leaf = held[0].nested; - held.reverse(); // clears the array's index cache, but not held descendants - replica = apply(replica, t.flush()); - t.state.rows.shift(); - replica = apply(replica, t.flush()); - await collect(); - t.state.rows.push(held); - replica = apply(replica, t.flush()); - await collect(); - leaf.n = 42; - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - break; - } - case "unheld": { - const t = track({ rows: [{ n: 1 }] }); - t.flush(); - const ref = sampleChild(t); - await waitForCollection(ref); - assert.equal(t.target.rows[0].n, 1); - let replica = apply(undefined, [["r", structuredClone(t.target)]]); - t.state.rows[0].n = 2; - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - break; - } - case "held-descendant": { - const t = track({ - rows: [ - { nested: { text: "a" }, values: [1] }, - { nested: { text: "b" }, values: [2] }, - ], - }); - let replica = apply(undefined, t.flush()); - const held = t.state.rows[1].nested; - const values = t.state.rows[1].values; - for (let i = 0; i < 8; i++) { - await collect(); - t.state.rows.unshift({ nested: { text: `head-${i}` }, values: [] }); - replica = apply(replica, t.flush()); - await collect(); - held.text += "x"; - values.push(i); - assert.equal(t.state.rows[i + 2].nested, held); - assert.equal(t.state.rows[i + 2].values, values); - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - } - t.state.rows.reverse(); - replica = apply(replica, t.flush()); - await collect(); - held.text += "reversed"; - values.push(99); - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - break; - } - case "held-array-reacquire": { - const t = track({ rows: [[1], [2]] }); - let replica = apply(undefined, t.flush()); - const held = t.state.rows[1]; - for (let i = 0; i < 5; i++) { - t.state.rows.unshift([]); - assert.equal(t.state.rows[i + 2], held); - held.push(10 + i); - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - await collect(); - } - break; - } - case "held-identity": { - const t = track({ rows: [{ n: 1 }, { n: 2 }] }); - let replica = apply(undefined, t.flush()); - const held = t.state.rows[1]; - for (let i = 0; i < 10; i++) { - await collect(); - assert.equal(t.state.rows[1], held); - held.n++; - replica = apply(replica, t.flush()); - assert.deepEqual(replica, t.target); - } - break; - } - case "alias-rewrap": { - const t = track<{ rows: { text: string }[]; selected: { text: string } | null }>({ - rows: [{ text: "a" }], - selected: null, - }); - let replica = apply(undefined, t.flush()); - const ref = select(t); - replica = apply(replica, t.flush()); - await waitForCollection(ref); - for (let i = 0; i < 5; i++) { - t.state.selected!.text += "x"; - replica = apply(replica, t.flush()); - assert.deepEqual(replica, JSON.parse(JSON.stringify(t.target))); - assert.equal(t.state.selected, t.state.rows[0]); - await collect(); - } - break; - } - case "detach-reinsert": { - const t = track({ rows: [{ nested: { n: 1 } }, { nested: { n: 2 } }] }); - let replica = apply(undefined, t.flush()); - const held = t.state.rows[1]; - t.state.rows.pop(); - replica = apply(replica, t.flush()); - await collect(); - held.nested.n = 9; - assert.deepEqual(t.flush(), []); - t.state.rows.unshift(held); - replica = apply(replica, t.flush()); - await collect(); - held.nested.n = 10; - replica = apply(replica, t.flush()); - assert.deepEqual(replica, JSON.parse(JSON.stringify(t.target))); - break; - } - case "root-array-windows": { - const t = track(Array.from({ length: 10_000 }, (_, n) => ({ n }))); - t.flush(); - await collect(); - const start = process.memoryUsage().heapUsed; - for (let round = 0; round < 12; round++) { - const ref = fillCaches(t.state); - await waitForCollection(ref); - } - for (let i = 0; i < 4; i++) await collect(); - const added = process.memoryUsage().heapUsed - start; - // Generous bound for engine/version variance, not a throughput assertion. - assert.ok(added < 12 * 1024 * 1024, `read/GC windows retained ${added} bytes of bookkeeping`); - assert.equal(t.state[9999].n, 9999); - console.log(JSON.stringify({ retainedBookkeepingBytes: added })); - break; - } - case "blocked-view": { - const t = track({ holder: { constructor: { n: 1 } }, safe: { n: 0 } }); - t.flush(); - const blocked = blockedView(t); - await waitForCollection(blocked); - assert.throws(() => { - t.state.holder.constructor.n = 2; - }); - assert.equal(t.state.holder.constructor.n, 1); - assert.deepEqual(t.flush(), []); - 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-traversal.bench.ts b/packages/chord/test/delta-traversal.bench.ts index db95bbef0..f50c4971b 100644 --- a/packages/chord/test/delta-traversal.bench.ts +++ b/packages/chord/test/delta-traversal.bench.ts @@ -53,18 +53,22 @@ function membrane(input: T): T { }; return wrap(input) as T; } -function setup(mode: Mode, input: Drawing): { state: Drawing; flush(): unknown } { +function setup(mode: Mode, input: Drawing): { state: Drawing; finish(): void } { if (mode === "delta") { - const t = track(input); - return { state: t.state, flush: () => t.flush() }; + const tracker = track(input); + const change = tracker.beginChange(); + return { + state: change.state, + finish() { + tracker.adopt(change.prepare()); + }, + }; } - return { state: mode === "proxy" ? membrane(input) : input, flush: () => null }; + return { state: mode === "proxy" ? membrane(input) : input, finish() {} }; } -// Keep temporary raw inputs and initial wire batches out of the measuring frame. +// Keep temporary raw inputs out of the measuring frame. function prepare(mode: Mode, strokes: number, points: number) { - const t = setup(mode, fixture(strokes, points)); - t.flush(); - return t; + return setup(mode, fixture(strokes, points)); } function traverse(state: Drawing): number { let sum = 0; @@ -81,10 +85,18 @@ function traverse(state: Drawing): number { function warm(mode: Mode): void { const t = prepare(mode, 10, 10); for (let i = 0; i < 10; i++) traverse(t.state); + t.finish(); } function sources() { return Object.fromEntries( - ["../src/delta/index.ts", "./delta-traversal.bench.ts"].map((path) => [ + [ + "../src/delta/index.ts", + "../src/delta/tracker.ts", + "../src/delta/draft.ts", + "../src/delta/diff.ts", + "../src/delta/value.ts", + "./delta-traversal.bench.ts", + ].map((path) => [ path, createHash("sha256") .update(readFileSync(new URL(path, import.meta.url))) @@ -120,6 +132,7 @@ async function measure(mode: Mode, strokes: number, points: number, trial: numbe await gc(); const retained = process.memoryUsage().heapUsed; assert.equal(t.state.strokes.length, strokes); + t.finish(); return { mode, strokes, diff --git a/packages/chord/test/delta.test.ts b/packages/chord/test/delta.test.ts index ef4653cfd..3a2bb32c2 100644 --- a/packages/chord/test/delta.test.ts +++ b/packages/chord/test/delta.test.ts @@ -15,1095 +15,298 @@ import { type WireOp, } from "../src/delta/index.ts"; -const PAD = "p".repeat(400); // see "adaptive emission": a delta must be able to win +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); -describe("overlap", () => { - it("finds an overlap shorter than the long probe", () => { - expect(overlap("abcdefgh", "defghxyz", 65_536)).toBe(5); + 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("honors a disabled scan", () => { + 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("releases the tracker and revokes drafts when preparation fails", () => { + const tracker = track({ values: [1, 2] }); + const change = tracker.beginChange(); + change.state.values.length = 4; + expect(() => change.prepare()).toThrow(/dense|holes/); + expect(() => change.state.values).toThrow(TypeError); + expect(() => change.abort()).not.toThrow(); + expect(() => change.prepare()).toThrow(/settled/); + const next = tracker.beginChange(); + next.abort(); + }); + + 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("copies replacement input and applies the same 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); + tracker.adopt(prepared); + + const noOp = tracker.prepareReplace({ nested: { value: 2 } }); + expect(noOp.value).toBe(tracker.value); + expect(noOp.ops).toEqual([]); + }); + + it("deeply freezes prepared metadata", () => { + 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); + }); + + 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("tracker: intent", () => { - it("records an append, not a replacement", () => { - const t = track({ s: "", pad: PAD }); - t.flush(); // drain the base batch - t.state.s += "ab"; - t.state.s += "cd"; - const ops = t.flush(); - expect(ops).toEqual([["a", ["s"], "abcd"]]); - expect(apply({ s: "", pad: PAD }, ops)).toEqual({ s: "abcd", pad: PAD }); - }); - - it("recovers truncate+append from a rolling window", () => { - // The case every effect-recording library degrades to a whole-value set. - const t = track({ s: "abcdefgh", pad: PAD }); - t.flush(); // drain the base batch - t.state.s = `${t.state.s.slice(3)}xyz`; - const ops = t.flush(); - expect(ops).toEqual([ - ["t", ["s"], 3], - ["a", ["s"], "xyz"], - ]); - expect(apply({ s: "abcdefgh", pad: PAD }, ops)).toEqual({ s: "defghxyz", pad: PAD }); - }); - - it("caches nested proxies while replacing changed children", () => { - const t = track({ child: { value: 1 } }); - const first = t.state.child; - expect(t.state.child).toBe(first); - t.state.child = { value: 2 }; - expect(t.state.child).not.toBe(first); - expect(t.state.child).toBe(t.state.child); - }); - - it("records array intent as one splice", () => { - const t = track({ xs: [1, 2], pad: PAD }); - t.flush(); // drain the base batch - t.state.xs.push(3); - expect(t.flush()).toEqual([["p", ["xs"], 2, 0, [3]]]); - }); - - it("normalises undefined to a delete", () => { - const t = track({ a: 1, pad: PAD }); - t.flush(); // drain the base batch - (t.state as Record).a = undefined; - expect(t.flush()).toEqual([["d", ["a"]]]); - }); - - it("uses absence and delete for optional object properties", () => { - type Settings = { something?: string; foo: number }; - const t = track({ foo: 1 }); - let replica = apply(undefined, t.flush()); - expect(t.state.something).toBeUndefined(); - t.state.something = "enabled"; - replica = apply(replica, t.flush()); - expect(replica).toEqual({ foo: 1, something: "enabled" }); - t.state.something = undefined; - const ops = t.flush(); - expect(ops).toEqual([["d", ["something"]]]); - expect(apply(replica, ops)).toEqual({ foo: 1 }); - }); - - it("round-trips interleaved writes", () => { - const initial = { out: "x".repeat(500), total: 0 }; - const t = track(structuredClone(initial)); - t.flush(); - for (let i = 0; i < 1_000; i++) { - t.state.out = `${t.state.out.slice(10)}${String(i).padStart(10, "0")}`; - t.state.total += 10; - } - const ops = t.flush(); - expect(ops.length).toBeLessThanOrEqual(3); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("deep-diffs reassigned objects", () => { - const initial = { message: { content: [{ text: "hello" }], count: 0 } }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.message = { content: [{ text: "hello world" }], count: 1 }; - expect(t.flush()).toEqual([ - ["a", ["message", "content", 0, "text"], " world"], - ["s", ["message", "count"], 1], - ]); - }); - - it("deep-diffs retained array edits combined with append in a replacement", () => { - const initial = { view: { messages: [{ text: "a" }, { text: "b" }] } }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.view = { messages: [{ text: "ax" }, { text: "b" }, { text: "c" }] }; - const ops = t.flush(); - expect(ops).toEqual([ - ["a", ["view", "messages", 0, "text"], "x"], - ["p", ["view", "messages"], 2, 0, [{ text: "c" }]], - ]); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("updates an anchored string through a later container replacement", () => { - const initial = { xs: [{ k: "a" }] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs[0]!.k += "b"; - t.state.xs = [{ k: "abc" }, { k: "z" }]; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("preserves string history across a null-prototype container replacement", () => { - const initial = { value: { text: "a" } }; - const t = track(structuredClone(initial)); - t.flush(); - const replacement = Object.assign(Object.create(null) as { text: string }, { text: "ab" }); - t.state.value = replacement; - t.state.value.text = "abc"; - const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]); - expect(applyImmutable(structuredClone(initial), ops)).toEqual(JSON.parse(JSON.stringify(t.state))); - }); - - it("front-truncates a pending string set from the correct end", () => { - const initial = { xs: [0] as Array }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs[0] = "abc"; - t.state.xs = ["bc", 0]; - const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]); - expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("emits nothing when a replacement is deeply equal", () => { - const t = track({ value: { nested: [1, { text: "same" }] } }); - t.flush(); - t.state.value = { nested: [1, { text: "same" }] }; - expect(t.dirty).toBe(true); - expect(t.flush()).toEqual([]); - expect(t.dirty).toBe(false); - }); - - it("invalidates a pending child when its parent is overwritten", () => { - const initial = { a: { x: 1 } as Record }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.a.b = 99; - t.state.a = { c: 2 }; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); -}); - -describe("tracker: root ops", () => { - it("splices a value that is itself an array", () => { - const t = track([1, 2, 3, PAD]); - t.flush(); // drain the base batch - t.state.push(4); - const ops = t.flush(); - expect(ops).toEqual([["p", [], 4, 0, [4]]]); - expect(apply([1, 2, 3, PAD], ops)).toEqual([1, 2, 3, PAD, 4]); - }); - - it("normalises a splice covering the whole root to a replacement", () => { - const t = track([1, 2, 3]); - t.flush(); // drain the base batch - t.state.splice(0, 3, 9); - const ops = t.flush(); - expect(ops).toEqual([["r", [9]]]); - expect(isBase(ops)).toBe(true); - }); - - it("normalises a nested splice-all to a set, not a replacement", () => { - const t = track({ xs: [1, 2, 3], pad: PAD }); - t.flush(); // drain the base batch - t.state.xs.splice(0, 3, 9); - expect(t.flush()).toEqual([["s", ["xs"], [9]]]); - }); - - it("matches splice with no arguments", () => { - const initial = { xs: [1, 2, 3] }; - const t = track(structuredClone(initial)); - t.flush(); - (t.state.xs.splice as (...args: never[]) => number[])(); - expect(t.state).toEqual(initial); - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("normalises splice arguments to integers", () => { - const initial = { xs: [1, 2, 3] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs.splice(0, undefined as unknown as number, 9); - t.state.xs.splice(1.5, 1); - const ops = t.flush(); - for (const op of ops) { - if (op[0] !== "p") continue; - expect(Number.isInteger(op[2])).toBe(true); - expect(Number.isInteger(op[3])).toBe(true); - } - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("normalises length = 0 on the root", () => { - const t = track([1, 2, 3]); - t.flush(); // drain the base batch - t.state.length = 0; - const ops = t.flush(); - expect(ops).toEqual([["r", []]]); - expect(apply([1, 2, 3], ops)).toEqual([]); - }); - - it("round-trips repeated nested splices", () => { - const initial = { xs: [1] }; - const t = track(structuredClone(initial)); - t.flush(); - for (let i = 2; i <= 100; i++) t.state.xs.push(i); - const ops = t.flush(); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("does not fold a child path across a parent splice", () => { - const initial = { xs: ["ab", "cd"] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs[0] += "x"; - t.state.xs.shift(); - t.state.xs[0] += "y"; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("drops child ops when repeated parent splices collapse to a snapshot", () => { - const initial = { xs: ["ab"] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs.push("q"); - t.state.xs[0] += "cd"; - t.state.xs.push("z"); - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("does not let a post-splice set dominate an earlier element append", () => { - const initial = { xs: ["ab"] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs[0] += "x"; - t.state.xs.unshift("q"); - t.state.xs[0] = "Z"; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("does not discard a nested append through a reindexed set", () => { - const initial = { xs: [{ k: "a" }] as ({ k: string } | number)[] }; - const t = track(structuredClone(initial)); - t.flush(); - (t.state.xs[0] as { k: string }).k += "x"; - t.state.xs.unshift(9); - t.state.xs[0] = 7; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("preserves element operations across middle-array insertions", () => { - const initial = { xs: ["a", "b"] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs[1] += "x"; - t.state.xs.splice(1, 0, "inserted"); - t.state.xs[1] = "changed"; - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("drops detached element writes when a root array is replaced", () => { - const initial = [{ k: "a" }, { k: "b" }]; - const t = track(structuredClone(initial)); - t.flush(); - t.state[0]!.k += "x"; - t.state.unshift({ k: "head" }); - t.state.splice(0, t.state.length, { k: "final" }); - const ops = t.flush(); - expect(ops[0]?.[0]).toBe("r"); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("keeps nested writes ordered across an inserted array reindex", () => { - const initial: number[][] = []; - const t = track(structuredClone(initial)); - t.flush(); - t.state.push([10, 20]); - t.state[0]!.shift(); - t.state[0]![0] = 30; - const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]); - expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state); - }); - - it.each([ - [ - "replacement", - (state: JsonValue[]) => { - state[0] = 0; - }, - ], - [ - "clear", - (state: JsonValue[]) => { - (state[0] as JsonValue[]).length = 0; - }, - ], - ] as const)("invalidates nested operations folded into an inserted payload on %s", (_name, replace) => { - const initial: JsonValue[] = []; - const t = track(structuredClone(initial)); - t.flush(); - t.state.push([]); - (t.state[0] as JsonValue[]).push(1); - replace(t.state); - const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]); - expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("round-trips deterministic element mutations across reindexing splices", () => { - let seed = 0x1234abcd; - const next = (): number => { - seed = (seed * 1_664_525 + 1_013_904_223) >>> 0; - return seed; - }; - for (let round = 0; round < 200; round++) { - const initial = { xs: [{ k: "a" }, { k: "b" }, { k: "c" }] }; - const t = track(structuredClone(initial)); - t.flush(); - for (let step = 0; step < 30; step++) { - const index = next() % t.state.xs.length; - switch (next() % 5) { - case 0: - t.state.xs[index]!.k += String.fromCharCode(97 + (next() % 26)); - break; - case 1: - t.state.xs[index] = { k: `set-${round}-${step}` }; - break; - case 2: - t.state.xs.unshift({ k: `head-${round}-${step}` }); - break; - case 3: - if (t.state.xs.length > 1) t.state.xs.shift(); - break; - default: { - const at = next() % (t.state.xs.length + 1); - const remove = t.state.xs.length > 1 && next() % 2 === 0 ? 1 : 0; - t.state.xs.splice(at, remove, { k: `mid-${round}-${step}` }); - } - } - if (t.state.xs.length > 10) t.state.xs.shift(); - } - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - } - }); - - it("keeps mutator chaining tracked", () => { - const initial = { xs: [3, 1, 2] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs.sort().push(4); - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); - - it("combines retained element edits with one append", () => { - const initial = { messages: [{ text: "a" }, { text: "b" }], flag: 0 }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.messages[0]!.text += "x"; - t.state.flag = 1; - t.state.messages.push({ text: "c" }); - t.state.messages[1]!.text += "y"; - t.state.messages[2]!.text += "z"; - const ops = t.flush(); - expect(ops.filter((op) => op[0] === "p")).toEqual([["p", ["messages"], 2, 0, [{ text: "cz" }]]]); - expect(ops.some((op) => op[0] === "s" && op[1].length === 1 && op[1][0] === "messages")).toBe(false); - expect(ops.some((op) => op[0] !== "r" && op[1].includes(2))).toBe(false); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("collapses many pushes into one append", () => { - const t = track({ xs: [1] }); - t.flush(); - for (let value = 2; value <= 100; value++) t.state.xs.push(value); - expect(t.flush()).toEqual([["p", ["xs"], 1, 0, Array.from({ length: 99 }, (_, index) => index + 2)]]); - }); - - it("keeps direct tail writes and length growth in append mode", () => { - const t = track({ xs: [1] as JsonValue[] }); - t.flush(); - t.state.xs[1] = { value: 2 }; - (t.state.xs[1] as { value: number }).value = 3; - t.state.xs.length = 4; - expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 3 }, null, null]]]); - }); - - it("keeps tail-only splices in append mode", () => { - const t = track({ xs: [{ value: 1 }] }); - t.flush(); - t.state.xs.push({ value: 2 }, { value: 3 }); - t.state.xs.splice(1, 1, { value: 4 }); - t.state.xs[2]!.value = 5; - expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 4 }, { value: 5 }]]]); - }); - - it("forgets append-tail mutations that cancel out", () => { - const t = track({ xs: [1] }); - t.flush(); - t.state.xs.push(2); - t.state.xs.push(3); - t.state.xs.pop(); - t.state.xs.pop(); - expect(t.flush()).toEqual([]); - }); - - it("accepts large append argument lists without spreading them internally", () => { - const t = track({ xs: [] as JsonValue[] }); - t.flush(); - const items = Array(100_000).fill(null); - expect(Reflect.apply(t.state.xs.push, t.state.xs, items)).toBe(items.length); - expect(t.flush()).toEqual([["p", ["xs"], 0, 0, items]]); - }); - - it("grows arrays with explicit null values", () => { - const initial = { xs: [1] as (number | null)[] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.xs.length = 4; - expect(t.state.xs).toEqual([1, null, null, null]); - expect(apply(structuredClone(initial), t.flush())).toEqual(t.state); - }); -}); - -describe("replacing the whole value", () => { - // The proxy cannot observe `tracker.state = next`: `state` belongs to the - // tracker, not to the tracked object, so assigning to it swaps the proxy for a - // plain object and silently stops tracking. Replacement must be a method. - it("assigning to state emits a base batch and keeps tracking", () => { - const t = track>({ p: 1, q: PAD }); - t.flush(); // drain the base batch - t.state.p = 2; - t.state = { r: 9, s: "new" }; - const ops = t.flush(); - expect(ops).toEqual([["r", { r: 9, s: "new" }]]); - expect(isBase(ops)).toBe(true); - expect(apply({}, ops)).toEqual(t.state); - - t.state.r = 10; // the new value is tracked - expect(t.flush()).toEqual([["s", ["r"], 10]]); - }); - - it("assigning the tracked root to state rebases without poisoning the tracker", () => { - const t = track({ value: 1 }); - t.flush(); - const trackedRoot = t.state; - t.state = trackedRoot; - expect(t.flush()).toEqual([["r", { value: 1 }]]); - t.state.value = 2; - expect(t.flush()).toEqual([["s", ["value"], 2]]); - }); - - it("allows a tracked child self-assignment as a no-op", () => { - const t = track({ child: { value: 1 } }); - t.flush(); - const child = t.state.child; - t.state.child = child; - expect(t.flush()).toEqual([]); - }); - - it("assigning to state discards ops recorded before it", () => { - // They describe a value that no longer exists. - const t = track>({ p: 1, q: PAD }); - t.flush(); // drain the base batch - t.state.p = 2; - t.state = { z: 1 }; - expect(t.flush()).toEqual([["r", { z: 1 }]]); - }); - - it("a partial rewrite keeps its ops", () => { - const t = track>({ a: "x".repeat(200), b: "y".repeat(200) }); - t.flush(); // drain the base batch - t.state.a = "p"; - const ops = t.flush(); - expect(isBase(ops)).toBe(false); - expect(ops).toEqual([["s", ["a"], "p"]]); - }); - - it("rebase() forces a base batch without changing the value", () => { - // This is the checkpoint. Recovery replays from the last base batch, so a - // producer must be able to bound that rather than depend on the adaptive - // size rule, which compares each flush independently. - const t = track>({ p: 1, pad: PAD }); - t.flush(); // drain the base batch - t.rebase(); - const ops = t.flush(); - expect(isBase(ops)).toBe(true); - expect(apply({}, ops)).toEqual(t.state); - }); - - it("rebase() applies once, not to every later flush", () => { - const t = track>({ p: 1, pad: PAD }); - t.flush(); // drain the base batch - t.rebase(); - t.flush(); - t.state.p = 2; - expect(t.flush()).toEqual([["s", ["p"], 2]]); - }); -}); - -describe("the first flush", () => { - it("is always a base batch", () => { - // A consumer starts with nothing, so a stream opening with deltas has - // nothing to apply them to. Requiring the producer to remember would fail - // at runtime, in the consumer, rather than where the mistake is. - const t = track>({ x: 0 }); - t.state.x = 100; - const ops = t.flush(); - expect(isBase(ops)).toBe(true); - expect(ops).toEqual([["r", { x: 100 }]]); // carries mutations made before it - }); - - it("is followed by deltas", () => { - const t = track>({ x: 0, pad: PAD }); - t.flush(); - t.state.x = 1; - expect(t.flush()).toEqual([["s", ["x"], 1]]); - }); - - it("emits nothing when untouched", () => { - // An unmutated tracker has nothing to say; the base batch is owed to a - // consumer, and there is no consumer until someone flushes. - const t = track>({ x: 0 }); - expect(t.dirty).toBe(true); - expect(t.flush()).toEqual([["r", { x: 0 }]]); - expect(t.dirty).toBe(false); - expect(t.flush()).toEqual([]); - }); - - it("discard accepts pending changes without publishing them", () => { - const t = track({ x: 0, y: 0 }); - t.flush(); - t.state.x = 1; - expect(t.dirty).toBe(true); - t.discard(); - expect(t.dirty).toBe(false); - expect(t.flush()).toEqual([]); - t.state.y = 1; - expect(t.flush()).toEqual([["s", ["y"], 1]]); - }); -}); - -describe("immutable operation application", () => { - 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], +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], - ]); - expect(replacement.nested.value).toBe(1); - expect(next.nested.value).toBe(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("apply and fan-out", () => { - it("adopts an `r` payload rather than copying it", () => { - // The consumer owns the batch it was handed. Copying every base batch - // defensively doubles the memory of the one op carrying the whole value. - // - // The rule that follows: do not hand one batch to two in-process - // consumers. This test pins the aliasing so nobody "fixes" it by adding a - // clone back. - const t = track>({ n: 0 }); - const batch = t.flush(); - const a = apply>(undefined, batch); - const b = apply>(undefined, batch); - a.n = 1; - expect(b.n).toBe(1); - }); - - it("does not alias the producer", () => { - const t = track>({ x: 0 }); - const replica = apply>(undefined, t.flush()); - t.state.x = 999; - t.flush(); - expect(replica.x).toBe(0); - }); - - it("adopts assigned values without cloning them", () => { - const t = track<{ item?: { value: number } }>({}); - const replica = apply<{ item?: { value: number } }>(undefined, t.flush()); - const supplied = { value: 0 }; - t.state.item = supplied; - t.state.item.value = 1; - expect(supplied.value).toBe(1); - expect(apply(replica, t.flush())).toEqual(t.state); - }); - - it("does not alias pushed values with an in-process consumer", () => { - const t = track({ xs: [] as { value: number }[] }); - const replica = apply<{ xs: { value: number }[] }>(undefined, t.flush()); - t.state.xs.push({ value: 1 }); - const next = apply(replica, t.flush()); - next.xs[0]!.value = 2; - expect(t.state.xs[0]!.value).toBe(1); - }); - - it("folds a whole stream without a base-batch branch", () => { - // `apply` handles `r` by replacing, and tolerates an undefined target, - // so a consumer needs no isBase check and no clone of its own. - const t = track({ x: 0, l: [] as string[] }); +describe("codec", () => { + it("interns paths, omits adjacent paths, and round-trips", () => { + const path = ["nested", "text"] as const; const enc = encoder(); const dec = decoder(); - let replica: unknown; - const send = () => { - replica = apply(replica, dec.decode(enc.encode(t.flush()))); - }; - - t.state.x = 100; - t.state.l.push("xyz"); - send(); - t.state.x = 101; - send(); - expect(replica).toEqual(t.state); - }); -}); - -describe("pending operation coalescing", () => { - // Repeated operations are coalesced when ordering and array index semantics - // make that safe. Exact operation shape is not part of the contract. - type DeadOpState = { a: Record; x?: number; y?: number }; - const run = (mutate: (state: DeadOpState) => void, init: DeadOpState = { a: { b: 1 }, x: 1, y: 2 }) => { - const t = track(structuredClone(init)); - t.flush(); // drain the base batch - mutate(t.state); - return t.flush(); - }; - - it("collapses repeated writes to one field", () => { - expect( - run((s) => { - s.x = 1; - s.x = 2; - s.x = 3; - }), - ).toEqual([["s", ["x"], 3]]); - }); - - it("drops a write superseded by a later, non-adjacent one", () => { - expect( - run((s) => { - s.x = 10; - s.y = 20; - s.x = 30; - }), - ).toEqual([ - ["s", ["y"], 20], - ["s", ["x"], 30], + 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("drops a child write when the parent is replaced after it", () => { - expect( - run((s) => { - s.a.b = 99; - s.a = { c: 5 }; - }), - ).toEqual([ - ["s", ["a", "c"], 5], - ["d", ["a", "b"]], - ]); + 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("folds a child write into its pending parent replacement", () => { - expect( - run((s) => { - s.a = { b: 1 }; - s.a.b = 7; - }), - ).toEqual([["s", ["a", "b"], 7]]); - }); - - it("collapses set-then-delete to the delete", () => { - expect( - run((s) => { - s.x = 5; - delete s.x; - }), - ).toEqual([["d", ["x"]]]); - }); - - it("collapses delete-then-set to the final value", () => { - // Delta replication preserves JSON values, not object insertion order. - expect( - run((s) => { - delete s.x; - s.x = 5; - }), - ).toEqual([["s", ["x"], 5]]); - }); - - it("converges after delete-then-recreate without requiring an append", () => { - const initial = { x: "", y: 1 }; - const t = track<{ x?: string; y: number }>(structuredClone(initial)); - t.flush(); - delete t.state.x; - t.state.x = "ab"; - t.state.x += "cd"; - const ops = t.flush(); - expect(ops).toEqual([["s", ["x"], "abcd"]]); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("may publish a redundant batch when mutations restore the prior value", () => { - const initial = { x: 1, xs: [1, 2] }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.x = 2; - t.state.x = 1; - t.state.xs.reverse(); - t.state.xs.reverse(); - const ops = t.flush(); - expect(ops.length).toBeGreaterThan(0); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("is linear in the number of ops", () => { - // The naive formulation compares every op against every dominator, which - // is quadratic and degrades on exactly the wide flush this pass cleans up. - const wide = (n: number) => { - const root: Record = {}; - for (let i = 0; i < n; i++) root[`f${i}`] = i; - const t = track(root); - t.flush(); // drain the base batch - const started = performance.now(); - for (let i = 0; i < n; i++) t.state[`f${i}`] = i + 1; - t.flush(); - return performance.now() - started; - }; - wide(200); // warm - const small = Math.max(wide(250), 0.1); - const large = wide(2500); - expect(large / small).toBeLessThan(40); // linear would be ~10x - }); - - it("collapses a pathological redundant producer", () => { - const t = track<{ a: Record; x: number }>({ a: { b: 1 }, x: 1 }); - t.flush(); // drain the base batch - for (let i = 0; i < 2000; i++) { - t.state.x = i; - t.state.a.b = i; - } - t.state.a = { done: true }; - const ops = t.flush(); - expect(ops).toHaveLength(3); - expect(apply({ a: { b: 1 }, x: 1 }, ops)).toEqual(t.state); - }); - - it("bounds long structural windows by collapsing them to a base", () => { - const initial = { xs: [{ value: 0 }, { value: 1 }] }; - const t = track(structuredClone(initial)); - t.flush(); - for (let i = 0; i < 5000; i++) { - t.state.xs[0]!.value = i; - t.state.xs.shift(); - t.state.xs.push({ value: i }); - } - const ops = t.flush(); - expect(isBase(ops)).toBe(true); - expect(apply(structuredClone(initial), ops)).toEqual(t.state); - }); -}); - -describe("flush", () => { - it("drops everything before a replacement", () => { - const t = track([1, 2]); - t.flush(); // drain the base batch - t.state.push(3); - t.state.splice(0, 3, 7); - expect(t.flush()).toEqual([["r", [7]]]); - }); - - it("keeps the prefix when the replacement is nested", () => { - // `s` on a subtree is not a root replacement, so earlier ops stay live. - const t = track({ a: 1, xs: [1, 2, 3], pad: PAD }); - t.flush(); // drain the base batch - t.state.a = 2; - t.state.xs.splice(0, 3, 9); - expect(t.flush()).toEqual([ - ["s", ["a"], 2], - ["s", ["xs"], [9]], - ]); - }); - - it("rejects a constructor walk", () => { - // ({}).constructor.constructor is Function — the classic escape ladder. - expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow(); - expect(({} as Record).gadget).toBeUndefined(); - }); - - it("rejects a forbidden path reached through an interned id", () => { + it("rejects unresolved short forms and unsafe interned paths", () => { + expect(() => decoder().decode([["a", "x"]])).toThrow(); const wire = [ - ["#", 0, ["__proto__", "w"]], + ["#", 0, ["__proto__"]], ["s", 0, true], ] as unknown as WireOp[]; - expect(() => decoder().decode(wire)).toThrow(); + expect(() => decoder().decode(wire)).toThrow(UnsafePathError); }); - it("does not run an inherited setter", () => { - Object.defineProperty(Object.prototype, "trap", { - get() { - throw new Error("inherited getter ran"); - }, - configurable: true, - }); - try { - expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow(); - } finally { - delete (Object.prototype as Record).trap; - } - }); - - it("allows a reserved name as a VALUE key", () => { - // Reserved as segments, not as values. - const out = apply({}, [["s", ["a"], JSON.parse('{"__proto__":{"z":1}}')]]); - expect(({} as Record).z).toBeUndefined(); - expect(out).toBeDefined(); - }); - - it("clones tracked values 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).flush()).not.toThrow(); - } finally { - delete (Object.prototype as Record).trap; - } - - let arrayOps: Op[] | undefined; - Object.defineProperty(Array.prototype, "0", { - set() { - throw new Error("inherited array setter ran"); - }, - configurable: true, - }); - try { - arrayOps = track({ values: [1, 2] }).flush(); - } finally { - delete (Array.prototype as unknown as Record)[0]; - } - expect(arrayOps).toEqual([["r", { values: [1, 2] }]]); - }); - - it("replaces the safe parent when an assigned object removes a reserved value key", () => { - const initial: { value: Record } = { - value: JSON.parse('{"constructor":{"label":"data"},"x":1}') as Record, - }; - const t = track(structuredClone(initial)); - t.flush(); - t.state.value = { x: 2 }; - const ops = decoder().decode(JSON.parse(JSON.stringify(encoder().encode(t.flush()))) as WireOp[]); - expect(applyImmutable(structuredClone(initial), ops)).toEqual(t.state); - }); - - it("clones and reads reserved value keys without invoking prototype setters", () => { - const t = track({ value: JSON.parse('{"__proto__":{"z":1}}') as JsonValue }); - const out = apply<{ value: JsonValue }>(undefined, t.flush()); - expect(Object.hasOwn(out.value as object, "__proto__")).toBe(true); - expect(JSON.parse(JSON.stringify(t.state))).toEqual({ value: JSON.parse('{"__proto__":{"z":1}}') }); - expect(() => { - ((t.state.value as Record).__proto__ as Record).z = 2; - }).toThrow(/unsafe path/); - expect(({} as Record).z).toBeUndefined(); - }); -}); - -describe("safety: array indices", () => { - // An index may address an existing element or append exactly one past the end. - // Not an arbitrary cap: a sparse array does not survive a JSON round trip, so - // arr[7] = x on a length-3 array already produces unreplicable state. - it("writes an existing index", () => { - expect(apply({ xs: [1, 2, 3] }, [["s", ["xs", 1], 9]])).toEqual({ xs: [1, 9, 3] }); - }); - it("appends one past the end", () => { - expect(apply({ xs: [1, 2, 3] }, [["s", ["xs", 3], 9]])).toEqual({ xs: [1, 2, 3, 9] }); - }); - it("rejects a gap", () => { - expect(() => apply({ xs: [1, 2, 3] }, [["s", ["xs", 5], 9]])).toThrow(); - }); - it("rejects a huge index", () => { - // Would otherwise allocate 4.29 billion entries from one op. - expect(() => apply({ xs: [] }, [["s", ["xs", 4_294_967_290], 1]])).toThrow(); - }); - it("rejects string-spelled array indices at the consumer", () => { - expect(() => apply({ xs: [1, 2, 3] }, [["s", ["xs", "7"], 9]])).toThrow(); - expect(() => apply({ xs: ["a"] }, [["a", ["xs", "0"], "b"]])).toThrow(); - }); - it("rejects sparse writes at the producer", () => { - const t = track({ xs: [1, 2, 3] }); - t.flush(); - expect(() => { - t.state.xs[5] = 9; - }).toThrow(); - expect(t.state.xs).toEqual([1, 2, 3]); - }); - it("rejects array deletes at the producer", () => { - const t = track({ xs: [1, 2, 3] }); - t.flush(); - expect(() => { - delete t.state.xs[1]; - }).toThrow(); - expect(t.state.xs).toEqual([1, 2, 3]); - }); - it("rejects undefined array elements at the producer", () => { - const t = track<{ xs: (number | undefined)[] }>({ xs: [1, 2, 3] }); - t.flush(); - expect(() => { - t.state.xs[1] = undefined; - }).toThrow(); - expect(t.state.xs).toEqual([1, 2, 3]); - }); - it("allows explicit growth with nulls", () => { - expect(apply({ xs: [1] }, [["p", ["xs"], 1, 0, [null, null, 9]]])).toEqual({ xs: [1, null, null, 9] }); - }); - it("rejects deleting one past an array's end", () => { - expect(() => apply({ xs: [1] }, [["d", ["xs", 1]]])).toThrow(); - }); - it("applies large splice payloads without spreading them at once", () => { - const items = Array(300_000).fill(null); - const result = apply({ xs: [] as JsonValue[] }, [["p", ["xs"], 0, 0, items]]); - expect(result.xs).toHaveLength(items.length); - }); -}); - -describe("safety: op structure", () => { - // The CVE-2025-55182 lesson: a decoder must not trust tuple shape. - it("rejects an unknown verb rather than skipping it", () => { - // Silently skipping is how a newer producer's op vanishes and a replica drifts. - expect(() => apply({ a: 1 }, [["ZZZ", ["a"], 9] as never])).toThrow(); - }); - it("rejects non-array splice items", () => { - // Unvalidated, this spreads a string: ["n","o","t","-","a",…] - expect(() => apply({ xs: [1] }, [["p", ["xs"], 0, 0, "not-an-array"] as never])).toThrow(); - }); - it("rejects a string path", () => { - // Unvalidated, "a".slice(0,-1) is "", so it resolved to the ROOT and wrote. - expect(() => apply({ a: 1 }, [["s", "a", 9] as never])).toThrow(); - }); - it("rejects a non-tuple op", () => { - expect(() => apply({ a: 1 }, [{ op: "s" } as never])).toThrow(); - expect(() => apply({ a: 1 }, [null as never])).toThrow(); - }); - it("rejects append to a missing or non-string value", () => { - expect(() => apply({ a: 1 }, [["a", ["missing"], "x"]])).toThrow(); - expect(() => apply({ a: 1 }, [["a", ["a"], "x"]])).toThrow(); - }); - it("rejects negative truncation", () => { - expect(() => apply({ a: "abc" }, [["t", ["a"], -1]])).toThrow(); - expect(() => decoder().decode([["t", ["a"], -1]])).toThrow(); - }); - it("clamps a splice remove past the end, as Array.prototype.splice does", () => { - // Deterministic and identical on both sides — not a hole. - expect(apply({ xs: [1, 2] }, [["p", ["xs"], 0, 1e9, []]])).toEqual({ xs: [] }); - }); -}); - -describe("tracker proxy boundaries", () => { - it("rejects descriptor and object-shape bypasses", () => { - const t = track>({ pad: PAD }); - t.flush(); - expect(() => Object.defineProperty(t.state, "g", { value: 42, enumerable: true })).toThrow(/assignment/); - expect(() => Object.setPrototypeOf(t.state, null)).toThrow(/setPrototypeOf/); - expect(() => Object.preventExtensions(t.state)).toThrow(/preventExtensions/); - expect(t.state).toEqual({ pad: PAD }); - expect(t.flush()).toEqual([]); - }); - - it("accepts a value that merely looks like an op", () => { - const t = track>({ pad: PAD }); - t.flush(); - t.state.x = ["r", { evil: true }]; - expect(t.flush()).toEqual([["s", ["x"], ["r", { evil: true }]]]); - }); -}); - -describe("assertions", () => { - it("assertValidOp accepts decoded ops and rejects wire forms", () => { - // Validating Op against the wire grammar would be laxer than the type: a - // two-element ["s", value] would pass, and apply would read the value as a - // path. Each vocabulary gets the validator that matches it. - for (const op of [ - ["r", { a: 1 }], - ["s", ["a"], 1], - ["d", ["a"]], - ["a", ["a"], "x"], - ["t", ["a"], 2], - ["p", ["a"], 0, 0, []], - ] as never[]) { - expect(() => assertValidOp(op)).not.toThrow(); - } - for (const wireOnly of [ - ["s", 1], - ["d"], - ["a", "x"], - ["t", 2], - ["p", 0, 0, []], - ["#", 0, ["a"]], - ["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(); - }); -}); - -describe("codec: path interning and arity omission", () => { - // One pair per stream. The table spans a whole subscription or file, so a - // second consumer joining later needs its own encoder. - const roundTrip = (batches: Op[][]) => { + it("omits an adjacent repeated path", () => { const enc = encoder(); - const dec = decoder(); - return batches.map((ops) => dec.decode(enc.encode(ops))); - }; - - it("round-trips a stream exactly", () => { - const t = track({ a: { deep: "" }, b: { deep: "" } }); - t.flush(); // drain the base batch - const batches: Op[][] = []; - for (let i = 0; i < 6; i++) { - t.state.a.deep += `x${i}`; - t.state.b.deep += `y${i}`; - batches.push(t.flush()); - } - expect(roundTrip(batches)).toEqual(batches); - }); - - it("interns on second use, not first", () => { - const enc = encoder(); - const p = ["a", "deep"] as const; - const first = enc.encode([["a", p, "1"]]); - const second = enc.encode([["a", p, "2"]]); - expect(first).toEqual([["a", p, "1"]]); // inline - expect(second).toEqual([ - ["#", 0, p], - ["a", 0, "2"], - ]); // define, then use - }); - - it("omits the path when it repeats", () => { - const enc = encoder(); - const p = ["a"] as const; + const path = ["value"] as const; expect( enc.encode([ - ["s", p, 1], - ["s", p, 2], + ["s", path, 1], + ["s", path, 2], ]), ).toEqual([ - ["s", p, 1], + ["s", path, 1], ["s", 2], ]); }); - it("does not collide paths containing null characters", () => { - const first = ["a\u0000b"] as const; - const second = ["a", "b"] as const; - const ops: Op[] = [ - ["s", first, 1], - ["s", second, 2], - ]; - expect(decoder().decode(encoder().encode(ops))).toEqual(ops); + 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("rejects a short form without a previous path", () => { - expect(() => decoder().decode([["a", "x"]])).toThrow(); + 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", () => { @@ -1116,318 +319,192 @@ describe("codec: path interning and arity omission", () => { expect(() => dec.decode([["a", 0, "2"]])).toThrow(); }); - it("resets the table on a base batch, so recovery is self-contained", () => { - // A reader replays from the LAST base batch with a fresh decoder. Ids - // defined before it were never seen, so keeping them breaks recovery with - // an unresolvable path id. + it("makes batches after a base self-contained", () => { const enc = encoder(); - const p = ["a", "deep"] as const; - enc.encode([["a", p, "1"]]); - enc.encode([["a", p, "2"]]); // id 0 assigned + 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", p, "3"]]); - expect(base).toEqual([["r", { a: { deep: "x" } }]]); - expect(after).toEqual([["a", p, "3"]]); // inline again - + const after = enc.encode([["a", path, "3"]]); + expect(after).toEqual([["a", path, "3"]]); const dec = decoder(); - expect(() => dec.decode(base)).not.toThrow(); - expect(dec.decode(after)).toEqual([["a", p, "3"]]); + expect(dec.decode(base)).toEqual([["r", { a: { deep: "x" } }]]); + expect(dec.decode(after)).toEqual([["a", path, "3"]]); }); - it("survives recovery from the last base batch", () => { + 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 t = track({ a: { deep: "" }, b: { deep: "" } }); - t.flush(); // drain the base batch - const wire: WireOp[][] = []; - for (let i = 0; i < 8; i++) { - t.state.a.deep += `x${i}`; - t.state.b.deep += `y${i}`; - if (i === 5) t.rebase(); - wire.push(enc.encode(t.flush())); - } - const lastBase = wire.map(isBase).lastIndexOf(true); const dec = decoder(); - let replica: unknown; - for (const w of wire.slice(lastBase)) { - const ops = dec.decode(w); - replica = replica === undefined ? structuredClone((ops[0] as ["r", unknown])[1]) : apply(replica, ops); - } - expect(replica).toEqual(t.state); - }); - - it("round-trips random streams", () => { - for (let round = 0; round < 300; round++) { - const t = track<{ a: { p: string; q: string }; b: JsonValue[]; c?: number }>({ - a: { p: "", q: "" }, - b: [], - c: 0, - }); - t.flush(); // drain the base batch - const batches: Op[][] = []; - for (let i = 0; i < 8; i++) { - const r = Math.random(); - if (r < 0.3) t.state.a.p += "x"; - else if (r < 0.5) t.state.a.q += "y"; - else if (r < 0.65) t.state.b.push(i); - else if (r < 0.8) t.state.c = i; - else if (r < 0.9) delete t.state.c; - else t.rebase(); - const ops = t.flush(); - if (ops.length > 0) batches.push(ops); - } - expect(roundTrip(batches)).toEqual(batches); - } + expect(batches.map((batch) => dec.decode(enc.encode(batch)))).toEqual(batches); }); }); -describe("property: operation-log tracking", () => { - it("converges across mixed nested writes, replacements, and array mutations", () => { - type State = { rows: { text: string; count: number }[]; meta: { revision: number } }; - let seed = 0x5eed1234; - const next = (): number => { - seed = (seed * 1_664_525 + 1_013_904_223) >>> 0; - return seed; - }; - for (let round = 0; round < 100; round++) { - const initial: State = { - rows: [ - { text: "a", count: 0 }, - { text: "b", count: 0 }, - ], - meta: { revision: 0 }, - }; - const tracker = track(structuredClone(initial)); - const enc = encoder(); - const dec = decoder(); - let replica = apply(undefined, dec.decode(enc.encode(tracker.flush()))); - for (let step = 0; step < 60; step++) { - const rows = tracker.state.rows; - switch (next() % 10) { - case 0: - if (rows.length > 0) rows[next() % rows.length]!.text += String.fromCharCode(97 + (next() % 26)); - break; - case 1: - if (rows.length > 0) rows[next() % rows.length]!.count++; - break; - case 2: - rows.push({ text: `tail-${round}-${step}`, count: step }); - break; - case 3: - if (rows.length > 0) rows.pop(); - break; - case 4: - rows.unshift({ text: `head-${round}-${step}`, count: step }); - break; - case 5: - if (rows.length > 0) rows.shift(); - break; - case 6: { - const index = next() % (rows.length + 1); - const remove = rows.length > 0 && next() % 2 === 0 ? 1 : 0; - rows.splice(index, remove, { text: `mid-${round}-${step}`, count: step }); - break; - } - case 7: { - const replacement = JSON.parse(JSON.stringify(rows)) as State["rows"]; - if (replacement.length > 0) replacement[0]!.text += "r"; - if (next() % 2 === 0) replacement.push({ text: "replacement-tail", count: step }); - tracker.state.rows = replacement; - break; - } - case 8: - tracker.state.meta = { revision: tracker.state.meta.revision + 1 }; - break; - default: - if (rows.length > 1) rows.reverse(); - } - if (tracker.state.rows.length > 12) tracker.state.rows.splice(0, tracker.state.rows.length - 12); - if (next() % 5 === 0) { - replica = apply(replica, dec.decode(enc.encode(tracker.flush()))); - expect(replica).toEqual(tracker.state); - } - } - replica = apply(replica, dec.decode(enc.encode(tracker.flush()))); - expect(replica).toEqual(tracker.state); - } +describe("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("property: random round-trip", () => { - // 3000 random sequences found the root-splice hole on the first run, before any - // hand-written case did. - const rnd = (d = 0): unknown => { - const r = Math.random(); - if (d > 2 || r < 0.3) return Math.floor(Math.random() * 5); - if (r < 0.45) return ["x", "y", null, true][Math.floor(Math.random() * 4)]; - if (r < 0.7) return Array.from({ length: Math.floor(Math.random() * 4) }, () => rnd(d + 1)); - const o: Record = {}; - for (const k of ["a", "b", "c"]) if (Math.random() < 0.6) o[k] = rnd(d + 1); - return o; - }; - - it("a replica always matches the producer", () => { - let checked = 0; - for (let i = 0; i < 3000; i++) { - const base = rnd(); - if (typeof base !== "object" || base === null) continue; - const t = track(structuredClone(base) as object); - t.flush(); // drain the base batch - const next = rnd(); - if (Array.isArray(t.state) && Array.isArray(next)) { - (t.state as unknown[]).splice(0, (t.state as unknown[]).length, ...next); - } else if (!Array.isArray(t.state) && typeof next === "object" && next !== null && !Array.isArray(next)) { - const s = t.state as Record; - for (const k of Object.keys(s)) if (!(k in next)) delete s[k]; - for (const [k, v] of Object.entries(next)) s[k] = v; - } else continue; - expect(apply(structuredClone(base), t.flush())).toEqual(t.state); - checked++; - } - // Most random pairs are shape-incompatible and skipped; this only guards - // against the loop silently checking nothing. - expect(checked).toBeGreaterThan(200); +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(); }); -}); -describe("references held across structural mutation", () => { - const roundTrip = (initial: JsonValue, mutate: (state: any) => void): { live: JsonValue; replica: JsonValue } => { - const t = track(structuredClone(initial) as object); - t.flush(); - mutate(t.state); - const replica = apply(structuredClone(initial), t.flush()); - return { live: JSON.parse(JSON.stringify(t.state)) as JsonValue, replica: replica as JsonValue }; - }; - const xs = () => ({ xs: [{ k: "v0" }, { k: "v1" }, { k: "v2" }, { k: "v3" }, { k: "v4" }] }); - - // A wrapper must not address its old index after the array is renumbered. - const mutators: Array<[string, (a: any[]) => void]> = [ - ["push", (a) => a.push({ k: "n" })], - ["pop", (a) => a.pop()], - ["shift", (a) => a.shift()], - ["unshift", (a) => a.unshift({ k: "n" })], - ["splice insert", (a) => a.splice(2, 0, { k: "n" })], - ["splice remove", (a) => a.splice(2, 1)], - ["splice replace", (a) => a.splice(2, 1, { k: "n" })], - ["splice remove two", (a) => a.splice(1, 2)], - ["sort", (a) => a.sort((x, y) => (x.k < y.k ? 1 : -1))], - ["reverse", (a) => a.reverse()], - [ - "length truncate", - (a) => { - a.length = 3; + it("does not run inherited setters", () => { + Object.defineProperty(Object.prototype, "trap", { + set() { + throw new Error("inherited setter ran"); }, - ], - [ - "length grow", - (a) => { - a.length = 7; - }, - ], - ]; - for (const [name, mutate] of mutators) { - for (const hold of [0, 2, 4]) { - it(`renumbers a held element across ${name} (held ${hold})`, () => { - const { live, replica } = roundTrip(xs(), (s) => { - const held = s.xs[hold]; - mutate(s.xs); - held.k = "EDITED"; - }); - expect(replica).toEqual(live); - }); + 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("renumbers a held nested object and array", () => { - const initial = { - xs: [ - { k: "a", obj: { deep: 1 }, arr: [1] }, - { k: "b", obj: { deep: 2 }, arr: [2] }, - ], - }; - const { live, replica } = roundTrip(initial, (s) => { - const obj = s.xs[1].obj; - const arr = s.xs[1].arr; - s.xs.unshift({ k: "n", obj: { deep: 0 }, arr: [] }); - obj.deep = 99; - arr.push(99); - }); - expect(replica).toEqual(live); }); - it("drops writes through an element that left the document", () => { - const { live, replica } = roundTrip(xs(), (s) => { - const held = s.xs[2]; - s.xs.splice(2, 1); - held.k = "EDITED"; // no position: mutates the object, records nothing - }); - expect(replica).toEqual(live); + 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("records again when a removed element is reinserted", () => { - const { live, replica } = roundTrip(xs(), (s) => { - const held = s.xs[2]; - s.xs.splice(2, 1); - held.k = "EDITED"; - s.xs.push(held); + 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, }); - expect(replica).toEqual(live); + 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("one object at several positions", () => { - const roundTrip = (initial: JsonValue, mutate: (state: any) => void) => { - const t = track(structuredClone(initial) as object); - t.flush(); - mutate(t.state); - const replica = apply(structuredClone(initial), t.flush()); - return { live: JSON.parse(JSON.stringify(t.state)) as JsonValue, replica: replica as JsonValue }; - }; +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("emits an op per position when a tracked value is assigned elsewhere", () => { - const { live, replica } = roundTrip({ xs: [{ k: "v0" }, { k: "v1" }], a: null }, (s) => { - const held = s.xs[1]; - s.a = held; - held.k = "EDITED"; + 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(replica).toEqual(live); + expect(() => apply({ values: [1] }, [["d", ["values", 1]]])).toThrow(); }); - it("emits an op per position when a tracked value is pushed into an array", () => { - const { live, replica } = roundTrip({ xs: [{ k: "v0" }] }, (s) => { - const held = s.xs[0]; - s.xs.push(held); - held.k = "EDITED"; - }); - expect(replica).toEqual(live); - }); - - it("keeps the surviving position when one is removed", () => { - const { live, replica } = roundTrip({ xs: [{ k: "v0" }, { k: "v1" }], a: null }, (s) => { - const held = s.xs[1]; - s.a = held; - s.xs.splice(1, 1); - held.k = "EDITED"; - }); - expect(replica).toEqual(live); - }); - - it("gives one proxy per object, so identity survives tracking", () => { - const raw: any = { xs: [{ k: "v0" }], a: null }; - raw.a = raw.xs[0]; - const t = track(raw); - t.flush(); - expect(t.state.a).toBe(t.state.xs[0]); - }); - - it("still blocks a reserved key reached after a safe alias", () => { - const reservedKey = "__proto__"; - const raw = JSON.parse('{"safe":null,"holder":{"__proto__":{"x":1}}}'); - raw.safe = raw.holder[reservedKey]; - const t = track(raw); - t.flush(); - expect(t.state.safe).toBeDefined(); // warm the unblocked wrapper - expect(() => { - t.state.holder[reservedKey].x = 9; - }).toThrow(UnsafePathError); + 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/services.test.ts b/packages/chord/test/services.test.ts index 0fff98098..46c9331ea 100644 --- a/packages/chord/test/services.test.ts +++ b/packages/chord/test/services.test.ts @@ -8,7 +8,10 @@ import { RemoteServiceProvider, type RemoteServiceTransport, type ReplicatedState, + type ReplicatedStateSource, + type ReplicatedStateSourceFrame, replicatedState, + type ServiceProviderUpdate, } from "../src/index.ts"; import { createLoopbackServiceTransport } from "./helpers.ts"; @@ -39,8 +42,9 @@ interface Echo { const Echo = defineService("test.echo"); +type TimelineState = { entries: { id: string }[]; retained: { value: number } }; interface Timeline { - readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>; + readonly state: ReplicatedState; } const Timeline = defineService("test.timeline"); @@ -278,6 +282,52 @@ describe("remote services", () => { provider.dispose(); }); + test("publishes authoritative source references through services without re-diffing", () => { + const initial: TimelineState = Object.freeze({ entries: [{ id: "one" }], retained: { value: 1 } }); + let publish: ((frame: ReplicatedStateSourceFrame) => void) | undefined; + let disposed = false; + const source: ReplicatedStateSource = { + attach() { + return { + snapshot: { value: initial, cursor: 20 }, + activate(listener) { + publish = listener; + }, + dispose() { + disposed = true; + }, + }; + }, + }; + const state = replicatedState(source); + const provider = new RemoteServiceProvider([Timeline]); + provider.provide(Timeline, { state }); + const updates: ServiceProviderUpdate[] = []; + const subscription = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update)); + const snapshotMember = subscription.snapshot.instances[0]?.members[0]; + expect(snapshotMember?.kind).toBe("state"); + if (snapshotMember?.kind === "state") expect(snapshotMember.ops[0]?.[1]).toBe(initial); + subscription.activate(); + + const next: TimelineState = Object.freeze({ + entries: [{ id: "one" }, { id: "two" }], + retained: initial.retained, + }); + const ops: ReplicatedStateSourceFrame["ops"] = Object.freeze([ + ["p", ["entries"], 1, 0, [{ id: "two" }]], + ]); + publish?.({ cursor: 21, value: next, ops, context: BACKGROUND_CONTEXT }); + expect(updates).toHaveLength(1); + const update = updates[0]; + expect(update?.type).toBe("state"); + if (update?.type === "state") expect(update.ops).toBe(ops); + + subscription.close(); + provider.dispose(); + state.dispose(); + expect(disposed).toBe(true); + }); + test("keeps singleton facades stable when their provider is replaced", async () => { const provider = new RemoteServiceProvider([Models]); provider.provide(Models, { diff --git a/packages/chord/test/state-diff.test.ts b/packages/chord/test/state-diff.test.ts index 4be1442ae..6d07512c5 100644 --- a/packages/chord/test/state-diff.test.ts +++ b/packages/chord/test/state-diff.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "vitest"; -import { applyImmutable, assertValidOp, assertValidWireOp, decoder, encoder, type Op } from "../src/delta/index.ts"; -import { diffRevisions } from "../src/state/diff.ts"; +import { + applyImmutable, + assertValidOp, + assertValidWireOp, + decoder, + diffRevisions, + encoder, + type Op, +} from "../src/delta/index.ts"; import type { JsonValue } from "../src/types.ts"; -const expectDiff = (before: JsonValue, after: JsonValue, expected: JsonValue): void => { +const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => { const operations = diffRevisions(before, after); expect(operations).toEqual(expected); expect(applyImmutable(before, operations)).toEqual(after); @@ -53,6 +60,13 @@ describe("immutable revision diff", () => { expectDiff({ values: [a, b, c] }, { values: [c, a, b] }, [["m", ["values"], [2, 0, 1]]]); }); + it("normalizes reordered distinct deeply-equal objects to a no-op", () => { + const first = { nested: { value: 1 } }; + const second = { nested: { value: 1 } }; + expect(first).not.toBe(second); + expect(diffRevisions({ values: [first, second] }, { values: [second, first] })).toEqual([]); + }); + it("validates and encodes permutations", () => { const operations: Op[] = [ ["m", ["values"], [2, 0, 1]], @@ -93,6 +107,146 @@ describe("immutable revision diff", () => { ); }); + it("emits payload-free splices for scattered removals", () => { + const [a, b, c, d, e] = ["a", "b", "c", "d", "e"].map((id) => ({ id })); + expectDiff({ values: [a, b, c, d, e] }, { values: [a, c, e] }, [ + ["p", ["values"], 1, 1, []], + ["p", ["values"], 2, 1, []], + ]); + }); + + it.each([ + ["front", [1, 2, 3, 4], [2, 3, 4], [["p", ["values"], 0, 1, []]]], + ["tail", [1, 2, 3, 4], [1, 2, 3], [["p", ["values"], 3, 1, []]]], + ["middle", [1, 2, 3, 4], [1, 3, 4], [["p", ["values"], 1, 1, []]]], + ["all", [1, 2, 3, 4], [], [["p", ["values"], 0, 4, []]]], + ["none", [1, 2, 3, 4], [1, 2, 3, 4], []], + ] as const)("encodes %s removal canonically", (_name, before, after, expected) => { + expectDiff({ values: [...before] }, { values: [...after] }, expected); + }); + + it("does not field-diff unrelated shifted objects with common fields", () => { + const before = [1, 2, 3].map((value) => ({ type: "row", value })); + const after = [2, 3, 4].map((value) => ({ type: "row", value })); + expectDiff({ values: before }, { values: after }, [ + ["p", ["values"], 0, 1, []], + ["p", ["values"], 2, 0, [{ type: "row", value: 4 }]], + ]); + }); + + it("does not treat coincidental id or key fields as structural identity", () => { + const left = { value: "left" }; + const right = { value: "right" }; + const before = [left, { id: 1, key: "a", value: "first" }, { id: 2, key: "b", value: "second" }, right]; + const replacements = [ + { id: 2, key: "b", value: "edited-second" }, + { id: 1, key: "a", value: "edited-first" }, + ]; + const after = [left, ...replacements, right]; + expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]); + }); + + it("combines removals, append, and a shared-subtree survivor edit", () => { + const a = { id: "a", stable: {}, detail: { text: "a" } }; + const b = { id: "b", stable: {}, detail: { text: "b" } }; + const c = { id: "c", stable: {}, detail: { text: "c" } }; + const d = { id: "d", stable: {}, detail: { text: "d" } }; + const changedC = { id: "c", stable: c.stable, detail: { text: "changed" } }; + const appended = { id: "e", stable: {}, detail: { text: "e" } }; + expectDiff({ values: [a, b, c, d] }, { values: [a, changedC, d, appended] }, [ + ["p", ["values"], 1, 1, []], + ["a", ["values", 1, "detail", "text"], "hanged"], + ["p", ["values"], 3, 0, [appended]], + ]); + }); + + it.each([256 * 1024, 1024 * 1024])("does not retain a %i-byte removed-neighbor payload", (size) => { + const payload = "x".repeat(size); + const retained = [ + { id: "a", payload }, + { id: "b", payload }, + { id: "c", payload }, + { id: "d", payload }, + { id: "e", payload }, + ]; + const before = Object.freeze({ values: Object.freeze(retained.slice()) }); + const after = Object.freeze({ values: Object.freeze([retained[0]!, retained[2]!, retained[4]!]) }); + const operations = diffRevisions(before as unknown as JsonValue, after as unknown as JsonValue); + expect(operations).toEqual([ + ["p", ["values"], 1, 1, []], + ["p", ["values"], 2, 1, []], + ]); + expect(JSON.stringify(operations).length).toBeLessThan(100); + expect(applyImmutable(before, operations)).toEqual(after); + expect(before.values).toEqual(retained); + }); + + it.each([1_001, 10_000])("keeps push, pop, and middle removal narrow at %,i items", (size) => { + const values = Array.from({ length: size }, (_, value) => ({ value })); + const appended = { value: size }; + expectDiff({ values }, { values: [...values, appended] }, [["p", ["values"], size, 0, [appended]]]); + expectDiff({ values }, { values: values.slice(0, -1) }, [["p", ["values"], size - 1, 1, []]]); + const middle = Math.floor(size / 2); + expectDiff({ values }, { values: [...values.slice(0, middle), ...values.slice(middle + 1)] }, [ + ["p", ["values"], middle, 1, []], + ]); + }); + + it("keeps forty-thousand-row sparse edits narrow", () => { + const values = Array.from({ length: 40_000 }, (_, value) => ({ value, stable: { value } })); + const after = values.slice(); + const changed: number[] = []; + for (let index = 100; index < after.length; index += 400) { + after[index] = { value: -index, stable: values[index]!.stable }; + changed.push(index); + } + const operations = diffRevisions({ values }, { values: after }); + expect(operations).toEqual(changed.map((index) => ["s", ["values", index, "value"], -index])); + expect(JSON.stringify(operations).length).toBeLessThan(7_500); + expect(applyImmutable({ values }, operations)).toEqual({ values: after }); + }); + + it("keeps a reconstructed large-array leaf edit narrow", () => { + const before = Array.from({ length: 1_000 }, (_, value) => ({ value, label: `row-${value}` })); + const after = structuredClone(before); + after[700]!.label = "changed"; + const operations = diffRevisions({ values: before }, { values: after }); + expect(operations).toEqual([["s", ["values", 700, "label"], "changed"]]); + expect(JSON.stringify(operations).length).toBeLessThan(100); + expect(applyImmutable({ values: before }, operations)).toEqual({ values: after }); + }); + + it("splices an ambiguous equal-count moved-and-edited gap", () => { + const left = { value: "left" }; + const right = { value: "right" }; + const before = [left, { id: 1, value: "a" }, { id: 2, value: "b" }, right]; + const replacements = [ + { id: 2, value: "edited" }, + { id: 1, value: "also-edited" }, + ]; + const after = [left, ...replacements, right]; + expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]); + }); + + it("encodes five hundred unshifts without snapshotting retained rows", () => { + const retained = Array.from({ length: 10_000 }, (_, value) => ({ value, payload: "x".repeat(100) })); + const inserted = Array.from({ length: 500 }, (_, value) => ({ value: -value - 1 })); + const operations = diffRevisions({ values: retained }, { values: [...inserted, ...retained] }); + expect(operations).toEqual([["p", ["values"], 0, 0, inserted]]); + expect(JSON.stringify(operations).length).toBeLessThan(20_000); + expect(applyImmutable({ values: retained }, operations)).toEqual({ values: [...inserted, ...retained] }); + }); + + it("bounds wide-object operation emission with a root replacement", () => { + const before: Record = {}; + const after: Record = {}; + for (let index = 0; index < 20_000; index++) { + before[`field${index}`] = 0; + after[`field${index}`] = 1; + } + expect(diffRevisions(before, after)).toEqual([["r", after]]); + }); + it("falls back to a base operation when leaf operations are larger", () => { const before = { values: Array.from({ length: 40_000 }, () => 0) }; const after = { values: Array.from({ length: 40_000 }, () => 1) }; diff --git a/packages/chord/test/state-draft.test.ts b/packages/chord/test/state-draft.test.ts index 4bf99fe8a..e8e3fc69c 100644 --- a/packages/chord/test/state-draft.test.ts +++ b/packages/chord/test/state-draft.test.ts @@ -1,463 +1,104 @@ import { describe, expect, it } from "vitest"; -import { type Draft, produce, produceWithMetadata } from "../src/state/draft.ts"; +import { applyImmutable, type Draft, track } from "../src/delta/index.ts"; -describe("produce", () => { - it("returns the base when the recipe makes no semantic write", () => { - const base = { count: 1, nested: { value: "same" }, values: [1, 2, 3] }; - const result = produce(base, (draft) => { - draft.count = 1; - draft.nested.value = "same"; - delete (draft as { missing?: boolean }).missing; - draft.values.sort((left, right) => left - right); - }); - expect(result).toBe(base); - }); - - it("copies only changed branches and keeps stable draft identities", () => { - const base = { +describe("copy-on-write change drafts", () => { + it("copies only changed branches", () => { + const tracker = track({ changed: { count: 1, sibling: { value: "kept" } }, untouched: { value: 2 }, - }; - let first: Draft | undefined; - const { value, owned } = produceWithMetadata(base, (draft) => { - first = draft.changed; - expect(draft.changed).toBe(first); - draft.changed.count = 2; - draft.changed.count = 3; }); - - expect(value).not.toBe(base); - expect(value.changed).not.toBe(base.changed); - expect(value.changed.sibling).toBe(base.changed.sibling); - expect(value.untouched).toBe(base.untouched); - expect(value.changed.count).toBe(3); - expect(owned.has(value)).toBe(true); - expect(owned.has(value.changed)).toBe(true); - expect(owned.has(value.changed.sibling)).toBe(false); - expect(owned.has(value.untouched)).toBe(false); - expect(owned.has(base)).toBe(false); + 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 property assignment and deletion without mutating a frozen base", () => { - const nested: Readonly<{ old: boolean }> = Object.freeze({ old: true }); - const base: Readonly<{ - name: string; - nested: Readonly<{ old: boolean }>; - optional?: string; - }> = Object.freeze({ name: "before", nested, optional: "remove" }); - const result = produce(base, (draft) => { - draft.name = "after"; - delete draft.optional; - draft.nested.old = false; - }); - - expect(result).toEqual({ name: "after", nested: { old: false } }); - expect(base).toEqual({ name: "before", nested: { old: true }, optional: "remove" }); - expect(nested.old).toBe(true); + 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("assigns base containers by value instead of creating aliases", () => { - const shared = { value: 1 }; - const base: { source: { value: number }; alias: { value: number } | null } = { source: shared, alias: null }; - const { value, owned } = produceWithMetadata(base, (draft) => { - draft.alias = base.source; - draft.alias.value = 2; - }); - - expect(base.source.value).toBe(1); - expect(value.source).toBe(base.source); - expect(value.source.value).toBe(1); - expect(value.alias).toEqual({ value: 2 }); - expect(value.alias).not.toBe(value.source); - expect(owned.has(value)).toBe(true); - expect(owned.has(value.alias!)).toBe(true); - expect(owned.has(value.source)).toBe(false); - }); - - it("preserves tuple positions in draft types", () => { - const base = { tuple: ["value", 1] as readonly [string, number] }; - produce(base, (draft) => { - const text: string = draft.tuple[0]; - const count: number = draft.tuple[1]; - draft.tuple = [text, count + 1]; - }); - }); - - it("rejects asynchronous recipes without changing the base", () => { - const base = { value: 1 }; - expect(() => - produce(base, async (draft) => { - draft.value = 2; - await Promise.resolve(); - }), - ).toThrow(/synchronous/); - expect(base).toEqual({ value: 1 }); - }); - - it("rolls back and revokes every created draft when the recipe throws", () => { - const base = { child: { count: 1 }, values: [1, 2] }; - const failure = new Error("stop"); - let escapedChild: Draft | undefined; - let escapedArray: Draft | undefined; - - expect(() => - produce(base, (draft) => { - escapedChild = draft.child; - escapedArray = draft.values; - draft.child.count = 9; - draft.values.push(3); - throw failure; - }), - ).toThrow(failure); - expect(base).toEqual({ child: { count: 1 }, values: [1, 2] }); - expect(() => escapedChild?.count).toThrow(TypeError); - expect(() => escapedArray?.length).toThrow(TypeError); - }); - - it("revokes escaped drafts after a successful recipe", () => { - const base = { child: { count: 1 } }; - let escapedRoot: Draft | undefined; - let escapedChild: Draft | undefined; - const result = produce(base, (draft) => { - escapedRoot = draft; - escapedChild = draft.child; - draft.child.count = 2; - }); - - expect(result.child.count).toBe(2); - expect(() => escapedRoot?.child).toThrow(TypeError); - expect(() => escapedChild?.count).toThrow(TypeError); - }); - - it("supports native mutating array methods", () => { - const base = { values: [3, 1, 2] }; - const result = produce(base, (draft) => { - draft.values.push(4); - expect(draft.values.pop()).toBe(4); - draft.values.unshift(0); - expect(draft.values.shift()).toBe(0); - expect(draft.values.splice(1, 1, 5, 4)).toEqual([1]); - draft.values.sort((left, right) => left - right); - draft.values.reverse(); - draft.values.fill(9, 1, 3); - draft.values.copyWithin(1, 0, 2); - }); - - expect(result.values).toEqual([5, 5, 9, 2]); - expect(base.values).toEqual([3, 1, 2]); - }); - - it("copies inserted draft values without detaching their original handle", () => { - const base = { values: [{ value: 1 }, { value: 2 }] }; - const result = produce(base, (draft) => { - const held = draft.values[0]!; - draft.values.unshift(held); - held.value = 9; - }); - expect(result.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]); - expect(result.values[0]).not.toBe(result.values[1]); - }); - - it("fill reads its value after coercing its range", () => { - const base = { values: [{ value: 0 }], other: { value: 1 } }; - const result = produce(base, (draft) => { - draft.values.fill(draft.other, { - valueOf() { - draft.other.value = 2; - return 0; - }, - } as unknown as number); - }); - expect(result.values).toEqual([{ value: 2 }]); - expect( - produce(base, (draft) => { - draft.values.fill(undefined as unknown as { value: number }, 0, 0); - }), - ).toBe(base); - }); - - it("copies object values for fill and copyWithin", () => { - const base = { values: [{ value: 1 }, { value: 2 }, { value: 3 }] }; - const result = produce(base, (draft) => { - draft.values.copyWithin(1, 0, 1); - draft.values[0]!.value = 9; - }); - expect(result.values).toEqual([{ value: 9 }, { value: 1 }, { value: 3 }]); - expect(result.values[0]).not.toBe(result.values[1]); - const filled = produce({ ...base, other: { value: 4 } }, (draft) => { - draft.values.fill(draft.other); - draft.values[0]!.value = 5; - }); - expect(filled.values).toEqual([{ value: 5 }, { value: 4 }, { value: 4 }]); - expect(new Set([...filled.values, filled.other]).size).toBe(4); - }); - - it("uses the actual receiver for borrowed array mutators", () => { - const base = { left: [{ value: 1 }, { value: 2 }], right: [{ value: 3 }] }; - const external = [{ value: 0 }]; - const result = produce(base, (draft) => { - const held = draft.left[0]!; - draft.right.reverse.call(draft.left); - held.value = 9; - draft.right.push.call(external, { value: 2 }); - expect(() => { - const push = draft.right.push; - push({ value: 4 }); - }).toThrow(TypeError); - }); - expect(result.left).toEqual([{ value: 2 }, { value: 9 }]); - expect(external).toEqual([{ value: 0 }, { value: 2 }]); - }); - - it("copies user assignments made while an array mutator coerces arguments", () => { - const base = { values: [{ value: 1 }], other: { value: 2 } }; - const result = produce(base, (draft) => { - draft.values.splice( - { - valueOf() { - draft.values[0] = draft.other; - return 0; - }, - } as unknown as number, - 0, - ); - draft.values[0]!.value = 3; - }); - expect(result).toEqual({ values: [{ value: 3 }], other: { value: 2 } }); - expect(result.values[0]).not.toBe(result.other); - - const sameArray = produce({ values: [{ value: 1 }, { value: 2 }] }, (draft) => { - draft.values.splice( - { - valueOf() { - draft.values[1] = draft.values[0]!; - return 0; - }, - } as unknown as number, - 0, - ); - draft.values[0]!.value = 9; - }); - expect(sameArray.values).toEqual([{ value: 9 }, { value: 1 }]); - expect(sameArray.values[0]).not.toBe(sameArray.values[1]); - }); - - it("copyWithin preserves handles across coercion side effects", () => { - const base = { values: [{ value: 1 }, { value: 2 }, { value: 3 }] }; - const result = produce(base, (draft) => { - const held = draft.values[2]!; - draft.values.copyWithin( - 0, - { - valueOf() { - draft.values.reverse(); - return 0; - }, - } as unknown as number, - 0, - ); - held.value = 9; - }); - expect(result.values).toEqual([{ value: 9 }, { value: 2 }, { value: 1 }]); - }); - - it("preserves native coercion semantics in array arguments", () => { - const spliced = produce({ values: [1, 2, 3] }, (draft) => { - draft.values.splice(Number.POSITIVE_INFINITY, 0, 4); - }); - expect(spliced.values).toEqual([1, 2, 3, 4]); - const filled = produce({ values: [1, 2, 3] }, (draft) => { - draft.values.fill(9, Number.POSITIVE_INFINITY); - draft.values.copyWithin(Number.POSITIVE_INFINITY, 0); - }); - expect(filled.values).toEqual([1, 2, 3]); - const explicitUndefined = produce({ values: [1, 2, 3], copied: [1, 2, 3] }, (draft) => { - draft.values.fill(9, 0, undefined as unknown as number); - draft.copied.copyWithin(1, 0, undefined as unknown as number); - }); - expect(explicitUndefined.values).toEqual([9, 9, 9]); - expect(explicitUndefined.copied).toEqual([1, 1, 2]); - const capturedLength = produce({ values: [1, 2] }, (draft) => { - draft.values.splice( - { - valueOf() { - draft.values.push(3); - return 1; - }, - } as unknown as number, - 0, - ); - }); - expect(capturedLength.values).toEqual([1, 2]); - const removedLength = produce({ values: [1, 2, 3], removed: 0 }, (draft) => { - const removed = draft.values.splice( - { - valueOf() { - draft.values.length = 0; - return 0; - }, - } as unknown as number, - 3, - ); - draft.removed = removed.length; - }); - expect(removedLength).toEqual({ values: [], removed: 3 }); - }); - - it("default sort observes pending nested edits", () => { - const base = { values: [[2], [1]] }; - const result = produce(base, (draft) => { - draft.values[0]![0] = 0; - draft.values.sort(); - }); - expect(result.values).toEqual([[0], [1]]); + 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 base = { + const tracker = track({ rows: [ { rank: 2, comparisons: 0 }, { rank: 1, comparisons: 0 }, ], - }; - const result = produce(base, (draft) => { - draft.rows.sort((left, right) => { - left.comparisons++; - right.comparisons++; - return left.rank - right.rank; - }); }); - - expect(result.rows.map((row) => row.rank)).toEqual([1, 2]); - expect(result.rows.map((row) => row.comparisons)).toEqual([1, 1]); - expect(result.rows[0]).not.toBe(base.rows[1]); - expect(result.rows[1]).not.toBe(base.rows[0]); - expect(base.rows).toEqual([ - { 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 kept = { value: "kept" }; - const removed = { value: "removed" }; - const base = { child: removed, items: [removed, kept] }; - const result = produce(base, (draft) => { - const child = draft.child; - const shifted = draft.items.shift(); - delete (draft as { child?: Draft; items: Draft }).child; - child.value = "detached child"; - if (shifted !== undefined) shifted.value = "detached item"; - }); - - expect(result).toEqual({ items: [kept] }); - expect(result.items[0]).toBe(kept); - expect(removed.value).toBe("removed"); + 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("deep-clones assigned values, expands aliases, and reports every clone as owned", () => { - const shared = { value: 2 }; - const external = { left: shared, right: shared }; - const base: { payload: typeof external | null; untouched: { value: number } } = { - payload: null, - untouched: { value: 1 }, - }; - const { value, owned } = produceWithMetadata(base, (draft) => { - draft.payload = external; - draft.payload.left.value = 3; - }); - - expect(external.left.value).toBe(2); - expect(value.payload).not.toBe(external); - expect(value.payload?.left).not.toBe(shared); - expect(value.payload?.right).not.toBe(shared); - expect(value.payload?.left).not.toBe(value.payload?.right); - expect(value.payload).toEqual({ left: { value: 3 }, right: { value: 2 } }); - expect(value.untouched).toBe(base.untouched); - expect(owned.has(value)).toBe(true); - expect(owned.has(value.payload!)).toBe(true); - expect(owned.has(value.payload!.left)).toBe(true); - expect(owned.has(value.payload!.right)).toBe(true); - expect(owned.has(external)).toBe(false); - expect(owned.has(shared)).toBe(false); + 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 cycles in assigned values without changing the base", () => { - const cyclic: { self?: unknown } = {}; - cyclic.self = cyclic; - const base: { payload: object | null } = { payload: null }; - expect(() => - produce(base, (draft) => { - draft.payload = cyclic; - }), - ).toThrow(/cycles/); - expect(base.payload).toBeNull(); - }); - - it("rejects sparse or undefined array results", () => { - const base = { values: [1, 2] }; - expect(() => - produce(base, (draft) => { - delete draft.values[0]; - }), - ).toThrow(/dense|holes/); - expect(() => - produce(base, (draft) => { - draft.values[0] = undefined as unknown as number; - }), - ).toThrow(/undefined/); - expect(() => - produce(base, (draft) => { - draft.values.length = 4; - }), - ).toThrow(/dense|holes/); - expect(base.values).toEqual([1, 2]); - }); - - it("writes own properties without invoking inherited setters", () => { - Object.defineProperty(Object.prototype, "trap", { - set() { - throw new Error("inherited setter ran"); - }, - configurable: true, - }); - try { - const result = produce({ nested: {} as Record }, (draft) => { - draft.nested.trap = 1; - }); - expect(result).toEqual({ nested: { trap: 1 } }); - } finally { - delete (Object.prototype as Record).trap; - } - - let pushed: { values: number[] } | undefined; - let unshifted: { values: number[] } | undefined; - Object.defineProperty(Array.prototype, "3", { - set() { - throw new Error("inherited array setter ran"); - }, - configurable: true, - }); - try { - pushed = produce({ values: [1, 2, 3] }, (draft) => { - draft.values.push(4); - }); - unshifted = produce({ values: [1, 2, 3] }, (draft) => { - draft.values.unshift(0); - }); - } finally { - delete (Array.prototype as unknown as Record)["3"]; - } - expect(pushed).toEqual({ values: [1, 2, 3, 4] }); - expect(unshifted).toEqual({ values: [0, 1, 2, 3] }); - }); - - it("rejects reflective mutations and symbol writes", () => { - const base = { value: 1 }; - expect(() => produce(base, (draft) => Object.defineProperty(draft, "other", { value: 2 }))).toThrow(TypeError); - expect(() => produce(base, (draft) => Object.setPrototypeOf(draft, null))).toThrow(TypeError); - expect(() => produce(base, (draft) => Object.preventExtensions(draft))).toThrow(TypeError); - expect(() => produce(base, (draft) => Reflect.set(draft, Symbol("key"), 2))).toThrow(/Symbol/); - expect(base).toEqual({ value: 1 }); + 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(); + expect(() => { + change.state.values[0] = undefined as unknown as number; + }).toThrow(/undefined/); + 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 7fdfc4f43..46aea678d 100644 --- a/packages/chord/test/state-fuzz.test.ts +++ b/packages/chord/test/state-fuzz.test.ts @@ -1,9 +1,5 @@ import { expect, it } from "vitest"; -import { BACKGROUND_CONTEXT } from "../src/context/index.ts"; -import { applyImmutable, type Op } from "../src/delta/index.ts"; -import { replicatedState } from "../src/index.ts"; -import { getReplicatedStateInternals } from "../src/services/state-internals.ts"; -import type { Draft } from "../src/state/draft.ts"; +import { applyImmutable, type Draft, type Op, track } from "../src/delta/index.ts"; type Item = { id: number; text: string; score: number }; type Document = { @@ -75,7 +71,7 @@ const mutate = (document: MutableDocument, choice: number, value: number): void } }; -it("converges across randomized transactional revisions", () => { +it("converges across randomized prepared revisions", () => { for (let seed = 1; seed <= 100; seed++) { const rng = random(seed); const initial: Document = { @@ -83,23 +79,22 @@ it("converges across randomized transactional revisions", () => { text: "start", meta: { revision: 0 }, }; - const state = replicatedState(initial); + const tracker = track(initial); const expected = clone(initial); - let replica = clone(state.value); - let latest: readonly Op[] = []; - getReplicatedStateInternals(state)!.subscribe((operations) => { - latest = operations; - }); + 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); - latest = []; - state.change(BACKGROUND_CONTEXT, (draft) => mutate(draft, choice, value)); - replica = applyImmutable(replica, latest); - expect(state.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); + 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); - expect(Object.isFrozen(state.value)).toBe(true); + expect(Object.isFrozen(tracker.value)).toBe(true); } } }); diff --git a/packages/chord/test/state-value.test.ts b/packages/chord/test/state-value.test.ts index dd31f7205..202deb590 100644 --- a/packages/chord/test/state-value.test.ts +++ b/packages/chord/test/state-value.test.ts @@ -1,40 +1,37 @@ import { describe, expect, it } from "vitest"; -import { produceWithMetadata } from "../src/state/draft.ts"; -import { JsonRevisionStore } from "../src/state/value.ts"; +import { track } from "../src/delta/index.ts"; -describe("JSON revision ownership", () => { +describe("immutable revision ownership", () => { it("imports detached frozen JSON and expands aliases", () => { const shared = { value: 1 }; const input = { left: shared, right: shared }; - const store = new JsonRevisionStore(); - const value = store.import(input); - expect(value).toEqual(input); - expect(value).not.toBe(input); - expect(value.left).not.toBe(value.right); - expect(Object.isFrozen(value)).toBe(true); - expect(Object.isFrozen(value.left)).toBe(true); + const tracker = track(input); + expect(tracker.value).toEqual(input); + expect(tracker.value).not.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); }); it("commits transaction copies in place while sharing unchanged branches", () => { - const store = new JsonRevisionStore(); - const base = store.import({ changed: { value: 1 }, retained: { value: 2 } }); - const produced = produceWithMetadata(base, (draft) => { - draft.changed.value = 3; - }); - const next = store.commit(produced.value, produced.owned); - expect(next).toBe(produced.value); - expect(next.changed).toBe(produced.value.changed); - expect(next.retained).toBe(base.retained); - expect(Object.isFrozen(next)).toBe(true); - expect(Object.isFrozen(next.changed)).toBe(true); + 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.changed).not.toBe(base.changed); + expect(prepared.value.retained).toBe(base.retained); + expect(Object.isFrozen(prepared.value.changed)).toBe(true); + tracker.adopt(prepared); + expect(tracker.value).toBe(prepared.value); }); - it("rejects non-JSON values and cycles", () => { - const store = new JsonRevisionStore(); - expect(() => store.import({ value: Number.NaN })).toThrow(/strict JSON/); - expect(() => store.import({ value: new Date() })).toThrow(/plain objects/); - const cyclic: { self?: object } = {}; - cyclic.self = cyclic; - expect(() => store.import(cyclic)).toThrow(/cycles/); + 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[0]).not.toBe(prepared.value.values[1]); }); }); diff --git a/packages/chord/test/state.test.ts b/packages/chord/test/state.test.ts index 1ccc7e808..a604083c2 100644 --- a/packages/chord/test/state.test.ts +++ b/packages/chord/test/state.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT } from "../src/context/index.ts"; -import { replicatedState } from "../src/index.ts"; +import type { Draft, Op } from "../src/delta/index.ts"; +import { + type Context, + type JsonValue, + type ReplicatedStateSource, + type ReplicatedStateSourceAttachment, + type ReplicatedStateSourceFrame, + replicatedState, +} from "../src/index.ts"; import { ReplicatedStateReplica } from "../src/services/state.ts"; import { getReplicatedStateInternals } from "../src/services/state-internals.ts"; -import type { Draft } from "../src/state/draft.ts"; -import type { JsonValue } from "../src/types.ts"; describe("transactional replicated state", () => { it("publishes one immutable structurally shared revision", () => { @@ -40,6 +46,18 @@ describe("transactional replicated state", () => { expect(() => escaped?.value).toThrow(TypeError); }); + it("rejects PromiseLike callbacks and aborts their draft", () => { + const state = replicatedState({ value: 0 }); + const previous = state.value; + expect(() => + state.change(BACKGROUND_CONTEXT, ((draft: Draft<{ value: number }>) => { + draft.value = 1; + return Promise.resolve(); + }) as (draft: Draft<{ value: number }>) => void), + ).toThrow(/synchronous/); + expect(state.value).toBe(previous); + }); + it("rejects nested changes and replacements without losing the outer rollback", () => { const state = replicatedState({ left: 0, right: 0 }); expect(() => @@ -192,3 +210,193 @@ describe("transactional replicated state", () => { expect(state.value.retained).not.toBe(previous.retained); }); }); + +type SourceValue = Readonly<{ value: number }>; + +class TestSource implements ReplicatedStateSource { + readonly attachments = new Set>(); + onAttach: (() => void) | undefined; + #value: T; + #cursor: number; + + constructor(value: T, cursor = 0) { + this.#value = value; + this.#cursor = cursor; + } + + attach(): ReplicatedStateSourceAttachment { + const attachment = new TestSourceAttachment(Object.freeze({ value: this.#value, cursor: this.#cursor }), () => + this.attachments.delete(attachment), + ); + this.attachments.add(attachment); + this.onAttach?.(); + return attachment; + } + + commit(value: T, ops: readonly Op[], context: Context = BACKGROUND_CONTEXT, cursor = this.#cursor + 1): void { + this.#value = value; + this.#cursor = cursor; + const frame = Object.freeze({ cursor, value, ops, context }); + for (const attachment of [...this.attachments]) attachment.publish(frame); + } +} + +class TestSourceAttachment implements ReplicatedStateSourceAttachment { + readonly snapshot: { readonly value: T; readonly cursor: number }; + readonly #onDispose: () => void; + readonly #buffer: ReplicatedStateSourceFrame[] = []; + #listener: ((frame: ReplicatedStateSourceFrame) => void) | undefined; + #activated = false; + #disposed = false; + + constructor(snapshot: { readonly value: T; readonly cursor: number }, onDispose: () => void) { + this.snapshot = snapshot; + this.#onDispose = onDispose; + } + + activate(listener: (frame: ReplicatedStateSourceFrame) => void): void { + if (this.#activated) throw new Error("attachment is already active"); + this.#activated = true; + this.#listener = listener; + for (const frame of this.#buffer.splice(0)) listener(frame); + } + + publish(frame: ReplicatedStateSourceFrame): void { + if (this.#disposed) return; + if (this.#listener === undefined) this.#buffer.push(frame); + else this.#listener(frame); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#buffer.length = 0; + this.#onDispose(); + } +} + +describe("authoritative replicated state sources", () => { + it("captures before activation and drains queued commits in order", () => { + const initial = Object.freeze({ value: 0 }); + const first = Object.freeze({ value: 1 }); + const second = Object.freeze({ value: 2 }); + const firstOps = Object.freeze([["s", ["value"], 1] as const]); + const secondOps = Object.freeze([["s", ["value"], 2] as const]); + const source = new TestSource(initial, 10); + source.onAttach = () => { + source.commit(first, firstOps); + source.commit(second, secondOps); + }; + + const state = replicatedState(source); + const deliveries: Array<{ value: SourceValue; sequence: number }> = []; + state.subscribe((value, _context, delivery) => deliveries.push({ value, sequence: delivery.sequence })); + expect(state.value).toBe(second); + expect(deliveries).toEqual([{ value: second, sequence: 2 }]); + expect(getReplicatedStateInternals(state)?.snapshot()).toEqual({ value: second, sequence: 2 }); + }); + + it("hydrates at sequence zero when attaching after existing commits", () => { + const initial = Object.freeze({ value: 0 }); + const current = Object.freeze({ value: 1 }); + const source = new TestSource(initial, 40); + source.commit(current, Object.freeze([["s", ["value"], 1] as const])); + const state = replicatedState(source); + const deliveries: number[] = []; + state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence)); + expect(state.value).toBe(current); + expect(deliveries).toEqual([0]); + }); + + it("publishes exact source value and operation references without applying or re-diffing", () => { + const source = new TestSource(Object.freeze({ value: 0 })); + const state = replicatedState(source); + const next = Object.freeze({ value: 1 }); + const ops = Object.freeze([["s", ["value"], 1] as const]); + let publishedOps: readonly Op[] | undefined; + getReplicatedStateInternals(state)?.subscribe((received) => { + publishedOps = received; + }); + let publishedValue: SourceValue | undefined; + state.subscribe((value, _context, delivery) => { + if (delivery.kind === "update") publishedValue = value; + }); + + source.commit(next, ops); + expect(state.value).toBe(next); + expect(publishedValue).toBe(next); + expect(publishedOps).toBe(ops); + }); + + it("buffers reentrant frames and skips updates covered by a late hydration", () => { + const source = new TestSource(Object.freeze({ value: 0 })); + const state = replicatedState(source); + const received: number[] = []; + const late: Array<{ kind: string; sequence: number; value: number }> = []; + let nested = false; + getReplicatedStateInternals(state)?.subscribe((_ops, sequence) => { + if (sequence !== 1 || nested) return; + nested = true; + source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const])); + state.subscribe((value, _context, delivery) => late.push({ ...delivery, value: value.value })); + }); + state.subscribe((value, _context, delivery) => { + if (delivery.kind === "update") received.push(value.value); + }); + + source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])); + expect(received).toEqual([1, 2]); + expect(late).toEqual([{ kind: "hydrate", sequence: 2, value: 2 }]); + }); + + it("reports listener failures without throwing them into the source", () => { + const source = new TestSource(Object.freeze({ value: 0 })); + const errors: Error[] = []; + const state = replicatedState(source, { onError: (error) => errors.push(error) }); + const received: number[] = []; + state.subscribe((_value, _context, delivery) => { + if (delivery.kind === "update") throw new Error("listener failed"); + }); + state.subscribe((value, _context, delivery) => { + if (delivery.kind === "update") received.push(value.value); + }); + + expect(() => + source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])), + ).not.toThrow(); + source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const])); + expect(received).toEqual([1, 2]); + expect(errors.map((error) => error.message)).toEqual(["listener failed", "listener failed"]); + }); + + it("reports cursor gaps, disposes the broken attachment, and ignores later frames", () => { + const source = new TestSource(Object.freeze({ value: 0 }), 5); + const errors: Error[] = []; + const state = replicatedState(source, { onError: (error) => errors.push(error) }); + source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]), BACKGROUND_CONTEXT, 7); + expect(errors[0]?.message).toContain("expected 6, received 7"); + expect(source.attachments.size).toBe(0); + expect(state.value).toEqual({ value: 0 }); + source.commit(Object.freeze({ value: 3 }), Object.freeze([["s", ["value"], 3] as const]), BACKGROUND_CONTEXT, 8); + expect(state.value).toEqual({ value: 0 }); + }); + + it("keeps attachments independent and disposes each idempotently", () => { + const source = new TestSource(Object.freeze({ value: 0 })); + const first = replicatedState(source); + const second = replicatedState(source); + expect(source.attachments.size).toBe(2); + source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])); + expect(first.value.value).toBe(1); + expect(second.value.value).toBe(1); + + first.dispose(); + first.dispose(); + expect(source.attachments.size).toBe(1); + source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const])); + expect(first.value.value).toBe(1); + expect(second.value.value).toBe(2); + second.dispose(); + expect(source.attachments.size).toBe(0); + }); +});