diff --git a/packages/chord/src/delta/astra/README.md b/packages/chord/src/delta/astra/README.md new file mode 100644 index 000000000..82449d6e6 --- /dev/null +++ b/packages/chord/src/delta/astra/README.md @@ -0,0 +1,24 @@ +# Astra trusted contract + +Astra is an exact overlay tracker for trusted mutable live state. `track()` and +`prepareReplace()` take ownership of their roots in O(1); callers must not mutate +transferred roots except through an active Astra draft. Successful adoption +mutates the committed root in place, so previously retained committed references +observe later transactions. + +Inputs and placements must already be alias-free strict JSON trees: plain objects, +dense arrays, strings, booleans, finite numbers, and `null`. Cycles, accessors, +proxies, symbols, classes, sparse arrays, `undefined`, and non-finite numbers are +outside the contract. Astra deliberately does not validate or freeze complete +input trees. + +Supported draft operations use ordinary property access, primitive array indices, +and non-reentrant array callbacks. The following are outside the trusted contract: + +- coercion callbacks that mutate the draft while an array method is converting an argument; +- mutations or `prepare()` calls from inside a `sort()` comparator; +- bigint or object-valued comparator results requiring coercion; +- non-primitive array indices. + +These exclusions avoid callback-order and coercion machinery on the overlay hot +paths. Behavior for excluded operations is unspecified. diff --git a/packages/chord/src/delta/astra/index.ts b/packages/chord/src/delta/astra/index.ts new file mode 100644 index 000000000..24e6889c6 --- /dev/null +++ b/packages/chord/src/delta/astra/index.ts @@ -0,0 +1,2005 @@ +import type { JsonValue } from "../../types.ts"; +import type { Draft } from "../draft.ts"; +import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; + +export type { Draft, JsonValue, Op }; + +type Primitive = null | boolean | number | string; +type Container = JsonValue[] | Record; +type Stored = JsonValue; +type Status = "open" | "prepared" | "consumed" | "aborted" | "stale" | "faulted"; + +type InsertSource = { values: Stored[] }; +type ParentKind = 0 | 1 | 2; // object, base-array entry, inserted-array entry + +type BasePiece = { kind: "base"; start: number; length: number; step: 1 | -1 }; +type InsertPiece = { kind: "insert"; source: InsertSource; start: number; length: number; step: 1 | -1 }; +type Piece = BasePiece | InsertPiece; + +type PieceNode = { + piece: Piece; + left: PieceNode | undefined; + right: PieceNode | undefined; + priority: number; + elements: number; +}; + +type PieceLocation = { piece: Piece; logicalStart: number; minimum: number; maximum: number }; + +type DenseRegion = { start: number; length: number }; +type DenseCandidates = { indices: number[]; bits: Uint8Array | undefined; length: number }; + +type ArrayPlan = { + removeRuns: number[]; + permutation: number[] | undefined; + insertRuns: number[]; +}; + +type ArrayOverlay = { + root: PieceNode | undefined; + pieces: Piece[] | undefined; + baseOverrides: Map; + insertOverrides: Map>; + structural: boolean; + generation: number; + plan: ArrayPlan | undefined; + seed: number; + locatedOffset: number; + baseLocations: PieceLocation[] | undefined; + insertLocations: Map | undefined; +}; + +type OverlayNode = { + context: OverlayContext; + base: Container; + parent: OverlayNode | undefined; + parentKind: ParentKind; + parentKey: string | number; + parentSource?: InsertSource; + parentExpected?: Container; + parentPlacement: boolean; + target: object; + proxy: object; + writeKey?: string; + writeValue?: Stored; + writes?: Map; + deleteKey?: string; + deletes?: Set; + readded?: Set; + array?: ArrayOverlay; + dirty?: boolean; + preparedPath?: Path; + applyDepth?: number; +}; + +type OverlayContext = { + owner: object; + tracker: WeakRef>; + baseRevision: number; + status: Status; + root: OverlayNode | undefined; + dirty: OverlayNode[]; + nodes: OverlayNode[]; + rawNodes: WeakMap; + ops: Op[] | undefined; + replacement: boolean; + replacementNoop: boolean; + baseValue: object | undefined; + adoptedValue: object | undefined; + registryRef: WeakRef | undefined; +}; + +type PreparedMetadata = { context: OverlayContext }; + +const NODE = Symbol("astra.overlay.node"); +const PREPARED = new WeakMap(); +const RELEASED: Record = {}; +const ARRAY_MUTATORS = new Set([ + "push", + "pop", + "shift", + "unshift", + "splice", + "reverse", + "sort", + "fill", + "copyWithin", +]); +const MAX_SPLICE_CHUNK = 10_000; +const MAX_DELTA_OPERATIONS = 4_096; + +export interface Prepared { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; + readonly baseRevision: number; + abort(): void; +} + +export interface Change { + readonly state: Draft; + prepare(): Prepared; + abort(): void; +} + +export interface Tracker { + readonly value: T; + readonly revision: number; + beginChange(): Change; + prepareReplace(value: T): Prepared; + adopt(prepared: Prepared): void; +} + +class PreparedImpl implements Prepared { + readonly #context: OverlayContext; + readonly #view: T; + readonly base: T; + + constructor(context: OverlayContext, value: T) { + this.#context = context; + this.#view = value; + this.base = context.baseValue as T; + PREPARED.set(this, { context }); + } + + get value(): T { + return (this.#context.status === "consumed" ? this.#context.adoptedValue : this.#view) as T; + } + + get baseRevision(): number { + return this.#context.baseRevision; + } + + get ops(): readonly Op[] { + return ensureOperations(this.#context); + } + + abort(): void { + abortContext(this.#context); + } +} + +class ChangeImpl implements Change { + readonly #state: WeakRef; + #context: OverlayContext | undefined; + #preparedContext: WeakRef | undefined; + #settled = false; + + constructor(context: OverlayContext) { + this.#context = context; + this.#state = new WeakRef(context.root!.proxy); + } + + get state(): Draft { + const state = this.#state.deref(); + if (state === undefined) throw new TypeError("Cannot use a settled overlay"); + return state as Draft; + } + + prepare(): Prepared { + if (this.#settled) throw new Error("Change has already been settled"); + const context = this.#context!; + assertWritable(context); + context.status = "prepared"; + try { + if (!context.replacement) context.ops = emitOperations(context); + const prepared = new PreparedImpl(context, context.root!.proxy as T); + this.#preparedContext = new WeakRef(context); + this.#settled = true; + return prepared; + } catch (error) { + context.status = "aborted"; + clearContext(context); + this.#settled = true; + throw error; + } finally { + this.#context = undefined; + } + } + + abort(): void { + if (this.#settled) { + const context = this.#preparedContext?.deref(); + this.#preparedContext = undefined; + if (context !== undefined) abortContext(context); + return; + } + this.#settled = true; + abortContext(this.#context!); + this.#context = undefined; + } +} + +class TrackerImpl implements Tracker { + readonly #owner = {}; + readonly #contexts = new Set>(); + #value: T; + #revision = 0; + #pruneBudget = 256; + #fault: unknown; + + constructor(initial: T) { + this.#value = initial; + } + + get value(): T { + this.#assertHealthy(); + return this.#value; + } + + get revision(): number { + return this.#revision; + } + + beginChange(): Change { + this.#assertHealthy(); + const context = createContext(this, this.#owner, this.#revision, this.#value, false, this.#value); + this.#register(context); + return new ChangeImpl(context); + } + + prepareReplace(value: T): Prepared { + this.#assertHealthy(); + const context = createContext(this, this.#owner, this.#revision, value, true, this.#value); + context.status = "prepared"; + this.#register(context); + return new PreparedImpl(context, context.root!.proxy as T); + } + + adopt(prepared: Prepared): void { + this.#assertHealthy(); + const context = PREPARED.get(prepared as object)?.context; + if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); + if (context.status === "consumed") throw new Error("Prepared change has already been used"); + if (context.status === "aborted") throw new Error("Prepared change has been aborted"); + if (context.status === "stale") throw new Error("Prepared change is stale"); + if (context.status !== "prepared") throw new Error("Prepared change is not ready"); + if (context.baseRevision !== this.#revision) { + context.status = "stale"; + clearContext(context); + throw new Error("Prepared change is stale"); + } + + // Detach payloads before the candidate placements become committed mutable data. + ensureOperations(context); + try { + if (context.replacement) { + if (!context.replacementNoop) this.#value = context.root!.base as T; + } else applyOverlay(context); + } catch (error) { + context.status = "faulted"; + this.#fault = error; + this.#invalidate(context); + throw new Error("Tracker faulted after a partial trusted adoption", { cause: error }); + } + context.adoptedValue = this.#value; + context.status = "consumed"; + this.#revision += 1; + this.#invalidate(context); + } + + releaseContext(context: OverlayContext): void { + if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef); + context.registryRef = undefined; + } + + #register(context: OverlayContext): void { + const reference = new WeakRef(context); + context.registryRef = reference; + this.#contexts.add(reference); + this.#pruneBudget -= 1; + if (this.#pruneBudget === 0) { + this.#prune(); + this.#pruneBudget = Math.max(256, this.#contexts.size); + } + } + + #prune(): void { + for (const reference of this.#contexts) { + if (reference.deref() === undefined) this.#contexts.delete(reference); + } + } + + #invalidate(winner: OverlayContext): void { + for (const reference of this.#contexts) { + const context = reference.deref(); + if (context === undefined) { + this.#contexts.delete(reference); + continue; + } + if (context !== winner && (context.status === "open" || context.status === "prepared")) { + context.status = "stale"; + } + clearContext(context); + this.#contexts.delete(reference); + } + this.#pruneBudget = 256; + } + + replacementBase(context: OverlayContext): T | undefined { + return context.baseRevision === this.#revision ? this.#value : undefined; + } + + #assertHealthy(): void { + if (this.#fault !== undefined) throw new Error("Tracker is faulted", { cause: this.#fault }); + } +} + +/** Take ownership of an alias-free mutable strict-JSON root in O(1). */ +export function track(initial: T): Tracker { + return new TrackerImpl(initial); +} + +function createContext( + tracker: TrackerImpl, + owner: object, + baseRevision: number, + root: T, + replacement: boolean, + base: T, +): OverlayContext { + const context: OverlayContext = { + owner, + tracker: new WeakRef(tracker as unknown as TrackerImpl), + baseRevision, + status: "open", + root: undefined, + dirty: [], + nodes: [], + rawNodes: new WeakMap(), + ops: undefined, + replacement, + replacementNoop: false, + baseValue: base, + adoptedValue: undefined, + registryRef: undefined, + }; + context.root = createNode(context, root as Container, undefined); + return context; +} + +const sharedObjectHandler: ProxyHandler = { + deleteProperty(target, property) { + return deleteProperty(nodeForTarget(target), property); + }, + defineProperty(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Defining overlay properties is not supported"); + }, + get(target, property) { + const node = nodeForTarget(target); + if (property === NODE) return node; + return getProperty(node, property); + }, + getOwnPropertyDescriptor(target, property) { + return getDescriptor(nodeForTarget(target), property); + }, + getPrototypeOf(target) { + const node = nodeForTarget(target); + assertReadable(node.context); + return Object.getPrototypeOf(node.base); + }, + has(target, property) { + return hasProperty(nodeForTarget(target), property); + }, + isExtensible(target) { + assertReadable(nodeForTarget(target).context); + return true; + }, + ownKeys(target) { + return ownKeys(nodeForTarget(target)); + }, + preventExtensions(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Overlays cannot be made non-extensible"); + }, + set(target, property, value) { + return setProperty(nodeForTarget(target), property, value); + }, + setPrototypeOf(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Changing an overlay prototype is not supported"); + }, +}; + +const sharedArrayHandler: ProxyHandler = sharedObjectHandler; + +function createNode( + context: OverlayContext, + base: Container, + parent: OverlayNode | undefined, + parentKind: ParentKind = 0, + parentKey: string | number = "", + parentSource: InsertSource | undefined = undefined, + parentExpected: Container | undefined = undefined, + parentPlacement = false, +): OverlayNode { + const existing = context.rawNodes.get(base); + if (existing !== undefined) return existing; + const target: object = Array.isArray(base) ? [] : {}; + if (Array.isArray(target)) target.length = (base as JsonValue[]).length; + const node: OverlayNode = { + context, + base, + parent, + parentKind, + parentKey, + parentPlacement, + target, + proxy: target, + }; + if (parentSource !== undefined) node.parentSource = parentSource; + if (parentExpected !== undefined) node.parentExpected = parentExpected; + Object.defineProperty(target, NODE, { value: node, configurable: true }); + node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); + context.rawNodes.set(base, node); + context.nodes.push(node); + return node; +} + +function nodeForTarget(target: object): OverlayNode { + const node = Object.getOwnPropertyDescriptor(target, NODE)?.value as OverlayNode | undefined; + if (node === undefined) throw new TypeError("Cannot use a settled overlay"); + return node; +} + +function assertReadable(context: OverlayContext): void { + if ( + context.status === "consumed" || + context.status === "aborted" || + context.status === "stale" || + context.status === "faulted" + ) { + throw new TypeError("Cannot use a settled overlay"); + } +} + +function assertWritable(context: OverlayContext): void { + assertReadable(context); + if (context.status !== "open") throw new TypeError("Prepared overlays are read-only"); +} + +function getProperty(node: OverlayNode, property: PropertyKey): unknown { + assertReadable(node.context); + if (Array.isArray(node.base)) { + const overlay = arrayOverlay(node); + if (property === "length") return arrayLength(overlay); + if (ARRAY_MUTATORS.has(property)) return arrayMutators[property as keyof typeof arrayMutators]; + const index = arrayIndex(property); + if (index !== undefined) { + if (index >= arrayLength(overlay)) return undefined; + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const value = entryValueAt(node, piece, sourceIndex); + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + piece.kind === "base" ? 1 : 2, + sourceIndex, + piece.kind === "insert" ? piece.source : undefined, + value, + piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex), + ).proxy; + } + return Reflect.get(Array.prototype, property, node.proxy); + } + if (typeof property === "symbol") return Reflect.get(node.base, property, node.proxy); + const key = String(property); + if (!objectHas(node, key)) { + if (isObjectDeleted(node, key) || Object.hasOwn(node.base, key)) return undefined; + return Reflect.get(node.base, property, node.proxy); + } + const value = objectValue(node, key); + if (!isContainer(value)) return value; + return createNode(node.context, value, node, 0, key, undefined, value, hasObjectWrite(node, key)).proxy; +} + +function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(node.base)) { + if (property === "length") { + setArrayLength(node, toArrayLength(supplied)); + return true; + } + const index = arrayIndex(property); + if (index === undefined) throw new TypeError("Only array indices and length can be written"); + if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); + setArrayIndex(node, index, clonePlacement(supplied)); + return true; + } + const key = String(property); + if (supplied === undefined) return deleteProperty(node, key); + const stored = clonePlacement(supplied); + const current = objectValue(node, key); + const wasDeleted = isObjectDeleted(node, key); + if (!wasDeleted && !isContainer(stored) && current === stored) return true; + setObjectWrite(node, key, stored); + if (wasDeleted && Object.hasOwn(node.base, key)) { + if (node.readded === undefined) node.readded = new Set(); + node.readded.add(key); + } + deleteObjectDeletion(node, key); + markDirty(node); + return true; +} + +function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(node.base)) throw new TypeError("Overlay arrays cannot contain holes"); + const key = String(property); + if (!objectHas(node, key)) return true; + deleteObjectWrite(node, key); + node.readded?.delete(key); + setObjectDeletion(node, key); + markDirty(node); + return true; +} + +function hasProperty(node: OverlayNode, property: PropertyKey): boolean { + assertReadable(node.context); + if (Array.isArray(node.base)) { + if (property === "length") return true; + const index = arrayIndex(property); + if (index !== undefined) return index < arrayLength(arrayOverlay(node)); + return property in Array.prototype; + } + return typeof property === "symbol" ? property in node.base : objectHas(node, String(property)); +} + +function ownKeys(node: OverlayNode): ArrayLike { + assertReadable(node.context); + if (Array.isArray(node.base)) { + const length = arrayLength(arrayOverlay(node)); + return [...Array.from({ length }, (_, index) => String(index)), "length"]; + } + const keys = Object.keys(node.base).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); + const seen = new Set(keys); + if (node.writeKey !== undefined && !seen.has(node.writeKey)) { + keys.push(node.writeKey); + seen.add(node.writeKey); + } + for (const key of node.writes?.keys() ?? []) { + if (seen.has(key)) continue; + keys.push(key); + seen.add(key); + } + const indices: number[] = []; + const strings: string[] = []; + for (const key of keys) { + const index = arrayIndex(key); + if (index === undefined) strings.push(key); + else indices.push(index); + } + indices.sort((left, right) => left - right); + return [...indices.map(String), ...strings]; +} + +function getDescriptor(node: OverlayNode, property: PropertyKey): PropertyDescriptor | undefined { + assertReadable(node.context); + if (Array.isArray(node.base)) { + if (property === "length") { + const length = arrayLength(arrayOverlay(node)); + (node.target as unknown[]).length = length; + return Reflect.getOwnPropertyDescriptor(node.target, "length"); + } + const index = arrayIndex(property); + if (index === undefined || index >= arrayLength(arrayOverlay(node))) return undefined; + } else { + if (typeof property === "symbol" || !objectHas(node, String(property))) return undefined; + } + return { + configurable: true, + enumerable: true, + writable: node.context.status === "open", + value: getProperty(node, property), + }; +} + +function hasObjectWrite(node: OverlayNode, key: string): boolean { + return node.writeKey === key || (node.writes?.has(key) ?? false); +} + +function setObjectWrite(node: OverlayNode, key: string, value: Stored): void { + if (node.writes !== undefined) { + node.writes.set(key, value); + return; + } + if (node.writeKey === undefined || node.writeKey === key) { + node.writeKey = key; + node.writeValue = value; + return; + } + node.writes = new Map([ + [node.writeKey, node.writeValue!], + [key, value], + ]); + node.writeKey = undefined; + node.writeValue = undefined; +} + +function deleteObjectWrite(node: OverlayNode, key: string): void { + if (node.writeKey === key) { + node.writeKey = undefined; + node.writeValue = undefined; + } else node.writes?.delete(key); +} + +function isObjectDeleted(node: OverlayNode, key: string): boolean { + return node.deleteKey === key || (node.deletes?.has(key) ?? false); +} + +function setObjectDeletion(node: OverlayNode, key: string): void { + if (node.deletes !== undefined) { + node.deletes.add(key); + return; + } + if (node.deleteKey === undefined || node.deleteKey === key) { + node.deleteKey = key; + return; + } + node.deletes = new Set([node.deleteKey, key]); + node.deleteKey = undefined; +} + +function deleteObjectDeletion(node: OverlayNode, key: string): void { + if (node.deleteKey === key) node.deleteKey = undefined; + else node.deletes?.delete(key); +} + +function objectHas(node: OverlayNode, key: string): boolean { + if (isObjectDeleted(node, key)) return false; + return hasObjectWrite(node, key) || Object.hasOwn(node.base, key); +} + +function objectValue(node: OverlayNode, key: string): Stored { + if (node.writeKey === key) return node.writeValue!; + if (node.writes?.has(key)) return node.writes.get(key)!; + return (node.base as Record)[key]!; +} + +function markDirty(node: OverlayNode): void { + if (node.dirty) return; + node.dirty = true; + node.context.dirty.push(node); +} + +function arrayOverlay(node: OverlayNode): ArrayOverlay { + if (node.array !== undefined) return node.array; + const base = node.base as JsonValue[]; + const overlay: ArrayOverlay = { + root: undefined, + pieces: undefined, + baseOverrides: new Map(), + insertOverrides: new Map(), + structural: false, + generation: 0, + plan: undefined, + seed: 0x9e3779b9, + locatedOffset: 0, + baseLocations: undefined, + insertLocations: undefined, + }; + if (base.length > 0) + overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); + node.array = overlay; + return overlay; +} + +function nextPiecePriority(overlay: ArrayOverlay): number { + let value = overlay.seed; + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + overlay.seed = value >>> 0; + return overlay.seed; +} + +function treeElements(node: PieceNode | undefined): number { + return node?.elements ?? 0; +} + +function updatePieceNode(node: PieceNode): void { + node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); +} + +function createPieceNode(overlay: ArrayOverlay, piece: Piece): PieceNode { + return { piece, left: undefined, right: undefined, priority: nextPiecePriority(overlay), elements: piece.length }; +} + +function mergePieceTrees(left: PieceNode | undefined, right: PieceNode | undefined): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + if (left.priority >= right.priority) { + left.right = mergePieceTrees(left.right, right); + updatePieceNode(left); + return left; + } + right.left = mergePieceTrees(left, right.left); + updatePieceNode(right); + return right; +} + +function splitPieceTree( + overlay: ArrayOverlay, + root: PieceNode | undefined, + index: number, +): [PieceNode | undefined, PieceNode | undefined] { + if (root === undefined) return [undefined, undefined]; + const leftLength = treeElements(root.left); + if (index < leftLength) { + const [left, right] = splitPieceTree(overlay, root.left, index); + root.left = right; + updatePieceNode(root); + return [left, root]; + } + const pieceEnd = leftLength + root.piece.length; + if (index > pieceEnd) { + const [left, right] = splitPieceTree(overlay, root.right, index - pieceEnd); + root.right = left; + updatePieceNode(root); + return [root, right]; + } + if (index === leftLength) { + const left = root.left; + root.left = undefined; + updatePieceNode(root); + return [left, root]; + } + if (index === pieceEnd) { + const right = root.right; + root.right = undefined; + updatePieceNode(root); + return [root, right]; + } + const offset = index - leftLength; + const first = { ...root.piece, length: offset }; + const second = { + ...root.piece, + start: root.piece.start + root.piece.step * offset, + length: root.piece.length - offset, + }; + return [ + mergePieceTrees(root.left, createPieceNode(overlay, first)), + mergePieceTrees(createPieceNode(overlay, second), root.right), + ]; +} + +function leftmostPieceNode(node: PieceNode): PieceNode { + while (node.left !== undefined) node = node.left; + return node; +} + +function rightmostPieceNode(node: PieceNode): PieceNode { + while (node.right !== undefined) node = node.right; + return node; +} + +function mergeablePieces(left: Piece, right: Piece): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "insert" && left.source !== (right as InsertPiece).source) return false; + if (left.length === 1 && right.length === 1) return Math.abs(right.start - left.start) === 1; + return left.step === right.step && left.start + left.step * left.length === right.start; +} + +function joinNormalized( + overlay: ArrayOverlay, + left: PieceNode | undefined, + right: PieceNode | undefined, +): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + const leftPiece = rightmostPieceNode(left).piece; + const rightPiece = leftmostPieceNode(right).piece; + if (!mergeablePieces(leftPiece, rightPiece)) return mergePieceTrees(left, right); + const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); + const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); + const step = leftPiece.length === 1 ? ((rightPiece.start - leftPiece.start) as 1 | -1) : leftPiece.step; + const combined: Piece = + leftPiece.kind === "base" + ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } + : { + kind: "insert", + source: leftPiece.source, + start: leftPiece.start, + length: leftPiece.length + rightPiece.length, + step, + }; + return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); +} + +function flattenPieceTree(node: PieceNode | undefined, output: Piece[]): void { + if (node === undefined) return; + flattenPieceTree(node.left, output); + output.push(node.piece); + flattenPieceTree(node.right, output); +} + +function piecesOf(overlay: ArrayOverlay): readonly Piece[] { + if (overlay.pieces === undefined) { + overlay.pieces = []; + flattenPieceTree(overlay.root, overlay.pieces); + } + return overlay.pieces; +} + +function treeFromPieces(overlay: ArrayOverlay, pieces: Piece[]): PieceNode | undefined { + mergePieces(pieces); + let root: PieceNode | undefined; + for (const piece of pieces) root = mergePieceTrees(root, createPieceNode(overlay, piece)); + return root; +} + +function replaceAllPieces(overlay: ArrayOverlay, pieces: Piece[]): void { + overlay.root = treeFromPieces(overlay, pieces); + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; +} + +function arrayLength(overlay: ArrayOverlay): number { + return treeElements(overlay.root); +} + +function locatePiece(overlay: ArrayOverlay, index: number): Piece { + let node = overlay.root; + while (node !== undefined) { + const leftLength = treeElements(node.left); + if (index < leftLength) node = node.left; + else if (index >= leftLength + node.piece.length) { + index -= leftLength + node.piece.length; + node = node.right; + } else { + overlay.locatedOffset = index - leftLength; + return node.piece; + } + } + throw new RangeError("Array overlay index is out of range"); +} + +function arrayIndex(property: PropertyKey): number | undefined { + if (typeof property !== "string" || property === "") return undefined; + const index = Number(property); + if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; + return index; +} + +function entryValueAt(node: OverlayNode, piece: Piece, sourceIndex: number): Stored { + const overlay = arrayOverlay(node); + if (piece.kind === "base") { + return overlay.baseOverrides.has(sourceIndex) + ? overlay.baseOverrides.get(sourceIndex)! + : (node.base as JsonValue[])[sourceIndex]!; + } + const overrides = overlay.insertOverrides.get(piece.source); + return overrides?.has(sourceIndex) ? overrides.get(sourceIndex)! : piece.source.values[sourceIndex]!; +} + +function hasEntryOverrideAt(overlay: ArrayOverlay, piece: Piece, sourceIndex: number): boolean { + return piece.kind === "base" + ? overlay.baseOverrides.has(sourceIndex) + : (overlay.insertOverrides.get(piece.source)?.has(sourceIndex) ?? false); +} + +function extendRightmostPiece(node: PieceNode, amount: number): void { + if (node.right !== undefined) extendRightmostPiece(node.right, amount); + else node.piece.length += amount; + updatePieceNode(node); +} + +function invalidatePieceCaches(overlay: ArrayOverlay): void { + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; + overlay.plan = undefined; +} + +function replacePieceRange(node: OverlayNode, index: number, remove: number, inserted: Piece[]): void { + if (remove === 0 && inserted.length === 0) return; + const overlay = arrayOverlay(node); + if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== undefined) { + const addition = inserted[0]!; + const tail = rightmostPieceNode(overlay.root).piece; + if ( + addition.kind === "insert" && + tail.kind === "insert" && + tail.step === 1 && + tail.start + tail.length === tail.source.values.length + ) { + for (const value of addition.source.values) tail.source.values.push(value); + extendRightmostPiece(overlay.root, addition.length); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + (node.target as unknown[]).length = arrayLength(overlay); + markDirty(node); + return; + } + } + const [left, rest] = splitPieceTree(overlay, overlay.root, index); + const [, right] = splitPieceTree(overlay, rest, remove); + const middle = treeFromPieces(overlay, inserted); + overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + (node.target as unknown[]).length = arrayLength(overlay); + markDirty(node); +} + +function mergePieces(pieces: Piece[]): void { + for (let index = 1; index < pieces.length; ) { + const left = pieces[index - 1]!; + const right = pieces[index]!; + const sameSource = + left.kind === right.kind && (left.kind === "base" || left.source === (right as InsertPiece).source); + if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { + left.step = (right.start - left.start) as 1 | -1; + left.length = 2; + pieces.splice(index, 1); + } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { + left.length += right.length; + pieces.splice(index, 1); + } else index += 1; + } +} + +function insertPiece(items: Stored[]): InsertPiece[] { + if (items.length === 0) return []; + return [{ kind: "insert", source: { values: items }, start: 0, length: items.length, step: 1 }]; +} + +function setArrayIndex(node: OverlayNode, index: number, stored: Stored): void { + const overlay = arrayOverlay(node); + const length = arrayLength(overlay); + if (index === length) { + replacePieceRange(node, length, 0, insertPiece([stored])); + return; + } + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const current = entryValueAt(node, piece, sourceIndex); + if (!isContainer(stored) && current === stored) return; + if (piece.kind === "base") { + if (!isContainer(stored) && stored === (node.base as JsonValue[])[sourceIndex]) { + overlay.baseOverrides.delete(sourceIndex); + } else overlay.baseOverrides.set(sourceIndex, stored); + } else { + let overrides = overlay.insertOverrides.get(piece.source); + if (!isContainer(stored) && stored === piece.source.values[sourceIndex]) { + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides.delete(piece.source); + } else { + if (overrides === undefined) { + overrides = new Map(); + overlay.insertOverrides.set(piece.source, overrides); + } + overrides.set(sourceIndex, stored); + } + } + markDirty(node); +} + +function setArrayLength(node: OverlayNode, next: number): void { + const current = arrayLength(arrayOverlay(node)); + if (next === current) return; + if (next < current) replacePieceRange(node, next, current - next, []); + else replacePieceRange(node, current, 0, insertPiece(Array.from({ length: next - current }, () => null))); +} + +function toArrayLength(value: unknown): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 0 || number >= 4_294_967_296) throw new RangeError("Invalid array length"); + return number; +} + +function toIntegerOrInfinity(value: unknown): number { + const number = Number(value); + if (Number.isNaN(number) || number === 0) return 0; + return Number.isFinite(number) ? Math.trunc(number) : number; +} + +function clampIndex(value: number, length: number): number { + if (value === Number.NEGATIVE_INFINITY) return 0; + if (value < 0) return Math.max(length + value, 0); + return Math.min(value, length); +} + +function mutatorNode(receiver: unknown): OverlayNode | undefined { + if (!isContainer(receiver)) return undefined; + const node = Reflect.get(receiver, NODE) as OverlayNode | undefined; + if (node === undefined) return undefined; + if (!Array.isArray(node.base)) throw new TypeError("Array mutator called on incompatible receiver"); + assertWritable(node.context); + return node; +} + +const arrayMutators = { + push(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.push, this, items) as number; + const length = arrayLength(arrayOverlay(node)); + replacePieceRange(node, length, 0, insertPiece(items.map(clonePlacement))); + return length + items.length; + }, + pop(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.pop, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getProperty(node, String(length - 1)); + replacePieceRange(node, length - 1, 1, []); + return value; + }, + shift(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.shift, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getProperty(node, "0"); + replacePieceRange(node, 0, 1, []); + return value; + }, + unshift(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.unshift, this, items) as number; + replacePieceRange(node, 0, 0, insertPiece(items.map(clonePlacement))); + return arrayLength(arrayOverlay(node)); + }, + splice(this: unknown, ...args: unknown[]): unknown[] { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; + const length = arrayLength(arrayOverlay(node)); + const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); + const remove = + args.length === 0 + ? 0 + : args.length === 1 + ? length - start + : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); + const removed = Array.from({ length: remove }, (_, offset) => getProperty(node, String(start + offset))); + const items = args.slice(2).map(clonePlacement); + replacePieceRange(node, start, remove, insertPiece(items)); + setArrayLength(node, length - remove + items.length); + return removed; + }, + reverse(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.reverse, this, []); + const overlay = arrayOverlay(node); + if (arrayLength(overlay) < 2) return node.proxy; + const pieces = [...piecesOf(overlay)].reverse(); + for (const piece of pieces) { + piece.start += piece.step * (piece.length - 1); + piece.step = piece.step === 1 ? -1 : 1; + } + replaceAllPieces(overlay, pieces); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); + return node.proxy; + }, + sort(this: unknown, comparator?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); + if (comparator !== undefined && typeof comparator !== "function") + throw new TypeError("Comparator must be a function"); + const overlay = arrayOverlay(node); + const insertedSources: InsertSource[] = []; + const insertedIndices: number[] = []; + const insertedValues: unknown[] = []; + const baseValues: unknown[] = new Array((node.base as JsonValue[]).length); + const order: number[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + if (piece.kind === "base") { + order.push(sourceIndex); + baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); + } else { + insertedSources.push(piece.source); + insertedIndices.push(sourceIndex); + const token = -insertedSources.length; + order.push(token); + insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); + } + } + } + const baseSnapshot = new Map(overlay.baseOverrides); + const insertSnapshots = new Map>(); + for (const [source, overrides] of overlay.insertOverrides) insertSnapshots.set(source, new Map(overrides)); + const generation = overlay.generation; + order.sort((left, right) => { + const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; + const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; + if (typeof comparator === "function") + return Number(Reflect.apply(comparator, undefined, [leftValue, rightValue])); + const a = String(leftValue); + const b = String(rightValue); + return a < b ? -1 : a > b ? 1 : 0; + }); + if ( + baseSnapshot.size > 0 || + insertSnapshots.size > 0 || + overlay.baseOverrides.size > 0 || + overlay.insertOverrides.size > 0 + ) { + for (const token of order) { + restoreSortOverride(overlay, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); + } + } + const comparatorWasStructural = overlay.generation !== generation; + const currentLength = arrayLength(overlay); + const samePrefix = + currentLength >= order.length && + order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); + if (!samePrefix) { + replacePieceRange( + node, + 0, + Math.min(order.length, currentLength), + piecesFromSortOrder(order, insertedSources, insertedIndices), + ); + } + if (comparatorWasStructural) deduplicateArrayEntries(node); + return node.proxy; + }, + fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + if (end <= start) return node.proxy; + const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); + replacePieceRange(node, start, end - start, insertPiece(items)); + return node.proxy; + }, + copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const target = clampIndex(toIntegerOrInfinity(targetArg), length); + const start = clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + const count = Math.min(Math.max(end - start, 0), length - target); + const values = Array.from({ length: count }, (_, offset) => + clonePlacement(getProperty(node, String(start + offset))), + ); + replacePieceRange(node, target, count, insertPiece(values)); + return node.proxy; + }, +}; + +function sortTokenSource(token: number, sources: readonly InsertSource[]): InsertSource | undefined { + return token < 0 ? sources[-token - 1] : undefined; +} + +function sortTokenIndex(token: number, indices: readonly number[]): number { + return token < 0 ? indices[-token - 1]! : token; +} + +function publicSortValue( + node: OverlayNode, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): unknown { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + const value = + source === undefined + ? overlay.baseOverrides.has(sourceIndex) + ? overlay.baseOverrides.get(sourceIndex)! + : (node.base as JsonValue[])[sourceIndex]! + : (overlay.insertOverrides.get(source)?.has(sourceIndex) ?? false) + ? overlay.insertOverrides.get(source)!.get(sourceIndex)! + : source.values[sourceIndex]!; + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + source === undefined ? 1 : 2, + sourceIndex, + source, + value, + source !== undefined || overlay.baseOverrides.has(sourceIndex), + ).proxy; +} + +function restoreSortOverride( + overlay: ArrayOverlay, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], + baseSnapshot: ReadonlyMap, + insertSnapshots: ReadonlyMap>, +): void { + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + if (source === undefined) { + if (baseSnapshot.has(sourceIndex)) overlay.baseOverrides.set(sourceIndex, baseSnapshot.get(sourceIndex)!); + else overlay.baseOverrides.delete(sourceIndex); + return; + } + let overrides = overlay.insertOverrides.get(source); + const snapshot = insertSnapshots.get(source); + if (snapshot?.has(sourceIndex)) { + if (overrides === undefined) { + overrides = new Map(); + overlay.insertOverrides.set(source, overrides); + } + overrides.set(sourceIndex, snapshot.get(sourceIndex)!); + } else { + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides.delete(source); + } +} + +function sameSortTokenAt( + overlay: ArrayOverlay, + logicalIndex: number, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): boolean { + const piece = locatePiece(overlay, logicalIndex); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const tokenSource = sortTokenSource(token, sources); + return ( + sourceIndex === sortTokenIndex(token, indices) && + ((piece.kind === "base" && tokenSource === undefined) || + (piece.kind === "insert" && piece.source === tokenSource)) + ); +} + +function deduplicateArrayEntries(node: OverlayNode): void { + const overlay = arrayOverlay(node); + const seenBase = new Set(); + const seenInsert = new Map>(); + const next: Piece[] = []; + let duplicated = false; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + let seen: boolean; + if (piece.kind === "base") { + seen = seenBase.has(sourceIndex); + seenBase.add(sourceIndex); + } else { + let indices = seenInsert.get(piece.source); + if (indices === undefined) { + indices = new Set(); + seenInsert.set(piece.source, indices); + } + seen = indices.has(sourceIndex); + indices.add(sourceIndex); + } + if (seen) { + duplicated = true; + appendMergedPiece( + next, + insertPiece([cloneStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]!, + ); + } else appendMergedPiece(next, singletonPiece(piece, sourceIndex)); + } + } + if (!duplicated) return; + replaceAllPieces(overlay, next); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); +} + +function piecesFromSortOrder( + order: readonly number[], + sources: readonly InsertSource[], + indices: readonly number[], +): Piece[] { + const pieces: Piece[] = []; + for (const token of order) { + const source = token < 0 ? sources[-token - 1] : undefined; + const sourceIndex = token < 0 ? indices[-token - 1]! : token; + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + ((source === undefined && previous.kind === "base") || + (source !== undefined && previous.kind === "insert" && previous.source === source)); + if (previous !== undefined && sameSource) { + if (previous.length === 1) { + const step = sourceIndex - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + continue; + } + } else if (previous.start + previous.step * previous.length === sourceIndex) { + previous.length += 1; + continue; + } + } + pieces.push( + source === undefined + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }, + ); + } + return pieces; +} + +function appendMergedPiece(pieces: Piece[], piece: Piece): void { + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + previous.kind === piece.kind && + (previous.kind === "base" || previous.source === (piece as InsertPiece).source); + if (previous !== undefined && sameSource && previous.length === 1 && piece.length === 1) { + const step = piece.start - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + return; + } + } + if ( + previous !== undefined && + sameSource && + previous.step === piece.step && + previous.start + previous.step * previous.length === piece.start + ) { + previous.length += piece.length; + } else pieces.push(piece); +} + +function singletonPiece(piece: Piece, sourceIndex: number): Piece { + return piece.kind === "base" + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; +} + +function clonePlacement(value: unknown): Stored { + const proxyNode = isContainer(value) ? (Reflect.get(value, NODE) as OverlayNode | undefined) : undefined; + return proxyNode === undefined ? cloneJson(value) : cloneNode(proxyNode); +} + +function cloneJson(value: unknown): Stored { + if (!isContainer(value)) return value as Primitive; + if (Array.isArray(value)) return value.map(cloneJson); + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); + return result; +} + +function cloneNode(node: OverlayNode): Container { + assertReadable(node.context); + if (Array.isArray(node.base)) { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result; + } + const result = Object.create(Object.getPrototypeOf(node.base)) as Record; + for (const key of ownKeys(node) as string[]) + defineData(result, key, cloneStored(objectValue(node, key), node.context)); + return result; +} + +function cloneStored(value: Stored, context: OverlayContext): Stored { + if (!isContainer(value)) return value; + const node = context.rawNodes.get(value); + return node === undefined ? cloneJson(value) : cloneNode(node); +} + +function defineData(target: object, key: PropertyKey, value: JsonValue): void { + Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); +} + +function emitOperations(context: OverlayContext): Op[] { + const operations: Op[] = []; + const forcedFolds = new Map(); + const emissionPaths = new Map(); + const denseIndices = new Map(); + for (const node of context.dirty) { + recordDenseArrayPosition(node, denseIndices); + if (Array.isArray(node.base)) { + const overlay = arrayOverlay(node); + if (!overlay.structural && overlay.baseOverrides.size > 0) { + let candidates = denseIndices.get(node); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (node.base as JsonValue[]).length }; + denseIndices.set(node, candidates); + } + for (const index of overlay.baseOverrides.keys()) addDenseCandidate(candidates, index); + } + } + } + const denseRegions = new Map(); + for (const [array, candidates] of denseIndices) { + const path = resolvePath(array); + if (path === undefined || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { + continue; + } + const regions = buildDenseRegions(candidates); + if (regions.length > 0) denseRegions.set(array, regions); + } + for (const node of context.dirty) { + const path = resolvePath(node); + if (path === undefined) continue; + node.applyDepth = path.length; + if (hasCoveringDenseRegion(node, path, denseRegions)) continue; + emissionPaths.set(node, path); + if (!Array.isArray(node.base) && hasReservedMutation(node)) forcedFolds.set(node, path); + const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); + if (reservedAt >= 0) { + let ancestor = node; + for (let depth = path.length; depth > reservedAt; depth--) ancestor = ancestor.parent!; + forcedFolds.set(ancestor, path.slice(0, reservedAt)); + } + } + for (const [node, path] of forcedFolds) emissionPaths.set(node, path); + for (const node of denseRegions.keys()) { + const path = resolvePath(node); + if (path !== undefined && !hasCoveringDenseRegion(node, path, denseRegions)) emissionPaths.set(node, path); + } + let maxDepth = 0; + for (const path of emissionPaths.values()) maxDepth = Math.max(maxDepth, path.length); + const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); + for (const [node, path] of emissionPaths) buckets[path.length]!.push(node); + const folded = new Set(); + for (const bucket of buckets) { + for (const node of bucket) { + const path = emissionPaths.get(node)!; + if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; + if (forcedFolds.has(node)) { + emitSet(operations, path, cloneNode(node)); + folded.add(node); + continue; + } + if (Array.isArray(node.base)) emitArrayOperations(node, path, operations, denseRegions.get(node)); + else emitObjectOperations(node, path, operations); + if (operations.length > MAX_DELTA_OPERATIONS) { + locatedDenseArray = undefined; + return [["r", cloneNode(context.root!)]]; + } + } + } + locatedDenseArray = undefined; + return operations; +} + +let locatedDenseArray: OverlayNode | undefined; +let locatedDenseIndex = 0; + +function locateDenseArrayPosition(node: OverlayNode): boolean { + let child = node; + while (child.parent !== undefined) { + const parent = child.parent; + if (Array.isArray(parent.base)) { + const overlay = arrayOverlay(parent); + if (child.parentKind !== 1 || overlay.structural) return false; + const sourceIndex = child.parentKey as number; + const current = overlay.baseOverrides.has(sourceIndex) + ? overlay.baseOverrides.get(sourceIndex) + : (parent.base as JsonValue[])[sourceIndex]; + if (current !== child.parentExpected) return false; + locatedDenseArray = parent; + locatedDenseIndex = sourceIndex; + return true; + } + child = parent; + } + return false; +} + +function hasCoveringDenseRegion( + node: OverlayNode, + path: Path, + denseRegions: ReadonlyMap, +): boolean { + for (let parent = node.parent; parent !== undefined; parent = parent.parent) { + const regions = denseRegions.get(parent); + if (regions === undefined) continue; + const parentPath = resolvePath(parent); + if (parentPath === undefined || path.length <= parentPath.length) continue; + let matches = true; + for (let index = 0; index < parentPath.length; index++) { + if (path[index] !== parentPath[index]) { + matches = false; + break; + } + } + if (!matches) continue; + const index = path[parentPath.length]; + if (typeof index === "number" && regionContaining(regions, index)) return true; + } + return false; +} + +function addDenseCandidate(candidates: DenseCandidates, index: number): void { + if (candidates.bits !== undefined) { + candidates.bits[index] = 1; + return; + } + candidates.indices.push(index); + if (candidates.indices.length < 256) return; + candidates.bits = new Uint8Array(candidates.length); + for (const existing of candidates.indices) candidates.bits[existing] = 1; + candidates.indices.length = 0; +} + +function recordDenseArrayPosition(node: OverlayNode, groups: Map): void { + if (!locateDenseArrayPosition(node)) return; + let candidates = groups.get(locatedDenseArray!); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (locatedDenseArray!.base as JsonValue[]).length }; + groups.set(locatedDenseArray!, candidates); + } + addDenseCandidate(candidates, locatedDenseIndex); +} + +function buildDenseRegions(candidates: DenseCandidates): DenseRegion[] { + if (candidates.bits === undefined) return []; + const regions: DenseRegion[] = []; + const bits = candidates.bits; + for (let at = 0; at < bits.length; ) { + while (at < bits.length && bits[at] === 0) at += 1; + if (at === bits.length) break; + const start = at; + let end = at; + let count = 0; + let gap = 0; + while (at < bits.length) { + if (bits[at] !== 0) { + count += 1; + end = at; + gap = 0; + } else if (++gap > 1) break; + at += 1; + } + const length = end - start + 1; + if (count >= 256 && count * 2 >= length) regions.push({ start, length }); + } + return regions; +} + +function regionContaining(regions: readonly DenseRegion[], index: number): boolean { + for (const region of regions) if (index >= region.start && index < region.start + region.length) return true; + return false; +} + +function hasReservedMutation(node: OverlayNode): boolean { + if (node.writeKey !== undefined && RESERVED_SEGMENTS.has(node.writeKey)) return true; + if (node.deleteKey !== undefined && RESERVED_SEGMENTS.has(node.deleteKey)) return true; + for (const key of node.writes?.keys() ?? []) if (RESERVED_SEGMENTS.has(key)) return true; + for (const key of node.deletes ?? []) if (RESERVED_SEGMENTS.has(key)) return true; + return false; +} + +function hasFoldedAncestor(node: OverlayNode, folded: ReadonlySet): boolean { + for (let parent = node.parent; parent !== undefined; parent = parent.parent) if (folded.has(parent)) return true; + return false; +} + +function hasPlacementAncestor(node: OverlayNode): boolean { + for (let current: OverlayNode | undefined = node; current?.parent !== undefined; current = current.parent) { + if (current.parentPlacement) return true; + } + return false; +} + +function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): void { + const nextPath = [...path, key] as unknown as NonEmptyPath; + const before = Object.hasOwn(node.base, key) ? (node.base as Record)[key] : undefined; + emitChangedValue(operations, nextPath, before, cloneStored(value, node.context)); +} + +function emitObjectOperations(node: OverlayNode, path: Path, operations: Op[]): void { + if (node.writeKey !== undefined) emitObjectWrite(node, path, operations, node.writeKey, node.writeValue!); + for (const [key, value] of node.writes ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + emitObjectWrite(node, path, operations, key, value); + } + if (node.deleteKey !== undefined && Object.hasOwn(node.base, node.deleteKey)) { + operations.push(["d", [...path, node.deleteKey] as unknown as NonEmptyPath]); + } + for (const key of node.deletes ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + if (Object.hasOwn(node.base, key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); + } +} + +function buildArrayPlan(node: OverlayNode): ArrayPlan { + const overlay = arrayOverlay(node); + if (overlay.plan !== undefined) return overlay.plan; + const base = node.base as JsonValue[]; + const pieces = piecesOf(overlay); + const retained = new Uint8Array(base.length); + const targetBase: number[] = []; + for (const piece of pieces) { + if (piece.kind !== "base") continue; + for (let offset = 0; offset < piece.length; offset++) { + const index = piece.start + piece.step * offset; + retained[index] = 1; + targetBase.push(index); + } + } + const removeRuns: number[] = []; + for (let end = base.length; end > 0; ) { + if (retained[end - 1] !== 0) { + end -= 1; + continue; + } + let start = end - 1; + while (start > 0 && retained[start - 1] === 0) start -= 1; + removeRuns.push(start, end - start); + end = start; + } + const retainedBase: number[] = []; + for (let index = 0; index < retained.length; index++) if (retained[index] !== 0) retainedBase.push(index); + let permutation: number[] | undefined; + if (targetBase.some((value, index) => value !== retainedBase[index])) { + const positions = new Map(retainedBase.map((value, index) => [value, index])); + permutation = targetBase.map((value) => positions.get(value)!); + } + const insertRuns: number[] = []; + let logicalIndex = 0; + for (let pieceIndex = 0; pieceIndex < pieces.length; ) { + const piece = pieces[pieceIndex]!; + if (piece.kind === "base") { + logicalIndex += piece.length; + pieceIndex += 1; + continue; + } + const startPiece = pieceIndex; + while (pieceIndex < pieces.length && pieces[pieceIndex]!.kind === "insert") { + logicalIndex += pieces[pieceIndex]!.length; + pieceIndex += 1; + } + insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); + } + overlay.plan = { removeRuns, permutation, insertRuns }; + return overlay.plan; +} + +function rangeLength(pieces: readonly Piece[], start: number, end: number): number { + let length = 0; + for (let index = start; index < end; index++) length += pieces[index]!.length; + return length; +} + +function emitArrayOperations( + node: OverlayNode, + path: Path, + operations: Op[], + denseRegions: readonly DenseRegion[] | undefined, +): void { + const overlay = arrayOverlay(node); + const base = node.base as JsonValue[]; + for (const region of denseRegions ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", [...path], region.start, region.length, cloneArrayRegion(node, region)]); + } + if (overlay.structural) { + const plan = buildArrayPlan(node); + for (let index = 0; index < plan.removeRuns.length; index += 2) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", [...path], plan.removeRuns[index]!, plan.removeRuns[index + 1]!, []]); + } + if (plan.permutation !== undefined) operations.push(["m", [...path], plan.permutation.slice()]); + const pieces = piecesOf(overlay); + for (let run = 0; run < plan.insertRuns.length; run += 3) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const logicalIndex = plan.insertRuns[run]!; + const items: JsonValue[] = []; + for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { + const piece = pieces[pieceIndex]! as InsertPiece; + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + operations.push(["p", [...path], logicalIndex, 0, items]); + } + } + for (const [baseIndex, value] of overlay.baseOverrides) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const index = findEntryIndex(overlay, 1, baseIndex, undefined); + if (index === undefined || (denseRegions !== undefined && regionContaining(denseRegions, index))) continue; + emitChangedValue( + operations, + [...path, index] as unknown as NonEmptyPath, + base[baseIndex], + cloneStored(value, node.context), + ); + } +} + +function cloneArrayRegion(node: OverlayNode, region: DenseRegion): JsonValue[] { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (let index = region.start; index < region.start + region.length; index++) { + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + return result; +} + +function emitChangedValue(operations: Op[], path: NonEmptyPath, before: JsonValue | undefined, after: JsonValue): void { + if (!isContainer(after) && before === after) return; + if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return; + if (typeof before === "string" && typeof after === "string") { + if (after.startsWith(before)) { + if (after.length > before.length) operations.push(["a", path, after.slice(before.length)]); + return; + } + const shared = overlap(before, after, 65_536); + if (shared > 0) { + operations.push(["t", path, before.length - shared]); + if (after.length > shared) operations.push(["a", path, after.slice(shared)]); + return; + } + } + operations.push(["s", path, after]); +} + +function emitSet(operations: Op[], path: Path, value: JsonValue): void { + if (path.length === 0) operations.push(["r", value]); + else operations.push(["s", path as NonEmptyPath, value]); +} + +function resolvePath(node: OverlayNode): Path | undefined { + if (node.preparedPath !== undefined) return node.preparedPath; + if (node.parent === undefined) { + node.preparedPath = []; + return node.preparedPath; + } + const parentPath = resolvePath(node.parent); + if (parentPath === undefined) return undefined; + if (node.parentKind === 0) { + const key = node.parentKey as string; + if (!objectHas(node.parent, key) || objectValue(node.parent, key) !== node.parentExpected) return undefined; + node.preparedPath = [...parentPath, key]; + return node.preparedPath; + } + const overlay = arrayOverlay(node.parent); + const index = findEntryIndex(overlay, node.parentKind, node.parentKey as number, node.parentSource); + if (index === undefined) return undefined; + const piece = locatePiece(overlay, index); + if (entryValueAt(node.parent, piece, node.parentKey as number) !== node.parentExpected) return undefined; + node.preparedPath = [...parentPath, index]; + return node.preparedPath; +} + +function ensurePieceLocations(overlay: ArrayOverlay): void { + if (overlay.baseLocations !== undefined) return; + overlay.baseLocations = []; + overlay.insertLocations = new Map(); + let logicalStart = 0; + for (const piece of piecesOf(overlay)) { + const last = piece.start + piece.step * (piece.length - 1); + const location = { + piece, + logicalStart, + minimum: Math.min(piece.start, last), + maximum: Math.max(piece.start, last), + }; + if (piece.kind === "base") overlay.baseLocations.push(location); + else { + let locations = overlay.insertLocations.get(piece.source); + if (locations === undefined) { + locations = []; + overlay.insertLocations.set(piece.source, locations); + } + locations.push(location); + } + logicalStart += piece.length; + } + const byMinimum = (left: PieceLocation, right: PieceLocation): number => left.minimum - right.minimum; + overlay.baseLocations.sort(byMinimum); + for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); +} + +function findEntryIndex( + overlay: ArrayOverlay, + kind: ParentKind, + sourceIndex: number, + source: InsertSource | undefined, +): number | undefined { + ensurePieceLocations(overlay); + const locations = kind === 1 ? overlay.baseLocations! : overlay.insertLocations!.get(source!); + if (locations === undefined) return undefined; + let low = 0; + let high = locations.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (locations[middle]!.minimum <= sourceIndex) low = middle + 1; + else high = middle; + } + const location = locations[low - 1]; + if (location === undefined || sourceIndex > location.maximum) return undefined; + const offset = (sourceIndex - location.piece.start) / location.piece.step; + return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : undefined; +} + +function ensureOperations(context: OverlayContext): Op[] { + if (context.ops !== undefined) return context.ops; + assertReadable(context); + if (context.replacement) { + const base = context.tracker.deref()?.replacementBase(context); + context.replacementNoop = + base !== undefined && equalTrustedJson(base as unknown as JsonValue, context.root!.base as JsonValue); + context.ops = context.replacementNoop ? [] : [["r", cloneNode(context.root!)]]; + } else context.ops = emitOperations(context); + return context.ops; +} + +function equalTrustedJson(left: JsonValue, right: JsonValue): boolean { + if (left === right) return true; + if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; + if (Array.isArray(left)) { + const other = right as JsonValue[]; + if (left.length !== other.length) return false; + for (let index = 0; index < left.length; index++) + if (!equalTrustedJson(left[index]!, other[index]!)) return false; + return true; + } + const other = right as Record; + const keys = Object.keys(left); + if (keys.length !== Object.keys(other).length) return false; + for (const key of keys) if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key]!, other[key]!)) return false; + return true; +} + +function applyOverlay(context: OverlayContext): void { + let maxDepth = 0; + for (const node of context.dirty) if (node.applyDepth !== undefined) maxDepth = Math.max(maxDepth, node.applyDepth); + const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); + for (const node of context.dirty) if (node.applyDepth !== undefined) buckets[node.applyDepth]!.push(node); + for (let depth = buckets.length - 1; depth >= 0; depth--) { + for (const node of buckets[depth]!) { + if (Array.isArray(node.base)) applyArrayNode(node); + else applyObjectNode(node); + } + } +} + +function applyObjectNode(node: OverlayNode): void { + const target = node.base as Record; + if (node.deleteKey !== undefined) Reflect.deleteProperty(target, node.deleteKey); + for (const key of node.deletes ?? []) Reflect.deleteProperty(target, key); + for (const key of node.readded ?? []) Reflect.deleteProperty(target, key); + if (node.writeKey !== undefined) applyObjectWrite(target, node, node.writeKey, node.writeValue!); + for (const [key, value] of node.writes ?? []) applyObjectWrite(target, node, key, value); +} + +function applyObjectWrite(target: Record, node: OverlayNode, key: string, value: JsonValue): void { + if (Object.hasOwn(target, key) && !node.readded?.has(key)) { + const current = target[key]!; + if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) return; + target[key] = value; + } else defineData(target, key, value); +} + +function applyArrayNode(node: OverlayNode): void { + const target = node.base as JsonValue[]; + const overlay = arrayOverlay(node); + if (overlay.structural) { + const plan = buildArrayPlan(node); + for (let index = 0; index < plan.removeRuns.length; index += 2) { + target.splice(plan.removeRuns[index]!, plan.removeRuns[index + 1]!); + } + if (plan.permutation !== undefined) permuteInPlace(target, plan.permutation); + const pieces = piecesOf(overlay); + for (let run = 0; run < plan.insertRuns.length; run += 3) { + const items: JsonValue[] = []; + for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { + const piece = pieces[pieceIndex]! as InsertPiece; + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + items.push(entryValueAt(node, piece, sourceIndex)); + } + } + insertChunked(target, plan.insertRuns[run]!, items); + } + } + for (const [baseIndex, value] of overlay.baseOverrides) { + const index = findEntryIndex(overlay, 1, baseIndex, undefined); + if (index === undefined) continue; + const current = target[index]!; + if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) continue; + target[index] = value; + } +} + +function permuteInPlace(values: JsonValue[], permutation: readonly number[]): void { + const visited = new Uint8Array(permutation.length); + for (let start = 0; start < permutation.length; start++) { + if (visited[start] !== 0) continue; + let current = start; + const saved = values[start]!; + while (true) { + visited[current] = 1; + const source = permutation[current]!; + if (source === start) { + values[current] = saved; + break; + } + values[current] = values[source]!; + current = source; + } + } +} + +function insertChunked(target: JsonValue[], index: number, items: JsonValue[]): void { + for (let offset = 0; offset < items.length; offset += MAX_SPLICE_CHUNK) { + target.splice(index + offset, 0, ...items.slice(offset, offset + MAX_SPLICE_CHUNK)); + } +} + +function abortContext(context: OverlayContext): void { + if ( + context.status === "aborted" || + context.status === "consumed" || + context.status === "stale" || + context.status === "faulted" + ) + return; + context.status = "aborted"; + clearContext(context); +} + +function clearContext(context: OverlayContext): void { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) locatedDenseArray = undefined; + for (const node of context.nodes) { + Reflect.deleteProperty(node.target, NODE); + node.writes?.clear(); + node.deletes?.clear(); + node.readded?.clear(); + node.array?.pieces?.splice(0); + if (node.array !== undefined) { + node.array.root = undefined; + node.array.baseLocations = undefined; + node.array.insertLocations = undefined; + } + node.array?.baseOverrides.clear(); + node.array?.insertOverrides.clear(); + node.base = RELEASED; + node.parent = undefined; + node.parentSource = undefined; + node.parentExpected = undefined; + node.target = RELEASED; + node.proxy = RELEASED; + node.writeKey = undefined; + node.writeValue = undefined; + node.writes = undefined; + node.deleteKey = undefined; + node.deletes = undefined; + node.readded = undefined; + node.array = undefined; + node.preparedPath = undefined; + node.applyDepth = undefined; + } + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = new WeakMap(); + context.root = undefined; + context.baseValue = undefined; +} + +function isContainer(value: unknown): value is Container { + return value !== null && typeof value === "object"; +} diff --git a/packages/chord/src/delta/cow/README.md b/packages/chord/src/delta/cow/README.md new file mode 100644 index 000000000..49e4d132d --- /dev/null +++ b/packages/chord/src/delta/cow/README.md @@ -0,0 +1,12 @@ +# Trusted COW contract + +This variant provides immutable copy-on-write revisions without recursive input validation or deep freezing. + +- `track(initial)` and `prepareReplace(value)` take ownership. Inputs are alias-free, acyclic strict JSON made from dense arrays, plain data objects, finite numbers, strings, booleans, and `null`. Accessors, custom mutation hooks, symbols, classes, sparse arrays, and external proxies are outside the contract. +- Do not mutate transferred roots, `tracker.value`, `Prepared.base`, `Prepared.value`, or `Prepared.ops` externally. `Prepared.value` and `Prepared.ops` are independent immutable ownership transfers. +- Values assigned through a draft are cloned by value for each placement. Mutate transaction state only through its draft handles. +- Every change must settle exactly once through `prepare()` followed by `adopt()`, or through `abort()`. Draft handles are invalid after preparation or abortion. +- Array indices and mutator index arguments must be primitive numbers. Objects with coercion callbacks and mutations caused during argument coercion are outside the contract. +- Sort comparators must be synchronous and pure: they return ordinary numbers and do not mutate drafts, prepare transactions, or start nested operations. + +Violating this contract is programmer error and may not be detected at runtime. diff --git a/packages/chord/src/delta/cow/apply-immutable.ts b/packages/chord/src/delta/cow/apply-immutable.ts new file mode 100644 index 000000000..991bba005 --- /dev/null +++ b/packages/chord/src/delta/cow/apply-immutable.ts @@ -0,0 +1,71 @@ +import type { JsonValue } from "../../types.ts"; +import { + apply as applyMutable, + assertValidOp, + type Op, + type Path, + PathError, + type Seg, + UnsafePathError, +} from "../index.ts"; + +type JsonContainer = JsonValue[] | Record; + +/** Apply one batch while copying each touched container at most once. */ +export function applyImmutable(target: T | undefined, operations: readonly Op[]): T { + let root = target as unknown as JsonValue; + const owned = new WeakSet(); + for (const operation of operations) { + assertValidOp(operation); + if (operation[0] === "r") { + root = operation[1]; + continue; + } + const path = operation[0] === "p" || operation[0] === "m" ? operation[1] : operation[1].slice(0, -1); + root = copyPath(root, path, owned); + root = applyMutable(root, [operation]); + } + return root as unknown as T; +} + +function copyPath(root: JsonValue, path: Path, owned: WeakSet): JsonValue { + if (!isContainer(root)) throw new PathError(path); + let copiedRoot = root; + if (!owned.has(root)) { + copiedRoot = shallowCopy(root); + owned.add(copiedRoot); + } + let destination = copiedRoot as JsonContainer; + for (const segment of path) { + if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment); + if (!Object.hasOwn(destination, segment)) throw new PathError(path); + let child = (destination as Record)[segment]!; + if (!isContainer(child)) throw new PathError(path); + if (!owned.has(child)) { + const copy = shallowCopy(child); + defineData(destination, segment, copy); + owned.add(copy); + child = copy; + } + destination = child; + } + return copiedRoot; +} + +function shallowCopy(value: JsonContainer): JsonContainer { + if (Array.isArray(value)) return Array.from(value); + const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record< + string, + JsonValue + >; + for (const key of Object.keys(value)) defineData(result, key, value[key]!); + return result; +} + +function defineData(target: object, key: PropertyKey, value: JsonValue): void { + Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); +} + +function isContainer(value: unknown): value is JsonContainer { + return value !== null && typeof value === "object"; +} diff --git a/packages/chord/src/delta/cow/diff.ts b/packages/chord/src/delta/cow/diff.ts new file mode 100644 index 000000000..e635a58fd --- /dev/null +++ b/packages/chord/src/delta/cow/diff.ts @@ -0,0 +1,562 @@ +import type { JsonValue } from "../../types.ts"; +import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; +import type { DiffHints } from "./draft.ts"; + +const DEFAULT_OVERLAP_SCAN = 65_536; +const MAX_DELTA_OPERATIONS = 4_096; +const overflowedBatches = new WeakSet(); + +const emitOperation = (operations: Op[], operation: Op): void => { + if (overflowedBatches.has(operations)) return; + if (operations.length >= MAX_DELTA_OPERATIONS) { + overflowedBatches.add(operations); + return; + } + operations.push(operation); +}; + +const isContainer = (value: JsonValue): value is JsonValue[] | Record => + value !== null && typeof value === "object"; + +const emitSet = (path: Path, value: JsonValue, operations: Op[]): void => { + if (path.length === 0) emitOperation(operations, ["r", value]); + else emitOperation(operations, ["s", path as NonEmptyPath, value]); +}; + +const equalJson = (left: JsonValue, right: JsonValue): boolean => { + if (left === right) return true; + if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; + if (Array.isArray(left)) { + const other = right as JsonValue[]; + if (left.length !== other.length) return false; + for (let index = 0; index < left.length; index++) { + if (!equalJson(left[index]!, other[index]!)) return false; + } + return true; + } + const other = right as Record; + const keys = Object.keys(left); + if (keys.length !== Object.keys(other).length) return false; + for (const key of keys) { + if (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false; + } + return true; +}; + +const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => { + if (before.length !== after.length) return undefined; + // Cheap native probes before building a map: shifted/appended arrays fail here. + if (before.indexOf(after[after.length - 1]!) === -1 || before.indexOf(after[0]!) === -1) return undefined; + // A single index per value; duplicates upgrade to an index list. + const positions = new Map(); + for (let index = 0; index < before.length; index++) { + const value = before[index]!; + const entry = positions.get(value); + if (entry === undefined) positions.set(value, index); + else if (typeof entry === "number") positions.set(value, [entry, index]); + else entry.push(index); + } + const used = new Map(); + const result = new Array(after.length); + for (let index = 0; index < after.length; index++) { + const value = after[index]!; + const entry = positions.get(value); + if (entry === undefined) return undefined; + if (typeof entry === "number") { + if (used.has(value)) return undefined; + used.set(value, 1); + result[index] = entry; + continue; + } + const taken = used.get(value) ?? 0; + if (taken === entry.length) return undefined; + used.set(value, taken + 1); + result[index] = entry[taken]!; + } + return result; +}; + +const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => { + if (before === after) return; + if (after.length > before.length && after.slice(0, before.length) === before) { + emitOperation(operations, ["a", path, after.slice(before.length)]); + return; + } + const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN); + if (shared === 0) { + emitOperation(operations, ["s", path, after]); + return; + } + emitOperation(operations, ["t", path, before.length - shared]); + if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]); +}; + +type ArrayMatch = readonly [before: number, after: number]; + +type MatchCandidate = { + before: number; + after: number; + previous: number; +}; + +const MAX_IDENTITY_CANDIDATES = 200_000; +const MAX_SEMANTIC_CELLS = 65_536; + +const sameValue = (left: JsonValue, right: JsonValue): boolean => left === right || equalJson(left, right); + +const lcsMatches = ( + before: readonly JsonValue[], + after: readonly JsonValue[], + equal: (left: JsonValue, right: JsonValue) => boolean, + maxCells: number, +): ArrayMatch[] | undefined => { + if (before.length === 0 || after.length === 0) return []; + if (before.length * after.length > maxCells) return undefined; + const width = after.length + 1; + const lengths = new Uint32Array((before.length + 1) * width); + for (let left = before.length - 1; left >= 0; left--) { + for (let right = after.length - 1; right >= 0; right--) { + const at = left * width + right; + lengths[at] = equal(before[left]!, after[right]!) + ? lengths[(left + 1) * width + right + 1]! + 1 + : Math.max(lengths[(left + 1) * width + right]!, lengths[left * width + right + 1]!); + } + } + const matches: ArrayMatch[] = []; + let left = 0; + let right = 0; + while (left < before.length && right < after.length) { + if ( + equal(before[left]!, after[right]!) && + lengths[left * width + right] === lengths[(left + 1) * width + right + 1]! + 1 + ) { + matches.push([left++, right++]); + } else if (lengths[(left + 1) * width + right]! >= lengths[left * width + right + 1]!) left += 1; + else right += 1; + } + return matches; +}; + +const semanticallyAligned = (left: JsonValue, right: JsonValue): boolean => { + if (sameValue(left, right)) return true; + if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false; + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) return false; + return left.some((value, index) => isContainer(value) && value === right[index]); + } + const leftObject = left as Record; + const rightObject = right as Record; + for (const key of Object.keys(leftObject)) { + const value = leftObject[key]; + if (isContainer(value) && Object.hasOwn(rightObject, key) && value === rightObject[key]) return true; + } + return false; +}; + +const indexOfIdentity = (values: readonly JsonValue[], value: JsonValue, start: number, end: number): number => { + if (!isContainer(value)) return -1; // primitives are not identity anchors + const at = values.indexOf(value, start); + return at === -1 || at >= end ? -1 : at; +}; + +const lowerBound = (values: readonly number[], value: number): number => { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (values[middle]! < value) low = middle + 1; + else high = middle; + } + return low; +}; + +const identitySubsequence = ( + before: readonly JsonValue[], + beforeStart: number, + beforeEnd: number, + after: readonly JsonValue[], + afterStart: number, + afterEnd: number, +): ArrayMatch[] | undefined => { + const beforeCount = beforeEnd - beforeStart; + const afterCount = afterEnd - afterStart; + const matches: ArrayMatch[] = []; + if (afterCount < beforeCount) { + let beforeIndex = beforeStart; + for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { + while (beforeIndex < beforeEnd && before[beforeIndex] !== after[afterIndex]) beforeIndex += 1; + if (beforeIndex === beforeEnd) return undefined; + matches.push([beforeIndex++, afterIndex]); + } + return matches; + } + if (beforeCount < afterCount) { + let afterIndex = afterStart; + for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex++) { + while (afterIndex < afterEnd && before[beforeIndex] !== after[afterIndex]) afterIndex += 1; + if (afterIndex === afterEnd) return undefined; + matches.push([beforeIndex, afterIndex++]); + } + return matches; + } + return undefined; +}; + +const greedyIdentityAnchors = ( + positions: ReadonlyMap, + after: readonly JsonValue[], + afterStart: number, + afterEnd: number, +): ArrayMatch[] => { + const matches: ArrayMatch[] = []; + let previous = -1; + for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { + const candidates = positions.get(after[afterIndex]!); + if (candidates === undefined) continue; + const at = lowerBound(candidates, previous + 1); + const beforeIndex = candidates[at]; + if (beforeIndex === undefined) continue; + matches.push([beforeIndex, afterIndex]); + previous = beforeIndex; + } + return matches; +}; + +const identityAnchors = ( + before: readonly JsonValue[], + beforeStart: number, + beforeEnd: number, + after: readonly JsonValue[], + afterStart: number, + afterEnd: number, +): ArrayMatch[] => { + const positions = new Map(); + for (let index = beforeStart; index < beforeEnd; index++) { + const value = before[index]!; + const existing = positions.get(value); + if (existing === undefined) positions.set(value, [index]); + else existing.push(index); + } + let candidateCount = 0; + for (let index = afterStart; index < afterEnd; index++) { + candidateCount += positions.get(after[index]!)?.length ?? 0; + if (candidateCount > MAX_IDENTITY_CANDIDATES) { + return greedyIdentityAnchors(positions, after, afterStart, afterEnd); + } + } + if (candidateCount === 0) return []; + + const candidates: MatchCandidate[] = []; + const tails: number[] = []; + const tailValues: number[] = []; + for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) { + const beforePositions = positions.get(after[afterIndex]!); + if (beforePositions === undefined) continue; + for (let index = beforePositions.length - 1; index >= 0; index--) { + const beforeIndex = beforePositions[index]!; + const at = lowerBound(tailValues, beforeIndex); + const candidateIndex = candidates.length; + candidates.push({ before: beforeIndex, after: afterIndex, previous: at === 0 ? -1 : tails[at - 1]! }); + tails[at] = candidateIndex; + tailValues[at] = beforeIndex; + } + } + const matches: ArrayMatch[] = []; + let candidateIndex = tails[tails.length - 1] ?? -1; + while (candidateIndex >= 0) { + const candidate = candidates[candidateIndex]!; + matches.push([candidate.before, candidate.after]); + candidateIndex = candidate.previous; + } + matches.reverse(); + return matches; +}; + +const processArrayMatches = ( + before: JsonValue[], + after: JsonValue[], + path: Path, + operations: Op[], + hints: DiffHints, + beforeStart: number, + beforeEnd: number, + afterStart: number, + afterEnd: number, + outputStart: number, + matches: readonly ArrayMatch[], +): void => { + let beforeAt = beforeStart; + let afterAt = afterStart; + let outputAt = outputStart; + for (const [beforeMatch, afterMatch] of matches) { + if (overflowedBatches.has(operations)) return; + diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeMatch, afterAt, afterMatch, outputAt); + outputAt += afterMatch - afterAt; + if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) { + diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations, hints); + } + outputAt += 1; + beforeAt = beforeMatch + 1; + afterAt = afterMatch + 1; + } + if (!overflowedBatches.has(operations)) { + diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeEnd, afterAt, afterEnd, outputAt); + } +}; + +function diffArrayRegion( + before: JsonValue[], + after: JsonValue[], + path: Path, + operations: Op[], + hints: DiffHints, + beforeStart: number, + beforeEnd: number, + afterStart: number, + afterEnd: number, + outputStart: number, +): void { + if (overflowedBatches.has(operations)) return; + while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeStart]!, after[afterStart]!)) { + beforeStart += 1; + afterStart += 1; + outputStart += 1; + } + while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeEnd - 1]!, after[afterEnd - 1]!)) { + beforeEnd -= 1; + afterEnd -= 1; + } + const beforeCount = beforeEnd - beforeStart; + const afterCount = afterEnd - afterStart; + if (beforeCount === 0 && afterCount === 0) return; + if (beforeCount === 0 || afterCount === 0) { + emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); + return; + } + + if (beforeCount === afterCount) { + // Streaming positional alignment: equivalent to collecting positional + // matches and diffing the gaps between them, without a tuple per match. + let matched = false; + let gapStart = -1; + for (let offset = 0; offset <= beforeCount; offset++) { + const same = offset < beforeCount && sameValue(before[beforeStart + offset]!, after[afterStart + offset]!); + if (same) { + matched = true; + if (gapStart !== -1) { + if (overflowedBatches.has(operations)) return; + diffArrayRegion( + before, + after, + path, + operations, + hints, + beforeStart + gapStart, + beforeStart + offset, + afterStart + gapStart, + afterStart + offset, + outputStart + gapStart, + ); + gapStart = -1; + } + } else if (gapStart === -1) gapStart = offset; + } + if (matched) { + if (gapStart !== -1 && !overflowedBatches.has(operations)) { + diffArrayRegion( + before, + after, + path, + operations, + hints, + beforeStart + gapStart, + beforeEnd, + afterStart + gapStart, + afterEnd, + outputStart + gapStart, + ); + } + return; + } + } + + // Identity run fast paths (queue shift+push, unshift+pop). The run must be + // longer than both remainders so it is the unique longest anchor chain; the + // output then equals the LIS alignment below without a map or match list. + { + const k = indexOfIdentity(before, after[afterStart]!, beforeStart, beforeEnd); + if (k !== -1) { + let run = 0; + const limit = Math.min(beforeEnd - k, afterCount); + // Container-only runs: object identity is unique, so this run is the unique longest anchor chain. + while (run < limit && before[k + run] === after[afterStart + run] && isContainer(before[k + run]!)) run += 1; + if (k + run === beforeEnd && run > k - beforeStart && run > afterCount - run) { + if (k > beforeStart) emitOperation(operations, ["p", path, outputStart, k - beforeStart, []]); + if (run < afterCount) { + emitOperation(operations, ["p", path, outputStart + run, 0, after.slice(afterStart + run, afterEnd)]); + } + return; + } + } + const j = indexOfIdentity(after, before[beforeStart]!, afterStart, afterEnd); + if (j !== -1) { + let run = 0; + const limit = Math.min(afterEnd - j, beforeCount); + while (run < limit && before[beforeStart + run] === after[j + run] && isContainer(after[j + run]!)) run += 1; + if (j + run === afterEnd && run > j - afterStart && run > beforeCount - run) { + const inserted = j - afterStart; + if (inserted > 0) emitOperation(operations, ["p", path, outputStart, 0, after.slice(afterStart, j)]); + if (run < beforeCount) { + emitOperation(operations, ["p", path, outputStart + inserted + run, beforeCount - run, []]); + } + return; + } + } + } + + if (beforeCount === 1 && afterCount === 1) { + // Every alignment strategy below ends in diffValue for a 1x1 region. + diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); + return; + } + + const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd); + if (subsequence !== undefined && subsequence.length > 0) { + processArrayMatches( + before, + after, + path, + operations, + hints, + beforeStart, + beforeEnd, + afterStart, + afterEnd, + outputStart, + subsequence, + ); + return; + } + + const identity = identityAnchors(before, beforeStart, beforeEnd, after, afterStart, afterEnd); + if (identity.length > 0) { + processArrayMatches( + before, + after, + path, + operations, + hints, + beforeStart, + beforeEnd, + afterStart, + afterEnd, + outputStart, + identity, + ); + return; + } + + const semantic = lcsMatches( + before.slice(beforeStart, beforeEnd), + after.slice(afterStart, afterEnd), + semanticallyAligned, + MAX_SEMANTIC_CELLS, + ); + if (semantic !== undefined && semantic.length > 0) { + const absolute = semantic.map( + ([beforeIndex, afterIndex]) => [beforeStart + beforeIndex, afterStart + afterIndex] as const, + ); + processArrayMatches( + before, + after, + path, + operations, + hints, + beforeStart, + beforeEnd, + afterStart, + afterEnd, + outputStart, + absolute, + ); + return; + } + + if (beforeCount === 1 && afterCount === 1) { + diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); + return; + } + emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); +} + +const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[], hints: DiffHints): void => { + if (before === after) return; + const hint = hints.get(after); + if (hint?.base === before && hint.keys.length < 2_048) { + for (const index of hint.keys) diffValue(before[index]!, after[index]!, [...path, index], operations, hints); + return; + } + if (equalJson(before, after)) return; + if ( + before.length === after.length && + before.length > 1 && + !sameValue(before[0]!, after[0]!) && + !sameValue(before[before.length - 1]!, after[after.length - 1]!) + ) { + const order = permutation(before, after); + if (order !== undefined) { + emitOperation(operations, ["m", path, order]); + return; + } + } + diffArrayRegion(before, after, path, operations, hints, 0, before.length, 0, after.length, 0); +}; + +const diffObject = ( + before: Record, + after: Record, + path: Path, + operations: Op[], + hints: DiffHints, +): void => { + const beforeKeys = Object.keys(before); + const afterKeys = Object.keys(after); + if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) { + if (!equalJson(before, after)) emitSet(path, after, operations); + return; + } + for (const key of afterKeys) { + if (overflowedBatches.has(operations)) return; + if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations, hints); + else emitSet([...path, key], after[key]!, operations); + } + for (const key of beforeKeys) { + if (overflowedBatches.has(operations)) return; + if (!Object.hasOwn(after, key)) emitOperation(operations, ["d", [...path, key] as unknown as NonEmptyPath]); + } +}; + +const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[], hints: DiffHints): void => { + if (before === after || overflowedBatches.has(operations)) return; + if (typeof before === "string" && typeof after === "string" && path.length > 0) { + emitString(before, after, path as NonEmptyPath, operations); + return; + } + if (Array.isArray(before) && Array.isArray(after)) { + diffArray(before, after, path, operations, hints); + return; + } + if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) { + diffObject(before, after, path, operations, hints); + return; + } + emitSet(path, after, operations); +}; + +/** Compute a normalized batch, with deterministic operation-count safety folding only. */ +export function diffRevisions(before: JsonValue, after: JsonValue, hints: DiffHints = new WeakMap()): Op[] { + const operations: Op[] = []; + diffValue(before, after, [], operations, hints); + if (overflowedBatches.has(operations)) return [["r", after]]; + return operations; +} diff --git a/packages/chord/src/delta/cow/draft.ts b/packages/chord/src/delta/cow/draft.ts new file mode 100644 index 000000000..75d966de5 --- /dev/null +++ b/packages/chord/src/delta/cow/draft.ts @@ -0,0 +1,717 @@ +import type { JsonValue } from "../../types.ts"; + +/** Mutable transaction-scoped view of a JSON value, preserving tuple positions. */ +export type Draft = Depth["length"] extends 8 + ? T + : T extends null | boolean | number | string + ? T + : T extends (...args: never[]) => unknown + ? T + : T extends object + ? { -readonly [Key in keyof T]: Draft } + : T; + +type Container = Record | unknown[]; +type ChildKey = string | number; + +export type ArrayDiffHint = { + readonly base: JsonValue[]; + readonly keys: readonly number[]; +}; + +export type DiffHints = WeakMap; + +export type ProduceMetadata = { + value: T; + hints: DiffHints; +}; + +type DraftContext = { + active: boolean; + states: WeakMap; + created: DraftState[]; + hints: DiffHints; +}; + +type ChildIndex = DraftState | Map; +type ChangedIndex = number | Set; + +class DraftState { + context: DraftContext; + base: Container; + copy: Container | undefined; + parent: DraftState | undefined; + key: ChildKey | undefined; + proxy: object; + target: object; + dirty = false; + structural = false; + children: ChildIndex | undefined; + changedIndices: ChangedIndex | undefined; + finalized: Container | undefined; + + constructor(context: DraftContext, base: Container, parent: DraftState | undefined, key: ChildKey | undefined) { + this.context = context; + this.base = base; + this.copy = undefined; + this.parent = parent; + this.key = key; + this.proxy = this; + this.target = this; + this.children = undefined; + this.changedIndices = undefined; + this.finalized = undefined; + } +} + +const DRAFT_STATE = Symbol("cow draft state"); +const RELEASED: Record = {}; +const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000; +const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); + +export type DraftTransaction = { + readonly state: Draft; + finish(): ProduceMetadata; + abort(): void; +}; + +export function createDraftTransaction(base: T): DraftTransaction { + const context: DraftContext = { + active: true, + states: new WeakMap(), + created: [], + hints: new WeakMap(), + }; + const root = stateFor(context, base as Container, undefined, undefined); + const release = (): void => { + assertActive(context); + context.active = false; + for (const state of context.created) { + state.base = RELEASED; + state.copy = undefined; + state.parent = undefined; + state.key = undefined; + state.proxy = RELEASED; + state.target = RELEASED; + state.children = undefined; + state.changedIndices = undefined; + state.finalized = undefined; + } + context.created.length = 0; + context.states = new WeakMap(); + }; + return { + state: root.proxy as Draft, + finish() { + assertActive(context); + try { + return { value: finalize(root) as T, hints: context.hints }; + } finally { + release(); + } + }, + abort: release, + }; +} + +/** Compatibility helper for synchronous COW recipes. */ +export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { + const transaction = createDraftTransaction(base); + try { + const outcome = (recipe as (draft: Draft) => unknown)(transaction.state); + if (isPromiseLike(outcome)) { + void Promise.resolve(outcome).catch(() => undefined); + throw new TypeError("Replicated state change callbacks must be synchronous"); + } + return transaction.finish(); + } catch (error) { + try { + transaction.abort(); + } catch { + // finish() already released the transaction. + } + throw error; + } +} + +export function produce(base: T, recipe: (draft: Draft) => void): T { + return produceWithMetadata(base, recipe).value; +} + +function stateFor( + context: DraftContext, + base: Container, + parent: DraftState | undefined, + key: ChildKey | undefined, +): DraftState { + const existing = context.states.get(base); + if (existing !== undefined) return existing; + const state = new DraftState(context, base, parent, key); + const target: object = Array.isArray(base) ? [] : state; + if (Array.isArray(base)) { + (target as unknown[]).length = base.length; + Object.defineProperty(target, DRAFT_STATE, { value: state, configurable: true }); + } + state.target = target; + state.proxy = new Proxy(target, SHARED_HANDLER); + context.states.set(base, state); + context.created.push(state); + return state; +} + +const SHARED_HANDLER: ProxyHandler = { + deleteProperty(target, property): boolean { + const state = targetState(target); + assertWritable(state, property); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(state.base)) throw new TypeError("Draft arrays cannot contain holes"); + return deleteObjectProperty(state, property); + }, + defineProperty(target): never { + assertActive(targetState(target).context); + throw new TypeError("Defining draft properties is not supported"); + }, + get(target, property): unknown { + const state = targetState(target); + assertActive(state.context); + if (property === DRAFT_STATE) return state; + if (Array.isArray(state.base) && typeof property === "string" && ARRAY_MUTATORS.has(property)) { + return MUTATORS[property as keyof typeof MUTATORS]; + } + const value = Reflect.get(currentValue(state), property, state.proxy); + return draftValue(state.context, value, state, childKey(property)); + }, + getOwnPropertyDescriptor(target, property): PropertyDescriptor | undefined { + const state = targetState(target); + assertActive(state.context); + const descriptor = Reflect.getOwnPropertyDescriptor(currentValue(state), property); + if (descriptor === undefined) return undefined; + if (Array.isArray(state.base) && property === "length") { + syncArrayTarget(state); + return Reflect.getOwnPropertyDescriptor(state.target, property); + } + return { + configurable: true, + enumerable: descriptor.enumerable, + writable: true, + value: + "value" in descriptor ? draftValue(state.context, descriptor.value, state, childKey(property)) : undefined, + }; + }, + getPrototypeOf(target): object | null { + const state = targetState(target); + assertActive(state.context); + return Object.getPrototypeOf(state.base); + }, + has(target, property): boolean { + const state = targetState(target); + assertActive(state.context); + return Reflect.has(currentValue(state), property); + }, + isExtensible(target): boolean { + assertActive(targetState(target).context); + return true; + }, + ownKeys(target): ArrayLike { + const state = targetState(target); + assertActive(state.context); + return Reflect.ownKeys(currentValue(state)); + }, + preventExtensions(target): never { + assertActive(targetState(target).context); + throw new TypeError("Drafts cannot be made non-extensible"); + }, + set(target, property, value): boolean { + const state = targetState(target); + assertWritable(state, property); + return writeProperty(state, property, value); + }, + setPrototypeOf(target): never { + assertActive(targetState(target).context); + throw new TypeError("Changing a draft prototype is not supported"); + }, +}; + +function targetState(target: object): DraftState { + return Array.isArray(target) + ? (target as unknown as Record)[DRAFT_STATE]! + : (target as DraftState); +} + +function childKey(property: PropertyKey): ChildKey | undefined { + if (typeof property === "symbol") return undefined; + const index = arrayIndex(property); + return index ?? property; +} + +function draftState(value: unknown): DraftState | undefined { + if (!isContainer(value)) return undefined; + return (value as Record)[DRAFT_STATE]; +} + +function draftValue(context: DraftContext, value: unknown, parent?: DraftState, key?: ChildKey): unknown { + if (!isContainer(value)) return value; + const existingDraft = draftState(value); + if (existingDraft !== undefined) return value; + return stateFor(context, value, parent, key).proxy; +} + +function currentValue(state: DraftState): Container { + assertActive(state.context); + return state.copy ?? state.base; +} + +function ensureCopy(state: DraftState): Container { + markChanged(state); + if (state.copy !== undefined) return state.copy; + state.copy = shallowCopy(state.base); + return state.copy; +} + +function shallowCopy(value: Container): Container { + if (Array.isArray(value)) return Array.from(value); + if (Object.getPrototypeOf(value) === null) + return Object.assign(Object.create(null) as Record, value); + return { ...value }; +} + +function markChanged(state: DraftState): void { + let current: DraftState | undefined = state; + while (current !== undefined && !current.dirty) { + current.dirty = true; + const parent: DraftState | undefined = current.parent; + if (parent !== undefined && current.key !== undefined) registerChild(parent, current.key, current); + current = parent; + } +} + +function registerChild(parent: DraftState, key: ChildKey, child: DraftState): void { + const children = parent.children; + if (children === undefined) parent.children = child; + else if (children instanceof Map) children.set(key, child); + else if (children !== child) + parent.children = new Map([ + [children.key!, children], + [key, child], + ]); + if (Array.isArray(parent.base) && typeof key === "number") markArrayIndex(parent, key); +} + +function markArrayIndex(state: DraftState, index: number): void { + const changed = state.changedIndices; + if (changed === undefined) state.changedIndices = index; + else if (changed instanceof Set) changed.add(index); + else if (changed !== index) state.changedIndices = new Set([changed, index]); +} + +function writeProperty(state: DraftState, property: string | symbol, supplied: unknown): boolean { + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(state.base)) { + if (property === "length") return writeArrayLength(state, supplied); + const index = arrayIndex(property); + if (index === undefined) throw new TypeError("Only array indices and length can be written"); + const current = currentValue(state) as unknown[]; + if (index > current.length) throw new TypeError("Draft arrays cannot contain holes"); + const stored = clonePlacement(supplied, state.context); + if (index < current.length && Object.is(current[index], stored)) return true; + if (index < current.length) detachCurrentChild(state, current[index]); + const copy = ensureCopy(state) as unknown[]; + defineData(copy, String(index), stored); + if (index === current.length) state.structural = true; + else markArrayIndex(state, index); + syncArrayTarget(state); + return true; + } + if (supplied === undefined) return deleteObjectProperty(state, property); + const stored = clonePlacement(supplied, state.context); + const current = currentValue(state) as Record; + if (Object.hasOwn(current, property) && !isContainer(stored) && Object.is(current[property], stored)) return true; + if (Object.hasOwn(current, property)) detachCurrentChild(state, current[property]); + const copy = ensureCopy(state) as Record; + defineData(copy, property, stored); + return true; +} + +function writeArrayLength(state: DraftState, supplied: unknown): boolean { + const probe: unknown[] = []; + Reflect.set(probe, "length", supplied); + const next = probe.length; + const current = (currentValue(state) as unknown[]).length; + if (next === current) return true; + const copy = ensureCopy(state) as unknown[]; + if (next < current) copy.length = next; + else { + copy.length = next; + for (let index = current; index < next; index++) copy[index] = null; + } + state.structural = true; + syncArrayTarget(state); + return true; +} + +function deleteObjectProperty(state: DraftState, property: string): boolean { + const current = currentValue(state); + if (!Object.hasOwn(current, property)) return true; + detachCurrentChild(state, (current as Record)[property]); + Reflect.deleteProperty(ensureCopy(state), property); + return true; +} + +function detachCurrentChild(parent: DraftState, value: unknown): void { + if (!isContainer(value)) return; + const child = draftState(value) ?? parent.context.states.get(value); + if (child?.parent !== parent) return; + const children = parent.children; + if (children === child) parent.children = undefined; + else if (children instanceof Map && child.key !== undefined && children.get(child.key) === child) { + children.delete(child.key); + } + child.parent = undefined; + child.key = undefined; +} + +function arrayIndex(property: PropertyKey): number | undefined { + if (typeof property !== "string" || property === "") return undefined; + const index = Number(property); + if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; + return index; +} + +function mutatorState(receiver: unknown): DraftState | undefined { + if (!isContainer(receiver)) return undefined; + return draftState(receiver); +} + +const MUTATORS = { + push(this: unknown, ...args: unknown[]): number { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.push, this, args) as number; + assertActive(state.context); + if (args.length === 0) return (currentValue(state) as unknown[]).length; + const target = ensureCopy(state) as unknown[]; + for (const item of cloneItems(args, state.context)) defineData(target, String(target.length), item); + state.structural = true; + syncArrayTarget(state); + return target.length; + }, + pop(this: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.pop, this, []); + assertActive(state.context); + const current = currentValue(state) as unknown[]; + if (current.length === 0) return undefined; + const target = ensureCopy(state) as unknown[]; + const value = target[target.length - 1]; + target.length -= 1; + state.structural = true; + syncArrayTarget(state); + return draftValue(state.context, value, state, target.length); + }, + shift(this: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.shift, this, []); + assertActive(state.context); + const current = currentValue(state) as unknown[]; + if (current.length === 0) return undefined; + const target = ensureCopy(state) as unknown[]; + const value = Reflect.apply(Array.prototype.shift, target, []); + state.structural = true; + syncArrayTarget(state); + return draftValue(state.context, value, state, 0); + }, + unshift(this: unknown, ...args: unknown[]): number { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.unshift, this, args) as number; + assertActive(state.context); + if (args.length === 0) return (currentValue(state) as unknown[]).length; + const target = ensureCopy(state) as unknown[]; + const items = cloneItems(args, state.context); + insertChunked(target, 0, items); + state.structural = true; + syncArrayTarget(state); + return target.length; + }, + splice(this: unknown, ...args: unknown[]): unknown[] { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; + assertActive(state.context); + const length = (currentValue(state) as unknown[]).length; + const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); + const remove = + args.length === 0 + ? 0 + : args.length === 1 + ? length - start + : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); + const inputs = cloneItems(args.slice(2), state.context); + if (remove === 0 && inputs.length === 0) return []; + const target = ensureCopy(state) as unknown[]; + const removed = spliceCapturedLength(target, start, remove, inputs, length); + state.structural = true; + syncArrayTarget(state); + return removed.map((value, offset) => draftValue(state.context, value, state, start + offset)); + }, + sort(this: unknown, comparator?: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); + assertActive(state.context); + if (comparator !== undefined && typeof comparator !== "function") + throw new TypeError("Comparator must be a function"); + const target = ensureCopy(state) as unknown[]; + state.structural = true; + Reflect.apply(Array.prototype.sort, target, [ + (left: unknown, right: unknown): number => { + const draftedLeft = draftValue(state.context, left, state); + const draftedRight = draftValue(state.context, right, state); + if (typeof comparator === "function") { + // Array.prototype.sort performs ToNumber on the return value. Returning + // it directly preserves native bigint TypeError behavior. + return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number; + } + const leftString = String(draftedLeft); + const rightString = String(draftedRight); + return leftString < rightString ? -1 : leftString > rightString ? 1 : 0; + }, + ]); + syncArrayTarget(state); + return state.proxy; + }, + reverse(this: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.reverse, this, []); + assertActive(state.context); + const target = ensureCopy(state) as unknown[]; + if (target.length > 1) { + Reflect.apply(Array.prototype.reverse, target, []); + state.structural = true; + } + return state.proxy; + }, + fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); + assertActive(state.context); + const length = (currentValue(state) as unknown[]).length; + const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + if (end <= start) return state.proxy; + const target = ensureCopy(state) as unknown[]; + for (let index = start; index < end; index++) target[index] = clonePlacement(supplied, state.context); + state.structural = true; + return state.proxy; + }, + copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { + const state = mutatorState(this); + if (state === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); + assertActive(state.context); + const length = (currentValue(state) as unknown[]).length; + const targetIndex = clampIndex(toIntegerOrInfinity(targetArg), length); + const start = clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + const count = Math.min(Math.max(end - start, 0), length - targetIndex); + if (count === 0) return state.proxy; + const source = (currentValue(state) as unknown[]).slice(start, start + count); + const target = ensureCopy(state) as unknown[]; + for (let offset = 0; offset < count; offset++) + target[targetIndex + offset] = cloneLogical(source[offset], state.context); + state.structural = true; + return state.proxy; + }, +}; + +function cloneItems(values: readonly unknown[], context: DraftContext): unknown[] { + return values.map((value) => clonePlacement(value, context)); +} + +function clonePlacement(value: unknown, _context: DraftContext): unknown { + if (!isContainer(value)) return value; + const state = draftState(value); + return state === undefined ? cloneJson(value) : cloneState(state); +} + +/** Fast trusted strict-JSON clone. Inputs are caller-guaranteed plain, dense, acyclic trees. */ +export function cloneJson(value: T): T { + if (!isContainer(value)) return value; + if (Array.isArray(value)) return value.map((child) => cloneJson(child)) as T; + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); + return result as T; +} + +function cloneState(state: DraftState): Container { + assertActive(state.context); + const context = state.context; + const current = state.copy ?? state.base; + if (Array.isArray(current)) { + const result = new Array(current.length); + for (let index = 0; index < current.length; index++) result[index] = cloneLogical(current[index], context); + return result; + } + const result = Object.create(Object.getPrototypeOf(current)) as Record; + for (const key of Object.keys(current)) defineData(result, key, cloneLogical(current[key], context)); + return result; +} + +function cloneLogical(value: unknown, context: DraftContext): unknown { + if (!isContainer(value)) return value; + const proxyState = draftState(value); + if (proxyState !== undefined) return cloneState(proxyState); + const state = context.states.get(value); + return state === undefined ? cloneJson(value) : cloneState(state); +} + +function finalize(state: DraftState): Container { + if (state.finalized !== undefined) return state.finalized; + if (!state.dirty) return state.base; + const current = state.copy ?? state.base; + let result = current; + if (Array.isArray(current) && state.structural) { + for (let index = 0; index < current.length; index++) { + const value = current[index]; + if (!isContainer(value)) continue; + const child = draftState(value) ?? state.context.states.get(value); + if (child === undefined || !child.dirty) continue; + const next = finalize(child); + if (next === value) continue; + if (result === state.base) result = shallowCopy(state.base); + (result as unknown[])[index] = next; + } + } else { + forEachDirtyChild(state, (key, child) => { + const value = (current as Record)[key]; + if (value !== child.base && draftState(value) !== child) return; + const next = finalize(child); + if (next === value) return; + if (result === state.base) result = shallowCopy(state.base); + defineData(result, String(key), next); + if (Array.isArray(current) && typeof key === "number") markArrayIndex(state, key); + }); + } + if (result !== state.base && shallowEqual(state.base, result)) result = state.base; + state.finalized = result; + if ( + Array.isArray(state.base) && + Array.isArray(result) && + result !== state.base && + !state.structural && + state.base.length === result.length + ) { + const base = state.base as unknown[]; + const output = result as unknown[]; + const changed = changedIndexList(state); + if (changed !== undefined) { + const keys = changed.filter((index) => index < base.length && index < output.length); + state.context.hints.set(result, { base: state.base as JsonValue[], keys }); + } + } + return result; +} + +function forEachDirtyChild(state: DraftState, visit: (key: ChildKey, child: DraftState) => void): void { + const children = state.children; + if (children === undefined) return; + if (children instanceof Map) { + for (const [key, child] of children) if (child.dirty) visit(key, child); + } else if (children.dirty && children.key !== undefined) visit(children.key, children); +} + +function changedIndexList(state: DraftState): number[] | undefined { + const changed = state.changedIndices; + if (changed === undefined) return []; + // The differ only consumes narrow hints. Avoid materializing and sorting a + // dense set that it would immediately discard. + if (changed instanceof Set) { + if (changed.size >= 2_048) return undefined; + return [...changed].sort((left, right) => left - right); + } + return [changed]; +} + +function shallowEqual(left: Container, right: Container): boolean { + if (Array.isArray(left) !== Array.isArray(right)) return false; + if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + for (const key of leftKeys) { + if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false; + } + return true; +} + +function defineData(target: object, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); +} + +function spliceCapturedLength( + target: unknown[], + start: number, + remove: number, + items: readonly unknown[], + capturedLength: number, +): unknown[] { + const removed = new Array(remove); + for (let offset = 0; offset < remove; offset++) { + defineData(removed, String(offset), denseArrayValue(target, start + offset)); + } + if (items.length < remove) { + for (let source = start + remove; source < capturedLength; source++) { + defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); + } + } else if (items.length > remove) { + for (let source = capturedLength - 1; source >= start + remove; source--) { + defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); + } + } + for (let offset = 0; offset < items.length; offset++) defineData(target, String(start + offset), items[offset]); + target.length = capturedLength - remove + items.length; + return removed; +} + +function denseArrayValue(target: readonly unknown[], index: number): unknown { + return index < target.length && Object.hasOwn(target, index) && target[index] !== undefined ? target[index] : null; +} + +function insertChunked(target: unknown[], index: number, values: readonly unknown[]): void { + for (let offset = 0; offset < values.length; offset += MAX_NATIVE_ARRAY_INSERT_ITEMS) { + target.splice(index + offset, 0, ...values.slice(offset, offset + MAX_NATIVE_ARRAY_INSERT_ITEMS)); + } +} + +function toIntegerOrInfinity(value: unknown): number { + const number = +(value as number); + if (Number.isNaN(number) || number === 0) return 0; + return Number.isFinite(number) ? Math.trunc(number) : number; +} + +function clampIndex(value: number, length: number): number { + if (value === Number.NEGATIVE_INFINITY) return 0; + if (value < 0) return Math.max(length + value, 0); + return Math.min(value, length); +} + +function syncArrayTarget(state: DraftState): void { + (state.target as unknown[]).length = ((state.copy ?? state.base) as unknown[]).length; +} + +function assertWritable(state: DraftState, property: PropertyKey): void { + assertActive(state.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); +} + +function assertActive(context: DraftContext): void { + if (!context.active) throw new TypeError("Cannot use a draft outside its change callback"); +} + +function isContainer(value: unknown): value is Container { + return value !== null && typeof value === "object"; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + ((typeof value === "object" && value !== null) || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function" + ); +} diff --git a/packages/chord/src/delta/cow/index.ts b/packages/chord/src/delta/cow/index.ts new file mode 100644 index 000000000..118806804 --- /dev/null +++ b/packages/chord/src/delta/cow/index.ts @@ -0,0 +1,20 @@ +export type { JsonValue, NonEmptyPath, Op, Path, PathRef, Seg, WireOp } from "../index.ts"; +export { + apply, + assertSafePath, + assertValidOp, + assertValidWireOp, + decoder, + encoder, + isBase, + isReplace, + overlap, + PathError, + RESERVED_SEGMENTS, + UnsafePathError, +} from "../index.ts"; +export { applyImmutable } from "./apply-immutable.ts"; +export { diffRevisions } from "./diff.ts"; +export type { Draft } from "./draft.ts"; +export type { Change, Prepared, Tracker } from "./tracker.ts"; +export { track } from "./tracker.ts"; diff --git a/packages/chord/src/delta/cow/tracker.ts b/packages/chord/src/delta/cow/tracker.ts new file mode 100644 index 000000000..20a34d4ae --- /dev/null +++ b/packages/chord/src/delta/cow/tracker.ts @@ -0,0 +1,225 @@ +import type { JsonValue } from "../../types.ts"; +import type { Op } from "../index.ts"; +import { applyImmutable } from "./apply-immutable.ts"; +import { diffRevisions } from "./diff.ts"; +import { cloneJson, createDraftTransaction, type Draft, type DraftTransaction, type ProduceMetadata } from "./draft.ts"; + +export interface Prepared { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; + abort(): void; +} + +export interface Change { + readonly state: Draft; + prepare(): Prepared; + abort(): void; +} + +export interface Tracker { + readonly value: T; + readonly revision: number; + beginChange(): Change; + prepareReplace(value: T): Prepared; + adopt(prepared: Prepared): void; +} + +type PreparedMetadata = { + readonly owner: object; + readonly revision: number; + status: "prepared" | "consumed" | "aborted"; +}; + +const PREPARED = new WeakMap(); + +class PreparedImpl implements Prepared { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; + + constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) { + this.base = base; + this.value = value; + this.ops = ops; + PREPARED.set(this, metadata); + Object.freeze(this); + } + + abort(): void { + const metadata = PREPARED.get(this)!; + if (metadata.status === "prepared") metadata.status = "aborted"; + } +} + +class ChangeImpl implements Change { + readonly state: Draft; + #tracker: TrackerImpl | undefined; + #transaction: DraftTransaction | undefined; + #preparedMetadata: PreparedMetadata | undefined; + #status: "open" | "prepared" | "aborted" = "open"; + + constructor(tracker: TrackerImpl, transaction: DraftTransaction) { + this.#tracker = tracker; + this.#transaction = transaction; + this.state = transaction.state; + } + + prepare(): Prepared { + if (this.#status !== "open") throw new Error("Change has already been settled"); + const tracker = this.#tracker!; + const transaction = this.#transaction!; + try { + const prepared = tracker.prepareDraft(transaction); + this.#preparedMetadata = PREPARED.get(prepared)!; + this.#status = "prepared"; + return prepared; + } catch (error) { + this.#status = "aborted"; + throw error; + } finally { + this.#tracker = undefined; + this.#transaction = undefined; + } + } + + abort(): void { + if (this.#status === "aborted") return; + if (this.#status === "prepared") { + if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted"; + this.#preparedMetadata = undefined; + this.#status = "aborted"; + return; + } + this.#status = "aborted"; + const tracker = this.#tracker!; + const transaction = this.#transaction!; + this.#tracker = undefined; + this.#transaction = undefined; + try { + transaction.abort(); + } finally { + tracker.releaseChange(); + } + } +} + +class TrackerImpl implements Tracker { + readonly #owner = {}; + #value: T; + #revision = 0; + #changing = false; + + constructor(initial: T) { + this.#value = initial; + } + + get value(): T { + return this.#value; + } + + get revision(): number { + return this.#revision; + } + + beginChange(): Change { + this.#reserveChange(); + try { + return new ChangeImpl(this, createDraftTransaction(this.#value)); + } catch (error) { + this.#changing = false; + throw error; + } + } + + prepareReplace(value: T): Prepared { + this.#reserveChange(); + try { + return this.#prepare({ value, hints: new WeakMap() }, true); + } finally { + this.#changing = false; + } + } + + adopt(prepared: Prepared): void { + const metadata = PREPARED.get(prepared as object); + if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); + if (metadata.status === "consumed") throw new Error("Prepared change has already been used"); + if (metadata.status === "aborted") throw new Error("Prepared change has been aborted"); + if (this.#changing) throw new Error("Cannot adopt while a change is active"); + if (metadata.revision !== this.#revision || this.#value !== prepared.base) + throw new Error("Prepared change is stale"); + metadata.status = "consumed"; + this.#value = prepared.value; + this.#revision += 1; + } + + prepareDraft(transaction: DraftTransaction): PreparedImpl { + try { + return this.#prepare(transaction.finish()); + } finally { + this.#changing = false; + } + } + + releaseChange(): void { + this.#changing = false; + } + + #reserveChange(): void { + if (this.#changing) throw new Error("Tracker already has an active change"); + this.#changing = true; + } + + #prepare(produced: ProduceMetadata, replacement = false): PreparedImpl { + const base = this.#value; + const operations = diffRevisions( + base as unknown as JsonValue, + produced.value as unknown as JsonValue, + produced.hints, + ); + if (operations.length === 0) { + return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, base, []); + } + // Trusted ownership rule: value and public ops are independent immutable + // transfers. Draft revisions are reconstructed from a private detached batch: + // differ payloads may reference retained base objects, so applying them directly + // could introduce aliases. Replacements are already caller-transferred roots. + // No freeze or validation runs. + const value = replacement ? produced.value : applyImmutable(base, detachOperations(operations)); + return new PreparedImpl( + { owner: this.#owner, revision: this.#revision, status: "prepared" }, + base, + value, + detachOperations(operations), + ); + } +} + +/** Take O(1) ownership of an alias-free mutable strict-JSON root. */ +export function track(initial: T): Tracker { + return new TrackerImpl(initial); +} + +function detachOperations(operations: readonly Op[]): Op[] { + return operations.map((operation): Op => { + switch (operation[0]) { + case "r": + return ["r", cloneJson(operation[1])]; + case "s": + return ["s", operation[1], cloneJson(operation[2])]; + case "p": + return ["p", operation[1], operation[2], operation[3], operation[4].map((value) => cloneJson(value))]; + case "m": + return ["m", operation[1], operation[2].slice()]; + case "d": + return operation; + case "a": + return operation; + case "t": + return operation; + default: + throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`); + } + }); +} diff --git a/packages/chord/test/delta-astra/astra.test.ts b/packages/chord/test/delta-astra/astra.test.ts new file mode 100644 index 000000000..72f6eb855 --- /dev/null +++ b/packages/chord/test/delta-astra/astra.test.ts @@ -0,0 +1,1168 @@ +import { isProxy } from "node:util/types"; +import { describe, expect, it } from "vitest"; +import { type Draft, type Op, track } from "../../src/delta/astra/index.ts"; +import { apply, decoder, encoder, type JsonValue } from "../../src/delta/index.ts"; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +function replay(base: T, operations: readonly Op[]): T { + return apply(clone(base), clone(operations)); +} + +function settle(tracker: ReturnType>, mutate: (draft: Draft) => void): T { + const base = clone(tracker.value); + const change = tracker.beginChange(); + mutate(change.state); + const prepared = change.prepare(); + const candidate = clone(prepared.value); + expect(replay(base, prepared.ops)).toEqual(candidate); + tracker.adopt(prepared); + expect(tracker.value).toEqual(candidate); + return candidate; +} + +describe("astra transactional overlay lifecycle", () => { + it("borrows the committed root and leaves it untouched until adopt", () => { + const initial = { count: 1, nested: { text: "a" }, values: [1] }; + const tracker = track(initial); + expect(tracker.value).toBe(initial); + const change = tracker.beginChange(); + change.state.count = 2; + change.state.nested.text += "b"; + change.state.values.push(2); + expect(initial).toEqual({ count: 1, nested: { text: "a" }, values: [1] }); + const prepared = change.prepare(); + expect(prepared.baseRevision).toBe(0); + expect(prepared.base).toBe(initial); + 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(() => { + (prepared.value as { count: number }).count = 9; + }).toThrow(/read-only/); + tracker.adopt(prepared); + expect(initial).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); + expect(prepared.value).toBe(tracker.value); + expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); + expect(prepared.base).toBe(prepared.value); + expect(() => change.state.count).toThrow(TypeError); + expect(prepared.ops).toEqual([ + ["s", ["count"], 2], + ["a", ["nested", "text"], "b"], + ["p", ["values"], 1, 0, [2]], + ]); + }); + + it("keeps a transaction live across await and seals it only at prepare", async () => { + const tracker = track({ left: 0, nested: { right: 0 } }); + const change = tracker.beginChange(); + change.state.left = 1; + await Promise.resolve(); + change.state.nested.right = 2; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ left: 1, nested: { right: 2 } }); + expect(() => { + change.state.left = 3; + }).toThrow(/read-only/); + tracker.adopt(prepared); + }); + + it("aborts idempotently and revokes held descendants", () => { + const tracker = track({ child: { value: 1 } }); + const change = tracker.beginChange(); + const child = change.state.child; + child.value = 2; + change.abort(); + change.abort(); + expect(tracker.value.child.value).toBe(1); + expect(() => child.value).toThrow(TypeError); + expect(() => change.prepare()).toThrow(/settled/); + }); + + it("allows competing contexts and invalidates loser views", () => { + const tracker = track({ value: 0 }); + const first = tracker.beginChange(); + const second = tracker.beginChange(); + first.state.value = 1; + second.state.value = 2; + const firstPrepared = first.prepare(); + const secondPrepared = second.prepare(); + const held = secondPrepared.value; + tracker.adopt(firstPrepared); + expect(tracker.value.value).toBe(1); + expect(() => held.value).toThrow(TypeError); + expect(() => tracker.adopt(secondPrepared)).toThrow(/stale/); + expect(() => tracker.adopt(firstPrepared)).toThrow(/already been used/); + }); + + it("adopts object edits in place with ordinary fast-layout descriptors", () => { + const initial = { first: 1, second: 2 } as { first: number; second?: number; third?: number }; + const tracker = track(initial); + const change = tracker.beginChange(); + change.state.first = 3; + delete change.state.second; + change.state.third = 4; + const prepared = change.prepare(); + tracker.adopt(prepared); + expect(tracker.value).toBe(initial); + expect(tracker.value).toEqual({ first: 3, third: 4 }); + expect(Object.getOwnPropertyDescriptor(initial, "third")).toEqual({ + value: 4, + writable: true, + enumerable: true, + configurable: true, + }); + }); + + it("supports adopt-then-publish ordering and retained publication reads", () => { + const tracker = track({ value: 0, nested: { count: 0 } }); + const change = tracker.beginChange(); + change.state.value = 1; + change.state.nested.count = 1; + const prepared = change.prepare(); + const operations = prepared.ops; + tracker.adopt(prepared); + // Matches services/state.ts: adopt first, then inspect ops and publish prepared.value. + expect(prepared.ops).toBe(operations); + const published = prepared.ops.length === 0 ? undefined : prepared.value; + expect(operations).toHaveLength(2); + expect(published).toBeDefined(); + expect(published).toBe(tracker.value); + expect(clone(published!)).toEqual({ value: 1, nested: { count: 1 } }); + + const next = tracker.beginChange(); + next.state.value = 2; + const nextPrepared = next.prepare(); + tracker.adopt(nextPrepared); + // In-place adoption intentionally advances retained references to the mutable committed root. + expect(published).toBe(tracker.value); + expect(prepared.value).toEqual({ value: 2, nested: { count: 1 } }); + }); + + it("keeps replacement base/value structurally compatible and readable after adoption", () => { + const original = { value: 0 }; + const replacement = { value: 1 }; + const tracker = track(original); + const prepared = tracker.prepareReplace(replacement); + expect(prepared.base).toBe(original); + expect(prepared.value).not.toBe(replacement); + tracker.adopt(prepared); + expect(prepared.base).toBe(original); + expect(prepared.value).toBe(replacement); + expect(prepared.value).toBe(tracker.value); + }); + + it("revokes aborted and stale prepared candidate views", () => { + const tracker = track({ value: 0 }); + const abortedChange = tracker.beginChange(); + abortedChange.state.value = 1; + const aborted = abortedChange.prepare(); + const abortedView = aborted.value; + aborted.abort(); + expect(() => abortedView.value).toThrow(TypeError); + expect(() => aborted.value.value).toThrow(TypeError); + + const loserChange = tracker.beginChange(); + loserChange.state.value = 2; + const loser = loserChange.prepare(); + const loserView = loser.value; + const winner = tracker.prepareReplace({ value: 3 }); + tracker.adopt(winner); + expect(() => loserView.value).toThrow(TypeError); + expect(() => loser.value.value).toThrow(TypeError); + }); + + it("rejects foreign and aborted prepared values", () => { + const first = track({ value: 0 }); + const second = track({ value: 0 }); + const prepared = first.prepareReplace({ value: 1 }); + expect(() => second.adopt(prepared)).toThrow(/different tracker/); + prepared.abort(); + prepared.abort(); + expect(() => first.adopt(prepared)).toThrow(/aborted/); + }); + + it("reads deleted own properties as absent", () => { + Object.defineProperty(Object.prototype, "astraInherited", { + value: 7, + writable: true, + configurable: true, + }); + try { + const tracker = track({ value: 1 } as { value?: number; astraInherited?: number }); + const change = tracker.beginChange(); + delete change.state.value; + change.state.astraInherited = 1; + delete change.state.astraInherited; + expect(change.state.value).toBeUndefined(); + expect(change.state.astraInherited).toBeUndefined(); + const prepared = change.prepare(); + expect(prepared.value.value).toBeUndefined(); + expect(prepared.value.astraInherited).toBeUndefined(); + tracker.adopt(prepared); + expect(Object.keys(tracker.value)).toEqual([]); + } finally { + delete (Object.prototype as Record).astraInherited; + } + }); + + it("detaches old handles for deeply equal object and array assignments before normalizing", () => { + const initial = { + object: { nested: { value: 1 } }, + array: [{ value: 1 }], + rows: [{ value: 1 }], + }; + const tracker = track(initial); + const change = tracker.beginChange(); + const oldObject = change.state.object; + const oldArray = change.state.array; + const oldRow = change.state.rows[0]!; + change.state.object = { nested: { value: 1 } }; + change.state.array = [{ value: 1 }]; + change.state.rows[0] = { value: 1 }; + expect(change.state.object).not.toBe(oldObject); + expect(change.state.array).not.toBe(oldArray); + expect(change.state.rows[0]).not.toBe(oldRow); + oldObject.nested.value = 9; + oldArray[0]!.value = 9; + oldRow.value = 9; + expect(change.state).toEqual({ + object: { nested: { value: 1 } }, + array: [{ value: 1 }], + rows: [{ value: 1 }], + }); + const prepared = change.prepare(); + expect(prepared.ops).toEqual([]); + tracker.adopt(prepared); + expect(tracker.value).toBe(initial); + expect(tracker.value.object).toBe(initial.object); + expect(tracker.value.array).toBe(initial.array); + expect(tracker.value.rows[0]).toBe(initial.rows[0]); + }); + + it("normalizes a deeply equal replacement without changing committed identity", () => { + const initial = { nested: { value: 1 }, rows: [1, 2, 3] }; + const tracker = track(initial); + const prepared = tracker.prepareReplace({ nested: { value: 1 }, rows: [1, 2, 3] }); + expect(prepared.ops).toEqual([]); + tracker.adopt(prepared); + expect(tracker.value).toBe(initial); + }); + + it("takes O(1) ownership for replacement and detaches its operation payload", () => { + const tracker = track({ value: 0, rows: [] as { value: number }[] }); + const replacement = { value: 1, rows: [{ value: 2 }] }; + const prepared = tracker.prepareReplace(replacement); + expect(prepared.base).toBe(tracker.value); + expect(prepared.value.rows).not.toBe(replacement.rows); + const operations = prepared.ops; + expect(operations).toEqual([["r", replacement]]); + expect((operations[0] as readonly ["r", typeof replacement])[1]).not.toBe(replacement); + tracker.adopt(prepared); + expect(tracker.value).toBe(replacement); + expect(prepared.value).toBe(replacement); + replacement.rows[0]!.value = 9; + expect(operations).toEqual([["r", { value: 1, rows: [{ value: 2 }] }]]); + }); +}); + +describe("astra policy view", () => { + it("supports native reads, descriptors, keys, iteration, map, and JSON", () => { + const tracker = track({ values: [1, 2, 3], object: { a: 1 } as { a: number; b?: number } }); + const change = tracker.beginChange(); + change.state.values.splice(1, 1, 4, 5); + change.state.object.b = 2; + expect(Array.isArray(change.state.values)).toBe(true); + expect(change.state.values.length).toBe(4); + expect([...change.state.values]).toEqual([1, 4, 5, 3]); + expect(change.state.values.map((value) => value * 2)).toEqual([2, 8, 10, 6]); + expect(Object.keys(change.state.values)).toEqual(["0", "1", "2", "3"]); + expect(Object.getOwnPropertyDescriptor(change.state.values, "2")?.value).toBe(5); + expect(Object.keys(change.state.object)).toEqual(["a", "b"]); + expect(JSON.parse(JSON.stringify(change.state))).toEqual({ values: [1, 4, 5, 3], object: { a: 1, b: 2 } }); + change.abort(); + }); + + it("keeps native coercion side effects and string operation forms", () => { + const tracker = track({ text: "abcdefgh", values: [1, 2, 3], marker: 0 }); + const change = tracker.beginChange(); + change.state.text = `${change.state.text.slice(3)}xyz`; + change.state.values.splice( + { + valueOf() { + change.state.marker = 1; + return 1; + }, + } as unknown as number, + 1, + 4, + ); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ text: "defghxyz", values: [1, 4, 3], marker: 1 }); + expect(prepared.ops).toContainEqual(["t", ["text"], 3]); + expect(prepared.ops).toContainEqual(["a", ["text"], "xyz"]); + expect(replay(tracker.value, decoder().decode(encoder().encode(prepared.ops)))).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("emits deletion and supports root arrays", () => { + const objectTracker = track({ keep: 1, remove: 2 } as { keep: number; remove?: number }); + settle(objectTracker, (draft) => { + delete draft.remove; + }); + expect(objectTracker.value).toEqual({ keep: 1 }); + + const arrayTracker = track([1, 2, 3]); + settle(arrayTracker, (draft) => { + draft.reverse(); + draft.push(4); + }); + expect(arrayTracker.value).toEqual([3, 2, 1, 4]); + }); + + it("preserves native object key order after delete and re-add", () => { + const tracker = track({ first: 1, second: 2 } as { first?: number; second: number }); + const change = tracker.beginChange(); + delete change.state.first; + change.state.first = 1; + expect(Object.keys(change.state)).toEqual(["second", "first"]); + const prepared = change.prepare(); + tracker.adopt(prepared); + expect(Object.keys(tracker.value)).toEqual(["second", "first"]); + }); + + it("orders new integer-like object keys before strings for recipe-visible reads", () => { + const tracker = track({ object: { label: "x" } as Record, first: "" }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + change.state.object["2"] = "two"; + change.state.object["1"] = "one"; + expect(Object.keys(change.state.object)).toEqual(["1", "2", "label"]); + change.state.first = Object.keys(change.state.object)[0]!; + const prepared = change.prepare(); + expect(prepared.value.first).toBe("1"); + expect(Object.keys(prepared.value.object)).toEqual(["1", "2", "label"]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expect(tracker.value.first).toBe("1"); + expect(Object.keys(tracker.value.object)).toEqual(["1", "2", "label"]); + }); + + it("keeps native integer and string ordering across deletion and re-addition", () => { + const tracker = track({ + object: { 1: "one", 2: "two", first: "a", second: "b" } as Record, + }); + const change = tracker.beginChange(); + delete change.state.object["2"]; + change.state.object["2"] = "two"; + delete change.state.object.first; + change.state.object.first = "a"; + change.state.object["3"] = "three"; + change.state.object["0"] = "zero"; + expect(Object.keys(change.state.object)).toEqual(["0", "1", "2", "3", "second", "first"]); + const prepared = change.prepare(); + expect(Object.keys(prepared.value.object)).toEqual(["0", "1", "2", "3", "second", "first"]); + tracker.adopt(prepared); + expect(Object.keys(tracker.value.object)).toEqual(["0", "1", "2", "3", "second", "first"]); + }); + + it("orders integer-like keys on null-prototype objects", () => { + const object = Object.create(null) as Record; + object.label = "x"; + const tracker = track({ object }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + change.state.object["2"] = "two"; + change.state.object["1"] = "one"; + expect(Object.keys(change.state.object)).toEqual(["1", "2", "label"]); + const prepared = change.prepare(); + expect(Object.getPrototypeOf(prepared.value.object)).toBeNull(); + expect(Object.keys(prepared.value.object)).toEqual(["1", "2", "label"]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expect(Object.getPrototypeOf(tracker.value.object)).toBeNull(); + expect(Object.keys(tracker.value.object)).toEqual(["1", "2", "label"]); + }); + + it("forwards borrowed mutators to ordinary generic array receivers", () => { + const tracker = track({ values: [1, 2, 3] }); + const change = tracker.beginChange(); + const receiver = [3, 1, 2]; + const push = change.state.values.push; + const splice = change.state.values.splice; + const sort = change.state.values.sort; + const fill = change.state.values.fill; + const copyWithin = change.state.values.copyWithin; + expect(Reflect.apply(push, receiver, [4])).toBe(4); + expect(Reflect.apply(splice, receiver, [1, 1, 5])).toEqual([1]); + expect(Reflect.apply(sort, receiver, [(left: number, right: number) => left - right])).toBe(receiver); + expect(Reflect.apply(fill, receiver, [7, 1, 2])).toBe(receiver); + expect(Reflect.apply(copyWithin, receiver, [2, 0, 1])).toBe(receiver); + expect(receiver).toEqual([2, 7, 2, 5]); + expect(change.state.values).toEqual([1, 2, 3]); + change.abort(); + }); + + it("matches native structural coercion ordering for splice, fill, and copyWithin", () => { + const compare = (mutateNative: (values: number[]) => void, mutateDraft: (values: number[]) => void): void => { + const native = [1, 2, 3]; + mutateNative(native); + const tracker = track({ values: [1, 2, 3] }); + settle(tracker, (draft) => mutateDraft(draft.values)); + expect(tracker.value.values).toEqual(native); + }; + compare( + (values) => { + values.splice( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 1, + 9, + ); + }, + (values) => { + values.splice( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 1, + 9, + ); + }, + ); + compare( + (values) => { + values.fill(7, { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number); + }, + (values) => { + values.fill(7, { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number); + }, + ); + compare( + (values) => { + values.copyWithin( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 0, + 2, + ); + }, + (values) => { + values.copyWithin( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 0, + 2, + ); + }, + ); + }); + + it("does not report inherited methods as own array properties", () => { + const tracker = track({ values: [1] }); + const change = tracker.beginChange(); + expect(Object.getOwnPropertyDescriptor(change.state.values, "map")).toBeUndefined(); + change.abort(); + }); + + it("keeps repeated identical writes instead of reverting their pending override", () => { + const tracker = track({ value: 0, values: [0] as Array }); + settle(tracker, (draft) => { + draft.value = 1; + draft.value = 1; + draft.values[0] = null; + draft.values[0] = null; + draft.values.push(2); + draft.values[1] = null; + draft.values[1] = null; + }); + expect(tracker.value).toEqual({ value: 1, values: [null, null] }); + }); + + it("rejects array index gaps without mutation while allowing replacement and append", () => { + const tracker = track({ values: [1, 2] }); + const change = tracker.beginChange(); + expect(() => { + change.state.values[3] = 4; + }).toThrow(/holes/); + expect(change.state.values).toEqual([1, 2]); + expect(tracker.value.values).toEqual([1, 2]); + + change.state.values[1] = 9; + change.state.values[2] = 3; + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([1, 9, 3]); + expect(replay(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expect(tracker.value.values).toEqual([1, 9, 3]); + }); + + it("supports optional deletion, explicit null length growth, shrinking, and default sort", () => { + const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] as Array }); + settle(tracker, (draft) => { + draft.optional = undefined; + draft.values.length = 5; + expect(draft.values).toEqual([3, 1, 2, null, null]); + draft.values.length = 4; + draft.values.sort(); + expect(() => delete draft.values[0]).toThrow(/holes/); + }); + expect(tracker.value).toEqual({ values: [1, 2, 3, null] }); + }); + + it("enumerates wide objects without duplicate keys", () => { + const tracker = track({ values: {} as Record }); + const change = tracker.beginChange(); + for (let index = 0; index < 20_000; index++) change.state.values[`field${index}`] = index; + const keys = Object.keys(change.state.values); + expect(keys).toHaveLength(20_000); + expect(keys[0]).toBe("field0"); + expect(keys.at(-1)).toBe("field19999"); + change.abort(); + }); + + it("does not dirty read-only traversals", () => { + const tracker = track({ nested: { rows: [{ value: 1 }] } }); + const change = tracker.beginChange(); + expect(change.state.nested.rows[0]!.value).toBe(1); + const prepared = change.prepare(); + expect(prepared.ops).toEqual([]); + tracker.adopt(prepared); + }); + + it("uses one proxy identity per accessed container", () => { + const tracker = track({ nested: { value: 1 }, rows: [{ value: 2 }] }); + const change = tracker.beginChange(); + expect(change.state.nested).toBe(change.state.nested); + expect(change.state.rows).toBe(change.state.rows); + expect(change.state.rows[0]).toBe(change.state.rows[0]); + change.abort(); + }); +}); + +describe("astra by-value placements", () => { + it("clones property, index, push, unshift, splice, fill, and copyWithin placements", () => { + const tracker = track({ + property: null as { value: number } | null, + values: [{ value: 0 }, { value: 1 }, { value: 2 }], + }); + const external = { value: 5 }; + const change = tracker.beginChange(); + change.state.property = external; + change.state.values[0] = external; + change.state.values.push(external); + change.state.values.unshift(external); + change.state.values.splice(2, 0, external); + external.value = 99; + change.state.values.fill(change.state.values[0]!, 1, 3); + change.state.values.copyWithin(3, 0, 2); + const prepared = change.prepare(); + const candidate = clone(prepared.value); + expect(candidate.property).toEqual({ value: 5 }); + expect(candidate.values.slice(0, 5)).toEqual([ + { value: 5 }, + { value: 5 }, + { value: 5 }, + { value: 5 }, + { value: 5 }, + ]); + tracker.adopt(prepared); + }); + + it("expands repeated source aliases into independent placements", () => { + const tracker = track({ + left: null as { nested: { value: number } } | null, + right: null as { nested: { value: number } } | null, + rows: [] as { nested: { value: number } }[], + }); + const shared = { nested: { value: 1 } }; + const change = tracker.beginChange(); + change.state.left = shared; + change.state.right = shared; + change.state.rows.push(shared, shared); + change.state.left.nested.value = 9; + change.state.rows[0]!.nested.value = 8; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ + left: { nested: { value: 9 } }, + right: { nested: { value: 1 } }, + rows: [{ nested: { value: 8 } }, { nested: { value: 1 } }], + }); + expect(prepared.value.left).not.toBe(prepared.value.right); + expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); + expect(replay(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("distinguishes raw committed references from draft references", () => { + const tracker = track({ + source: { value: 1 }, + rawCopy: null as { value: number } | null, + draftCopy: null as { value: number } | null, + }); + const raw = tracker.value.source; + const change = tracker.beginChange(); + change.state.source.value = 2; + change.state.rawCopy = raw; + change.state.draftCopy = change.state.source; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ source: { value: 2 }, rawCopy: { value: 1 }, draftCopy: { value: 2 } }); + tracker.adopt(prepared); + }); + + it("folds edits to introduced object and array subtrees into placement payloads", () => { + const tracker = track({ + nested: null as { rows: { value: number }[] } | null, + rows: [] as { values: number[] }[], + }); + const change = tracker.beginChange(); + change.state.nested = { rows: [{ value: 1 }] }; + change.state.nested.rows[0]!.value = 2; + change.state.nested.rows.push({ value: 3 }); + change.state.rows.push({ values: [1] }); + change.state.rows[0]!.values.push(2); + const prepared = change.prepare(); + expect(prepared.ops).toEqual([ + ["s", ["nested"], { rows: [{ value: 2 }, { value: 3 }] }], + ["p", ["rows"], 0, 0, [{ values: [1, 2] }]], + ]); + expect(replay(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("folds pathological operation counts without payload-cost estimation", () => { + const wide = Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, 0])) as Record< + string, + number + >; + const wideTracker = track(wide); + const wideBase = clone(wideTracker.value); + const wideChange = wideTracker.beginChange(); + for (let index = 0; index < 5_000; index++) wideChange.state[`field${index}`] = 1; + const widePrepared = wideChange.prepare(); + expect(widePrepared.ops).toHaveLength(1); + expect(widePrepared.ops[0]?.[0]).toBe("r"); + expect(replay(wideBase, widePrepared.ops)).toEqual(widePrepared.value); + wideTracker.adopt(widePrepared); + + const sparseTracker = track({ rows: Array.from({ length: 15_000 }, (_, value) => ({ value })) }); + const sparseBase = clone(sparseTracker.value); + const sparseChange = sparseTracker.beginChange(); + for (let index = 0; index < 15_000; index += 3) sparseChange.state.rows[index]!.value = -index - 1; + const sparsePrepared = sparseChange.prepare(); + expect(sparsePrepared.ops).toHaveLength(1); + expect(sparsePrepared.ops[0]?.[0]).toBe("r"); + expect(replay(sparseBase, sparsePrepared.ops)).toEqual(sparsePrepared.value); + sparseTracker.adopt(sparsePrepared); + }, 30_000); + + it("keeps operation payloads detached from candidate and adopted placement mutation", () => { + const tracker = track({ rows: [] as { value: number }[] }); + const change = tracker.beginChange(); + change.state.rows.push({ value: 1 }); + const prepared = change.prepare(); + const operations = prepared.ops; + const pending: unknown[] = [...operations]; + while (pending.length > 0) { + const value = pending.pop(); + if (value === null || typeof value !== "object") continue; + expect(isProxy(value)).toBe(false); + pending.push(...Object.values(value)); + } + tracker.adopt(prepared); + tracker.value.rows[0]!.value = 9; + expect(operations).toEqual([["p", ["rows"], 0, 0, [{ value: 1 }]]]); + }); +}); + +describe("astra piece arrays", () => { + it("keeps adoption independent from mutable detached permutation metadata", () => { + const tracker = track({ values: [3, 1, 2] }); + const change = tracker.beginChange(); + change.state.values.sort((left, right) => left - right); + const prepared = change.prepare(); + const permutation = prepared.ops.find((operation) => operation[0] === "m"); + if (permutation?.[0] !== "m") throw new Error("expected permutation"); + permutation[2].reverse(); + tracker.adopt(prepared); + expect(tracker.value.values).toEqual([1, 2, 3]); + }); + + it("supports all structural mutators and native return values", () => { + const tracker = track({ values: [3, 1, 2] }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + expect(change.state.values.push(4)).toBe(4); + expect(change.state.values.pop()).toBe(4); + expect(change.state.values.unshift(0)).toBe(4); + expect(change.state.values.shift()).toBe(0); + expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]); + expect(change.state.values.sort((left, right) => left - right)).toBe(change.state.values); + expect(change.state.values.reverse()).toBe(change.state.values); + 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] }); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("tracks held handles after reindex and suppresses detached writes", () => { + const tracker = track({ values: [{ value: "a" }, { value: "b" }, { value: "c" }] }); + const change = tracker.beginChange(); + const held = change.state.values[1]!; + change.state.values.unshift({ value: "front" }); + held.value = "moved"; + expect(change.state.values[2]!.value).toBe("moved"); + change.state.values.splice(2, 1); + held.value = "detached"; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ values: [{ value: "front" }, { value: "a" }, { value: "c" }] }); + expect(replay(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("replays introduced, moved, then edited array descendants", () => { + const tracker = track({ values: [] as { id: number; nested: number[] }[] }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + change.state.values.push({ id: 1, nested: [1] }); + const held = change.state.values[0]!; + change.state.values.unshift({ id: 0, nested: [] }); + held.nested.push(2); + change.state.values.reverse(); + held.nested.push(3); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ + values: [ + { id: 1, nested: [1, 2, 3] }, + { id: 0, nested: [] }, + ], + }); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("combines movement and edits with permutation plus final paths", () => { + const tracker = track({ + values: [ + { rank: 3, edited: 0 }, + { rank: 1, edited: 0 }, + { rank: 2, edited: 0 }, + ], + }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + const held = change.state.values[0]!; + change.state.values.sort((left, right) => left.rank - right.rank); + held.edited = 1; + const prepared = change.prepare(); + expect(prepared.ops[0]).toEqual(["m", ["values"], [1, 2, 0]]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("keeps comparator writes and structurally reentrant appends", () => { + const tracker = track({ + values: [ + { rank: 2, comparisons: 0 }, + { rank: 1, comparisons: 0 }, + ], + }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + let appended = false; + change.state.values.sort((left, right) => { + left.comparisons++; + right.comparisons++; + if (!appended) { + appended = true; + change.state.values[0] = { rank: 9, comparisons: 0 }; + change.state.values.push({ rank: 3, comparisons: 0 }); + } + return left.rank - right.rank; + }); + const prepared = change.prepare(); + expect(prepared.value.values.map((value) => value.rank)).toEqual([1, 2, 3]); + expect(prepared.value.values.map((value) => value.comparisons)).toEqual([1, 1, 0]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("normalizes duplicate entries created by structurally reentrant sort callbacks", () => { + const tracker = track({ values: [{ rank: 2 }, { rank: 1 }] }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + let inserted = false; + change.state.values.sort((left, right) => { + if (!inserted) { + inserted = true; + change.state.values.unshift({ rank: 4 }); + } + return left.rank - right.rank; + }); + const prepared = change.prepare(); + expect(prepared.value.values.map((value) => value.rank)).toEqual([1, 2, 1]); + expect(prepared.value.values[0]).not.toBe(prepared.value.values[2]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("folds dense child and direct-index edits into one full-array operation", () => { + const rowsTracker = track({ rows: Array.from({ length: 1_000 }, (_, value) => ({ value })) }); + const rowsBase = clone(rowsTracker.value); + const rowsChange = rowsTracker.beginChange(); + for (const row of rowsChange.state.rows) row.value += 1; + const rowsPrepared = rowsChange.prepare(); + expect(rowsPrepared.ops).toHaveLength(1); + expect(rowsPrepared.ops[0]?.[0]).toBe("p"); + expect(replay(rowsBase, rowsPrepared.ops)).toEqual(rowsPrepared.value); + rowsTracker.adopt(rowsPrepared); + + const valuesTracker = track({ values: Array.from({ length: 1_000 }, (_, value) => value) }); + const valuesBase = clone(valuesTracker.value); + const valuesChange = valuesTracker.beginChange(); + for (let index = 0; index < 600; index++) valuesChange.state.values[index] = -index - 1; + const valuesPrepared = valuesChange.prepare(); + expect(valuesPrepared.ops).toHaveLength(1); + expect(valuesPrepared.ops[0]?.[0]).toBe("p"); + expect(replay(valuesBase, valuesPrepared.ops)).toEqual(valuesPrepared.value); + valuesTracker.adopt(valuesPrepared); + }); + + it("folds only a deeply nested dense array region", () => { + const tracker = track({ + rows: Array.from({ length: 2_000 }, (_, value) => ({ nested: { value } })), + }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + change.state.rows[0]!.nested.value = -1; + for (let index = 500; index < 1_000; index++) change.state.rows[index]!.nested.value = -index; + const prepared = change.prepare(); + const splice = prepared.ops.find((operation) => operation[0] === "p"); + expect(splice?.slice(0, 4)).toEqual(["p", ["rows"], 500, 500]); + if (splice?.[0] !== "p") throw new Error("expected regional splice"); + expect(splice[4]).toHaveLength(500); + expect(prepared.ops).toHaveLength(2); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("does not cache emission paths for nested reserved-key folds covered by an outer region", () => { + type Row = { flag: number; special?: { __proto__: { values: number[] } } }; + const rows: Row[] = Array.from({ length: 400 }, () => ({ flag: 0 })); + rows[100]!.special = JSON.parse( + `{"__proto__":{"values":[${Array.from({ length: 400 }, (_, value) => value).join(",")}]}}`, + ) as Row["special"]; + const tracker = track({ rows }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (let index = 0; index < 256; index++) change.state.rows[index]!.flag = 1; + for (let index = 0; index < 256; index++) { + change.state.rows[100]!.special!.__proto__.values[index] = -index - 1; + } + const prepared = change.prepare(); + expect(prepared.ops).toHaveLength(1); + expect(prepared.ops[0]?.slice(0, 4)).toEqual(["p", ["rows"], 0, 256]); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("suppresses nested structural and leaf operations covered by an outer dense region", () => { + const tracker = track({ + rows: Array.from({ length: 400 }, (_, index) => ({ + flag: 0, + values: index === 100 ? Array.from({ length: 400 }, (_, value) => ({ value })) : [], + })), + }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (let index = 0; index < 256; index++) change.state.rows[index]!.flag = 1; + for (let index = 0; index < 256; index++) change.state.rows[100]!.values[index]!.value = -index - 1; + change.state.rows[100]!.values.push({ value: 999 }); + const prepared = change.prepare(); + expect(prepared.ops).toHaveLength(1); + expect(prepared.ops[0]?.slice(0, 4)).toEqual(["p", ["rows"], 0, 256]); + expect(prepared.value.rows[100]!.values).toHaveLength(401); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("emits multiple disjoint dense regions and preserves operations outside their boundaries", () => { + const tracker = track({ values: Array.from({ length: 1_400 }, (_, value) => ({ value })) }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (const index of [97, 358, 897, 1_158]) change.state.values[index]!.value = -index - 1; + for (let index = 100; index < 356; index++) change.state.values[index]!.value = -index - 1; + for (let index = 900; index < 1_156; index++) change.state.values[index]!.value = -index - 1; + const prepared = change.prepare(); + const splices = prepared.ops.filter((operation) => operation[0] === "p"); + expect(splices.map((operation) => operation.slice(0, 4))).toEqual([ + ["p", ["values"], 100, 256], + ["p", ["values"], 900, 256], + ]); + for (const index of [97, 358, 897, 1_158]) { + expect(prepared.ops).toContainEqual(["s", ["values", index, "value"], -index - 1]); + } + expect(prepared.ops).toHaveLength(6); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("normalizes a 20,000-operation queue transaction to two operations", () => { + const tracker = track({ values: Array.from({ length: 20_000 }, (_, value) => ({ value })) }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (let index = 0; index < 10_000; index++) { + change.state.values.shift(); + change.state.values.push({ value: 20_000 + index }); + } + const prepared = change.prepare(); + expect(prepared.ops).toHaveLength(2); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("handles adversarial fragmentation and resolves held handles without piece scans", () => { + const size = 20_000; + const tracker = track({ values: Array.from({ length: size }, (_, value) => ({ value })) }); + const expected = clone(tracker.value); + const base = clone(tracker.value); + const change = tracker.beginChange(); + const held = [1, 1_001, 5_001, 10_001, 15_001, 19_999].map((index) => change.state.values[index]!); + for (let index = 0; index < size; index += 2) { + change.state.values.splice(index, 1, { value: -index - 1 }); + expected.values.splice(index, 1, { value: -index - 1 }); + } + for (const value of held) value.value += 100_000; + for (const index of [1, 1_001, 5_001, 10_001, 15_001, 19_999]) expected.values[index]!.value += 100_000; + const prepared = change.prepare(); + expect(prepared.value).toEqual(expected); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }, 30_000); + + it("normalizes restored overrides and cancelled structural edits to no operations", () => { + const tracker = track({ values: [1, 2, 3] }); + const change = tracker.beginChange(); + change.state.values[1] = 9; + change.state.values[1] = 2; + change.state.values.reverse(); + change.state.values.reverse(); + change.state.values.push(4); + expect(change.state.values.pop()).toBe(4); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ values: [1, 2, 3] }); + expect(prepared.ops).toEqual([]); + tracker.adopt(prepared); + }); + + it("handles null sparse overrides without confusing absence", () => { + const tracker = track({ values: Array.from({ length: 10_000 }, (_, value): number | null => value) }); + settle(tracker, (draft) => { + draft.values[17] = null; + draft.values[9_000] = null; + }); + expect(tracker.value.values[17]).toBeNull(); + expect(tracker.value.values[9_000]).toBeNull(); + }); + + it("supports self-overlapping fill and copyWithin by value", () => { + const tracker = track({ values: [{ n: 0 }, { n: 1 }, { n: 2 }, { n: 3 }] }); + settle(tracker, (draft) => { + draft.values.copyWithin(1, 0, 3); + draft.values.fill(draft.values[1]!, 0, 2); + draft.values[0]!.n = 9; + }); + expect(tracker.value.values.map((value) => value.n)).toEqual([9, 0, 1, 2]); + expect(tracker.value.values[0]).not.toBe(tracker.value.values[1]); + }); + + it.each(["unshift", "splice"] as const)( + "inserts 100,000 items with %s", + (method) => { + const tracker = track({ values: [-1] }); + const base = clone(tracker.value); + 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" ? 99_999 : 100_000]).toBe(99_999); + expect(replay(base, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }, + 30_000, + ); +}); + +describe("astra randomized transactions", () => { + type Document = { + values: { id: number; score: number }[]; + text: string; + meta: { revision: number; label?: string }; + }; + const random = + (seed: number): (() => number) => + () => { + seed = (seed + 0x6d2b79f5) | 0; + let value = Math.imul(seed ^ (seed >>> 15), 1 | seed); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; + const mutate = (document: Document | Draft, choice: number, value: number): void => { + const item = (): { id: number; score: number } => ({ id: value, score: value % 7 }); + switch (choice) { + case 0: + document.text += `-${value}`; + break; + case 1: + document.values.push(item()); + break; + case 2: + document.values.unshift(item()); + break; + case 3: + if (document.values.length > 0) document.values.shift(); + break; + case 4: + if (document.values.length > 0) document.values.pop(); + break; + case 5: { + const index = document.values.length === 0 ? 0 : value % (document.values.length + 1); + document.values.splice(index, document.values.length === 0 ? 0 : value % 2, item()); + break; + } + case 6: + document.values.reverse(); + break; + case 7: + document.values.sort((left, right) => left.id - right.id); + break; + case 8: + if (document.values.length > 0) document.values[value % document.values.length]!.score = value; + break; + case 9: + document.meta.revision++; + document.meta.label = `r-${value}`; + break; + default: + delete document.meta.label; + } + }; + + it("matches policy, detached replay, and adopted state after multi-operation transactions", () => { + for (let seed = 1; seed <= 40; seed++) { + const rng = random(seed); + const initial: Document = { + values: Array.from({ length: 4 }, (_, id) => ({ id, score: 0 })), + text: "start", + meta: { revision: 0 }, + }; + const tracker = track(initial); + const expected = clone(initial); + for (let transaction = 0; transaction < 25; transaction++) { + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (let operation = 0; operation < 5; operation++) { + const choice = Math.floor(rng() * 11); + const value = seed * 10_000 + transaction * 10 + operation; + mutate(expected, choice, value); + mutate(change.state, choice, value); + } + const prepared = change.prepare(); + const policy = clone(prepared.value); + expect(policy, `policy seed ${seed} transaction ${transaction}`).toEqual(expected); + expect(replay(base, prepared.ops), `replay seed ${seed} transaction ${transaction}`).toEqual(policy); + tracker.adopt(prepared); + expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(policy); + } + } + }, 30_000); +}); + +describe("astra security and storage-style cloning", () => { + it("defines own properties without invoking inherited setters", () => { + const tracker = track({} as Record); + Object.defineProperty(Object.prototype, "trap", { + set() { + throw new Error("inherited setter ran"); + }, + configurable: true, + }); + try { + settle(tracker, (draft) => { + draft.trap = 1; + }); + expect(tracker.value).toEqual({ trap: 1 }); + } finally { + delete (Object.prototype as Record).trap; + } + }); + + it("handles reserved own keys without prototype pollution", () => { + const initial = JSON.parse('{"safe":{"__proto__":{"value":1}}}') as { + safe: { __proto__: { value: number } }; + }; + const tracker = track(initial); + const change = tracker.beginChange(); + change.state.safe.__proto__.value = 2; + const prepared = change.prepare(); + expect(prepared.value.safe.__proto__.value).toBe(2); + expect(replay(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expect(({} as Record).value).toBeUndefined(); + }); + + it("supports a MemoryStorage-style recursive clone of Prepared.value", () => { + const storageClone = (value: T): T => { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(storageClone) as T; + const output: Record = {}; + for (const key of Object.keys(value)) + Object.defineProperty(output, key, { + value: storageClone((value as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); + return output as T; + }; + const tracker = track({ rows: [{ value: 1 }] }); + const change = tracker.beginChange(); + change.state.rows[0]!.value = 2; + change.state.rows.push({ value: 3 }); + const prepared = change.prepare(); + expect(storageClone(prepared.value)).toEqual({ rows: [{ value: 2 }, { value: 3 }] }); + tracker.adopt(prepared); + }); +}); diff --git a/packages/chord/test/delta-astra/benchmark-head.worker.ts b/packages/chord/test/delta-astra/benchmark-head.worker.ts new file mode 100644 index 000000000..f5c06b0ca --- /dev/null +++ b/packages/chord/test/delta-astra/benchmark-head.worker.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import { diffRevisions } from "../../src/delta/diff.ts"; +import { type Draft, produceWithMetadata } from "../../src/delta/draft.ts"; +import type { Op } from "../../src/delta/index.ts"; +import { JsonRevisionStore } from "../../src/delta/value.ts"; +import type { JsonValue } from "../../src/types.ts"; + +type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; + +const MiB = 1024 * 1024; +const scenario = process.argv[2] as Scenario; +const size = Number(process.argv[3] ?? 100_000); +const trial = Number(process.argv[4] ?? 0); + +function fixture(count: number): Document { + return { + rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), + }; +} + +function sum(document: Document | Draft): number { + let checksum = 0; + for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; + return checksum; +} + +async function gc(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let index = 0; index < 3; index++) { + await setImmediate(); + global.gc(); + } +} + +await gc(); +const baselineHeap = process.memoryUsage().heapUsed; +let input: Document | undefined = fixture(size); +if (scenario === "sort") input.rows.reverse(); +const store = new JsonRevisionStore(); +const importStart = performance.now(); +let value = store.import(input); +const importMs = performance.now() - importStart; +input = undefined; +await gc(); +const readyHeap = process.memoryUsage().heapUsed; +let peakHeap = readyHeap; +let mutateMs = 0; +let prepareMs = 0; +let serializeMs = 0; +let adoptMs = 0; +let opBytes = 0; +let operationCount = 0; +let checksum = 0; +const queueOperations = Math.min(size, 1_000); + +if (scenario === "committed-read") { + const start = performance.now(); + checksum = sum(value); + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +} else if (scenario !== "import") { + const draftStart = performance.now(); + const produced = produceWithMetadata(value, (draft) => { + const start = performance.now(); + switch (scenario) { + case "draft-read": + checksum = sum(draft); + break; + case "sparse": + for (let index = 0; index < size; index += 1_000) draft.rows[index]!.value = -index; + checksum = draft.rows.at(-1)!.value; + break; + case "queue": + for (let index = 0; index < queueOperations; index++) { + draft.rows.shift(); + draft.rows.push({ id: size + index, value: index, payload: "queue" }); + } + checksum = draft.rows.length; + break; + case "sort": + draft.rows.sort((left, right) => left.id - right.id); + checksum = draft.rows[0]!.id; + break; + case "dense": + for (let index = 0; index < size; index++) draft.rows[index]!.value = -index; + checksum = draft.rows.at(-1)!.value; + break; + case "unshift": { + const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); + Reflect.apply(draft.rows.unshift, draft.rows, inserted); + checksum = draft.rows.length; + break; + } + } + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + }); + const draftFinalizeMs = performance.now() - draftStart - mutateMs; + const prepareStart = performance.now(); + const next = produced.value === value ? value : store.commit(produced.value, produced.owned); + const operations: readonly Op[] = + next === value ? [] : diffRevisions(value as unknown as JsonValue, next as unknown as JsonValue); + prepareMs = draftFinalizeMs + performance.now() - prepareStart; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const serializeStart = performance.now(); + const serialized = JSON.stringify(operations); + serializeMs = performance.now() - serializeStart; + opBytes = Buffer.byteLength(serialized); + operationCount = operations.length; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const adoptStart = performance.now(); + value = next; + adoptMs = performance.now() - adoptStart; +} + +await gc(); +const retainedHeap = process.memoryUsage().heapUsed; +console.log( + JSON.stringify({ + mode: "head", + scenario, + size, + trial, + queueOperations, + importMs, + mutateMs, + prepareMs, + serializeMs, + adoptMs, + opBytes, + operationCount, + checksum, + readyMiB: (readyHeap - baselineHeap) / MiB, + transientMiB: (peakHeap - readyHeap) / MiB, + retainedMiB: (retainedHeap - baselineHeap) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + }), +); diff --git a/packages/chord/test/delta-astra/benchmark.ts b/packages/chord/test/delta-astra/benchmark.ts new file mode 100644 index 000000000..c6cf9af44 --- /dev/null +++ b/packages/chord/test/delta-astra/benchmark.ts @@ -0,0 +1,134 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "astra" | "delta" | "head"; +type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; +type Result = { + mode: Mode; + scenario: Scenario; + size: number; + trial: number; + queueOperations: number; + importMs: number; + mutateMs: number; + prepareMs: number; + serializeMs: number; + adoptMs: number; + opBytes: number; + operationCount: number; + checksum: number; + readyMiB: number; + transientMiB: number; + retainedMiB: number; + maxRssMiB: number; +}; +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const scenarios: readonly Scenario[] = [ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", +]; +const modes: readonly Mode[] = ["astra", "delta", "head"]; +const quick = process.argv.includes("--quick"); +const size = quick ? 10_000 : 100_000; +const trials = quick ? 1 : 3; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); +const headRoot = join(tmpdir(), `chord-astra-head-${process.pid}`); +const headFiles = [ + "packages/chord/src/state/diff.ts", + "packages/chord/src/state/draft.ts", + "packages/chord/src/state/value.ts", + "packages/chord/src/delta/index.ts", + "packages/chord/src/types.ts", +] as const; + +function writeHeadFixture(): string { + for (const path of headFiles) { + const destination = join(headRoot, path); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, execFileSync("git", ["show", `HEAD:${path}`], { cwd: root })); + } + const worker = join(headRoot, "packages/chord/test/delta-astra/benchmark-head.worker.ts"); + mkdirSync(dirname(worker), { recursive: true }); + const source = readFileSync(new URL("./benchmark-head.worker.ts", import.meta.url), "utf8") + .replaceAll("../../src/delta/diff.ts", "../../src/state/diff.ts") + .replaceAll("../../src/delta/draft.ts", "../../src/state/draft.ts") + .replaceAll("../../src/delta/value.ts", "../../src/state/value.ts"); + writeFileSync(worker, source); + writeFileSync(join(headRoot, "package.json"), JSON.stringify({ type: "module" })); + return worker; +} + +function average(values: readonly number[]): number | null { + return values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; +} + +const headWorker = writeHeadFixture(); +const currentWorker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; +try { + for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + for (const mode of modes) { + const worker = mode === "head" ? headWorker : currentWorker; + const args = + mode === "head" + ? [scenario, String(size), String(trial)] + : [mode, scenario, String(size), String(trial)]; + const child = spawnSync(process.execPath, ["--expose-gc", "--max-old-space-size=4096", worker, ...args], { + cwd: mode === "head" ? headRoot : root, + encoding: "utf8", + timeout: 300_000, + maxBuffer: 10 * 1024 * 1024, + }); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + continue; + } + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } + } +} finally { + rmSync(headRoot, { recursive: true, force: true }); +} + +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); + return { + mode, + scenario, + importMs: average(selected.map((result) => result.importMs)), + mutateMs: average(selected.map((result) => result.mutateMs)), + prepareMs: average(selected.map((result) => result.prepareMs)), + serializeMs: average(selected.map((result) => result.serializeMs)), + adoptMs: average(selected.map((result) => result.adoptMs)), + opBytes: average(selected.map((result) => result.opBytes)), + readyMiB: average(selected.map((result) => result.readyMiB)), + transientMiB: average(selected.map((result) => result.transientMiB)), + retainedMiB: average(selected.map((result) => result.retainedMiB)), + maxRssMiB: average(selected.map((result) => result.maxRssMiB)), + failures: failures.filter((failure) => failure.mode === mode && failure.scenario === scenario).length, + }; + }), +); +const report = { node: process.version, head, size, trials, results, failures, summary }; +const output = join(tmpdir(), "chord-astra-benchmark.json"); +writeFileSync(output, JSON.stringify(report, null, 2)); +console.log(JSON.stringify({ output, node: process.version, head, size, trials, summary })); diff --git a/packages/chord/test/delta-astra/benchmark.worker.ts b/packages/chord/test/delta-astra/benchmark.worker.ts new file mode 100644 index 000000000..03692966f --- /dev/null +++ b/packages/chord/test/delta-astra/benchmark.worker.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import { track as trackAstra } from "../../src/delta/astra/index.ts"; +import type { Draft } from "../../src/delta/draft.ts"; +import type { Op } from "../../src/delta/index.ts"; +import { track as trackDelta } from "../../src/delta/tracker.ts"; + +type Mode = "astra" | "delta"; +type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; + +const MiB = 1024 * 1024; +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const size = Number(process.argv[4] ?? 100_000); +const trial = Number(process.argv[5] ?? 0); + +function fixture(count: number): Document { + return { + rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), + }; +} + +function sum(document: Document | Draft): number { + let checksum = 0; + for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; + return checksum; +} + +async function gc(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let index = 0; index < 3; index++) { + await setImmediate(); + global.gc(); + } +} + +await gc(); +const baselineHeap = process.memoryUsage().heapUsed; +let input: Document | undefined = fixture(size); +if (scenario === "sort") input.rows.reverse(); +const importStart = performance.now(); +const tracker = (mode === "astra" ? trackAstra(input) : trackDelta(input)) as TrackerLike; +const importMs = performance.now() - importStart; +input = undefined; +await gc(); +const readyHeap = process.memoryUsage().heapUsed; +let peakHeap = readyHeap; +let mutateMs = 0; +let prepareMs = 0; +let serializeMs = 0; +let adoptMs = 0; +let opBytes = 0; +let operationCount = 0; +let checksum = 0; +const queueOperations = Math.min(size, 1_000); + +if (scenario === "committed-read") { + const start = performance.now(); + checksum = sum(tracker.value); + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +} else if (scenario !== "import") { + let change: ChangeLike | undefined = tracker.beginChange(); + const start = performance.now(); + switch (scenario) { + case "draft-read": + checksum = sum(change.state); + break; + case "sparse": + for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "queue": + for (let index = 0; index < queueOperations; index++) { + change.state.rows.shift(); + change.state.rows.push({ id: size + index, value: index, payload: "queue" }); + } + checksum = change.state.rows.length; + break; + case "sort": + change.state.rows.sort((left, right) => left.id - right.id); + checksum = change.state.rows[0]!.id; + break; + case "dense": + for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "unshift": { + const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); + Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); + checksum = change.state.rows.length; + break; + } + } + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs = performance.now() - prepareStart; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const serializeStart = performance.now(); + const serialized = JSON.stringify(prepared.ops); + serializeMs = performance.now() - serializeStart; + opBytes = Buffer.byteLength(serialized); + operationCount = prepared.ops.length; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs = performance.now() - adoptStart; + change = undefined; + prepared = undefined; +} + +await gc(); +const retainedHeap = process.memoryUsage().heapUsed; +console.log( + JSON.stringify({ + mode, + scenario, + size, + trial, + queueOperations, + importMs, + mutateMs, + prepareMs, + serializeMs, + adoptMs, + opBytes, + operationCount, + checksum, + readyMiB: (readyHeap - baselineHeap) / MiB, + transientMiB: (peakHeap - readyHeap) / MiB, + retainedMiB: (retainedHeap - baselineHeap) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + }), +); diff --git a/packages/chord/test/delta-astra/retention.test.ts b/packages/chord/test/delta-astra/retention.test.ts new file mode 100644 index 000000000..9e9dd3e98 --- /dev/null +++ b/packages/chord/test/delta-astra/retention.test.ts @@ -0,0 +1,27 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("astra overlay retention", () => { + it.each([ + "aborted", + "dropped-prepared", + "settled-change", + "stale-unprepared", + "stale-held", + "held-revoked", + "registry-churn", + ])( + "releases or invalidates %s", + (scenario) => { + const child = spawnSync( + process.execPath, + ["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario], + { encoding: "utf8", timeout: 60_000 }, + ); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); + }, + 65_000, + ); +}); diff --git a/packages/chord/test/delta-astra/retention.worker.ts b/packages/chord/test/delta-astra/retention.worker.ts new file mode 100644 index 000000000..54bc91567 --- /dev/null +++ b/packages/chord/test/delta-astra/retention.worker.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { setImmediate } from "node:timers/promises"; +import { track } from "../../src/delta/astra/index.ts"; + +async function collect(reference: WeakRef): Promise { + assert.ok(global.gc, "worker requires --expose-gc"); + for (let attempt = 0; attempt < 40; attempt++) { + await setImmediate(); + global.gc(); + if (reference.deref() === undefined) return; + } + assert.fail("overlay data is still retained"); +} + +const retainedRevoked: object[] = []; +const retainedSettledChanges: object[] = []; +const retainedStaleChanges: object[] = []; +const scenario = process.argv[2]; +if (scenario === "aborted") { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const reference = new WeakRef(change.state.payload.rows); + change.abort(); + await collect(reference); + assert.deepEqual(tracker.value, { payload: null }); +} else if (scenario === "dropped-prepared") { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + const makeReference = (): WeakRef => { + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const prepared = change.prepare(); + return new WeakRef(prepared.value.payload!.rows); + }; + const reference = makeReference(); + await collect(reference); + assert.deepEqual(tracker.value, { payload: null }); +} else if (scenario === "settled-change") { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + const makeReference = (): WeakRef => { + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const prepared = change.prepare(); + const reference = new WeakRef(prepared.value.payload!.rows); + retainedSettledChanges.push(change); + return reference; + }; + const reference = makeReference(); + await collect(reference); + assert.deepEqual(tracker.value, { payload: null }); +} else if (scenario === "stale-unprepared") { + const setup = (): { + tracker: ReturnType>; + reference: WeakRef; + } => { + const initial = { payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }; + const tracker = track(initial); + const reference = new WeakRef(initial.payload.rows); + retainedStaleChanges.push(tracker.beginChange()); + const winner = tracker.prepareReplace({ payload: { rows: [{ value: -1 }] } }); + tracker.adopt(winner); + return { tracker, reference }; + }; + const { tracker, reference } = setup(); + await collect(reference); + assert.deepEqual(tracker.value, { payload: { rows: [{ value: -1 }] } }); + assert.throws(() => (retainedStaleChanges[0] as { state: { payload: unknown } }).state.payload, TypeError); +} else if (scenario === "stale-held") { + const tracker = track({ child: { value: 0 } }); + let prepared = (() => { + const change = tracker.beginChange(); + change.state.child.value = 1; + return change.prepare(); + })(); + const held = prepared.value; + const wrapper = new WeakRef(prepared); + prepared = undefined as never; + await collect(wrapper); + const winner = tracker.prepareReplace({ child: { value: 2 } }); + tracker.adopt(winner); + assert.throws(() => held.child.value, TypeError); +} else if (scenario === "held-revoked") { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const held = change.state.payload; + const child = new WeakRef(change.state.payload.rows); + retainedRevoked.push(held); + change.abort(); + await collect(child); + assert.throws(() => held.rows, TypeError); +} else if (scenario === "registry-churn") { + const tracker = track({ value: 0 }); + for (let index = 0; index < 100_000; index++) { + const change = tracker.beginChange(); + change.state.value = index; + change.abort(); + } + const winner = tracker.prepareReplace({ value: 1 }); + tracker.adopt(winner); + assert.deepEqual(tracker.value, { value: 1 }); +} else { + throw new Error(`unknown scenario: ${scenario}`); +} + +console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-cow/benchmark.ts b/packages/chord/test/delta-cow/benchmark.ts new file mode 100644 index 000000000..60ad76322 --- /dev/null +++ b/packages/chord/test/delta-cow/benchmark.ts @@ -0,0 +1,95 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "cow" | "delta" | "astra"; +type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; +type Result = { + mode: Mode; + scenario: Scenario; + size: number; + trial: number; + queueOperations: number; + importMs: number; + mutateMs: number; + prepareMs: number; + serializeMs: number; + adoptMs: number; + opBytes: number; + operationCount: number; + checksum: number; + readyMiB: number; + transientMiB: number; + retainedMiB: number; + maxRssMiB: number; +}; + +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const scenarios: readonly Scenario[] = [ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", +]; +const modes: readonly Mode[] = ["cow", "delta", "astra"]; +const quick = process.argv.includes("--quick"); +const size = quick ? 10_000 : 100_000; +const trials = quick ? 1 : 3; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + for (const mode of modes) { + const child = spawnSync( + process.execPath, + ["--expose-gc", "--max-old-space-size=4096", worker, mode, scenario, String(size), String(trial)], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + } else { + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } + } +} + +const average = (values: readonly number[]): number | null => + values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); + return { + mode, + scenario, + importMs: average(selected.map((result) => result.importMs)), + mutateMs: average(selected.map((result) => result.mutateMs)), + prepareMs: average(selected.map((result) => result.prepareMs)), + serializeMs: average(selected.map((result) => result.serializeMs)), + adoptMs: average(selected.map((result) => result.adoptMs)), + opBytes: average(selected.map((result) => result.opBytes)), + operationCount: average(selected.map((result) => result.operationCount)), + readyMiB: average(selected.map((result) => result.readyMiB)), + transientMiB: average(selected.map((result) => result.transientMiB)), + retainedMiB: average(selected.map((result) => result.retainedMiB)), + maxRssMiB: average(selected.map((result) => result.maxRssMiB)), + }; + }), +); +const output = join(tmpdir(), "chord-cow-benchmark.json"); +writeFileSync(output, JSON.stringify({ node: process.version, size, trials, results, failures, summary }, null, 2)); +console.log(JSON.stringify({ output, node: process.version, size, trials, failures: failures.length, summary })); diff --git a/packages/chord/test/delta-cow/benchmark.worker.ts b/packages/chord/test/delta-cow/benchmark.worker.ts new file mode 100644 index 000000000..6af1e16bf --- /dev/null +++ b/packages/chord/test/delta-cow/benchmark.worker.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import { track as trackAstra } from "../../src/delta/astra/index.ts"; +import { track as trackCow } from "../../src/delta/cow/index.ts"; +import type { Draft } from "../../src/delta/draft.ts"; +import type { Op } from "../../src/delta/index.ts"; +import { track as trackDelta } from "../../src/delta/tracker.ts"; + +type Mode = "cow" | "astra" | "delta"; +type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; + +const MiB = 1024 * 1024; +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const size = Number(process.argv[4] ?? 100_000); +const trial = Number(process.argv[5] ?? 0); + +function fixture(count: number): Document { + return { + rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), + }; +} + +function sum(document: Document | Draft): number { + let checksum = 0; + for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; + return checksum; +} + +async function gc(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let index = 0; index < 3; index++) { + await setImmediate(); + global.gc(); + } +} + +await gc(); +const baselineHeap = process.memoryUsage().heapUsed; +let input: Document | undefined = fixture(size); +if (scenario === "sort") input.rows.reverse(); +const importStart = performance.now(); +const tracker = ( + mode === "cow" ? trackCow(input) : mode === "astra" ? trackAstra(input) : trackDelta(input) +) as TrackerLike; +const importMs = performance.now() - importStart; +input = undefined; +await gc(); +const readyHeap = process.memoryUsage().heapUsed; +let peakHeap = readyHeap; +let mutateMs = 0; +let prepareMs = 0; +let serializeMs = 0; +let adoptMs = 0; +let opBytes = 0; +let operationCount = 0; +let checksum = 0; +const queueOperations = Math.min(size, 1_000); + +if (scenario === "committed-read") { + const start = performance.now(); + checksum = sum(tracker.value); + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +} else if (scenario !== "import") { + let change: ChangeLike | undefined = tracker.beginChange(); + const start = performance.now(); + switch (scenario) { + case "draft-read": + checksum = sum(change.state); + break; + case "sparse": + for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "queue": + for (let index = 0; index < queueOperations; index++) { + change.state.rows.shift(); + change.state.rows.push({ id: size + index, value: index, payload: "queue" }); + } + checksum = change.state.rows.length; + break; + case "sort": + change.state.rows.sort((left, right) => left.id - right.id); + checksum = change.state.rows[0]!.id; + break; + case "dense": + for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "unshift": { + const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); + Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); + checksum = change.state.rows.length; + break; + } + } + mutateMs = performance.now() - start; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs = performance.now() - prepareStart; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const serializeStart = performance.now(); + const serialized = JSON.stringify(prepared.ops); + serializeMs = performance.now() - serializeStart; + opBytes = Buffer.byteLength(serialized); + operationCount = prepared.ops.length; + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs = performance.now() - adoptStart; + change = undefined; + prepared = undefined; +} + +await gc(); +const retainedHeap = process.memoryUsage().heapUsed; +console.log( + JSON.stringify({ + mode, + scenario, + size, + trial, + queueOperations, + importMs, + mutateMs, + prepareMs, + serializeMs, + adoptMs, + opBytes, + operationCount, + checksum, + readyMiB: (readyHeap - baselineHeap) / MiB, + transientMiB: (peakHeap - readyHeap) / MiB, + retainedMiB: (retainedHeap - baselineHeap) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + }), +); diff --git a/packages/chord/test/delta-cow/core.test.ts b/packages/chord/test/delta-cow/core.test.ts new file mode 100644 index 000000000..7edb31242 --- /dev/null +++ b/packages/chord/test/delta-cow/core.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, it } from "vitest"; +import { + apply, + applyImmutable, + assertValidOp, + assertValidWireOp, + decoder, + encoder, + isBase, + type JsonValue, + type Op, + overlap, + track, + UnsafePathError, + type WireOp, +} from "../../src/delta/cow/index.ts"; + +describe("immutable tracker lifecycle", () => { + it("keeps a draft alive across await and adopts only a prepared change", async () => { + const input = { count: 1, nested: { text: "a" }, values: [1] }; + const tracker = track(input); + expect(tracker.value).toBe(input); + + const change = tracker.beginChange(); + change.state.count = 2; + await Promise.resolve(); + change.state.nested.text += "b"; + change.state.values.push(2); + expect(tracker.value).toEqual(input); + + const prepared = change.prepare(); + expect(prepared.base).toBe(tracker.value); + expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); + expect(prepared.ops).toEqual([ + ["s", ["count"], 2], + ["a", ["nested", "text"], "b"], + ["p", ["values"], 1, 0, [2]], + ]); + expect(() => change.state.count).toThrow(TypeError); + expect(tracker.value).toEqual(input); + + tracker.adopt(prepared); + expect(tracker.value).toBe(prepared.value); + }); + + it("aborts and revokes without changing the committed value", () => { + const tracker = track({ child: { value: 1 } }); + const change = tracker.beginChange(); + const child = change.state.child; + child.value = 2; + change.abort(); + expect(tracker.value.child.value).toBe(1); + expect(() => child.value).toThrow(TypeError); + expect(() => change.abort()).not.toThrow(); + expect(() => change.prepare()).toThrow(/settled/); + const next = tracker.beginChange(); + next.abort(); + }); + + it("grows arrays with explicit nulls and revokes after preparation", () => { + const tracker = track({ values: [1, 2] as Array }); + const change = tracker.beginChange(); + change.state.values.length = 4; + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([1, 2, null, null]); + expect(() => change.state.values).toThrow(TypeError); + tracker.adopt(prepared); + }); + + it("normalizes a deep no-op to exact previous identity", () => { + const tracker = track({ value: { nested: [1, 2] } }); + const change = tracker.beginChange(); + change.state.value = { nested: [1, 2] }; + const prepared = change.prepare(); + expect(prepared.value).toBe(prepared.base); + expect(prepared.ops).toEqual([]); + tracker.adopt(prepared); + expect(tracker.value).toBe(prepared.base); + }); + + it("takes O(1) replacement ownership and applies no-op normalization", () => { + const tracker = track({ nested: { value: 1 } }); + const replacement = { nested: { value: 2 } }; + const prepared = tracker.prepareReplace(replacement); + expect(prepared.value).toBe(replacement); + tracker.adopt(prepared); + + const noOp = tracker.prepareReplace({ nested: { value: 2 } }); + expect(noOp.value).toBe(tracker.value); + expect(noOp.ops).toEqual([]); + }); + + it("detaches operation payloads from candidate placements", () => { + const tracker = track({ rows: [] as { id: number }[] }); + const change = tracker.beginChange(); + change.state.rows.push({ id: 1 }); + const prepared = change.prepare(); + const splice = prepared.ops[0]!; + if (splice[0] !== "p") throw new Error("expected splice"); + expect(splice[4][0]).not.toBe(prepared.value.rows[0]); + tracker.adopt(prepared); + tracker.value.rows[0]!.id = 2; + expect(splice[4]).toEqual([{ id: 1 }]); + }); + + it("rejects foreign, stale, repeated, and overlapping changes", () => { + const first = track({ value: 0 }); + const second = track({ value: 0 }); + const change = first.beginChange(); + expect(() => first.beginChange()).toThrow(/active/); + change.state.value = 1; + const prepared = change.prepare(); + expect(() => second.adopt(prepared)).toThrow(/different tracker/); + first.adopt(prepared); + expect(() => first.adopt(prepared)).toThrow(/already been used/); + + const stale = first.prepareReplace({ value: 2 }); + const winner = first.prepareReplace({ value: 3 }); + first.adopt(winner); + expect(() => first.adopt(stale)).toThrow(/stale/); + expect(() => first.adopt(stale)).toThrow(/stale/); + }); + + it("invalidates a prepared result when its change is aborted", () => { + const tracker = track({ value: 0 }); + const change = tracker.beginChange(); + change.state.value = 1; + const prepared = change.prepare(); + change.abort(); + expect(() => tracker.adopt(prepared)).toThrow(/aborted/); + expect(() => change.abort()).not.toThrow(); + }); + + it("makes competing same-base preparations stale after adopting a no-op", () => { + const tracker = track({ value: { count: 1 } }); + const first = tracker.prepareReplace({ value: { count: 1 } }); + const competing = tracker.prepareReplace({ value: { count: 1 } }); + expect(first.value).toBe(first.base); + expect(competing.value).toBe(competing.base); + tracker.adopt(first); + expect(() => tracker.adopt(first)).toThrow(/already been used/); + expect(() => tracker.adopt(competing)).toThrow(/stale/); + }); + + it("keeps large prepareReplace array edits narrow", () => { + const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } })); + const tracker = track({ rows }); + const replacement = tracker.value.rows.slice(); + replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable }; + const prepared = tracker.prepareReplace({ rows: replacement }); + expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]); + expect(JSON.stringify(prepared.ops).length).toBeLessThan(100); + expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); + }); +}); + +describe("canonical strings", () => { + it("emits append and rolling-window operations", () => { + const tracker = track({ text: "abcdefgh" }); + let change = tracker.beginChange(); + change.state.text += "ij"; + let prepared = change.prepare(); + expect(prepared.ops).toEqual([["a", ["text"], "ij"]]); + tracker.adopt(prepared); + + change = tracker.beginChange(); + change.state.text = `${change.state.text.slice(3)}xyz`; + prepared = change.prepare(); + expect(prepared.ops).toEqual([ + ["t", ["text"], 3], + ["a", ["text"], "xyz"], + ]); + }); + + it("finds bounded overlaps", () => { + expect(overlap("abcdefgh", "defghxyz", 65_536)).toBe(5); + expect(overlap("abcdef", "defghi", 0)).toBe(0); + }); +}); + +describe("operation application and validation", () => { + it("applies mutable and immutable operations", () => { + const operations: Op[] = [ + ["a", ["text"], "b"], + ["p", ["values"], 1, 1, [3, 4]], + ["s", ["nested", "value"], 2], + ]; + const base = { text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }; + const immutable = applyImmutable(base, operations); + expect(immutable).toEqual({ text: "ab", values: [1, 3, 4], nested: { value: 2 }, stable: { value: 9 } }); + expect(base).toEqual({ text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }); + expect(immutable.stable).toBe(base.stable); + expect(apply(structuredClone(base), operations)).toEqual(immutable); + }); + + it("supports root replacement, root splice, and permutations", () => { + const base: Op[] = [["r", [1, 2, 3]]]; + expect(isBase(base)).toBe(true); + let value = apply(undefined, base); + value = apply(value, [["p", [], 1, 1, [4]]]); + value = apply(value, [["m", [], [2, 0, 1]]]); + expect(value).toEqual([3, 1, 4]); + }); + + it("rejects unsafe and malformed paths", () => { + expect(() => apply({}, [["s", ["constructor", "prototype", "x"], true]])).toThrow(UnsafePathError); + expect(() => apply({ values: [1] }, [["s", ["values", 3], 2]])).toThrow(UnsafePathError); + expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); + expect(() => assertValidOp(["s", "value", 1] as never)).toThrow(); + expect(() => assertValidOp(["m", [], [0, 0]] as never)).toThrow(/bijection/); + }); + + it("validates immutable operations before traversing the target", () => { + let reads = 0; + const target = Object.defineProperty({}, "trap", { + enumerable: true, + get() { + reads += 1; + return {}; + }, + }); + expect(() => applyImmutable(target, [["s", "bad-path", 1] as never])).toThrow(/path/); + expect(reads).toBe(0); + }); + + it("validates decoded and wire vocabularies separately", () => { + expect(() => assertValidOp(["s", ["value"], 1])).not.toThrow(); + expect(() => assertValidOp(["s", 1] as never)).toThrow(); + expect(() => assertValidWireOp(["s", 1])).not.toThrow(); + expect(() => assertValidWireOp(["#", 0, ["value"]])).not.toThrow(); + }); +}); + +describe("codec", () => { + it("interns paths, omits adjacent paths, and round-trips", () => { + const path = ["nested", "text"] as const; + const enc = encoder(); + const dec = decoder(); + const first: Op[] = [ + ["t", path, 1], + ["a", path, "x"], + ]; + expect(dec.decode(enc.encode(first))).toEqual(first); + const second: Op[] = [["a", path, "y"]]; + const wire = enc.encode(second); + expect(wire).toEqual([ + ["#", 0, path], + ["a", 0, "y"], + ]); + expect(dec.decode(wire)).toEqual(second); + }); + + it("resets path dictionaries on a base", () => { + const enc = encoder(); + const path = ["value"] as const; + enc.encode([["s", path, 1]]); + enc.encode([["s", path, 2]]); + expect(enc.encode([["r", { value: 3 }]])).toEqual([["r", { value: 3 }]]); + expect(enc.encode([["s", path, 4]])).toEqual([["s", path, 4]]); + }); + + it("rejects unresolved short forms and unsafe interned paths", () => { + expect(() => decoder().decode([["a", "x"]])).toThrow(); + const wire = [ + ["#", 0, ["__proto__"]], + ["s", 0, true], + ] as unknown as WireOp[]; + expect(() => decoder().decode(wire)).toThrow(UnsafePathError); + }); + + it("omits an adjacent repeated path", () => { + const enc = encoder(); + const path = ["value"] as const; + expect( + enc.encode([ + ["s", path, 1], + ["s", path, 2], + ]), + ).toEqual([ + ["s", path, 1], + ["s", 2], + ]); + }); + + it("interns on second use rather than first", () => { + const enc = encoder(); + const path = ["a", "deep"] as const; + expect(enc.encode([["a", path, "1"]])).toEqual([["a", path, "1"]]); + expect(enc.encode([["a", path, "2"]])).toEqual([ + ["#", 0, path], + ["a", 0, "2"], + ]); + }); + + it("does not collide paths containing null characters", () => { + const operations: Op[] = [ + ["s", ["a\u0000b"], 1], + ["s", ["a", "b"], 2], + ]; + expect(decoder().decode(encoder().encode(operations))).toEqual(operations); + }); + + it("clears decoder ids on a base batch", () => { + const dec = decoder(); + dec.decode([ + ["#", 0, ["a"]], + ["a", 0, "1"], + ]); + dec.decode([["r", { a: "" }]]); + expect(() => dec.decode([["a", 0, "2"]])).toThrow(); + }); + + it("makes batches after a base self-contained", () => { + const enc = encoder(); + const path = ["a", "deep"] as const; + enc.encode([["a", path, "1"]]); + enc.encode([["a", path, "2"]]); + const base = enc.encode([["r", { a: { deep: "x" } }]]); + const after = enc.encode([["a", path, "3"]]); + expect(after).toEqual([["a", path, "3"]]); + const dec = decoder(); + expect(dec.decode(base)).toEqual([["r", { a: { deep: "x" } }]]); + expect(dec.decode(after)).toEqual([["a", path, "3"]]); + }); + + it("round-trips deterministic mixed operation streams", () => { + const batches: Op[][] = Array.from({ length: 100 }, (_, index) => [ + ["s", ["rows", index, "value"], index], + ["a", ["output"], String(index)], + ["p", ["tail"], index, 0, [index]], + ]); + const enc = encoder(); + const dec = decoder(); + expect(batches.map((batch) => dec.decode(enc.encode(batch)))).toEqual(batches); + }); +}); + +describe("immutable application ownership", () => { + it("does not mutate a replacement payload targeted by a later operation", () => { + const replacement = { nested: { value: 1 } }; + const next = applyImmutable<{ nested: { value: number } }>(undefined, [ + ["r", replacement], + ["s", ["nested", "value"], 2], + ]); + expect(replacement.nested.value).toBe(1); + expect(next.nested.value).toBe(2); + }); + + it("adopts a mutable replacement payload rather than copying it", () => { + const batch: Op[] = [["r", { value: 0 }]]; + const first = apply<{ value: number }>(undefined, batch); + const second = apply<{ value: number }>(undefined, batch); + first.value = 1; + expect(second.value).toBe(1); + }); +}); + +describe("path and prototype safety", () => { + it("rejects constructor walks and forbidden interned paths", () => { + expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow(); + expect(({} as Record).gadget).toBeUndefined(); + const wire = [ + ["#", 0, ["__proto__", "w"]], + ["s", 0, true], + ] as unknown as WireOp[]; + expect(() => decoder().decode(wire)).toThrow(); + }); + + it("does not run inherited setters", () => { + Object.defineProperty(Object.prototype, "trap", { + set() { + throw new Error("inherited setter ran"); + }, + configurable: true, + }); + try { + expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow(); + expect(apply({}, [["s", ["trap"], 1]])).toEqual({ trap: 1 }); + } finally { + delete (Object.prototype as Record).trap; + } + }); + + it("allows reserved names inside values without prototype pollution", () => { + const value = JSON.parse('{"__proto__":{"z":1}}') as JsonValue; + const out = apply({}, [["s", ["value"], value]]); + expect(Object.hasOwn((out as { value: object }).value, "__proto__")).toBe(true); + expect(({} as Record).z).toBeUndefined(); + }); + + it("imports own properties without invoking inherited setters", () => { + const root = {} as Record; + Object.defineProperty(root, "trap", { value: 1, writable: true, enumerable: true, configurable: true }); + Object.defineProperty(Object.prototype, "trap", { + set() { + throw new Error("inherited setter ran"); + }, + configurable: true, + }); + try { + expect(() => track(root)).not.toThrow(); + } finally { + delete (Object.prototype as Record).trap; + } + + let value: { values: number[] } | undefined; + Object.defineProperty(Array.prototype, "0", { + set() { + throw new Error("inherited array setter ran"); + }, + configurable: true, + }); + try { + value = track({ values: [1, 2] }).value; + } finally { + delete (Array.prototype as unknown as Record)[0]; + } + expect(value).toEqual({ values: [1, 2] }); + }); +}); + +describe("array index safety", () => { + it("writes an existing index and appends exactly one past the end", () => { + expect(apply({ values: [1, 2, 3] }, [["s", ["values", 1], 9]])).toEqual({ values: [1, 9, 3] }); + expect(apply({ values: [1, 2, 3] }, [["s", ["values", 3], 9]])).toEqual({ values: [1, 2, 3, 9] }); + }); + + it("rejects gaps, huge indices, and string-spelled indices", () => { + expect(() => apply({ values: [1, 2, 3] }, [["s", ["values", 5], 9]])).toThrow(); + expect(() => apply({ values: [] }, [["s", ["values", 4_294_967_290], 1]])).toThrow(); + expect(() => apply({ values: [1] }, [["s", ["values", "0"], 9]])).toThrow(); + expect(() => apply({ values: ["a"] }, [["a", ["values", "0"], "b"]])).toThrow(); + }); + + it("allows explicit growth values and rejects deletion past the end", () => { + expect(apply({ values: [1] }, [["p", ["values"], 1, 0, [null, null, 9]]])).toEqual({ + values: [1, null, null, 9], + }); + expect(() => apply({ values: [1] }, [["d", ["values", 1]]])).toThrow(); + }); + + it("applies large splice payloads without spreading them at once", () => { + const items = Array(300_000).fill(null); + const result = apply({ values: [] as JsonValue[] }, [["p", ["values"], 0, 0, items]]); + expect(result.values).toHaveLength(items.length); + }); +}); + +describe("operation structure safety", () => { + it("rejects unknown verbs, malformed tuples, paths, and splice payloads", () => { + expect(() => apply({ value: 1 }, [["ZZZ", ["value"], 9] as never])).toThrow(); + expect(() => apply({ values: [1] }, [["p", ["values"], 0, 0, "not-an-array"] as never])).toThrow(); + expect(() => apply({ value: 1 }, [["s", "value", 9] as never])).toThrow(); + expect(() => apply({ value: 1 }, [{ op: "s" } as never])).toThrow(); + expect(() => apply({ value: 1 }, [null as never])).toThrow(); + }); + + it("rejects invalid append and truncation operations", () => { + expect(() => apply({ value: 1 }, [["a", ["missing"], "x"]])).toThrow(); + expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); + expect(() => apply({ value: "abc" }, [["t", ["value"], -1]])).toThrow(); + expect(() => decoder().decode([["t", ["value"], -1]])).toThrow(); + }); + + it("clamps splice removal past the end", () => { + expect(apply({ values: [1, 2] }, [["p", ["values"], 0, 1e9, []]])).toEqual({ values: [] }); + }); +}); + +describe("operation assertions", () => { + it("accepts decoded operations and rejects wire-only forms", () => { + for (const operation of [ + ["r", { value: 1 }], + ["s", ["value"], 1], + ["d", ["value"]], + ["a", ["value"], "x"], + ["t", ["value"], 2], + ["p", ["value"], 0, 0, []], + ["m", ["value"], [0]], + ] as never[]) { + expect(() => assertValidOp(operation)).not.toThrow(); + } + for (const wireOnly of [ + ["s", 1], + ["d"], + ["a", "x"], + ["t", 2], + ["p", 0, 0, []], + ["#", 0, ["value"]], + ["s", 0, 1], + ] as never[]) { + expect(() => assertValidOp(wireOnly)).toThrow(); + expect(() => assertValidWireOp(wireOnly)).not.toThrow(); + } + }); + + it("does not recursively inspect operation payloads", () => { + expect(() => assertValidOp(["s", ["value"], new Map()] as never)).not.toThrow(); + expect(() => assertValidWireOp(["r", new Date()] as never)).not.toThrow(); + }); +}); diff --git a/packages/chord/test/delta-cow/cow.test.ts b/packages/chord/test/delta-cow/cow.test.ts new file mode 100644 index 000000000..13638ee24 --- /dev/null +++ b/packages/chord/test/delta-cow/cow.test.ts @@ -0,0 +1,538 @@ +import { describe, expect, it } from "vitest"; +import { apply, applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +describe("optimized COW transaction invariants", () => { + it("matches candidate, detached replay, and adopted value", () => { + const tracker = track({ rows: [{ value: 1 }], text: "a" }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + change.state.rows[0]!.value = 2; + change.state.rows.push({ value: 3 }); + change.state.text += "b"; + const prepared = change.prepare(); + const candidate = clone(prepared.value); + const operations = clone(prepared.ops); + expect(apply(base, operations)).toEqual(candidate); + tracker.adopt(prepared); + expect(tracker.value).toEqual(candidate); + }); + + it("clones external and draft placements independently", () => { + const tracker = track({ + source: { value: 1 }, + left: null as { value: number } | null, + right: null as { value: number } | null, + rows: [] as { value: number }[], + }); + const external = { value: 2 }; + const change = tracker.beginChange(); + change.state.left = external; + change.state.right = change.state.source; + change.state.rows.push(external, external, change.state.source); + external.value = 9; + change.state.source.value = 7; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ + source: { value: 7 }, + left: { value: 2 }, + right: { value: 1 }, + rows: [{ value: 2 }, { value: 2 }, { value: 1 }], + }); + expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); + }); + + it("distinguishes raw committed and draft-subtree assignments", () => { + const tracker = track({ + source: { value: 1 }, + rawCopy: null as { value: number } | null, + draftCopy: null as { value: number } | null, + }); + const raw = tracker.value.source; + const change = tracker.beginChange(); + change.state.source.value = 5; + change.state.rawCopy = raw; + change.state.draftCopy = change.state.source; + expect(change.prepare().value).toEqual({ source: { value: 5 }, rawCopy: { value: 1 }, draftCopy: { value: 5 } }); + }); + + it("keeps Prepared.value authoritative and independent from public operations", () => { + const tracker = track({ rows: [] as { value: number }[] }); + const change = tracker.beginChange(); + change.state.rows.push({ value: 1 }); + const prepared = change.prepare(); + const splice = prepared.ops[0]; + if (splice?.[0] !== "p") throw new Error("expected splice"); + expect(splice[4][0]).not.toBe(prepared.value.rows[0]); + (splice[4][0] as { value: number }).value = 99; + expect(prepared.value.rows[0]!.value).toBe(1); + tracker.adopt(prepared); + expect(tracker.value).toBe(prepared.value); + expect(tracker.value.rows[0]!.value).toBe(1); + }); + + it("supports a MemoryStorage-style recursive clone", () => { + const storageClone = (value: T): T => { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(storageClone) as T; + const output: Record = {}; + for (const key of Object.keys(value)) output[key] = storageClone((value as Record)[key]); + return output as T; + }; + const tracker = track({ rows: [{ value: 1 }] }); + const change = tracker.beginChange(); + change.state.rows[0]!.value = 2; + change.state.rows.push({ value: 3 }); + expect(storageClone(change.prepare().value)).toEqual({ rows: [{ value: 2 }, { value: 3 }] }); + }); + + it("folds reserved-key changes without prototype pollution", () => { + const initial = JSON.parse('{"safe":{"__proto__":{"value":1}}}') as { + safe: { __proto__: { value: number } }; + }; + const tracker = track(initial); + const change = tracker.beginChange(); + change.state.safe.__proto__.value = 2; + const prepared = change.prepare(); + expect(applyImmutable(tracker.value, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expect(({} as Record).value).toBeUndefined(); + }); +}); + +describe("logical placement cloning", () => { + it("does not let a detached dirty object handle overwrite its replacement", () => { + const tracker = track({ child: { value: 1 }, stable: { value: 9 } }); + const change = tracker.beginChange(); + const held = change.state.child; + held.value = 2; + change.state.child = { value: 3 }; + held.value = 4; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ child: { value: 3 }, stable: { value: 9 } }); + expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + expect(tracker.value.child.value).toBe(3); + }); + + it("does not let a detached dirty array handle overwrite its replacement", () => { + const tracker = track({ rows: [{ value: 1 }, { value: 9 }] }); + const change = tracker.beginChange(); + const held = change.state.rows[0]!; + held.value = 2; + change.state.rows[0] = { value: 3 }; + held.value = 4; + const prepared = change.prepare(); + expect(prepared.value).toEqual({ rows: [{ value: 3 }, { value: 9 }] }); + expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + expect(tracker.value.rows[0]!.value).toBe(3); + }); + + it("copyWithin clones pending dirty descendants with primitive indices", () => { + const tracker = track({ rows: [{ nested: { value: 1 } }, { nested: { value: 2 } }] }); + const change = tracker.beginChange(); + change.state.rows[0]!.nested.value = 9; + change.state.rows.copyWithin(1, 0, 1); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ rows: [{ nested: { value: 9 } }, { nested: { value: 9 } }] }); + expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); + expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("keeps diff-aligned moved placements independent in the authoritative candidate", () => { + const tracker = track({ + rows: [ + { id: 0, score: 0, label: "r-0" }, + { id: 1, score: 1 }, + { id: 2, score: 2 }, + { id: 2, score: 2 }, + { id: 200106, score: 16, label: "r-200106" }, + { id: 4, score: 200103 }, + { id: 5, score: 200002 }, + { id: 6, score: 6, label: "r-6" }, + { id: 7, score: 7 }, + { id: 200001, score: 13, label: "r-200001" }, + ], + text: "start-200100-200107", + meta: { count: 0 }, + }); + const base = clone(tracker.value); + const change = tracker.beginChange(); + let held = change.state.rows[5]!; + change.state.rows.unshift({ id: 200200, score: 13 }); + held.score += 1_000; + change.state.meta.count -= 2; + held = change.state.rows[3]!; + change.state.rows.unshift({ id: 200202, score: 15, label: "r-200202" }); + held.score += 1_000; + change.state.text += "-200203"; + change.state.rows[0] = { id: 200204, score: 12 }; + change.state.rows[1] = change.state.rows[2]!; + change.state.text += "-200206"; + change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); + const prepared = change.prepare(); + expect(prepared.value.rows[0]!.id).toBe(0); + expect(prepared.value.rows[1]!.id).toBe(0); + expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); + expect(apply(base, clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + + const next = tracker.beginChange(); + const secondScore = next.state.rows[1]!.score; + next.state.rows[0]!.score = 99; + const nextPrepared = next.prepare(); + expect(nextPrepared.value.rows[0]!.score).toBe(99); + expect(nextPrepared.value.rows[1]!.score).toBe(secondScore); + expect(nextPrepared.value.rows[0]).not.toBe(nextPrepared.value.rows[1]); + nextPrepared.abort(); + }); + + it("fuzzes deeply-equal duplicate placements through movement and stable sort", () => { + for (let seed = 1; seed <= 60; seed++) { + const initial = { + rows: Array.from({ length: 20 }, (_, index) => ({ + id: Math.floor(index / 2), + score: Math.floor(index / 2), + label: `row-${Math.floor(index / 2)}`, + })), + }; + const tracker = track(initial); + const expected = clone(initial); + const change = tracker.beginChange(); + const heldIndex = seed % expected.rows.length; + const expectedHeld = expected.rows[heldIndex]!; + const draftHeld = change.state.rows[heldIndex]!; + expected.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); + change.state.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); + expectedHeld.score += 1_000; + draftHeld.score += 1_000; + expected.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); + change.state.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); + const source = (seed * 7) % expected.rows.length; + let target = (seed * 11 + 3) % expected.rows.length; + if (target === source) target = (target + 1) % expected.rows.length; + expected.rows[target] = clone(expected.rows[source]!); + change.state.rows[target] = change.state.rows[source]!; + expected.rows.sort((left, right) => left.id - right.id || left.score - right.score); + change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); + const prepared = change.prepare(); + expect(prepared.value, `candidate seed ${seed}`).toEqual(expected); + expect(new Set(prepared.value.rows).size, `identity seed ${seed}`).toBe(prepared.value.rows.length); + expect(apply(clone(initial), clone(prepared.ops)), `replay seed ${seed}`).toEqual(expected); + prepared.abort(); + } + }); + + it("clones a dirty draft subtree from its source tracker context", () => { + const source = track({ subtree: { nested: { value: 1 }, rows: [{ value: 2 }] } }); + const destination = track({ copy: null as { nested: { value: number }; rows: { value: number }[] } | null }); + const sourceChange = source.beginChange(); + sourceChange.state.subtree.nested.value = 7; + sourceChange.state.subtree.rows[0]!.value = 8; + const destinationChange = destination.beginChange(); + destinationChange.state.copy = sourceChange.state.subtree; + const prepared = destinationChange.prepare(); + expect(prepared.value.copy).toEqual({ nested: { value: 7 }, rows: [{ value: 8 }] }); + expect(apply(clone(destination.value), clone(prepared.ops))).toEqual(prepared.value); + destination.adopt(prepared); + sourceChange.abort(); + }); +}); + +describe("native mutator parity", () => { + const compareNumbers = ( + nativeMutation: (values: number[]) => void, + draftMutation: (values: number[]) => void, + ): void => { + const native = [1, 2, 3]; + nativeMutation(native); + const tracker = track({ values: [1, 2, 3] }); + const change = tracker.beginChange(); + draftMutation(change.state.values); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual(native); + tracker.adopt(prepared); + }; + + it("matches captured-length splice coercion when coercion mutates the array", () => { + compareNumbers( + (values) => { + values.splice( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 1, + 9, + ); + }, + (values) => { + values.splice( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 1, + 9, + ); + }, + ); + }); + + it("keeps reentrant splice shrink results dense strict JSON", () => { + const tracker = track({ values: [1, 2, 3] as Array }); + const change = tracker.beginChange(); + change.state.values.splice( + { + valueOf() { + change.state.values.length = 1; + return 0; + }, + } as unknown as number, + 0, + 9, + ); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([9, 1, null, null]); + expect(Object.keys(prepared.value.values)).toEqual(["0", "1", "2", "3"]); + expect(prepared.value.values.every((value) => value !== undefined)).toBe(true); + expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("matches fill and copyWithin coercion side effects", () => { + compareNumbers( + (values) => { + values.fill(7, { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number); + }, + (values) => { + values.fill(7, { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number); + }, + ); + compareNumbers( + (values) => { + values.copyWithin( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 0, + 2, + ); + }, + (values) => { + values.copyWithin( + { + valueOf() { + values.push(4); + return 1; + }, + } as unknown as number, + 0, + 2, + ); + }, + ); + }); + + it("matches comparator direct-index and structural writes", () => { + type Row = { rank: number; touches: number }; + const mutate = (values: Row[]): void => { + let changed = false; + values.sort((left, right) => { + left.touches += 1; + right.touches += 1; + if (!changed) { + changed = true; + values[0] = { rank: 9, touches: 0 }; + values.push({ rank: 3, touches: 0 }); + } + return left.rank - right.rank; + }); + }; + const native: Row[] = [ + { rank: 2, touches: 0 }, + { rank: 1, touches: 0 }, + ]; + mutate(native); + const tracker = track({ + values: [ + { rank: 2, touches: 0 }, + { rank: 1, touches: 0 }, + ], + }); + const change = tracker.beginChange(); + mutate(change.state.values); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual(native); + expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); + tracker.adopt(prepared); + }); + + it("rejects bigint comparator results like native sort", () => { + expect(() => [2, 1].sort(() => 1n as unknown as number)).toThrow(TypeError); + const tracker = track({ values: [2, 1] }); + const change = tracker.beginChange(); + expect(() => change.state.values.sort(() => 1n as unknown as number)).toThrow(TypeError); + change.abort(); + expect(tracker.value.values).toEqual([2, 1]); + }); + + it("forwards borrowed mutators to ordinary generic array receivers", () => { + const tracker = track({ values: [1, 2, 3] }); + const change = tracker.beginChange(); + const receiver = [3, 1, 2]; + expect(Reflect.apply(change.state.values.push, receiver, [4])).toBe(4); + expect(Reflect.apply(change.state.values.splice, receiver, [1, 1, 5])).toEqual([1]); + expect(Reflect.apply(change.state.values.sort, receiver, [(left: number, right: number) => left - right])).toBe( + receiver, + ); + expect(Reflect.apply(change.state.values.fill, receiver, [7, 1, 2])).toBe(receiver); + expect(Reflect.apply(change.state.values.copyWithin, receiver, [2, 0, 1])).toBe(receiver); + expect(receiver).toEqual([2, 7, 2, 5]); + expect(change.state.values).toEqual([1, 2, 3]); + change.abort(); + }); +}); + +describe("batched immutable application", () => { + it("copies shared ancestors once while preserving untouched subtrees", () => { + const base = { + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ], + stable: { value: 9 }, + }; + const operations: Op[] = [ + ["s", ["rows", 0, "a"], 10], + ["s", ["rows", 0, "b"], 20], + ["s", ["rows", 1, "a"], 30], + ]; + const result = applyImmutable(base, operations); + expect(result).toEqual({ + rows: [ + { a: 10, b: 20 }, + { a: 30, b: 4 }, + ], + stable: { value: 9 }, + }); + expect(base).toEqual({ + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ], + stable: { value: 9 }, + }); + expect(result.stable).toBe(base.stable); + }); + + it("does not mutate replacement or insertion payloads targeted later in the batch", () => { + const replacement = { rows: [{ value: 1 }] }; + const result = applyImmutable(undefined, [ + ["r", replacement], + ["s", ["rows", 0, "value"], 2], + ]); + expect(result.rows[0]!.value).toBe(2); + expect(replacement.rows[0]!.value).toBe(1); + }); +}); + +type Item = { id: number; score: number }; +type Document = { rows: Item[]; text: string; meta: { revision: number; label?: string } }; +type MutableDocument = Document | Draft; + +function mutate(document: MutableDocument, choice: number, value: number): void { + const item = (): Item => ({ id: value, score: value % 7 }); + switch (choice) { + case 0: + document.text += `-${value}`; + break; + case 1: + document.rows.push(item()); + break; + case 2: + document.rows.unshift(item()); + break; + case 3: + if (document.rows.length > 0) document.rows.shift(); + break; + case 4: + if (document.rows.length > 0) document.rows.pop(); + break; + case 5: { + const index = document.rows.length === 0 ? 0 : value % (document.rows.length + 1); + document.rows.splice(index, document.rows.length === 0 ? 0 : value % 2, item()); + break; + } + case 6: + document.rows.reverse(); + break; + case 7: + document.rows.sort((left, right) => left.id - right.id); + break; + case 8: + if (document.rows.length > 0) document.rows[value % document.rows.length]!.score = value; + break; + case 9: + document.meta.revision += 1; + document.meta.label = `r-${value}`; + break; + default: + delete document.meta.label; + } +} + +it("converges through randomized multi-operation transactions", () => { + for (let seed = 1; seed <= 30; seed++) { + let randomState = seed; + const random = (): number => { + randomState = (randomState + 0x6d2b79f5) | 0; + let value = Math.imul(randomState ^ (randomState >>> 15), 1 | randomState); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; + const initial: Document = { + rows: Array.from({ length: 4 }, (_, id) => ({ id, score: 0 })), + text: "start", + meta: { revision: 0 }, + }; + const tracker = track(initial); + const expected = clone(initial); + for (let transaction = 0; transaction < 20; transaction++) { + const base = clone(tracker.value); + const change = tracker.beginChange(); + for (let operation = 0; operation < 5; operation++) { + const choice = Math.floor(random() * 11); + const value = seed * 10_000 + transaction * 10 + operation; + mutate(expected, choice, value); + mutate(change.state, choice, value); + } + const prepared = change.prepare(); + const candidate = clone(prepared.value); + expect(candidate, `candidate seed ${seed} transaction ${transaction}`).toEqual(expected); + expect(apply(base, clone(prepared.ops)), `replay seed ${seed} transaction ${transaction}`).toEqual(expected); + tracker.adopt(prepared); + expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(expected); + } + } +}, 30_000); diff --git a/packages/chord/test/delta-cow/diff.test.ts b/packages/chord/test/delta-cow/diff.test.ts new file mode 100644 index 000000000..75d3005e6 --- /dev/null +++ b/packages/chord/test/delta-cow/diff.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; +import { + applyImmutable, + assertValidOp, + assertValidWireOp, + decoder, + diffRevisions, + encoder, + type Op, +} from "../../src/delta/cow/index.ts"; +import type { JsonValue } from "../../src/types.ts"; + +const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => { + const operations = diffRevisions(before, after); + expect(operations).toEqual(expected); + expect(applyImmutable(before, operations)).toEqual(after); +}; + +describe("immutable revision diff", () => { + it("emits sets and deletes", () => { + expectDiff({ keep: 1, change: 1, remove: true }, { keep: 1, change: 2, add: 3 }, [ + ["s", ["change"], 2], + ["s", ["add"], 3], + ["d", ["remove"]], + ]); + }); + + it("emits string append and front truncation", () => { + expectDiff({ text: "hello" }, { text: "hello world" }, [["a", ["text"], " world"]]); + expectDiff({ text: "hello world" }, { text: "world" }, [["t", ["text"], 6]]); + expectDiff({ text: "abcdefgh" }, { text: "defghxyz" }, [ + ["t", ["text"], 3], + ["a", ["text"], "xyz"], + ]); + }); + + it("represents array insertion, removal, and shift with splices", () => { + const a = { id: "a" }; + const b = { id: "b" }; + const c = { id: "c" }; + expectDiff({ values: [a, b] }, { values: [a, c, b] }, [["p", ["values"], 1, 0, [c]]]); + expectDiff({ values: [a, b, c] }, { values: [b, c] }, [["p", ["values"], 0, 1, []]]); + }); + + it("collapses a same-length queue update to two splices", () => { + const a = { id: "a" }; + const b = { id: "b" }; + const c = { id: "c" }; + const d = { id: "d" }; + expectDiff({ values: [a, b, c] }, { values: [b, c, d] }, [ + ["p", ["values"], 0, 1, []], + ["p", ["values"], 2, 0, [d]], + ]); + }); + + it("emits a permutation for a pure reorder", () => { + const a = { id: "a" }; + const b = { id: "b" }; + const c = { id: "c" }; + 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]], + ["m", ["values"], [1, 2, 0]], + ]; + for (const operation of operations) assertValidOp(operation); + const wire = encoder().encode(operations); + expect(wire).toEqual([ + ["m", ["values"], [2, 0, 1]], + ["m", [1, 2, 0]], + ]); + for (const operation of wire) assertValidWireOp(operation); + expect(decoder().decode(wire)).toEqual(operations); + expect(() => assertValidOp(["m", ["values"], [0, 0]])).toThrow(/bijection/); + }); + + it("emits nothing for deeply equal reconstructed values", () => { + expect(diffRevisions({ value: { nested: [1, 2] } }, { value: { nested: [1, 2] } })).toEqual([]); + expect(diffRevisions({ values: [{ id: 1 }, { id: 2 }] }, { values: [{ id: 1 }, { id: 2 }] })).toEqual([]); + expect(diffRevisions({ values: [true, true, true] }, { values: [true, true, true] })).toEqual([]); + }); + + it("keeps a leaf edit inside a reconstructed array narrow", () => { + expectDiff( + { + values: [ + { id: 1, label: "one" }, + { id: 2, label: "two" }, + ], + }, + { + values: [ + { id: 1, label: "one" }, + { id: 2, label: "changed" }, + ], + }, + [["s", ["values", 1, "label"], "changed"]], + ); + }); + + 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("keeps a large rotation payload-free", () => { + const values = Array.from({ length: 10_000 }, (_, value) => ({ value })); + const rotated = [...values.slice(1_000), ...values.slice(0, 1_000)]; + const operations = diffRevisions({ values }, { values: rotated }); + expect(operations).toHaveLength(1); + expect(operations[0]?.[0]).toBe("m"); + expect(JSON.stringify(operations).length).toBeLessThan(60_000); + expect(applyImmutable({ values }, operations)).toEqual({ values: rotated }); + }); + + 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("uses normalized array fallback without payload-cost policy", () => { + const before = { values: Array.from({ length: 40_000 }, () => 0) }; + const after = { values: Array.from({ length: 40_000 }, () => 1) }; + const operations = diffRevisions(before, after); + expect(operations).toEqual([["p", ["values"], 0, 40_000, after.values]]); + expect(applyImmutable(before, operations)).toEqual(after); + }); +}); diff --git a/packages/chord/test/delta-cow/draft.test.ts b/packages/chord/test/delta-cow/draft.test.ts new file mode 100644 index 000000000..bbe1584b5 --- /dev/null +++ b/packages/chord/test/delta-cow/draft.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { applyImmutable, type Draft, track } from "../../src/delta/cow/index.ts"; + +describe("copy-on-write change drafts", () => { + it("copies only changed branches", () => { + const tracker = track({ + changed: { count: 1, sibling: { value: "kept" } }, + untouched: { value: 2 }, + }); + const base = tracker.value; + const change = tracker.beginChange(); + const first = change.state.changed; + expect(change.state.changed).toBe(first); + change.state.changed.count = 3; + const prepared = change.prepare(); + expect(prepared.value).not.toBe(base); + expect(prepared.value.changed).not.toBe(base.changed); + expect(prepared.value.changed.sibling).toBe(base.changed.sibling); + expect(prepared.value.untouched).toBe(base.untouched); + }); + + it("supports frozen bases, deletion, and native array mutators", () => { + const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] }); + const change = tracker.beginChange(); + delete change.state.optional; + change.state.values.push(4); + expect(change.state.values.pop()).toBe(4); + change.state.values.unshift(0); + expect(change.state.values.shift()).toBe(0); + expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]); + change.state.values.sort((left, right) => left - right); + change.state.values.reverse(); + change.state.values.fill(9, 1, 3); + change.state.values.copyWithin(1, 0, 2); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ values: [5, 5, 9, 2] }); + }); + + it("copies inserted draft values without aliasing their original handle", () => { + const tracker = track({ values: [{ value: 1 }, { value: 2 }] }); + const change = tracker.beginChange(); + const held = change.state.values[0]!; + change.state.values.unshift(held); + held.value = 9; + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]); + expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); + }); + + it("keeps comparator edits when sorting object drafts", () => { + const tracker = track({ + rows: [ + { rank: 2, comparisons: 0 }, + { rank: 1, comparisons: 0 }, + ], + }); + const change = tracker.beginChange(); + change.state.rows.sort((left, right) => { + left.comparisons++; + right.comparisons++; + return left.rank - right.rank; + }); + const prepared = change.prepare(); + expect(prepared.value.rows.map((row) => row.rank)).toEqual([1, 2]); + expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]); + }); + + it("drops writes through detached child handles", () => { + const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] }); + const change = tracker.beginChange(); + const child = change.state.child; + const shifted = change.state.items.shift(); + delete (change.state as { child?: Draft<{ value: string }>; items: Draft<{ value: string }[]> }).child; + child.value = "detached child"; + if (shifted !== undefined) shifted.value = "detached item"; + expect(change.prepare().value).toEqual({ items: [{ value: "kept" }] }); + }); + + it.each(["unshift", "splice"] as const)("inserts 100,000 items with %s without argument overflow", (method) => { + const tracker = track({ values: [-1] }); + const change = tracker.beginChange(); + const items = Array.from({ length: 100_000 }, (_, value) => value); + if (method === "unshift") Reflect.apply(change.state.values.unshift, change.state.values, items); + else Reflect.apply(change.state.values.splice, change.state.values, [1, 0, ...items]); + const prepared = change.prepare(); + expect(prepared.value.values).toHaveLength(100_001); + expect(prepared.value.values[method === "unshift" ? 0 : 1]).toBe(0); + expect(prepared.value.values[method === "unshift" ? 99_999 : 100_000]).toBe(99_999); + expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); + }); + + it("rejects array holes without mutating the committed base", () => { + const tracker = track({ values: [1, 2] }); + const change = tracker.beginChange(); + expect(() => delete change.state.values[0]).toThrow(/holes/); + change.abort(); + expect(tracker.value.values).toEqual([1, 2]); + }); +}); diff --git a/packages/chord/test/delta-cow/fuzz.test.ts b/packages/chord/test/delta-cow/fuzz.test.ts new file mode 100644 index 000000000..0020e8976 --- /dev/null +++ b/packages/chord/test/delta-cow/fuzz.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from "vitest"; +import { applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; + +type Item = { id: number; text: string; score: number }; +type Document = { + items: Item[]; + text: string; + meta: { revision: number; label?: string }; +}; + +type MutableDocument = Document | Draft; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const random = + (seed: number): (() => number) => + () => { + seed = (seed + 0x6d2b79f5) | 0; + let value = Math.imul(seed ^ (seed >>> 15), 1 | seed); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; + +const mutate = (document: MutableDocument, choice: number, value: number): void => { + const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 }); + switch (choice) { + case 0: + document.text += `-${value}`; + break; + case 1: + document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`; + break; + case 2: + document.items.push(item()); + break; + case 3: + document.items.unshift(item()); + break; + case 4: + if (document.items.length > 0) document.items.shift(); + break; + case 5: + if (document.items.length > 0) document.items.pop(); + break; + case 6: { + const index = document.items.length === 0 ? 0 : value % (document.items.length + 1); + document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item()); + break; + } + case 7: + document.items.reverse(); + break; + case 8: + document.items.sort((left, right) => left.id - right.id); + break; + case 9: + if (document.items.length > 0) document.items[value % document.items.length]!.score = value; + break; + case 10: + document.meta.revision += 1; + document.meta.label = `revision-${value}`; + break; + case 11: + delete document.meta.label; + break; + case 12: + if (document.items.length > 1) document.items[1] = clone(document.items[0]!); + break; + default: + for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item(); + } +}; + +it("converges across randomized prepared revisions", () => { + for (let seed = 1; seed <= 100; seed++) { + const rng = random(seed); + const initial: Document = { + items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })), + text: "start", + meta: { revision: 0 }, + }; + const tracker = track(initial); + const expected = clone(initial); + let replica = clone(tracker.value); + for (let step = 0; step < 100; step++) { + const choice = Math.floor(rng() * 14); + const value = seed * 1_000 + step; + mutate(expected, choice, value); + const change = tracker.beginChange(); + mutate(change.state, choice, value); + const prepared = change.prepare(); + const operations: readonly Op[] = prepared.ops; + replica = applyImmutable(replica, operations); + tracker.adopt(prepared); + expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); + expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected); + } + } +}); diff --git a/packages/chord/test/delta-cow/retention.test.ts b/packages/chord/test/delta-cow/retention.test.ts new file mode 100644 index 000000000..9ae8fd9de --- /dev/null +++ b/packages/chord/test/delta-cow/retention.test.ts @@ -0,0 +1,28 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("delta change retention across GC jobs", () => { + it.each([ + "aborted-payload", + "unadopted-prepared", + "draft-proxies", + "settled-lifecycle", + "obsolete-revisions", + "retained-settled-prepared", + "retained-settled-change", + "lifecycle-churn", + ])( + "validates %s retention semantics", + (scenario) => { + const child = spawnSync( + process.execPath, + ["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario], + { encoding: "utf8", timeout: 60_000 }, + ); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); + }, + 65_000, + ); +}); diff --git a/packages/chord/test/delta-cow/retention.worker.ts b/packages/chord/test/delta-cow/retention.worker.ts new file mode 100644 index 000000000..772377000 --- /dev/null +++ b/packages/chord/test/delta-cow/retention.worker.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { setImmediate } from "node:timers/promises"; +import { type Tracker, track } from "../../src/delta/cow/index.ts"; + +const retainedSettledObjects: object[] = []; +const retainedLargePrepared: object[] = []; +const retainedSettledChanges: object[] = []; + +async function collect(ref: WeakRef): Promise { + assert.ok(global.gc, "worker requires --expose-gc"); + for (let attempt = 0; attempt < 30; attempt++) { + await setImmediate(); + global.gc(); + if (ref.deref() === undefined) return; + } + assert.fail("released change data is still retained"); +} + +function abortedPayload(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const reference = new WeakRef(change.state.payload.rows); + change.abort(); + return reference; +} + +function unadoptedPrepared(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { + const change = tracker.beginChange(); + change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; + const prepared = change.prepare(); + return new WeakRef(prepared.value.payload!.rows); +} + +function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { + const change = tracker.beginChange(); + change.state.payload = { rows: [{ value: 1 }] }; + const reference = new WeakRef(change.state.payload.rows[0]!); + change.abort(); + return reference; +} + +async function assertRetained(reference: WeakRef): Promise { + assert.ok(global.gc, "worker requires --expose-gc"); + for (let attempt = 0; attempt < 5; attempt++) { + await setImmediate(); + global.gc(); + } + assert.notEqual(reference.deref(), undefined, "retained Prepared must retain its immutable revisions"); +} + +function settledLifecycle(): { tracker: WeakRef; future: WeakRef } { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + const change = tracker.beginChange(); + change.state.payload = { rows: [{ value: 1 }] }; + const retainedPrepared = change.prepare(); + tracker.adopt(retainedPrepared); + retainedSettledObjects.push(change, retainedPrepared); + + const futurePrepared = tracker.prepareReplace({ + payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }, + }); + tracker.adopt(futurePrepared); + return { + tracker: new WeakRef(tracker), + future: new WeakRef(futurePrepared.value.payload!.rows), + }; +} + +const scenario = process.argv[2]; +if (scenario === "settled-lifecycle") { + const references = settledLifecycle(); + await collect(references.tracker); + await collect(references.future); +} else if (scenario === "retained-settled-prepared") { + const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }); + const retain = (): { base: WeakRef; value: WeakRef } => { + const change = tracker.beginChange(); + change.state.payload.rows[0]!.value = -1; + const prepared = change.prepare(); + const references = { + base: new WeakRef(prepared.base.payload.rows), + value: new WeakRef(prepared.value.payload.rows), + }; + tracker.adopt(prepared); + retainedLargePrepared.push(prepared); + return references; + }; + const references = retain(); + const replace = (): void => { + const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } }); + tracker.adopt(replacement); + }; + replace(); + await assertRetained(references.base); + await assertRetained(references.value); + retainedLargePrepared.length = 0; + await collect(references.base); + await collect(references.value); +} else if (scenario === "retained-settled-change") { + const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }); + const settleChange = (): { base: WeakRef; value: WeakRef } => { + const change = tracker.beginChange(); + change.state.payload.rows[0]!.value = -1; + const prepared = change.prepare(); + const references = { + base: new WeakRef(prepared.base.payload.rows), + value: new WeakRef(prepared.value.payload.rows), + }; + tracker.adopt(prepared); + retainedSettledChanges.push(change); + return references; + }; + const references = settleChange(); + const replace = (): void => { + const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } }); + tracker.adopt(replacement); + }; + replace(); + await collect(references.base); + await collect(references.value); + assert.equal(retainedSettledChanges.length, 1); +} else if (scenario === "lifecycle-churn") { + const tracker = track({ value: 0 }); + for (let index = 0; index < 100_000; index++) { + const change = tracker.beginChange(); + change.state.value = index; + change.abort(); + } + const prepared = tracker.prepareReplace({ value: 1 }); + tracker.adopt(prepared); + assert.deepEqual(tracker.value, { value: 1 }); +} else if (scenario === "obsolete-revisions") { + const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }); + const oldRoot = new WeakRef(tracker.value.payload.rows); + const replace = (value: number): void => { + const prepared = tracker.prepareReplace({ payload: { rows: [{ value }] } }); + tracker.adopt(prepared); + }; + for (let value = 0; value < 100; value++) replace(value); + await collect(oldRoot); + assert.deepEqual(tracker.value, { payload: { rows: [{ value: 99 }] } }); +} else { + const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); + let reference: WeakRef; + switch (scenario) { + case "aborted-payload": + reference = abortedPayload(tracker); + break; + case "unadopted-prepared": + reference = unadoptedPrepared(tracker); + break; + case "draft-proxies": + reference = draftProxies(tracker); + break; + default: + throw new Error(`unknown retention scenario: ${scenario}`); + } + await collect(reference); + assert.deepEqual(tracker.value, { payload: null }); +} +console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-cow/value.test.ts b/packages/chord/test/delta-cow/value.test.ts new file mode 100644 index 000000000..a896f5476 --- /dev/null +++ b/packages/chord/test/delta-cow/value.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { track } from "../../src/delta/cow/index.ts"; + +describe("trusted immutable revision ownership", () => { + it("takes O(1) ownership of an alias-free strict-JSON root", () => { + const input = { left: { value: 1 }, right: { value: 2 } }; + const tracker = track(input); + expect(tracker.value).toBe(input); + expect(tracker.value.left).toBe(input.left); + }); + + it("copies changed branches while sharing untouched branches", () => { + const tracker = track({ changed: { value: 1 }, retained: { value: 2 } }); + const base = tracker.value; + const change = tracker.beginChange(); + change.state.changed.value = 3; + const prepared = change.prepare(); + expect(prepared.value).not.toBe(base); + expect(prepared.value.changed).not.toBe(base.changed); + expect(prepared.value.retained).toBe(base.retained); + tracker.adopt(prepared); + expect(tracker.value).toBe(prepared.value); + }); + + it("makes repeated placements independent", () => { + const tracker = track({ values: [] as { value: number }[] }); + const change = tracker.beginChange(); + const shared = { value: 1 }; + change.state.values.push(shared, shared); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([{ value: 1 }, { value: 1 }]); + expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); + }); +});