From 17f9ca76c9a17da2ae59d771a75e757a4e204b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 9 Sep 2026 07:27:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Core=EC=9D=98=20=EC=A4=91=EB=B3=B5=20patch?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=EC=99=80=20=EB=B0=B0=EC=97=B4=20fast=20pa?= =?UTF-8?q?th=EB=A5=BC=20=EC=B6=95=EC=86=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document/benchmarks/core.mjs | 48 ++ .../src/domain/json-document/create.ts | 99 +-- .../src/foundation/json/shared-array.ts | 6 +- .../src/foundation/patch/fast/apply.ts | 309 ++------ .../src/foundation/patch/fast/array.ts | 738 +++--------------- .../src/foundation/patch/fast/replace.ts | 331 -------- .../src/foundation/patch/object.ts | 38 +- .../src/foundation/patch/path.ts | 169 +--- .../foundation/patch/sequential-replace.ts | 133 +--- .../src/foundation/patch/trusted.ts | 45 +- .../src/foundation/patch/value.ts | 13 +- .../src/foundation/protocol/apply.ts | 75 +- .../tests/conformance/json-document.test.ts | 21 + .../tests/foundation/array-patch.test.ts | 105 +++ .../tests/foundation/object-patch.test.ts | 64 ++ .../tests/foundation/owned-freeze.test.ts | 123 ++- 16 files changed, 658 insertions(+), 1659 deletions(-) delete mode 100644 packages/json-document/src/foundation/patch/fast/replace.ts create mode 100644 packages/json-document/tests/foundation/array-patch.test.ts create mode 100644 packages/json-document/tests/foundation/object-patch.test.ts diff --git a/packages/json-document/benchmarks/core.mjs b/packages/json-document/benchmarks/core.mjs index 46556baf8..4a7e66cd4 100644 --- a/packages/json-document/benchmarks/core.mjs +++ b/packages/json-document/benchmarks/core.mjs @@ -75,6 +75,54 @@ for (const size of sizes) { commitBudgetPerTenThousandMs * (size / 10_000), ); + const batchSize = Math.min(size, 1_000); + const batchDocument = createJSONDocument(initial); + let batchDone = false; + const batchOperations = Array.from({ length: batchSize }, (_, index) => ({ + op: "replace", + path: `/items/${Math.floor(index * size / batchSize)}/done`, + value: batchDone, + })); + measure(`commit ${batchSize} leaf replaces`, () => { + batchDone = !batchDone; + for (const operation of batchOperations) operation.value = batchDone; + const result = batchDocument.commit(batchOperations); + return result.ok && result.change.applied.length === batchSize; + }); + measure(`commit ${batchSize} equivalent leaf replaces`, () => { + const result = batchDocument.commit(batchOperations); + return result.ok && result.change.applied.length === 0; + }); + + const rootDocument = createJSONDocument(Object.fromEntries( + Array.from({ length: size }, (_, index) => [`field-${index}`, false]), + )); + const rootOperations = Array.from({ length: batchSize }, (_, index) => ({ + op: "add", path: `/field-${index}`, value: false, + })); + let rootDone = false; + measure(`commit ${batchSize} root object writes`, () => { + rootDone = !rootDone; + for (const operation of rootOperations) operation.value = rootDone; + const result = rootDocument.commit(rootOperations); + return result.ok && result.change.applied.length === batchSize; + }); + + const structuralDocument = createJSONDocument(initial); + const appended = Array.from({ length: batchSize }, (_, index) => ({ + op: "add", path: "/items/-", value: { id: `added-${index}`, done: false }, + })); + const removed = Array.from({ length: batchSize }, (_, index) => ({ + op: "remove", path: `/items/${size - index - 1}`, + })); + let structuralDone = false; + measure(`commit ${batchSize} appends and descending removes`, () => { + structuralDone = !structuralDone; + for (const operation of appended) operation.value.done = structuralDone; + const result = structuralDocument.commit([...appended, ...removed]); + return result.ok && result.change.applied.length === batchSize * 2; + }); + const queryDocument = createJSONDocument(initial); measure("query direct item", () => { const result = queryDocument.query(`$.items[${middle}].id`); diff --git a/packages/json-document/src/domain/json-document/create.ts b/packages/json-document/src/domain/json-document/create.ts index 62b6c0f48..75c985c95 100644 --- a/packages/json-document/src/domain/json-document/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -116,12 +116,10 @@ export function createJSONDocumentState( const metadata = ownMetadata(commitOptions?.metadata); if (!metadata.ok) return metadata; - const local = localCommitEffect(state, operations); const result = prepare(operations); if (!result.ok) return result; - const unchanged = local === "noop" || (local === "unknown" && jsonEqual(state, result.value)); - if (unchanged) { + if (isUnchangedCommit(state, result.value, result.change.applied)) { return Object.freeze({ ok: true, change: createChange([], metadata.value), @@ -171,78 +169,39 @@ export function createJSONDocumentState( } } -function localCommitEffect( - state: JSONValue, +function isUnchangedCommit( + before: JSONValue, + after: JSONValue, operations: ReadonlyArray, -): "noop" | "changed" | "unknown" { - if (operations.length === 0) return "noop"; - if (operations.length === 1 && operations[0]?.op === "add") { - return singleAddEffect(state, operations[0]); - } - const seen: string[] = []; - let changed = false; - for (const operation of operations) { - if (operation === undefined || typeof operation !== "object" || operation === null) return "unknown"; - if (operation.op === "replace") { - if (typeof operation.path !== "string") return "unknown"; - if (operation.path === "") { - if (operations.length !== 1) return "unknown"; - return jsonEqual(state, operation.value) ? "noop" : "changed"; - } - if (overlapsLocalPath(seen, operation.path)) return "unknown"; - seen.push(operation.path); - let segments: string[]; - try { - segments = parsePointer(operation.path); - } catch { - return "unknown"; +): boolean { + if (before === after) return true; + if (operations.length === 1) { + const operation = operations[0]!; + if (operation.op === "remove") return false; + if (operation.op === "add") { + const segments = parsePointer(operation.path); + if (segments.length > 0) { + const parent = readAt(before, segments.slice(0, -1)); + if (parent.ok && Array.isArray(parent.value)) return false; } - const current = readAt(state, segments); - if (!current.ok) return "unknown"; - if (!jsonEqual(current.value, operation.value)) changed = true; - continue; + const current = readAt(before, segments); + return current.ok && jsonEqual(current.value, operation.value); } - if (operation.op === "add" || operation.op === "remove") { - if (typeof operation.path !== "string" || operation.path === "") return "unknown"; - // A later operation can cancel a structural mutation or overwrite an - // object add. Only the final value establishes a multi-operation effect. - if (operations.length > 1) return "unknown"; - changed = true; - continue; - } - return "unknown"; - } - return changed ? "changed" : "noop"; -} - -function overlapsLocalPath(seen: ReadonlyArray, path: string): boolean { - return seen.some((existing) => ( - existing === path - || existing.startsWith(`${path}/`) - || path.startsWith(`${existing}/`) - )); -} - -function singleAddEffect( - state: JSONValue, - operation: Extract, -): "noop" | "changed" | "unknown" { - if (typeof operation.path !== "string") return "unknown"; - let segments: string[]; - try { - segments = parsePointer(operation.path); - } catch { - return "unknown"; } - if (segments.length === 0) { - return jsonEqual(state, operation.value) ? "noop" : "changed"; + // Successful replacements can only change their target subtrees. Comparing + // those paths in the final value also covers repeated and overlapping paths, + // without pairwise overlap checks or a walk through unchanged array siblings. + if (operations.every((operation) => operation.op === "replace" || operation.op === "test")) { + return operations.every((operation) => { + if (operation.op === "test") return true; + const segments = parsePointer(operation.path); + const previous = readAt(before, segments); + const current = readAt(after, segments); + return previous.ok === current.ok + && (!previous.ok || (current.ok && jsonEqual(previous.value, current.value))); + }); } - const parent = readAt(state, segments.slice(0, -1)); - if (!parent.ok || Array.isArray(parent.value)) return "changed"; - const current = readAt(state, segments); - return current.ok && jsonEqual(current.value, operation.value) - ? "noop" - : "changed"; + return jsonEqual(before, after); } const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); diff --git a/packages/json-document/src/foundation/json/shared-array.ts b/packages/json-document/src/foundation/json/shared-array.ts index 1adf3f9b9..1f9e22741 100644 --- a/packages/json-document/src/foundation/json/shared-array.ts +++ b/packages/json-document/src/foundation/json/shared-array.ts @@ -17,9 +17,9 @@ export function denseArrayCopies(): number { return denseCopies; } -/** Internal ownership check; public reflection may materialize a dense snapshot. */ -export function isSharedArray(value: object): boolean { - return overlays.has(value); +/** Internal overlay metadata, without materializing a dense snapshot. */ +export function getSharedArrayOverlay(value: object): SharedArrayOverlay | undefined { + return overlays.get(value); } export function replaceArrayIndex( diff --git a/packages/json-document/src/foundation/patch/fast/apply.ts b/packages/json-document/src/foundation/patch/fast/apply.ts index bb679e9a9..5e557e9cc 100644 --- a/packages/json-document/src/foundation/patch/fast/apply.ts +++ b/packages/json-document/src/foundation/patch/fast/apply.ts @@ -1,24 +1,8 @@ import { jsonSerializableError } from "../../json/serializable.js"; -import { - copyRootObject, - copyRootObjectKeyPrefix, - copyRootObjectKeys, - objectHasOwn, - removedRootKeysMatchSuffix, -} from "../object.js"; +import { copyRootObject, objectHasOwn } from "../object.js"; import { validateOperationShape } from "../apply.js"; import { applySequentialReplacePatch } from "../sequential-replace.js"; -import { - applyAppendOnlyAddPatch, - applySameArrayStructuralPatch, - applyTailRemovePatch, -} from "./array.js"; -import { - applyIndependentReplacePatch, - applySameArrayElementReplacePatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, -} from "./replace.js"; +import { applySameArrayStructuralPatch } from "./array.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; type FastPatchSuccess = Extract; @@ -29,39 +13,15 @@ type FastPatchStrategy = ( valuesTrusted: boolean, ) => FastPatchResult; -const rootObjectReplaceWhenValuesTrusted: FastPatchStrategy = (state, ops, valuesTrusted) => - valuesTrusted - ? applyRootObjectReplacePatch(state, ops, true) - : { handled: false }; - -export const trustedStrategies: readonly FastPatchStrategy[] = [ - applyAppendOnlyAddPatch, - applyTailRemovePatch, - applyRootObjectRemovePatch, - applyRootObjectAddPatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, - rootObjectReplaceWhenValuesTrusted, - applySameArrayElementReplacePatch, - applyIndependentReplacePatch, +const strategies: readonly FastPatchStrategy[] = [ + applyRootObjectPatch, applySequentialReplacePatch, applySameArrayStructuralPatch, ]; -export const validatedStrategies: readonly FastPatchStrategy[] = [ - applyRootObjectRemovePatch, - applyRootObjectAddPatch, - applyRootObjectReplacePatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, - applySameArrayElementReplacePatch, - applySequentialReplacePatch, -]; - export function applyFastPatchStrategies( state: unknown, ops: ReadonlyArray, - strategies: readonly FastPatchStrategy[], valuesTrusted: boolean, ): FastPatchSuccess | null { for (const strategy of strategies) { @@ -71,238 +31,77 @@ export function applyFastPatchStrategies( return null; } -function applyRootObjectRemovePatch( +function applyRootObjectPatch( state: unknown, ops: ReadonlyArray, + valuesTrusted: boolean, ): FastPatchResult { + const first = ops[0]; if ( ops.length < 2 || state === null || typeof state !== "object" || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "remove") - ) { - return { handled: false }; - } + || (first?.op !== "add" && first?.op !== "remove") + ) return { handled: false }; + const firstKey = flatRootObjectKey(first); + if (firstKey === null) return { handled: false }; const source = state as Record; - const sourceKeys = Object.keys(source); - let matchesSourceKeyOrder = ops.length === sourceKeys.length; - let removedKeys: Record | null = null; - let matchesReverseSuffix = ops.length <= sourceKeys.length; - const applied = new Array(ops.length); + const next = first.op === "add" ? copyRootObject(source) : null; + const keys = next === null ? Object.keys(source) : []; + let matchesKeyOrder = ops.length === keys.length; + let matchesReverseSuffix = ops.length <= keys.length; + let removedKeys: Set | null = null; for (let index = 0; index < ops.length; index += 1) { if (!(index in ops)) return { handled: false }; const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "remove" - || typeof op.path !== "string" - || op.path === "" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - - const key = op.path.slice(1); - if (matchesSourceKeyOrder && key === sourceKeys[index]) { - matchesReverseSuffix = false; - applied[index] = op; - continue; - } - matchesSourceKeyOrder = false; - if (matchesReverseSuffix && key === sourceKeys[sourceKeys.length - index - 1]) { - applied[index] = op; - continue; - } - matchesReverseSuffix = false; - if (removedKeys === null) { - removedKeys = Object.create(null) as Record; - for (let seenIndex = 0; seenIndex < index; seenIndex += 1) { - removedKeys[ops[seenIndex]!.path.slice(1)] = true; + const key = index === 0 ? firstKey : flatRootObjectKey(op); + if (key === null || op.op !== first.op) return { handled: false }; + + if (op.op === "add" && next !== null) { + if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; + if (key === "__proto__") { + Object.defineProperty(next, key, { value: op.value, enumerable: true, configurable: true, writable: true }); + } else { + next[key] = op.value; } - } - if (!objectHasOwn.call(source, key) || objectHasOwn.call(removedKeys, key)) return { handled: false }; - removedKeys[key] = true; - applied[index] = op; - } - - if (ops.length === sourceKeys.length) return { handled: true, state: {}, applied }; - const keepCount = sourceKeys.length - ops.length; - if (removedKeys === null || removedRootKeysMatchSuffix(sourceKeys, keepCount, removedKeys)) { - return { - handled: true, - state: copyRootObjectKeyPrefix(source, sourceKeys, keepCount), - applied, - }; - } - if (ops.length * 2 < sourceKeys.length) { - const next = copyRootObjectKeys(source, sourceKeys); - for (let index = 0; index < ops.length; index += 1) { - delete next[ops[index]!.path.slice(1)]; - } - return { handled: true, state: next, applied }; - } - - const next: Record = {}; - for (const key of sourceKeys) { - if (objectHasOwn.call(removedKeys, key)) continue; - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: source[key], - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = source[key]; - } - } - - return { handled: true, state: next, applied }; -} - -function applyRootObjectAddPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if ( - ops.length < 2 - || state === null - || typeof state !== "object" - || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "add") - ) return { handled: false }; - - let next: Record | null = null; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "add" - || typeof op.path !== "string" - || op.path === "" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - const key = op.path.slice(1); - if (next === null) next = copyRootObject(state as Record); - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); } else { - next[key] = op.value; + matchesKeyOrder &&= key === keys[index]; + matchesReverseSuffix &&= key === keys[keys.length - index - 1]; + // Ordered removals need neither membership checks nor a deletion set. + if (matchesKeyOrder || matchesReverseSuffix) continue; + removedKeys ??= new Set(ops.slice(0, index).map((seen) => seen.path.slice(1))); + if (!objectHasOwn.call(source, key) || removedKeys.has(key)) return { handled: false }; + removedKeys.add(key); } - applied[index] = op; } - return next === null - ? { handled: false } - : { handled: true, state: next, applied }; -} + const applied = ops.slice(); + if (next !== null) return { handled: true, state: next, applied }; -function applyRootObjectReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if ( - ops.length < 2 - || state === null - || typeof state !== "object" - || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "replace") - ) return { handled: false }; - - const source = state as Record; - const sourceKeys = Object.keys(source); - let matchesSourceKeyOrder = ops.length === sourceKeys.length; - const orderedNext: Record | null = matchesSourceKeyOrder ? {} : null; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "replace" - || typeof op.path !== "string" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - const key = op.path.slice(1); - if (matchesSourceKeyOrder) { - if (key !== "" && key === sourceKeys[index]) { - if (key === "__proto__") { - Object.defineProperty(orderedNext, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - orderedNext![key] = op.value; - } - applied[index] = op; - continue; - } - matchesSourceKeyOrder = false; - } - - if (key === "" || !objectHasOwn.call(state, key)) return { handled: false }; - applied[index] = op; + const keepCount = keys.length - ops.length; + if (removedKeys === null || keys.slice(keepCount).every((key) => removedKeys.has(key))) { + return { handled: true, state: copyRootObject(source, keys.slice(0, keepCount)), applied }; } - - if (matchesSourceKeyOrder && orderedNext !== null) return { handled: true, state: orderedNext, applied }; - - const next = copyRootObjectKeys(source, sourceKeys); - const replaceOps = ops as ReadonlyArray>; - for (let index = 0; index < replaceOps.length; index += 1) { - const op = replaceOps[index]!; - const key = op.path.slice(1); - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = op.value; - } + if (ops.length * 2 < keys.length) { + const retained = copyRootObject(source, keys); + for (const key of removedKeys) delete retained[key]; + return { handled: true, state: retained, applied }; } - return { handled: true, state: next, applied }; + return { + handled: true, + state: copyRootObject(source, keys.filter((key) => !removedKeys.has(key))), + applied, + }; } -function firstFlatRootObjectOperationIs( - ops: ReadonlyArray, - operation: "add" | "remove" | "replace", -): boolean { - if (!(0 in ops)) return false; - const first = ops[0]!; - return validateOperationShape(first) === null - && first.op === operation - && typeof first.path === "string" - && first.path.length > 1 - && first.path[0] === "/" - && !first.path.includes("~") - && first.path.indexOf("/", 1) === -1; +function flatRootObjectKey(op: JSONPatchOperation): string | null { + if ( + validateOperationShape(op) !== null + || op.path[0] !== "/" + || op.path.includes("~") + || op.path.indexOf("/", 1) !== -1 + ) return null; + return op.path.slice(1); } diff --git a/packages/json-document/src/foundation/patch/fast/array.ts b/packages/json-document/src/foundation/patch/fast/array.ts index ab90786d7..4a6d7ee26 100644 --- a/packages/json-document/src/foundation/patch/fast/array.ts +++ b/packages/json-document/src/foundation/patch/fast/array.ts @@ -1,663 +1,179 @@ import { jsonSerializableError } from "../../json/serializable.js"; import { cloneTrustedPlainJson } from "../../json/trusted-clone.js"; -import { appendSegment, type Pointer } from "../../pointer/core.js"; +import { appendSegment } from "../../pointer/core.js"; import { getValueAt, parseSafe } from "../container.js"; -import { appendArrayIndexPath, arrayLocation, arrayRemoveLocation } from "../path.js"; +import { arrayLocation } from "../path.js"; import { replaceValueAtSegments } from "../replace-value.js"; import { validateOperationShape } from "../apply.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; -type SameArrayStructuralItem = - | { op: "add"; path: Pointer; index: number | "-"; value: unknown } - | { op: "remove"; path: Pointer; index: number } - | { op: "copy"; from: Pointer; path: Pointer; fromIndex: number; index: number | "-" } - | { op: "move"; from: Pointer; path: Pointer; fromIndex: number; index: number | "-" }; - -export function applyAppendOnlyAddPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let appendPath: Pointer | null = null; - const values = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - op === null - || typeof op !== "object" - || op.op !== "add" - || typeof op.path !== "string" - || !("value" in op) - || !op.path.endsWith("/-") - ) { - return { handled: false }; - } - - if (appendPath === null) { - appendPath = op.path; - parent = op.path.slice(0, -2); - } else if (op.path !== appendPath) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - values[index] = op.value; - } - - if (parent === null) return { handled: false }; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - - const initialLength = current.value.length; - const stateWithArray = replaceValueAtSegments( - state, - parsedParent.segs, - 0, - current.value.concat(values), - ); - if (stateWithArray === null) return { handled: false }; - - const applied = new Array(values.length); - for (let index = 0; index < values.length; index += 1) { - applied[index] = { - op: "add", - path: appendArrayIndexPath(parent, initialLength + index), - value: values[index], - }; - } - - return { - handled: true, - state: stateWithArray, - applied, - }; -} - -export function applyTailRemovePatch( - state: unknown, - ops: ReadonlyArray, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let parentSegments: string[] | null = null; - let currentArray: unknown[] | null = null; - let initialLength = 0; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - op === null - || typeof op !== "object" - || op.op !== "remove" - || typeof op.path !== "string" - || op.path === "" - ) { - return { handled: false }; - } - - const location = arrayRemoveLocation(op.path); - if (location === null) return { handled: false }; - - if (parent === null) { - parent = location.parent; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value) || ops.length > current.value.length) { - return { handled: false }; - } - parentSegments = parsedParent.segs; - currentArray = current.value; - initialLength = current.value.length; - } else if (parent !== location.parent) { - return { handled: false }; - } - - if (location.index !== initialLength - index - 1) return { handled: false }; - applied[index] = { op: "remove", path: op.path }; - } - - if (parentSegments === null || currentArray === null) return { handled: false }; - const stateWithArray = replaceValueAtSegments( - state, - parentSegments, - 0, - currentArray.slice(0, initialLength - ops.length), - ); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} +type ArrayItem = + | { op: "add"; index: number; value: unknown } + | { op: "remove"; index: number } + | { op: "copy" | "move"; fromIndex: number; index: number }; +/** Prepare addresses and canonical operations once, then copy the array once. */ export function applySameArrayStructuralPatch( state: unknown, - ops: ReadonlyArray, + operations: ReadonlyArray, valuesTrusted = false, ): FastPatchResult { - if (ops.length < 1) return { handled: false }; - - const increasingAddFast = applyIncreasingArrayAddOpsPatch(state, ops, valuesTrusted); - if (increasingAddFast !== null) return increasingAddFast; - - let parent: string | null = null; - const items: SameArrayStructuralItem[] = []; - - for (let index = 0; index < ops.length; index++) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || ( - op.op !== "add" - && op.op !== "remove" - && op.op !== "copy" - && op.op !== "move" - ) - || op.path === "" - ) { - return { handled: false }; - } - const location = arrayLocation(op.path); - if (!location) return { handled: false }; - if (parent === null) { - parent = location.parent; - } else if (location.parent !== parent) { - return { handled: false }; - } - if (op.op === "add") { - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - items.push({ op: "add", path: op.path, index: location.index, value: op.value }); - } else if (op.op === "remove") { - if (location.index === "-") return { handled: false }; - items.push({ op: "remove", path: op.path, index: location.index }); - } else { - const fromLocation = arrayLocation(op.from); - if (!fromLocation || fromLocation.parent !== parent || fromLocation.index === "-") { - return { handled: false }; - } - items.push({ - op: op.op, - from: op.from, - path: op.path, - fromIndex: fromLocation.index, - index: location.index, - }); - } - } - - if (parent === null) return { handled: false }; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); + const first = operations[0]; + if (first === undefined || validateOperationShape(first) !== null) return { handled: false }; + const location = arrayLocation(first.path); + if (location === null) return { handled: false }; + const parent = location.parent; + const parsed = parseSafe(parent); + if (!("ok" in parsed)) return { handled: false }; + const current = getValueAt(state, parsed.segs); if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - const parsedIncreasingAddFast = applyIncreasingArrayAddPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (parsedIncreasingAddFast !== null) return parsedIncreasingAddFast; - - const nonDecreasingRemoveFast = applyNonDecreasingArrayRemovePatch( - state, - parsedParent.segs, - current.value, - items, - ); - if (nonDecreasingRemoveFast !== null) return nonDecreasingRemoveFast; - - const nonIncreasingAddFast = applyNonIncreasingArrayAddPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (nonIncreasingAddFast !== null) return nonIncreasingAddFast; - - const nonIncreasingCopyFast = applyNonIncreasingArrayCopyPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (nonIncreasingCopyFast !== null) return nonIncreasingCopyFast; - - const appendThenRemoveFast = applyAppendThenNonDecreasingRemovePatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (appendThenRemoveFast !== null) return appendThenRemoveFast; - - const single = applySingleStructuralItem(state, parent, parsedParent.segs, current.value, items); - if (single !== null) return single; - - const next = current.value.slice(); + const items: ArrayItem[] = []; const applied: JSONPatchOperation[] = []; - for (const item of items) { - if (item.op === "add") { - const index = item.index === "-" ? next.length : item.index; - if (index < 0 || index > next.length) return { handled: false }; - if (index === next.length) next.push(item.value); - else next.splice(index, 0, item.value); - applied.push({ op: "add", path: appendSegment(parent, index), value: item.value }); - continue; - } - - if (item.op === "remove") { - if (item.index < 0 || item.index >= next.length) return { handled: false }; - if (item.index === next.length - 1) next.pop(); - else next.splice(item.index, 1); - applied.push({ op: "remove", path: item.path }); - continue; - } - - if (item.op === "copy") { - if (item.fromIndex < 0 || item.fromIndex >= next.length) return { handled: false }; - const index = item.index === "-" ? next.length : item.index; - if (index < 0 || index > next.length) return { handled: false }; - const value = cloneTrustedPlainJson(next[item.fromIndex]); - if (index === next.length) next.push(value); - else next.splice(index, 0, value); - applied.push({ op: "copy", from: item.from, path: appendSegment(parent, index) }); - continue; - } - - if (item.fromIndex < 0 || item.fromIndex >= next.length) return { handled: false }; - if (item.index === "-") { - const [value] = next.splice(item.fromIndex, 1); - const index = next.length; - next.push(value); - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - continue; - } - - const index = item.index; - if (index < 0 || index >= next.length) return { handled: false }; - if (item.fromIndex === index) { - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - continue; - } - if (Math.abs(item.fromIndex - index) === 1) { - const value = next[item.fromIndex]; - next[item.fromIndex] = next[index]; - next[index] = value; - } else { - const [value] = next.splice(item.fromIndex, 1); - if (index < 0 || index > next.length) return { handled: false }; - next.splice(index, 0, value); - } - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - } - - const stateWithArray = replaceValueAtSegments(state, parsedParent.segs, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function applySingleStructuralItem( - state: unknown, - parent: string, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length !== 1) return null; - const item = items[0]!; - if (item.op === "add") { - const index = item.index === "-" ? current.length : item.index; - if (index < 0 || index > current.length) return { handled: false }; - if (index !== current.length) return null; - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.concat([item.value])); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied: [{ op: "add", path: appendSegment(parent, index), value: item.value }] }; - } - if (item.op === "remove") { - if (item.index < 0 || item.index >= current.length) return { handled: false }; - if (item.index !== current.length - 1) return null; - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.slice(0, item.index)); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied: [{ op: "remove", path: item.path }] }; - } - if (item.op !== "copy") return null; - if (item.fromIndex < 0 || item.fromIndex >= current.length) return { handled: false }; - const index = item.index === "-" ? current.length : item.index; - if (index < 0 || index > current.length) return { handled: false }; - if (index !== current.length) return null; - const value = cloneTrustedPlainJson(current[item.fromIndex]); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.concat([value])); - return stateWithArray === null - ? { handled: false } - : { - handled: true, - state: stateWithArray, - applied: [{ op: "copy", from: item.from, path: appendSegment(parent, index) }], - }; -} - -function applyIncreasingArrayAddOpsPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted: boolean, -): FastPatchResult | null { - if (ops.length < 2) return null; - const first = ops[0]; - if ( - first === undefined - || validateOperationShape(first) !== null - || first.op !== "add" - || first.path === "" - || first.path.endsWith("/-") - ) { - return null; - } - - const firstLocation = arrayLocation(first.path); - if (firstLocation === null || firstLocation.index === "-") return null; - - const parent = firstLocation.parent; - const start = firstLocation.index; - const values = new Array(ops.length); - const applied = new Array(ops.length); - - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; + let length = current.value.length; + for (let opIndex = 0; opIndex < operations.length; opIndex += 1) { + if (!(opIndex in operations)) return { handled: false }; + const operation = operations[opIndex]!; if ( - validateOperationShape(op) !== null - || op.op !== "add" - || op.path === "" - || op.path.endsWith("/-") - ) { - return null; + validateOperationShape(operation) !== null + || (operation.op !== "add" && operation.op !== "remove" && operation.op !== "copy" && operation.op !== "move") + ) return { handled: false }; + const target = operation.path === first.path ? location : arrayLocation(operation.path); + if (target === null || target.parent !== parent) return { handled: false }; + if (operation.op === "remove" && target.index === "-") return { handled: false }; + const lastIndex = length - (operation.op === "remove" || operation.op === "move" ? 1 : 0); + const index = target.index === "-" ? lastIndex : target.index; + if (index < 0 || index > lastIndex) return { handled: false }; + + if (operation.op === "add") { + if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return { handled: false }; + items.push({ op: "add", index, value: operation.value }); + length += 1; + } else if (operation.op === "remove") { + items.push({ op: "remove", index }); + length -= 1; + } else { + const from = arrayLocation(operation.from); + if (from === null || from.parent !== parent || from.index === "-" || from.index >= length) return { handled: false }; + items.push({ op: operation.op, index, fromIndex: from.index }); + if (operation.op === "copy") length += 1; } - - const location = arrayLocation(op.path); - if (location === null || location.index === "-" || location.parent !== parent) return null; - if (location.index !== start + index) return null; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - values[index] = op.value; - applied[index] = { - op: "add", - path: appendSegment(parent, location.index), - value: op.value, - }; + applied.push(target.index === "-" ? { ...operation, path: appendSegment(parent, index) } : operation); } - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - if (start < 0 || start > current.value.length) return { handled: false }; - - const next = start === current.value.length - ? current.value.concat(values) - : current.value.slice(0, start).concat(values, current.value.slice(start)); - const stateWithArray = replaceValueAtSegments(state, parsedParent.segs, 0, next); + const next = applyContiguousArrayAdd(current.value, items) + ?? applyNonIncreasingArrayInsert(current.value, items) + ?? applyAppendThenArrayRemove(current.value, items) + ?? applySequentialArrayItems(current.value, items); + const stateWithArray = replaceValueAtSegments(state, parsed.segs, 0, next); return stateWithArray === null ? { handled: false } : { handled: true, state: stateWithArray, applied }; } -function applyIncreasingArrayAddPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 1) return null; - - let start = -1; - const values = new Array(items.length); - const applied = new Array(items.length); - - for (let index = 0; index < items.length; index += 1) { - const item = items[index]!; - if (item.op !== "add" || item.index === "-") return null; - if (index === 0) { - start = item.index; - if (start < 0 || start > current.length) return { handled: false }; - } else if (item.index !== start + index) { - return null; - } - values[index] = item.value; - applied[index] = { - op: "add", - path: appendSegment(parent, start + index), - value: item.value, - }; +function applyContiguousArrayAdd( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { + const start = items[0]!.index; + const values: unknown[] = []; + for (const item of items) { + if (item.op !== "add" || item.index !== start + values.length) return null; + values.push(item.value); } - - const next = start === current.length + if (start === 0) return values.concat(current); + return start === current.length ? current.concat(values) : current.slice(0, start).concat(values, current.slice(start)); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; } -function applyNonIncreasingArrayAddPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - +function applyNonIncreasingArrayInsert( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { let previousIndex = Number.POSITIVE_INFINITY; - const buckets = new Array(current.length + 1); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "add" || item.index === "-") return null; - if (item.index > previousIndex) return null; - if (item.index < 0 || item.index > current.length) return { handled: false }; - - const bucket = buckets[item.index]; - if (bucket === undefined) buckets[item.index] = [item.value]; - else bucket.push(item.value); - applied[itemIndex] = { - op: "add", - path: appendSegment(parent, item.index), - value: item.value, - }; - previousIndex = item.index; - } - - return insertBuckets(state, parentSegments, current, buckets, items.length, applied); -} - -function applyNonIncreasingArrayCopyPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - - let previousIndex = Number.POSITIVE_INFINITY; - let previousMinimumInsertIndex = Number.POSITIVE_INFINITY; - const buckets = new Array(current.length + 1); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "copy" || item.index === "-") return null; - if (item.index > previousIndex) return null; - if (item.index < 0 || item.index > current.length) return { handled: false }; - if (item.fromIndex < 0 || item.fromIndex >= current.length) return { handled: false }; - if (item.fromIndex >= previousMinimumInsertIndex) return null; - - const value = cloneTrustedPlainJson(current[item.fromIndex]); - const bucket = buckets[item.index]; - if (bucket === undefined) buckets[item.index] = [value]; - else bucket.push(value); - applied[itemIndex] = { - op: "copy", - from: item.from, - path: appendSegment(parent, item.index), - }; - previousIndex = item.index; - if (item.index < previousMinimumInsertIndex) previousMinimumInsertIndex = item.index; - } - - return insertBuckets(state, parentSegments, current, buckets, items.length, applied); -} - -function insertBuckets( - state: unknown, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - buckets: ReadonlyArray, - insertCount: number, - applied: ReadonlyArray, -): FastPatchResult { - const next = new Array(current.length + insertCount); - let write = 0; - for (let index = 0; index <= current.length; index += 1) { - const bucket = buckets[index]; - if (bucket !== undefined) { - for (let bucketIndex = bucket.length - 1; bucketIndex >= 0; bucketIndex -= 1) { - next[write] = bucket[bucketIndex]; - write += 1; - } - } - if (index < current.length) { - next[write] = current[index]; - write += 1; - } - } - - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function applyNonDecreasingArrayRemovePatch( - state: unknown, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - - let previousIndex = -1; - const removedIndexes = new Array(items.length); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "remove") return null; - if (item.index < previousIndex) return null; - - const sourceIndex = item.index + itemIndex; - if (item.index < 0 || sourceIndex >= current.length) return { handled: false }; - removedIndexes[itemIndex] = sourceIndex; - applied[itemIndex] = { op: "remove", path: item.path }; + const values: unknown[] = []; + for (const item of items) { + if ((item.op !== "add" && item.op !== "copy") || item.index > previousIndex) return null; + // A copy can use the original array only while preceding insertions have + // not shifted its source. Otherwise the sequential executor resolves it. + if (item.op === "copy" && (item.fromIndex >= previousIndex || item.fromIndex >= current.length)) return null; + values.push(item.op === "add" ? item.value : cloneTrustedPlainJson(current[item.fromIndex])); previousIndex = item.index; } - const next = new Array(current.length - items.length); - let removeIndex = 0; + const next = new Array(current.length + items.length); + let read = 0; let write = 0; - for (let index = 0; index < current.length; index += 1) { - if (removeIndex < removedIndexes.length && index === removedIndexes[removeIndex]) { - removeIndex += 1; - continue; - } - next[write] = current[index]; - write += 1; + for (let index = items.length - 1; index >= 0; index -= 1) { + while (read < items[index]!.index) next[write++] = current[read++]; + next[write++] = values[index]; } - - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; + while (read < current.length) next[write++] = current[read++]; + return next; } -function applyAppendThenNonDecreasingRemovePatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - +function applyAppendThenArrayRemove( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { const values: unknown[] = []; const removedIndexes: number[] = []; - const applied = new Array(items.length); - let removing = false; - let previousRemoveIndex = -1; - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op === "add") { - if (removing) return null; - const expectedAppendIndex = current.length + values.length; - if (item.index !== "-" && item.index !== expectedAppendIndex) return null; + let previousIndex = -1; + let descending = false; + for (const item of items) { + if (item.op === "add" && removedIndexes.length === 0 && item.index === current.length + values.length) { values.push(item.value); - applied[itemIndex] = { - op: "add", - path: appendSegment(parent, expectedAppendIndex), - value: item.value, - }; continue; } - if (item.op !== "remove") return null; - removing = true; - if (item.index < previousRemoveIndex) return null; - const sourceIndex = item.index + removedIndexes.length; - if (item.index < 0 || sourceIndex >= current.length) return { handled: false }; + if (removedIndexes.length === 1) descending = item.index < previousIndex; + if (removedIndexes.length > 0 && (descending ? item.index >= previousIndex : item.index < previousIndex)) return null; + const sourceIndex = item.index + (descending ? 0 : removedIndexes.length); + if (sourceIndex >= current.length) return null; removedIndexes.push(sourceIndex); - applied[itemIndex] = { op: "remove", path: item.path }; - previousRemoveIndex = item.index; + previousIndex = item.index; } + if (descending) removedIndexes.reverse(); - if (values.length === 0 || removedIndexes.length === 0) return null; - - const next = new Array(current.length - removedIndexes.length + values.length); + const keepCount = current.length - removedIndexes.length; + if (removedIndexes[0] === keepCount) { + const prefix = current.slice(0, keepCount); + return values.length === 0 ? prefix : prefix.concat(values); + } + const next = new Array(keepCount + values.length); let removeIndex = 0; let write = 0; for (let index = 0; index < current.length; index += 1) { - if (removeIndex < removedIndexes.length && index === removedIndexes[removeIndex]) { - removeIndex += 1; - continue; - } - next[write] = current[index]; - write += 1; - } - for (let index = 0; index < values.length; index += 1) { - next[write] = values[index]; - write += 1; + if (index === removedIndexes[removeIndex]) removeIndex += 1; + else next[write++] = current[index]; } + for (const value of values) next[write++] = value; + return next; +} - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; +function applySequentialArrayItems( + current: unknown[], + items: ReadonlyArray, +): unknown[] { + const next = current.slice(); + for (const item of items) { + if (item.op === "remove") { + next.splice(item.index, 1); + } else if (item.op === "move") { + if (item.fromIndex === item.index) continue; + if (Math.abs(item.fromIndex - item.index) === 1) { + const value = next[item.fromIndex]; + next[item.fromIndex] = next[item.index]; + next[item.index] = value; + } else { + const [value] = next.splice(item.fromIndex, 1); + next.splice(item.index, 0, value); + } + } else { + const value = item.op === "add" ? item.value : cloneTrustedPlainJson(next[item.fromIndex]); + next.splice(item.index, 0, value); + } + } + return next; } diff --git a/packages/json-document/src/foundation/patch/fast/replace.ts b/packages/json-document/src/foundation/patch/fast/replace.ts deleted file mode 100644 index a3c4c73f0..000000000 --- a/packages/json-document/src/foundation/patch/fast/replace.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { replaceArrayIndex } from "../../json/shared-array.js"; -import { jsonSerializableError } from "../../json/serializable.js"; -import type { Pointer } from "../../pointer/core.js"; -import { getValueAt, parseSafe } from "../container.js"; -import { objectHasOwn } from "../object.js"; -import { - arrayRemoveLocation, - arrayFieldText, - indexDirection, - parseArrayFieldPath, - parseFirstArrayNestedPath, - parseKnownArrayNestedIndex, - parseKnownArrayFieldIndex, -} from "../path.js"; -import { replaceValueAtSegments } from "../replace-value.js"; -import { validateOperationShape } from "../apply.js"; -import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; -import type { ArrayFieldPath, ArrayFieldText } from "../path.js"; - -interface ReplaceTree { - value?: unknown; - children: Map; -} - -export function applySameArrayFieldReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let arrayPath: Pointer | null = null; - let arraySegments: string[] | null = null; - let field: string | null = null; - let fieldText: ArrayFieldText | null = null; - let arrayValue: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - let previousUpdateIndex: number | null = null; - let monotonicDirection: -1 | 0 | 1 = 0; - let hasRepeatedOrNonMonotonicIndex = false; - - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return { handled: false }; - const op = ops[opIndex]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const knownIndex = fieldText === null ? null : parseKnownArrayFieldIndex(op.path, fieldText); - let location: ArrayFieldPath | null; - if (knownIndex === null) { - location = parseArrayFieldPath(op.path); - } else { - if (arrayPath === null || field === null) return { handled: false }; - location = { arrayPath, index: knownIndex, key: field }; - } - if (location === null) return { handled: false }; - if (field === null) { - field = location.key; - fieldText = arrayFieldText(op.path); - } else if (field !== location.key) return { handled: false }; - - if (arrayValue === null) { - arrayPath = location.arrayPath; - const parsedArray = parseSafe(arrayPath); - if (!("ok" in parsedArray)) return { handled: false }; - arraySegments = parsedArray.segs; - const current = getValueAt(state, arraySegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - arrayValue = current.value; - } else if (arrayPath !== location.arrayPath) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - if (arrayValue === null || location.index < 0 || location.index >= arrayValue.length) return { handled: false }; - const row = arrayValue[location.index]; - if (row === null || typeof row !== "object" || Array.isArray(row)) return { handled: false }; - if (!objectHasOwn.call(row, location.key)) return { handled: false }; - if (previousUpdateIndex !== null) { - const direction = indexDirection(previousUpdateIndex, location.index); - if (direction === 0) { - hasRepeatedOrNonMonotonicIndex = true; - } else if (monotonicDirection === 0) { - monotonicDirection = direction; - } else if (direction !== monotonicDirection) { - hasRepeatedOrNonMonotonicIndex = true; - } - } - previousUpdateIndex = location.index; - updateIndexes[opIndex] = location.index; - updateValues[opIndex] = op.value; - applied[opIndex] = op; - } - - if (arraySegments === null || field === null || arrayValue === null) return { handled: false }; - const next = applyIndexedReplacements(arrayValue, updateIndexes, updateValues, (source, rowIndex, value) => ( - replaceRowField(source, rowIndex, field, value) - ), hasRepeatedOrNonMonotonicIndex); - const stateWithArray = replaceValueAtSegments(state, arraySegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function replaceRowField( - source: unknown[], - rowIndex: number, - field: string, - value: unknown, -): unknown { - const row = source[rowIndex] as Record; - const replaced = { ...row }; - if (field === "__proto__") { - Object.defineProperty(replaced, field, { - value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - replaced[field] = value; - } - return replaced; -} - -export function applySameArrayNestedReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let arrayPath: Pointer | null = null; - let arraySegments: string[] | null = null; - let prefixText: string | null = null; - let suffixText: string | null = null; - let suffixSegments: string[] | null = null; - let arrayValue: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return { handled: false }; - const op = ops[opIndex]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - let rowIndex: number; - if (arrayPath === null) { - const location = parseFirstArrayNestedPath(state, op.path); - if (location === null) return { handled: false }; - arrayPath = location.arrayPath; - arraySegments = location.arraySegments; - prefixText = location.prefixText; - suffixText = location.suffixText; - suffixSegments = location.suffixSegments; - const current = getValueAt(state, location.arraySegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - arrayValue = current.value; - rowIndex = location.index; - } else { - if (suffixSegments === null || prefixText === null || suffixText === null) return { handled: false }; - const parsedIndex = parseKnownArrayNestedIndex( - op.path, - arrayPath, - suffixSegments, - prefixText, - suffixText, - ); - if (parsedIndex === null) return { handled: false }; - rowIndex = parsedIndex; - } - - if (arrayValue === null || rowIndex < 0 || rowIndex >= arrayValue.length) return { handled: false }; - updateIndexes[opIndex] = rowIndex; - updateValues[opIndex] = op.value; - applied[opIndex] = op; - } - - if (arraySegments === null || suffixSegments === null || arrayValue === null) return { handled: false }; - const replacedRows: unknown[] = []; - for (let index = 0; index < ops.length; index += 1) { - const replaced = replaceValueAtSegments(arrayValue[updateIndexes[index]!], suffixSegments, 0, updateValues[index]); - if (replaced === null) return { handled: false }; - replacedRows[index] = replaced; - } - const next = applyIndexedReplacements(arrayValue, updateIndexes, replacedRows, (source, rowIndex, value) => value); - const stateWithArray = replaceValueAtSegments(state, arraySegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -export function applySameArrayElementReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let parentSegments: string[] | null = null; - let currentArray: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const location = arrayRemoveLocation(op.path); - if (location === null) return { handled: false }; - if (parent === null) { - parent = location.parent; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - parentSegments = parsedParent.segs; - const current = getValueAt(state, parentSegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - currentArray = current.value; - } else if (parent !== location.parent) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - if (currentArray === null || location.index < 0 || location.index >= currentArray.length) return { handled: false }; - updateIndexes[index] = location.index; - updateValues[index] = op.value; - applied[index] = op; - } - - if (parentSegments === null || currentArray === null) return { handled: false }; - const next = applyIndexedReplacements(currentArray, updateIndexes, updateValues, (_source, _rowIndex, value) => value); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -export function applyIndependentReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - const items: Array<{ op: JSONPatchOperation; path: Pointer; segments: string[]; value: unknown }> = []; - for (let index = 0; index < ops.length; index++) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const parsed = parseSafe(op.path); - if (!("ok" in parsed)) return { handled: false }; - if (!getValueAt(state, parsed.segs).ok) return { handled: false }; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - items.push({ op, path: op.path, segments: parsed.segs, value: op.value }); - } - - if (!hasIndependentPaths(items)) return { handled: false }; - return { handled: true, state: applyReplaceTree(state, buildReplaceTree(items)), applied: items.map((item) => item.op) }; -} - -function buildReplaceTree(items: ReadonlyArray<{ segments: string[]; value: unknown }>): ReplaceTree { - const root: ReplaceTree = { children: new Map() }; - for (const item of items) { - let node = root; - for (const segment of item.segments) { - let child = node.children.get(segment); - if (!child) { - child = { children: new Map() }; - node.children.set(segment, child); - } - node = child; - } - node.value = item.value; - } - return root; -} - -function applyIndexedReplacements( - source: unknown[], - indexes: ReadonlyArray, - values: ReadonlyArray, - replace: (source: unknown[], rowIndex: number, value: unknown) => unknown, - lastWriteWins = false, -): unknown[] { - let next: unknown[] = source; - const seen = new Set(); - const start = lastWriteWins ? indexes.length - 1 : 0; - const step = lastWriteWins ? -1 : 1; - for (let index = start; lastWriteWins ? index >= 0 : index < indexes.length; index += step) { - const rowIndex = indexes[index]!; - if (lastWriteWins && seen.has(rowIndex)) continue; - seen.add(rowIndex); - next = replaceArrayIndex(next, rowIndex, replace(source, rowIndex, values[index])); - } - return next; -} - -function applyReplaceTree(value: unknown, tree: ReplaceTree): unknown { - if (tree.children.size === 0) return tree.value; - if (Array.isArray(value)) { - let next: unknown[] = value; - for (const [segment, child] of tree.children) { - const index = Number(segment); - next = replaceArrayIndex(next, index, applyReplaceTree( - Array.isArray(next) ? next[index] : undefined, - child, - )); - } - return next; - } - const next = { ...(value as Record) }; - for (const [segment, child] of tree.children) { - next[segment] = applyReplaceTree(next[segment], child); - } - return next; -} - -function hasIndependentPaths(paths: ReadonlyArray<{ path: string }>): boolean { - const sorted = paths.map((item) => item.path).sort(); - for (let index = 1; index < sorted.length; index++) { - const previous = sorted[index - 1]!; - const current = sorted[index]!; - if (current === previous || current.startsWith(`${previous}/`)) return false; - } - return true; -} diff --git a/packages/json-document/src/foundation/patch/object.ts b/packages/json-document/src/foundation/patch/object.ts index 77a364c84..2bae975da 100644 --- a/packages/json-document/src/foundation/patch/object.ts +++ b/packages/json-document/src/foundation/patch/object.ts @@ -1,32 +1,11 @@ export const objectHasOwn = Object.prototype.hasOwnProperty; -export function copyRootObject(source: Record): Record { - return copyRootObjectKeys(source, Object.keys(source)); -} - -export function copyRootObjectKeys( +export function copyRootObject( source: Record, - keys: ReadonlyArray, -): Record { - return copyRootObjectKeyPrefix(source, keys, keys.length); -} - -export function copyRootObjectKeyPrefix( - source: Record, - keys: ReadonlyArray, - end: number, + keys: ReadonlyArray = Object.keys(source), ): Record { const next: Record = {}; - if (!objectHasOwn.call(source, "__proto__")) { - for (let index = 0; index < end; index += 1) { - const key = keys[index]!; - next[key] = source[key]; - } - return next; - } - - for (let index = 0; index < end; index += 1) { - const key = keys[index]!; + for (const key of keys) { if (key !== "__proto__") { next[key] = source[key]; continue; @@ -40,14 +19,3 @@ export function copyRootObjectKeyPrefix( } return next; } - -export function removedRootKeysMatchSuffix( - keys: ReadonlyArray, - keepCount: number, - removedKeys: Record, -): boolean { - for (let index = keepCount; index < keys.length; index += 1) { - if (!objectHasOwn.call(removedKeys, keys[index]!)) return false; - } - return true; -} diff --git a/packages/json-document/src/foundation/patch/path.ts b/packages/json-document/src/foundation/patch/path.ts index 417265647..ed2ec5d2f 100644 --- a/packages/json-document/src/foundation/patch/path.ts +++ b/packages/json-document/src/foundation/patch/path.ts @@ -1,170 +1,11 @@ -import { buildPointer, parentPointer, type Pointer } from "../pointer/core.js"; +import type { Pointer } from "../pointer/core.js"; import { parseArrayIndex } from "../pointer/array-index.js"; -import { getValueAt, parseSafe } from "./container.js"; - -export interface ArrayFieldPath { - arrayPath: Pointer; - index: number; - key: string; -} - -interface ArrayNestedPath { - arrayPath: Pointer; - arraySegments: string[]; - index: number; - prefixText: string; - suffixText: string; - suffixSegments: string[]; -} - -export interface ArrayFieldText { - prefixText: string; - suffixText: string; -} export function arrayLocation(path: Pointer): { parent: Pointer; index: number | "-" } | null { - const parent = parentPointer(path); - if (parent === null) return null; - const parsed = parseSafe(path); - if (!("ok" in parsed)) return null; - const segment = parsed.segs[parsed.segs.length - 1]; - if (segment === undefined) return null; + if (path[0] !== "/") return null; + const slash = path.lastIndexOf("/"); + const parent = path.slice(0, slash); + const segment = path.slice(slash + 1); const index = segment === "-" ? "-" : parseArrayIndex(segment); return index === null ? null : { parent, index }; } - -export function arrayRemoveLocation(path: Pointer): { parent: Pointer; index: number } | null { - const simple = parseSimpleArrayElementPath(path); - if (simple !== null) return simple; - - const location = arrayLocation(path); - return location === null || location.index === "-" - ? null - : { parent: location.parent, index: location.index }; -} - -export function appendArrayIndexPath(parent: Pointer, index: number): Pointer { - return parent === "" ? `/${index}` : `${parent}/${index}`; -} - -export function indexDirection(previous: number, current: number): -1 | 0 | 1 { - return current > previous ? 1 : current < previous ? -1 : 0; -} - -export function parseArrayFieldPath(path: Pointer): ArrayFieldPath | null { - const simple = parseSimpleArrayFieldPath(path); - if (simple !== null) return simple; - - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < 2) return null; - const key = parsed.segs[parsed.segs.length - 1]!; - const index = parseArrayIndex(parsed.segs[parsed.segs.length - 2]!); - return index === null - ? null - : { arrayPath: buildPointer(parsed.segs.slice(0, -2)), index, key }; -} - -export function arrayFieldText(path: Pointer): ArrayFieldText | null { - const keySlash = path.lastIndexOf("/"); - if (keySlash <= 0) return null; - const indexSlash = path.lastIndexOf("/", keySlash - 1); - return indexSlash < 0 - ? null - : { - prefixText: path.slice(0, indexSlash + 1), - suffixText: path.slice(keySlash), - }; -} - -export function parseKnownArrayFieldIndex(path: Pointer, text: ArrayFieldText): number | null { - if (!path.startsWith(text.prefixText) || !path.endsWith(text.suffixText)) return null; - const indexEnd = path.length - text.suffixText.length; - const indexText = path.slice(text.prefixText.length, indexEnd); - return indexText.includes("/") ? null : parseArrayIndex(indexText); -} - -export function parseFirstArrayNestedPath(state: unknown, path: Pointer): ArrayNestedPath | null { - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < 3) return null; - - for (let index = 0; index < parsed.segs.length - 1; index += 1) { - const rowIndex = parseArrayIndex(parsed.segs[index]!); - if (rowIndex === null) continue; - - const arraySegments = parsed.segs.slice(0, index); - const current = getValueAt(state, arraySegments); - if (!current.ok || !Array.isArray(current.value)) continue; - - const arrayPath = buildPointer(arraySegments); - const suffixSegments = parsed.segs.slice(index + 1); - return { - arrayPath, - arraySegments, - index: rowIndex, - prefixText: arrayPath === "" ? "/" : `${arrayPath}/`, - suffixText: buildPointer(suffixSegments), - suffixSegments, - }; - } - - return null; -} - -export function parseKnownArrayNestedIndex( - path: Pointer, - arrayPath: Pointer, - suffixSegments: string[], - prefixText: string, - suffixText: string, -): number | null { - const knownIndex = parseKnownArrayNestedIndexText(path, prefixText, suffixText); - if (knownIndex !== null) return knownIndex; - - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < suffixSegments.length + 2) return null; - - const arraySegmentsLength = parsed.segs.length - suffixSegments.length - 1; - for (let index = 0; index < suffixSegments.length; index += 1) { - if (parsed.segs[arraySegmentsLength + 1 + index] !== suffixSegments[index]) return null; - } - - const arraySegments = parsed.segs.slice(0, arraySegmentsLength); - if (buildPointer(arraySegments) !== arrayPath) return null; - - return parseArrayIndex(parsed.segs[arraySegmentsLength]!); -} - -function parseKnownArrayNestedIndexText( - path: Pointer, - prefixText: string, - suffixText: string, -): number | null { - if (!path.startsWith(prefixText) || !path.endsWith(suffixText)) return null; - const indexEnd = path.length - suffixText.length; - const indexText = path.slice(prefixText.length, indexEnd); - return indexText.includes("/") ? null : parseArrayIndex(indexText); -} - -function parseSimpleArrayFieldPath(path: Pointer): ArrayFieldPath | null { - if (path === "" || path[0] !== "/" || path.includes("~")) return null; - const keySlash = path.lastIndexOf("/"); - if (keySlash <= 0) return null; - const indexSlash = path.lastIndexOf("/", keySlash - 1); - if (indexSlash < 0) return null; - - const index = parseArrayIndex(path.slice(indexSlash + 1, keySlash)); - if (index === null) return null; - - return { arrayPath: path.slice(0, indexSlash), index, key: path.slice(keySlash + 1) }; -} - -function parseSimpleArrayElementPath(path: Pointer): { parent: Pointer; index: number } | null { - if (path === "" || path[0] !== "/" || path.includes("~")) return null; - const indexSlash = path.lastIndexOf("/"); - if (indexSlash < 0) return null; - - const index = parseArrayIndex(path.slice(indexSlash + 1)); - return index === null - ? null - : { parent: path.slice(0, indexSlash), index }; -} diff --git a/packages/json-document/src/foundation/patch/sequential-replace.ts b/packages/json-document/src/foundation/patch/sequential-replace.ts index 0afd16111..187662555 100644 --- a/packages/json-document/src/foundation/patch/sequential-replace.ts +++ b/packages/json-document/src/foundation/patch/sequential-replace.ts @@ -1,4 +1,5 @@ import { jsonSerializableError } from "../json/serializable.js"; +import { replaceArrayIndex } from "../json/shared-array.js"; import { parseArrayIndex } from "../pointer/array-index.js"; import { validateOperationShape } from "./apply.js"; import { parseSafe } from "./container.js"; @@ -7,16 +8,6 @@ import { objectHasOwn } from "./object.js"; type ReplaceOperation = Extract; -interface PreparedSequentialReplace { - operation: ReplaceOperation; - segments: string[]; -} - -interface SequentialReplaceRun { - state: unknown; - applied: ReplaceOperation[]; -} - /** * Applies a multi-operation, non-root replace batch through one private COW * draft. Unsupported or invalid batches decline so the reference executor @@ -27,53 +18,36 @@ export function applySequentialReplacePatch( operations: ReadonlyArray, valuesTrusted = false, ): FastPatchResult { - const run = runSequentialReplaceBatch(state, operations, valuesTrusted); - return run === null - ? { handled: false } - : { handled: true, state: run.state, applied: run.applied }; -} - -function runSequentialReplaceBatch( - state: unknown, - operations: ReadonlyArray, - valuesTrusted: boolean, -): SequentialReplaceRun | null { - if (operations.length < 2) return null; + if (operations.length < 2) return { handled: false }; - const prepared = new Array(operations.length); const applied = new Array(operations.length); + const draftContainers = new WeakSet(); + let draft = state; for (let index = 0; index < operations.length; index += 1) { - if (!(index in operations)) return null; + if (!(index in operations)) return { handled: false }; const operation = operations[index]!; if ( validateOperationShape(operation) !== null || operation.op !== "replace" - || operation.path === "" + || operation.path[0] !== "/" ) { - return null; + return { handled: false }; } - if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return null; + if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return { handled: false }; const parsed = parseSafe(operation.path); - if (!("ok" in parsed)) return null; - prepared[index] = { operation, segments: parsed.segs }; - applied[index] = operation; - } - - const draftContainers = new WeakSet(); - let draft = state; - for (let index = 0; index < prepared.length; index += 1) { - const item = prepared[index]!; + if (!("ok" in parsed)) return { handled: false }; const replaced = replaceDraftValue( draft, - item.segments, - item.operation, + parsed.segs, + operation, draftContainers, ); - if (replaced === null) return null; + if (replaced === null) return { handled: false }; draft = replaced; + applied[index] = operation; } - return { state: draft, applied }; + return { handled: true, state: draft, applied }; } function replaceDraftValue( @@ -82,42 +56,41 @@ function replaceDraftValue( operation: ReplaceOperation, draftContainers: WeakSet, ): unknown | null { - if (segments.length === 0) return null; - const root = ensureDraftContainer(state, draftContainers); - if (root === null) return null; - - let current = root; - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index]!; - const child = readDraftChild(current, segment); + const parents: Array<{ container: DraftContainer; key: number | string; value: unknown }> = []; + let current = state; + for (const segment of segments) { + if (current === null || typeof current !== "object") return null; + const container = current as DraftContainer; + const child = readDraftChild(container, segment); if (!child.ok) return null; - const childDraft = ensureDraftContainer(child.value, draftContainers); - if (childDraft === null) return null; - if (childDraft !== child.value) writeDraftChild(current, child.key, childDraft); - current = childDraft; + parents.push({ container, key: child.key, value: child.value }); + current = child.value; } - const target = readDraftChild(current, segments[segments.length - 1]!); - if (!target.ok) return null; - writeDraftChild(current, target.key, operation.value); - return root; + let next = operation.value; + for (let index = parents.length - 1; index >= 0; index -= 1) { + const { container, key, value } = parents[index]!; + if (value === next) { + next = container; + } else if (Array.isArray(container)) { + next = replaceArrayIndex(container, key as number, next); + } else { + const draft = draftContainers.has(container) ? container : { ...container }; + draftContainers.add(draft); + Object.defineProperty(draft, key, { + value: next, + enumerable: true, + configurable: true, + writable: true, + }); + next = draft; + } + } + return next; } type DraftContainer = unknown[] | Record; -function ensureDraftContainer( - value: unknown, - draftContainers: WeakSet, -): DraftContainer | null { - if (value === null || typeof value !== "object") return null; - if (draftContainers.has(value)) return value as DraftContainer; - const draft: DraftContainer = Array.isArray(value) - ? value.slice() - : { ...(value as Record) }; - draftContainers.add(draft); - return draft; -} - function readDraftChild( container: DraftContainer, segment: string, @@ -131,25 +104,3 @@ function readDraftChild( if (!objectHasOwn.call(container, segment)) return { ok: false }; return { ok: true, key: segment, value: container[segment] }; } - -function writeDraftChild( - container: DraftContainer, - key: number | string, - value: unknown, -): void { - if (Array.isArray(container)) { - container[key as number] = value; - return; - } - const property = key as string; - if (property === "__proto__") { - Object.defineProperty(container, property, { - value, - enumerable: true, - configurable: true, - writable: true, - }); - return; - } - container[property] = value; -} diff --git a/packages/json-document/src/foundation/patch/trusted.ts b/packages/json-document/src/foundation/patch/trusted.ts index f1b91a291..c582834ff 100644 --- a/packages/json-document/src/foundation/patch/trusted.ts +++ b/packages/json-document/src/foundation/patch/trusted.ts @@ -1,7 +1,7 @@ import { jsonSerializableError } from "../json/serializable.js"; import { applyOpRaw, validateOperationPointers, validateOperationShape } from "./apply.js"; import { normalizeAppliedOp, normalizeOp } from "./container.js"; -import { validatedStrategies, applyFastPatchStrategies, trustedStrategies } from "./fast/apply.js"; +import { applyFastPatchStrategies } from "./fast/apply.js"; import { fail, ok } from "./result.js"; import { applyTrustedValueMutation } from "./value.js"; import type { @@ -20,7 +20,7 @@ export function applyTrustedPatch( const singleValueFast = applySingleTrustedValuePatch(state, ops, valuesTrusted); if (singleValueFast !== null) return singleValueFast as TrustedApplyResult; - const fast = applyFastPatchStrategies(state, ops, trustedStrategies, valuesTrusted); + const fast = applyFastPatchStrategies(state, ops, valuesTrusted); if (fast !== null) return { state: fast.state as T, result: ok, applied: fast.applied }; let cur: unknown = state; @@ -52,23 +52,6 @@ export function applyTrustedPatch( return { state: cur as T, result: ok, applied: normalized }; } -export function applyValidatedPatch( - state: T, - ops: ReadonlyArray, -): TrustedApplyResult { - if (!Array.isArray(ops)) return { state, result: fail("invalid_pointer", "patch must be an array"), applied: [] }; - - if (ops.length === 1 && 0 in ops) { - const single = applyValidatedSingleTrustedValuePatch(state, ops[0]!); - if (single !== null) return single as TrustedApplyResult; - } - - const fast = applyFastPatchStrategies(state, ops, validatedStrategies, true); - if (fast !== null) return { state: fast.state as T, result: ok, applied: fast.applied }; - - return applyTrustedPatch(state, ops, { valuesTrusted: true }); -} - function applySingleTrustedValuePatch( state: unknown, ops: ReadonlyArray, @@ -102,27 +85,3 @@ function applySingleTrustedValuePatch( return { state: applied.state, result: ok, applied: [normalized] }; } - -function applyValidatedSingleTrustedValuePatch( - state: unknown, - op: JSONPatchOperation, -): TrustedApplyResult | null { - if (op === null || typeof op !== "object" || (op.op !== "add" && op.op !== "replace") || typeof op.path !== "string" || !("value" in op)) { - return null; - } - const pointerError = validateOperationPointers(op); - if (pointerError) { - return { - state, - result: fail(pointerError.error, `op[0]: ${pointerError.reason}`, pointerError.pointer), - applied: [], - }; - } - const normalized = op.op === "add" && op.path.endsWith("/-") ? normalizeOp(op, state) : op; - if (normalized.op !== "add" && normalized.op !== "replace") return null; - const applied = applyTrustedValueMutation(state, normalized); - if ("error" in applied) { - return { state, result: fail(applied.error, applied.reason ? `op[0]: ${applied.reason}` : "op[0]", applied.pointer), applied: [] }; - } - return { state: applied.state, result: ok, applied: [normalized] }; -} diff --git a/packages/json-document/src/foundation/patch/value.ts b/packages/json-document/src/foundation/patch/value.ts index eabcbdf12..0e80fbcdd 100644 --- a/packages/json-document/src/foundation/patch/value.ts +++ b/packages/json-document/src/foundation/patch/value.ts @@ -46,18 +46,7 @@ function applySingleSegmentTrustedValueMutation( if (op.op === "replace" && !objectHasOwn.call(state, key)) { return { error: "path_not_found", reason: `object key: ${key}`, pointer: op.path }; } - const next = { ...(state as Record) }; - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = op.value; - } - return { state: next }; + return { state: { ...(state as Record), [key]: op.value } }; } const verb = op.op === "add" ? "set" : "replace"; diff --git a/packages/json-document/src/foundation/protocol/apply.ts b/packages/json-document/src/foundation/protocol/apply.ts index b8f9c8dc6..a72c6cbae 100644 --- a/packages/json-document/src/foundation/protocol/apply.ts +++ b/packages/json-document/src/foundation/protocol/apply.ts @@ -3,13 +3,10 @@ import { cloneTrustedPlainJson, } from "../json/index.js"; import type { JSONPatchOperation as AppliedPatchOperation } from "../patch/contract.js"; -import { - applyValidatedPatch, - applyTrustedPatch, -} from "../patch/trusted.js"; +import { applyTrustedPatch } from "../patch/trusted.js"; import { parseArrayIndex } from "../pointer/array-index.js"; -import { parsePointer } from "../pointer/core.js"; -import { isSharedArray } from "../json/shared-array.js"; +import { parentPointer, parsePointer, readAt } from "../pointer/core.js"; +import { getSharedArrayOverlay } from "../json/shared-array.js"; import type { JSONAppliedChange, JSONPatchFailure, @@ -38,16 +35,7 @@ export function applyProtocolPatch( operations as ReadonlyArray, ); if (!result.result.ok) { - return freezeFailure({ - ok: false, - code: result.result.code, - ...(result.result.reason === undefined - ? {} - : { reason: result.result.reason }), - ...(result.result.pointer === undefined - ? {} - : { pointer: result.result.pointer }), - }); + return freezeFailure(result.result); } const ownedValue = operations.length === 0 @@ -75,16 +63,7 @@ export function applyOwnedProtocolPatch( operations as ReadonlyArray, ); if (!prepared.result.ok) { - return freezeFailure({ - ok: false, - code: prepared.result.code, - ...(prepared.result.reason === undefined - ? {} - : { reason: prepared.result.reason }), - ...(prepared.result.pointer === undefined - ? {} - : { pointer: prepared.result.pointer }), - }); + return freezeFailure(prepared.result); } // Canonical operations own their payloads. Replaying only these validated @@ -97,7 +76,7 @@ export function applyOwnedProtocolPatch( && typeof operation.value === "object" )); const validated = replayRequired - ? applyValidatedPatch(value, applied as ReadonlyArray) + ? applyTrustedPatch(value, applied as ReadonlyArray, { valuesTrusted: true }) : prepared; const ownedValue = validated.result.ok ? freezeOwnedState(validated.state as JSONValue, applied) @@ -166,7 +145,9 @@ function freezeAlongOperations( value: JSONValue, operations: ReadonlyArray, ): boolean { - const paths: string[][] = []; + const containers = new Set(); + const parent = parentPointer(operations.find((operation) => operation.op !== "test")?.path ?? ""); + const sameParent = operations.every((operation) => operation.op === "test" || parentPointer(operation.path) === parent); for (const operation of operations) { if (operation.op === "test") continue; if ( @@ -175,29 +156,35 @@ function freezeAlongOperations( ) { return false; } + let segments: string[]; try { - paths.push(parsePointer(operation.path)); + segments = parsePointer(operation.path); } catch { return false; } + // Array insertion/removal may shift another operation's final address. + // Sibling-only writes are safe: every inserted/replaced payload is owned. + if (operation.op !== "replace" && !sameParent) { + const target = readAt(value, segments.slice(0, -1)); + if (target.ok && Array.isArray(target.value)) return false; + } + if (!freezeAlongPath(value, segments, containers)) return false; } - for (const segments of paths) { - if (!freezeAlongPath(value, segments)) return false; - } - if (value !== null && typeof value === "object" && !isSharedArray(value) && !Object.isFrozen(value)) { + if (value !== null && typeof value === "object") containers.add(value); + // Freeze each shared ancestor once, after all paths succeed. A fallback must + // never mistake a partially frozen ancestor for a fully frozen subtree. + for (const container of containers) { freezeInspections += 1; - Object.freeze(value); + if (!getSharedArrayOverlay(container) && !Object.isFrozen(container)) Object.freeze(container); } return true; } -function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): boolean { - const stack: object[] = []; +function freezeAlongPath(root: JSONValue, segments: ReadonlyArray, containers: Set): boolean { let current: JSONValue = root; for (const segment of segments) { if (current === null || typeof current !== "object") return false; - freezeInspections += 1; - stack.push(current); + containers.add(current); if (Array.isArray(current)) { const index = parseArrayIndex(segment); if (index === null || index >= current.length) return false; @@ -211,17 +198,19 @@ function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): bool } } freezeJSON(current); - for (let index = stack.length - 1; index >= 0; index -= 1) { - const container = stack[index]!; - if (!isSharedArray(container) && !Object.isFrozen(container)) Object.freeze(container); - } return true; } function freezeJSON(value: T): T { if (value === null || typeof value !== "object") return value; freezeInspections += 1; - if (isSharedArray(value) || Object.isFrozen(value)) return value; + const overlay = getSharedArrayOverlay(value); + if (overlay !== undefined) { + freezeJSON(overlay.base as JSONValue); + for (const child of overlay.replacements.values()) freezeJSON(child as JSONValue); + return value; + } + if (Object.isFrozen(value)) return value; for (const child of Object.values(value)) freezeJSON(child as JSONValue); Object.freeze(value); return value; diff --git a/packages/json-document/tests/conformance/json-document.test.ts b/packages/json-document/tests/conformance/json-document.test.ts index bcdba4b7e..852ffc17e 100644 --- a/packages/json-document/tests/conformance/json-document.test.ts +++ b/packages/json-document/tests/conformance/json-document.test.ts @@ -408,6 +408,27 @@ test("a leaf replace keeps unrelated sibling identity and does not emit a root r change: { applied: [] }, }); }); +test.each([ + { operations: [ + { op: "replace", path: "/row/text", value: "temporary" }, + { op: "test", path: "/row/text", value: "temporary" }, + { op: "replace", path: "/row/text", value: "before" }, + ] }, + { operations: [ + { op: "replace", path: "/row", value: { transient: 0 } }, + { op: "replace", path: "/row/transient", value: 1 }, + { op: "replace", path: "/row", value: { text: "before" } }, + ] }, +] satisfies Array<{ operations: JSONPatchOperation[] }>)("canceling replacements compare final values at overlapping paths (%#)", ({ operations }) => { + const document = createJSONDocument({ row: { text: "before" } }); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: [] } }); + expect(document.value).toBe(before); + expect(notifications).toBe(0); +}); + test("an equivalent object add stays a no-op while an array add remains a change", () => { const document = createJSONDocument({ item: { title: "Draft" }, values: ["same"] }); const notifications: unknown[] = []; diff --git a/packages/json-document/tests/foundation/array-patch.test.ts b/packages/json-document/tests/foundation/array-patch.test.ts new file mode 100644 index 000000000..204df9a20 --- /dev/null +++ b/packages/json-document/tests/foundation/array-patch.test.ts @@ -0,0 +1,105 @@ +import { applyPatch, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { expect, test } from "vitest"; + +test.each([ + { name: "repeated insertions", operations: [ + { op: "add", path: "/items/2", value: "a" }, + { op: "add", path: "/items/2", value: "b" }, + ], expected: [0, 1, "b", "a", 2, 3, 4] }, + { name: "descending mixed insertions", operations: [ + { op: "add", path: "/items/4", value: "tail" }, + { op: "copy", from: "/items/0", path: "/items/2" }, + { op: "add", path: "/items/0", value: "head" }, + ], expected: ["head", 0, 1, 0, 2, 3, "tail", 4] }, + { name: "copies with shifted sources", operations: [ + { op: "copy", from: "/items/0", path: "/items/2" }, + { op: "copy", from: "/items/3", path: "/items/1" }, + ], expected: [0, 2, 1, 0, 2, 3, 4] }, + { name: "increasing removals", operations: [ + { op: "remove", path: "/items/0" }, + { op: "remove", path: "/items/1" }, + { op: "remove", path: "/items/1" }, + ], expected: [1, 4] }, + { name: "descending removals", operations: [ + { op: "remove", path: "/items/4" }, + { op: "remove", path: "/items/2" }, + { op: "remove", path: "/items/0" }, + ], expected: [1, 3] }, + { name: "unordered removals", operations: [ + { op: "remove", path: "/items/1" }, + { op: "remove", path: "/items/2" }, + { op: "remove", path: "/items/0" }, + ], expected: [2, 4] }, + { name: "appends followed by removals", operations: [ + { op: "add", path: "/items/-", value: "a" }, + { op: "add", path: "/items/6", value: "b" }, + { op: "remove", path: "/items/0" }, + { op: "remove", path: "/items/1" }, + ], expected: [1, 3, 4, "a", "b"] }, + { name: "moves including append and adjacent swap", operations: [ + { op: "move", from: "/items/0", path: "/items/-" }, + { op: "move", from: "/items/4", path: "/items/1" }, + { op: "move", from: "/items/3", path: "/items/2" }, + ], expected: [1, 0, 3, 2, 4] }, + { name: "removing an appended item", operations: [ + { op: "add", path: "/items/-", value: "temporary" }, + { op: "remove", path: "/items/5" }, + ], expected: [0, 1, 2, 3, 4] }, +] satisfies Array<{ name: string; operations: JSONPatchOperation[]; expected: Array }>)( + "array batches preserve sequential meaning: $name", + ({ operations, expected }) => { + const initial = { items: [0, 1, 2, 3, 4] }; + const document = createJSONDocument(initial); + const before = document.value; + expect(document.validatePatch(operations)).toEqual({ ok: true }); + expect(document.value).toBe(before); + expect(document.commit(operations)).toMatchObject({ ok: true }); + expect(document.value).toEqual({ items: expected }); + const result = applyPatch(initial, operations); + expect(result).toMatchObject({ ok: true, value: { items: expected } }); + if (result.ok) { + expect(result.change.applied).toHaveLength(operations.length); + expect(result.change.applied.every((operation) => !operation.path.endsWith("/-"))).toBe(true); + expect(applyPatch(initial, result.change.applied)).toMatchObject({ ok: true, value: { items: expected } }); + } + expect(initial.items).toEqual([0, 1, 2, 3, 4]); + expect(before).toEqual(initial); + }, +); + +test("copies can read earlier inserted values without sharing mutable payloads", () => { + const payload = { label: "inserted" }; + const document = createJSONDocument({ "a/b": [0, 1] }); + const result = document.commit([ + { op: "add", path: "/a~1b/0", value: payload }, + { op: "copy", from: "/a~1b/0", path: "/a~1b/1" }, + { op: "copy", from: "/a~1b/1", path: "/a~1b/-" }, + ]); + expect(result).toMatchObject({ ok: true }); + payload.label = "caller mutation"; + const items = (document.value as { "a/b": unknown[] })["a/b"]; + expect(items).toEqual([{ label: "inserted" }, { label: "inserted" }, 0, 1, { label: "inserted" }]); + expect(items[0]).not.toBe(items[1]); + expect(items[1]).not.toBe(items[4]); + expect(Object.isFrozen(items[1])).toBe(true); +}); + +test.each(["/items/01", "/items/99", "/items/-", "#/items/0"])( + "array fast paths preserve rollback and failure precedence (%s)", + (path) => { + const document = createJSONDocument({ items: [0, 1, 2] }); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "add", path: "/items/-", value: 3 }, + { op: "remove", path }, + { op: "add", path: "/items/-", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { ok: false, pointer: path, code: path[0] === "#" ? "invalid_pointer" : "path_not_found" }; + expect(document.validatePatch(operations)).toMatchObject(expected); + expect(document.commit(operations)).toMatchObject(expected); + expect(document.value).toBe(before); + expect(notifications).toBe(0); + }, +); diff --git a/packages/json-document/tests/foundation/object-patch.test.ts b/packages/json-document/tests/foundation/object-patch.test.ts new file mode 100644 index 000000000..1b64e5147 --- /dev/null +++ b/packages/json-document/tests/foundation/object-patch.test.ts @@ -0,0 +1,64 @@ +import { applyPatch, buildPointer, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { expect, test } from "vitest"; + +const keys = ["0", "1", "a", "b", "c", "d", "e", "", "__proto__"]; + +test.each([ + keys, + [...keys].reverse(), + ["0", "1", ...keys.slice(2).reverse()], + ["__proto__", "", "c", "e"], + ["0", "c"], + ["0", "1", "b", "d", "e"], + ["", "__proto__"], +].map((removed) => ({ removed })))("root removals retain key order and own properties: $removed", ({ removed }) => { + const initial = Object.fromEntries(keys.map((key) => [key, { key }])); + const operations = removed.map((key): JSONPatchOperation => ({ op: "remove", path: buildPointer([key]) })); + const retained = keys.filter((key) => !removed.includes(key)); + const expected = Object.fromEntries(retained.map((key) => [key, { key }])); + const document = createJSONDocument(initial); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: operations } }); + expect(document.value).toEqual(expected); + expect(Object.keys(document.value as object)).toEqual(retained); + expect(Object.getPrototypeOf(document.value)).toBe(Object.prototype); + expect(applyPatch(initial, operations)).toMatchObject({ ok: true, value: expected }); + expect(Object.keys(initial)).toEqual(keys); +}); + +test("root additions preserve repeated and special keys without owning caller payloads", () => { + const payload = { n: 1 }; + const document = createJSONDocument({ a: 0 }); + const operations: JSONPatchOperation[] = [ + { op: "add", path: "/__proto__", value: { n: 0 } }, + { op: "add", path: "/", value: 2 }, + { op: "add", path: "/__proto__", value: payload }, + { op: "add", path: "/a", value: 3 }, + ]; + const result = document.commit(operations); + expect(result).toEqual({ ok: true, change: { applied: operations } }); + payload.n = 99; + expect(document.value).toEqual(JSON.parse('{"a":3,"__proto__":{"n":1},"":2}')); + expect(Object.getPrototypeOf(document.value)).toBe(Object.prototype); + expect(Object.keys(document.value as object)).toEqual(["a", "__proto__", ""]); + expect(applyPatch({ a: 0 }, result.ok ? result.change.applied : [])).toMatchObject({ ok: true, value: document.value }); +}); + +test.each([["a", "b"], ["d", "c"]])("duplicate removal after an ordered prefix stays atomic (%s, %s)", (first, second) => { + const initial = { a: 0, b: 1, c: 2, d: 3 }; + const document = createJSONDocument(initial); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "remove", path: `/${first}` }, + { op: "remove", path: `/${second}` }, + { op: "remove", path: `/${first}` }, + { op: "add", path: "/later", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { ok: false, code: "path_not_found", pointer: `/${first}`, reason: `op[2]: object key: ${first}` }; + expect(applyPatch(initial, operations)).toEqual(expected); + expect(document.validatePatch(operations)).toEqual(expected); + expect(document.commit(operations)).toEqual(expected); + expect(document.value).toBe(before); + expect(notifications).toBe(0); +}); diff --git a/packages/json-document/tests/foundation/owned-freeze.test.ts b/packages/json-document/tests/foundation/owned-freeze.test.ts index c5752d062..e123ddd85 100644 --- a/packages/json-document/tests/foundation/owned-freeze.test.ts +++ b/packages/json-document/tests/foundation/owned-freeze.test.ts @@ -1,4 +1,4 @@ -import { applyPatch, createJSONDocument } from "@interactive-os/json-document"; +import { applyPatch, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; import { expect, test } from "vitest"; import { @@ -58,6 +58,34 @@ test("a leaf replace freeze inspects the changed path, not every sibling", () => expect(large.inspections).toBe(small.inspections); }); +test("a flat replacement batch inspects its common ancestor only once for freezing", () => { + const document = createJSONDocument(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [i, 0]))); + resetOwnedPatchFreezeInspections(); + expect(document.commit(Array.from({ length: 1_000 }, (_, i) => ({ + op: "replace", path: `/${i}`, value: 1, + }))).ok).toBe(true); + expect(ownedPatchFreezeInspections()).toBe(1); + expect(isDeepFrozen(document.value)).toBe(true); +}); + +test.each([ + { op: "remove", path: "/deleted" }, + { op: "replace", path: "", value: { last: { n: 1 } } }, + { op: "copy", from: "/first", path: "/copied" }, +] satisfies JSONPatchOperation[])("a freeze fallback after $op does not skip later changed nodes", (operation) => { + const document = createJSONDocument({ first: { n: 0 }, deleted: true, last: { n: 0 } }); + const before = document.value; + const result = document.commit([ + { op: "replace", path: "/first/n", value: 1 }, + operation, + { op: "replace", path: "/last/n", value: 2 }, + ]); + expect(result.ok).toBe(true); + expect(isDeepFrozen(document.value)).toBe(true); + expect(Reflect.set((document.value as { last: object }).last, "n", 99)).toBe(false); + expect(before).toEqual({ first: { n: 0 }, deleted: true, last: { n: 0 } }); +}); + test("a leaf replace does not dense-copy a large sibling array", () => { const items = Array.from({ length: 10_000 }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); @@ -76,6 +104,22 @@ test("a leaf replace does not dense-copy a large sibling array", () => { expect(denseArrayCopies()).toBe(0); }); +test.each(([ + [{ op: "replace", path: "/items/0/n", value: 1 }, { op: "remove", path: "/deleted" }], + [{ op: "copy", from: "/items/0", path: "/items/-" }], + [{ op: "add", path: "/items/50/n", value: 1 }, { op: "add", path: "/items/0", value: { n: 0 } }], + [{ op: "replace", path: "/items/50/n", value: 1 }, { op: "remove", path: "/items/0" }], +] satisfies JSONPatchOperation[][]).map((prefix) => ({ prefix })))("mixed array patches freeze shifted values, overlays and copied bases: $prefix", ({ prefix }) => { + const initial = { items: Array.from({ length: 64 }, () => ({ n: 0 })), deleted: true }; + const document = createJSONDocument(initial); + const before = document.value; + resetDenseArrayCopies(); + expect(document.commit([...prefix, { op: "replace", path: "/items/1/n", value: 2 }]).ok).toBe(true); + expect(denseArrayCopies()).toBe(0); + expect(isDeepFrozen(document.value)).toBe(true); + expect(before).toEqual(initial); +}); + test("a batch of leaf replaces does not walk the whole value or dense-copy the array", () => { const items = Array.from({ length: 10_000 }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); @@ -122,6 +166,83 @@ test("a root replace still freezes the whole owned tree", () => { expect(isDeepFrozen(result.value)).toBe(true); }); +test("overlapping array replacements preserve payloads, snapshots and untouched siblings", () => { + const document = createJSONDocument({ + items: Array.from({ length: 128 }, (_, id) => ({ id, nested: { text: "before" } })), + }); + const before = document.value; + const untouched = document.at("/items/11"); + const payload = { id: 10, nested: { text: "injected" } }; + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/items/10", value: payload }, + { op: "replace", path: "/items/10/nested/text", value: "first" }, + { op: "replace", path: "/items/10/nested/text", value: "last" }, + { op: "replace", path: "/items/80/nested/text", value: null }, + ]; + + resetDenseArrayCopies(); + resetOwnedPatchFreezeInspections(); + expect(document.validatePatch(operations)).toEqual({ ok: true }); + expect(document.value).toBe(before); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: operations } }); + expect(denseArrayCopies()).toBe(0); + expect(ownedPatchFreezeInspections()).toBeLessThan(100); + expect(document.at("/items/10/nested/text")).toMatchObject({ ok: true, value: "last" }); + expect(document.at("/items/80/nested/text")).toMatchObject({ ok: true, value: null }); + expect(document.at("/items/11")).toEqual(untouched); + const after = document.value as { items: Array<{ nested: { text: string | null } }> }; + expect(after.items[11]).toBe((before as typeof after).items[11]); + expect((before as typeof after).items[10]!.nested.text).toBe("before"); + expect(payload.nested.text).toBe("injected"); + expect(Object.isFrozen(payload.nested)).toBe(false); + expect(isDeepFrozen(after)).toBe(true); +}); + +test("replace batches preserve escaped, empty and __proto__ object keys", () => { + const initial = JSON.parse('{"__proto__":{"value":0},"a/b":{"~":1},"":2}'); + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/__proto__/value", value: 3 }, + { op: "replace", path: "/a~1b/~0", value: null }, + { op: "replace", path: "/", value: 4 }, + ]; + const result = applyPatch(initial, operations); + expect(result).toEqual({ + ok: true, + value: JSON.parse('{"__proto__":{"value":3},"a/b":{"~":null},"":4}'), + change: { applied: operations }, + }); + expect(initial.__proto__.value).toBe(0); + if (result.ok) expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype); +}); + +test.each(["/missing", "/items/01/text", "#/items/0/text"])( + "a failed replace batch stays atomic and preserves the first failure (%s)", + (path) => { + const initial = { items: Array.from({ length: 64 }, () => ({ text: "before" })) }; + const document = createJSONDocument(initial); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "replace", path: "/items/0/text", value: "first" }, + { op: "replace", path, value: "invalid" }, + { op: "replace", path: "/items/1/text", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { + ok: false, + code: path[0] === "#" ? "invalid_pointer" : "path_not_found", + pointer: path, + }; + expect(applyPatch(initial, operations)).toMatchObject(expected); + expect(document.validatePatch(operations)).toMatchObject(expected); + expect(document.commit(operations)).toMatchObject(expected); + expect(document.value).toBe(before); + expect(document.at("/items/0/text")).toMatchObject({ ok: true, value: "before" }); + expect(notifications).toBe(0); + expect(initial.items[0]!.text).toBe("before"); + }, +); + function applyOwnedAndCount(size: number, index: number) { const items = Array.from({ length: size }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); From 9be88b4fd678fbdf9649bd50371984fddd96e139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 9 Sep 2026 07:27:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=A3=BC=EB=B3=80?= =?UTF-8?q?=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EB=B3=B5=EC=82=AC=EC=99=80=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=9E=AC=ED=83=90=EC=83=89=EC=9D=84=20?= =?UTF-8?q?=EC=A4=84=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference/editing.md | 5 + .../benchmarks/runtime.mjs | 13 +++ .../src/checkpoint.ts | 42 ++++---- .../src/document-patch.ts | 20 ++-- .../tests/unit/checkpoint.test.ts | 33 ++++++ .../tests/unit/document-tracking.test.ts | 21 +++- packages/json-document-editing/README.md | 7 ++ .../benchmarks/editors.mjs | 13 ++- .../json-document-editing/src/calendar.ts | 22 ++-- .../json-document-editing/src/document.ts | 21 +--- .../json-document-editing/src/identity.ts | 19 ++++ packages/json-document-editing/src/index.ts | 2 +- .../json-document-editing/src/invert-patch.ts | 6 +- packages/json-document-editing/src/object.ts | 19 +--- packages/json-document-editing/src/order.ts | 19 +--- packages/json-document-editing/src/session.ts | 25 ++--- packages/json-document-editing/src/tree.ts | 22 +--- .../tests/identity.test.ts | 39 ++++++- .../tests/session-history.test.ts | 72 ++++++++++++- .../benchmarks/form.mjs | 36 +++++++ .../src/index.ts | 19 ++-- .../tests/react-hook-form-connector.test.tsx | 100 +++++++++++++++++- packages/json-document-rich-text/src/diff.ts | 13 ++- .../json-document-rich-text/src/editor.ts | 87 ++++++++------- packages/json-document-rich-text/src/path.ts | 5 - .../tests/editor.test.ts | 54 ++++++++++ .../tests/local-edit.test.ts | 25 ++++- .../tests/schema.test.ts | 19 ++++ .../routes/editing-demos/useClipboardLab.ts | 9 +- .../src/shared/demo-workbench/demo-sources.ts | 5 + site/tests/unit/demo-workbench.test.tsx | 18 +++- 31 files changed, 613 insertions(+), 197 deletions(-) diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 9cd1f1c66..2137c922d 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -595,6 +595,11 @@ createDocumentEditor(source: EditingDocumentSource, options?: Edi ```ts createEditingId(prefix: string): string ``` +## `createEditingIdAllocator` + +```ts +createEditingIdAllocator(existingIds: Iterable, createId: () => string, subject: string): () => string +``` ## `createEditingSession` ```ts diff --git a/packages/json-document-collaboration/benchmarks/runtime.mjs b/packages/json-document-collaboration/benchmarks/runtime.mjs index bd4fabbab..e73a30647 100644 --- a/packages/json-document-collaboration/benchmarks/runtime.mjs +++ b/packages/json-document-collaboration/benchmarks/runtime.mjs @@ -11,6 +11,7 @@ console.log("json-document collaboration benchmark"); console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${config.warmups}`); const ingestRows = []; +const objectRows = []; for (const size of config.sizes) { const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) }; const author = createCollaborationRuntime(initial, { ...runtimeOptions, actorId: "author" }); @@ -29,6 +30,16 @@ for (const size of config.sizes) { }); ingestRows.push({ size, ...ingest }); + const wide = Object.fromEntries(Array.from({ length: size }, (_, index) => [`field${index}`, index])); + const objectAuthor = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "author" }); + objectAuthor.document.commit([{ op: "replace", path: `/field${middle}`, value: -1 }]); + const objectBundle = objectAuthor.replica.exportBundle(); + const objectIngest = measure(config, "remote wide object leaf ingest", () => { + const receiver = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "receiver" }); + return () => receiver.replica.ingest(objectBundle).ok && receiver.document.value[`field${middle}`] === -1; + }); + objectRows.push({ size, ...objectIngest }); + measure(config, "export one-change bundle", () => () => ( author.replica.exportBundle().changes.length === 1 )); @@ -36,6 +47,8 @@ for (const size of config.sizes) { console.log("\nremote leaf ingest"); reportScaling(ingestRows); +console.log("\nremote wide object leaf ingest"); +reportScaling(objectRows); const ledgerSizes = (process.env.PERF_COLLABORATION_CHANGES ?? "100,1000,10000") .split(",") diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts index 58c399592..de1edb6a8 100644 --- a/packages/json-document-collaboration/src/checkpoint.ts +++ b/packages/json-document-collaboration/src/checkpoint.ts @@ -68,22 +68,26 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { rawPayload.reason ?? "checkpoint payload must contain only JSON values", ); } + const ownedPayload = rawPayload.value as Readonly>; if ( - input.payload.kind !== "json-document-collaboration/checkpoint" - || input.payload.version !== 1 + ownedPayload.kind !== "json-document-collaboration/checkpoint" + || ownedPayload.version !== 1 ) { return invalid("checkpoint payload kind or version is unsupported"); } - const base = applyPatch(input.payload.base, []); - if (!base.ok) { - return invalid(base.reason ?? "checkpoint base must be JSON"); + const base = ownedPayload.base; + // Only missing fields still need Core's non-JSON diagnostic; present fields + // were already validated and detached with the complete payload. + if (base === undefined || ownedPayload.membership === undefined) { + const missing = applyPatch(undefined, []); + if (!missing.ok) return invalid(missing.reason!); } - const membership = prepareMembership(input.payload.membership); + const membership = prepareMembership(ownedPayload.membership!); if (!membership.ok) return membership; const bundle = prepareBundle({ - epoch: input.payload.epoch, - changes: input.payload.changes, + epoch: ownedPayload.epoch, + changes: ownedPayload.changes, }); if (!bundle.ok) return bundle; for (let index = 1; index < bundle.bundle.changes.length; index += 1) { @@ -99,7 +103,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { ); } } - if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base.value)) { + if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base!)) { return invalid("checkpoint base does not match epoch baseDigest"); } if ( @@ -115,7 +119,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { kind: "json-document-collaboration/checkpoint" as const, version: 1 as const, epoch: bundle.bundle.epoch, - base: base.value, + base: base!, membership: membership.membership, changes: bundle.bundle.changes, }); @@ -202,7 +206,7 @@ export function verifyCheckpointProof( } function prepareMembership( - input: unknown, + input: JSONValue, ): | { readonly ok: true; @@ -210,25 +214,19 @@ function prepareMembership( } | { readonly ok: false; readonly reason: string } { if (input === null) return { ok: true, membership: null }; - const validated = applyPatch(input, []); - if (!validated.ok) { - return invalid( - validated.reason ?? "checkpoint membership must contain only JSON values", - ); - } if ( - !isRecord(validated.value) - || validated.value.version !== 1 - || !Array.isArray(validated.value.members) + !isRecord(input) + || input.version !== 1 + || !Array.isArray(input.members) ) { return invalid("checkpoint membership must be null or a version 1 list"); } try { const membership = canonicalMembership( - validated.value as unknown as CollaborationMembership, + input as unknown as CollaborationMembership, ); if ( - canonicalStringify(validated.value) + canonicalStringify(input) !== canonicalStringify(membership as unknown as JSONValue) ) { return invalid("checkpoint membership must be canonical"); diff --git a/packages/json-document-collaboration/src/document-patch.ts b/packages/json-document-collaboration/src/document-patch.ts index 70e27301c..c0bb07b7a 100644 --- a/packages/json-document-collaboration/src/document-patch.ts +++ b/packages/json-document-collaboration/src/document-patch.ts @@ -8,6 +8,7 @@ interface VisibleMember { parent?: VisibleMember; key: string; children: VisibleMember[]; + readonly childrenByKey: Map | undefined; } /** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */ @@ -59,8 +60,8 @@ export function patchBetweenTrees( if (staging === undefined) { let key = "__json_document_transfer__"; if (Array.isArray(root.value)) key = String(root.children.length); - else while ([...root.children, ...target.children].some((child) => child.key === key)) key += "_"; - staging = { id: "", value: [], container: "", key, children: [] }; + else while (root.childrenByKey?.has(key) || target.childrenByKey?.has(key)) key += "_"; + staging = { id: "", value: [], container: "", key, children: [], childrenByKey: undefined }; insert(staging, root, key); operations.push({ op: "add", path: pointer(staging), value: [] }); } @@ -74,12 +75,13 @@ export function patchBetweenTrees( for (const child of [...node.children]) vacate(child); const value = wanted.container === undefined ? wanted.value : Array.isArray(wanted.value) ? [] : {}; operations.push({ op: "replace", path: pointer(node), value }); - const replacement: VisibleMember = { ...wanted, children: [], key: node.key }; + const replacement: VisibleMember = { ...wanted, children: [], childrenByKey: wanted.childrenByKey && new Map(), key: node.key }; if (node.parent !== undefined) { const parent = node.parent; const index = parent.children.indexOf(node); replacement.parent = parent; parent.children[index] = replacement; + parent.childrenByKey?.set(replacement.key, replacement); } current.set(wanted.id, replacement); node = replacement; @@ -91,11 +93,11 @@ export function patchBetweenTrees( if (existing !== undefined && !attached(existing, root)) existing = undefined; const occupant = Array.isArray(node.value) ? node.children[index] - : node.children.find((entry) => entry.key === key); + : node.childrenByKey?.get(key); if (!Array.isArray(node.value) && occupant !== undefined && occupant !== existing) vacate(occupant); if (existing === undefined) { const value = child.container === undefined ? child.value : Array.isArray(child.value) ? [] : {}; - existing = { ...child, value, children: [] }; + existing = { ...child, value, children: [], childrenByKey: child.childrenByKey && new Map() }; insert(existing, node, key); current.set(child.id, existing); operations.push({ op: "add", path: pointer(existing), value }); @@ -123,6 +125,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me container: reference.kind === "container" ? reference.containerId : undefined, key, children: [], + childrenByKey: value !== null && typeof value === "object" && !Array.isArray(value) ? new Map() : undefined, }; members.set(node.id, node); if (value !== null && typeof value === "object") { @@ -131,6 +134,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me const member = snapshot(tree, childId, child, key, members); member.parent = node; node.children.push(member); + node.childrenByKey?.set(key, member); } } return node; @@ -155,6 +159,7 @@ function pointer(node: VisibleMember): string { return buildPointer(segments(nod function detach(node: VisibleMember): void { if (node.parent === undefined) throw new Error("cannot detach the document root"); node.parent.children.splice(node.parent.children.indexOf(node), 1); + node.parent.childrenByKey?.delete(node.key); delete node.parent; } @@ -162,5 +167,8 @@ function insert(node: VisibleMember, parent: VisibleMember, key: string): void { node.parent = parent; node.key = key; if (Array.isArray(parent.value)) parent.children.splice(Number(key), 0, node); - else parent.children.push(node); + else { + parent.children.push(node); + parent.childrenByKey!.set(key, node); + } } diff --git a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts index 7d605c07f..93d2203ba 100644 --- a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts +++ b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { describe, expect, test, vi } from "vitest"; +import * as core from "@interactive-os/json-document"; +import { prepareCheckpoint } from "../../src/checkpoint.js"; import { compactCollaborationCheckpoint, @@ -108,6 +110,37 @@ function canonicalJSON(value: unknown): string { } describe("@interactive-os/json-document-collaboration checkpoints", () => { + test("owns the raw payload once and reuses its base and membership", () => { + const source = createCollaborationRuntime( + { rows: Array.from({ length: 1_000 }, (_, id) => ({ id })) }, + options("author", "ownership/v1", membership("author")), + ); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + const applyPatch = vi.spyOn(core, "applyPatch"); + try { + const prepared = prepareCheckpoint(input); + expect(prepared.ok).toBe(true); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload)).toHaveLength(1); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload.base || value === input.payload.membership)).toHaveLength(0); + input.payload.base.rows[0].id = -1; + input.payload.membership.members[0].actorId = "poison"; + if (!prepared.ok) throw new Error(prepared.reason); + expect(core.readPointer(prepared.checkpoint.payload.base, "/rows/0/id")).toMatchObject({ value: 0 }); + expect(prepared.checkpoint.payload.membership).toEqual(membership("author")); + expect(Object.isFrozen(prepared.checkpoint.payload.base)).toBe(true); + } finally { applyPatch.mockRestore(); } + }); + + test.each(["base", "membership"])("preserves the missing %s JSON diagnostic and validation order", (field) => { + const source = createCollaborationRuntime(null, options("author", "missing/v1", undefined)); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + delete input.payload[field]; + const missing = core.applyPatch(undefined, []); + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: !missing.ok && missing.reason }); + input.payload.version = 2; + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: "checkpoint payload kind or version is unsupported" }); + }); + test("round-trips the complete same-epoch causal state", () => { const members = membership("actor-a", "actor-b"); const source = createCollaborationRuntime( diff --git a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts index fea4862b3..9e1329020 100644 --- a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts +++ b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts @@ -1,10 +1,28 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { trackPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { createCollaborationRuntime } from "../../src/index.js"; +import { createInitialTree } from "../../src/tree.js"; +import { patchBetweenTrees } from "../../src/document-patch.js"; const options = { epochId: "tracking/v1", ruleset: { id: "tracking", digest: "v1" } }; describe("remote structural notification", () => { + test("looks up a wide object's keys without rescanning siblings for each key", () => { + const before = Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, index])); + const after = { ...before, field2500: -1 }; + const beforeTree = createInitialTree(before, "wide"); + const afterTree = createInitialTree(after, "wide"); + const find = vi.spyOn(Array.prototype, "find"); + try { + const operations = patchBetweenTrees(before, after, beforeTree, afterTree); + const siblingSearches = find.mock.contexts.filter((value) => ( + Array.isArray(value) && value.length > 100 && value[0] && "key" in value[0] + )); + expect(siblingSearches).toHaveLength(0); + expect(operations).toEqual([{ op: "replace", path: "/field2500", value: -1 }]); + } finally { find.mockRestore(); } + }); + test.each([ { name: "move then edit", initial: { items: [{ label: "a" }, { label: "b" }] }, pointer: "/items/0/label", patch: [{ op: "move", from: "/items/0", path: "/items/1" }, { op: "replace", path: "/items/1/label", value: "edited" }], expected: "/items/1/label" }, { name: "escaped object member", initial: { "a/b": { label: "a" } }, pointer: "/a~1b/label", patch: [{ op: "move", from: "/a~1b", path: "/~0" }], expected: "/~0/label" }, @@ -18,6 +36,7 @@ describe("remote structural notification", () => { { name: "object swap", initial: { a: { label: "a" }, b: { label: "b" } }, pointer: "/a/label", patch: [{ op: "move", from: "/a", path: "/temp" }, { op: "move", from: "/b", path: "/a" }, { op: "move", from: "/temp", path: "/b" }], expected: "/b/label" }, { name: "move out before replacing its parent", initial: { left: { item: { label: "a" } }, right: {} }, pointer: "/left/item/label", patch: [{ op: "move", from: "/left/item", path: "/right/item" }, { op: "replace", path: "/left", value: {} }], expected: "/right/item/label" }, { name: "root array reorder", initial: [{ label: "a" }, { label: "b" }], pointer: "/0/label", patch: [{ op: "move", from: "/0", path: "/1" }], expected: "/1/label" }, + { name: "successive key swaps and a replaced container", initial: { a: { item: { label: "a" } }, b: { label: "b" }, __json_document_transfer__: 1, __json_document_transfer___: 2 }, pointer: "/a/item/label", patch: [{ op: "move", from: "/a/item", path: "/temp" }, { op: "replace", path: "/a", value: {} }, { op: "move", from: "/b", path: "/a/new" }, { op: "move", from: "/temp", path: "/b" }, { op: "replace", path: "/a/new/label", value: "edited" }], expected: "/b/label" }, ] as const)("$name preserves the address of the same member", ({ initial, pointer, patch, expected }) => { const local = createCollaborationRuntime(initial, { ...options, actorId: "local" }); const remote = createCollaborationRuntime(initial, { ...options, actorId: "remote" }); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 1bef768fa..eef058567 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -79,6 +79,13 @@ replica. Custom `createId` injection remains supported; its provider must ensure uniqueness across all writers. Environments without `crypto.randomUUID` fail explicitly with `editing.id-provider-unavailable`; no weak random fallback is used. +`createEditingIdAllocator(existingIds, createId, subject)` reads an iterable of +occupied IDs once and returns a function that reserves each newly allocated ID. +Use one allocator for a batch; the five structural editors share this owner. +Each call tries the injected provider at most 100 times before throwing +`createId did not produce a unique id`. The allocator covers its local +reservation set, not cross-replica uniqueness; the provider still owns that. + The last UI unsubscribe releases the session's document and external-history observation connections. Local undo/redo validity is independent of UI subscriptions: a one-shot change marker retains no session, history stack or UI callback and diff --git a/packages/json-document-editing/benchmarks/editors.mjs b/packages/json-document-editing/benchmarks/editors.mjs index ea4b9d7a3..453fc6cd4 100644 --- a/packages/json-document-editing/benchmarks/editors.mjs +++ b/packages/json-document-editing/benchmarks/editors.mjs @@ -1,5 +1,5 @@ import { benchmarkConfig, measure, reportScaling } from "../../../benchmarks/measure.mjs"; -import { createDatabaseEditor, createSheetEditor, createTreeEditor } from "../dist/index.js"; +import { createDatabaseEditor, createSheetEditor, createTreeEditor, createObjectEditor } from "../dist/index.js"; const config = benchmarkConfig("PERF_EDITING_ITEMS"); console.log("json-document editing benchmark"); @@ -8,6 +8,17 @@ console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${c const workloads = new Map(); for (const size of config.sizes) { console.log(`\nitems=${size}`); + const objects = Array.from({ length: size }, (_, index) => ({ + id: `object-${index}`, label: "Object", x: 0, y: 0, width: 1, height: 1, color: "subtle", + })); + const copies = Math.min(size, 1_000); + record("object batch paste", size, measure(config, "object batch paste", () => { + let sequence = 0; + const editor = createObjectEditor({ objects }, { createId: () => `copy-${sequence++}` }); + const clipboard = { type: "application/vnd.interactive-os.objects+json", objects: objects.slice(0, copies), text: "" }; + return () => editor.dispatch({ type: "clipboard.paste", clipboard }).ok + && editor.snapshot.value.objects.length === size + copies; + })); const treeDocument = { nodes: Array.from({ length: size }, (_, index) => ({ id: `node-${index}`, parentId: index === 0 ? null : "node-0", diff --git a/packages/json-document-editing/src/calendar.ts b/packages/json-document-editing/src/calendar.ts index 5e6bb9203..ae2385771 100644 --- a/packages/json-document-editing/src/calendar.ts +++ b/packages/json-document-editing/src/calendar.ts @@ -18,7 +18,7 @@ import { } from "./session.js"; import { cutEditingClipboard, type EditingClipboardCut } from "./clipboard.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { addCalendarDate, @@ -335,7 +335,7 @@ export function createCalendarEditor( if (start === null || end === null) return failure("event.invalid-instant"); const events = value().events; const event: CalendarEvent = { - id: createUniqueId(events, createId), + id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), title: intent.title ?? "Event", start: intent.start, end: intent.end, @@ -545,7 +545,7 @@ export function createCalendarEditor( if (intent.scope === "this") { const split: CalendarEvent = { ...event, - id: createUniqueId(events, createId), + id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), title: intent.title ?? event.title, start: times.start, end: times.end, @@ -568,7 +568,7 @@ export function createCalendarEditor( const until = addCalendarDate(occurrenceDate, -1) ?? occurrenceDate; const following: CalendarEvent = { ...event, - id: createUniqueId(events, createId), + id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), title: intent.title ?? event.title, start: times.start, end: times.end, @@ -737,7 +737,8 @@ export function createCalendarEditor( ?? null; const dateAnchor = parseCalendarDate(calendarDatePart(clipboard.anchorOccurrenceStart)); if (dateAnchor === null) return failure("clipboard.invalid"); - const existing = [...value().events]; + const existing = value().events; + const allocateId = createEditingIdAllocator(existing.map((event) => event.id), createId, "calendar event"); const pasted: CalendarEvent[] = []; for (const item of clipboard.items) { const source = item.event; @@ -762,7 +763,7 @@ export function createCalendarEditor( start = source.allDay ? formatCalendarDate(nextStart) : `${formatCalendarDate(nextStart)}T${source.start.slice(11)}`; end = source.allDay ? formatCalendarDate(nextStart.add({ days: duration })) : `${formatCalendarDate(nextStart.add({ days: duration }))}T${source.end.slice(11)}`; } - const event = { ...source, id: createUniqueId([...existing, ...pasted], createId), start, end, recurrence: null, excludeDates: [] }; + const event = { ...source, id: allocateId(), start, end, recurrence: null, excludeDates: [] }; pasted.push(event); } return session.apply({ @@ -1207,15 +1208,6 @@ function shiftedOccurrenceTimes( return nextStart < nextEnd ? { start: nextStart, end: nextEnd } : null; } -function createUniqueId(events: ReadonlyArray, createId: () => string): string { - const existing = new Set(events.map((event) => event.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique calendar event id"); -} - function emptyCalendarSelection(): CalendarSelection { return { kind: "range", ranges: [], primaryIndex: null }; } diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index ad3d2937c..9eedb681b 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -1,6 +1,6 @@ import { type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createEditingSession, type EditingResult, type EditingSession, type EditingSnapshot } from "./session.js"; @@ -153,7 +153,7 @@ export function createDocumentEditor(source: EditingDocumentSource block.id === intent.afterId); if (intent.afterId !== undefined && afterIndex < 0) return failure("insert.target-not-found"); - const block: DocumentBlock = { id: createUniqueId(blocks, createId), text: intent.text ?? "" }; + const block: DocumentBlock = { id: createEditingIdAllocator(blocks.map((block) => block.id), createId, "block")(), text: intent.text ?? "" }; const index = afterIndex + 1; return session.apply({ operations: [{ op: "add", path: `/blocks/${index}`, value: block }], @@ -279,26 +279,13 @@ function rangesFor(blocks: ReadonlyArray): DocumentSelection { return { kind: "range", ranges: blocks.map((block) => ({ anchor: pointAt(block), focus: pointAt(block) })), primaryIndex: blocks.length === 0 ? null : 0 }; } -function createUniqueId(blocks: ReadonlyArray, createId: () => string): string { - const existing = new Set(blocks.map((block) => block.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique block id"); -} - function cloneBlocksWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): DocumentBlock[] { - const occupied = [...existing]; - return source.map((block) => { - const copy = { ...block, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator(existing.map((block) => block.id), createId, "block"); + return source.map((block) => ({ ...block, id: allocateId() })); } function success(snapshot: EditingSnapshot): EditingResult { diff --git a/packages/json-document-editing/src/identity.ts b/packages/json-document-editing/src/identity.ts index a4bbb09ea..d2ef11623 100644 --- a/packages/json-document-editing/src/identity.ts +++ b/packages/json-document-editing/src/identity.ts @@ -4,3 +4,22 @@ export function createEditingId(prefix: string): string { if (typeof provider?.randomUUID !== "function") throw new TypeError("editing.id-provider-unavailable"); return `${prefix}-${provider.randomUUID()}`; } + +/** Reserve collision-free IDs across one editing batch. Reads existing IDs once. */ +export function createEditingIdAllocator( + existingIds: Iterable, + createId: () => string, + subject: string, +): () => string { + const occupied = new Set(existingIds); + return () => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const id = createId(); + if (!occupied.has(id)) { + occupied.add(id); + return id; + } + } + throw new Error(`createId did not produce a unique ${subject} id`); + }; +} diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 96439167f..ca8f04548 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -18,7 +18,7 @@ export { acceptsDatabaseValue, databaseValueFromText, defaultDatabaseValue } fro export { createObjectEditor, objectClipboardFormat } from "./object.js"; export { createOrderEditor, orderClipboardFormat } from "./order.js"; export { createEditingSession } from "./session.js"; -export { createEditingId } from "./identity.js"; +export { createEditingId, createEditingIdAllocator } from "./identity.js"; export type { EditingHistory, EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js"; export { createSheetEditor, sheetClipboardFormat } from "./sheet.js"; export { createTreeEditor, treeClipboardFormat } from "./tree.js"; diff --git a/packages/json-document-editing/src/invert-patch.ts b/packages/json-document-editing/src/invert-patch.ts index a077bd7f1..7fc9fdf7b 100644 --- a/packages/json-document-editing/src/invert-patch.ts +++ b/packages/json-document-editing/src/invert-patch.ts @@ -13,7 +13,7 @@ import { export function invertEditingPatch(document: JSONDocument, operations: ReadonlyArray): ReadonlyArray | null { const isolated = operations.length > 1 || operations.some((op) => op.op === "move" || op.op === "copy"); const working = isolated ? createJSONDocument(document.value) : document; - let inverse: JSONPatchOperation[] = []; + const inverse: JSONPatchOperation[] = []; for (const operation of operations) { if (tryParsePointer(operation.path) === null) return null; let step: JSONPatchOperation[] = []; @@ -67,9 +67,9 @@ export function invertEditingPatch(document: JSONDocument, operations: ReadonlyA step = [{ op: operation.op === "replace" ? "replace" : "add", path: operation.path, value: previous.value }]; if (isolated && !working.commit([operation]).ok) return null; } else if (isolated && !working.commit([operation]).ok) return null; - inverse = [...step, ...inverse]; + for (let index = step.length - 1; index >= 0; index -= 1) inverse.push(step[index]!); } - return inverse; + return inverse.reverse(); } function insertionPath(document: JSONDocument, path: string): string | null { diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index a764eb65f..37f488e04 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -14,7 +14,7 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { assertObjectDocument } from "./object-validation.js"; @@ -287,26 +287,13 @@ export function createObjectEditor( }; } -function createUniqueId(objects: ReadonlyArray, createId: () => string): string { - const existing = new Set(objects.map((object) => object.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique object id"); -} - function cloneObjectsWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): DocumentObject[] { - const occupied = [...existing]; - return source.map((object) => { - const copy = { ...object, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator(existing.map((object) => object.id), createId, "object"); + return source.map((object) => ({ ...object, id: allocateId() })); } function selectionFor( diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index 86dc5fd19..57727ca21 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -3,7 +3,7 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { @@ -232,26 +232,13 @@ function rangesFor(items: ReadonlyArray): OrderSelection { }; } -function createUniqueId(items: ReadonlyArray, createId: () => string): string { - const existing = new Set(items.map((item) => item.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique order item id"); -} - function cloneItemsWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): OrderItem[] { - const occupied = [...existing]; - return source.map((item) => { - const copy = { ...item, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator(existing.map((item) => item.id), createId, "order item"); + return source.map((item) => ({ ...item, id: allocateId() })); } function success(snapshot: EditingSnapshot): EditingResult { diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 3f476ff0a..cecdf2bce 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -89,7 +89,8 @@ export function createEditingSession(options: Editi let isNotifying = false; function ownSelection(value: Selection): Selection { - // JSON Document owns detachment and immutable JSON values, including selection. + // Selection families may alias anchor/focus/points. JSON serialization lowers + // that graph to a tree before Core validates and owns the immutable snapshot. return createJSONDocument(clone(value)).value as Selection; } @@ -235,7 +236,7 @@ export function createEditingSession(options: Editi return { ok: true, snapshot: publish() }; } - const inverse = options.history ? [] : invertEditingPatch(document, plan.operations); + const inverse = options.history || plan.history === "ignore" ? [] : invertEditingPatch(document, plan.operations); if (inverse === null) { const validation = document.validatePatch(plan.operations); return validation.ok ? { ok: false, code: "history.inverse-unavailable" } : validation; @@ -243,8 +244,8 @@ export function createEditingSession(options: Editi const result = commit(plan.operations, { editing: { origin: plan.origin, - selectionBefore: clone(beforeSelection), - selectionAfter: clone(selectionAfter), + selectionBefore: beforeSelection, + selectionAfter, }, }); if (!result.ok) return result; @@ -269,14 +270,14 @@ export function createEditingSession(options: Editi }; const previous = undoStack.at(-1); if (previous && plan.historyGroup !== undefined && activeHistoryGroup === plan.historyGroup && previous.group === plan.historyGroup) { - undoStack = [...undoStack.slice(0, -1), { + undoStack[undoStack.length - 1] = { ...entry, forward: [...previous.forward, ...entry.forward], inverse: [...entry.inverse, ...previous.inverse], selectionBefore: previous.selectionBefore, - }]; + }; } else { - undoStack = [...undoStack, entry]; + undoStack.push(entry); } activeHistoryGroup = plan.historyGroup; redoStack = []; @@ -289,7 +290,7 @@ export function createEditingSession(options: Editi const operations = direction === "undo" ? entry.inverse : entry.forward; const nextSelection = direction === "undo" ? entry.selectionBefore : entry.selectionAfter; const result = commit(operations, { - editing: { origin: direction, selectionAfter: clone(nextSelection) }, + editing: { origin: direction, selectionAfter: nextSelection }, }); if (!result.ok) return result; selection = nextSelection; @@ -366,8 +367,8 @@ export function createEditingSession(options: Editi if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "undo"); if (result.ok) { - undoStack = undoStack.slice(0, -1); - redoStack = [...redoStack, entry]; + undoStack.pop(); + redoStack.push(entry); return { ...result, snapshot: publishCommit() }; } return result; @@ -380,8 +381,8 @@ export function createEditingSession(options: Editi if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "redo"); if (result.ok) { - redoStack = redoStack.slice(0, -1); - undoStack = [...undoStack, entry]; + redoStack.pop(); + undoStack.push(entry); return { ...result, snapshot: publishCommit() }; } return result; diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index e6845085a..a723aafc9 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -3,7 +3,7 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; @@ -355,34 +355,22 @@ function rangesFor(nodes: ReadonlyArray): TreeSelection { }; } -function createUniqueId(nodes: ReadonlyArray, createId: () => string): string { - const existing = new Set(nodes.map((node) => node.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique tree node id"); -} - function cloneNodesWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, rootParentId: string | null, ): TreeNode[] { - const occupied = [...existing]; + const allocateId = createEditingIdAllocator(existing.map((node) => node.id), createId, "tree node"); const idMap = new Map(); const copied = source.map((node) => { - const id = createUniqueId(occupied, createId); + const id = allocateId(); idMap.set(node.id, id); - const copy = { ...node, id }; - occupied.push(copy); - return copy; + return { ...node, id }; }); - const sourceIds = new Set(source.map((node) => node.id)); return copied.map((node, index) => { const original = source[index]!; - const parentId = original.parentId !== null && sourceIds.has(original.parentId) + const parentId = original.parentId !== null ? idMap.get(original.parentId) ?? rootParentId : rootParentId; return { ...node, parentId }; diff --git a/packages/json-document-editing/tests/identity.test.ts b/packages/json-document-editing/tests/identity.test.ts index 5b6c88da0..dbdaaa68a 100644 --- a/packages/json-document-editing/tests/identity.test.ts +++ b/packages/json-document-editing/tests/identity.test.ts @@ -1,6 +1,6 @@ import { createJSONDocument, type JSONDocument, type JSONValue } from "@interactive-os/json-document"; import { describe, expect, test, vi } from "vitest"; -import { createDocumentEditor, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, createEditingId } from "../src/index.js"; +import { createDocumentEditor, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, createEditingId, createEditingIdAllocator } from "../src/index.js"; interface Case { readonly name: string; @@ -31,6 +31,43 @@ const cases: Case[] = [ ]; describe("default domain identities", () => { + test("reads 50,000 existing IDs once while reserving 1,000 batch IDs", () => { + let reads = 0; + function* existingIds() { + for (let index = 0; index < 50_000; index++) { reads++; yield `existing-${index}`; } + } + let sequence = 0; + const allocateId = createEditingIdAllocator(existingIds(), () => `copy-${sequence++}`, "object"); + const allocated = Array.from({ length: 1_000 }, allocateId); + expect(reads).toBe(50_000); + expect(new Set(allocated).size).toBe(1_000); + }); + + test("reserves new IDs and gives each allocation exactly 100 collision attempts", () => { + const createId = vi.fn().mockReturnValueOnce("occupied").mockReturnValueOnce("copy").mockReturnValue("copy"); + const allocateId = createEditingIdAllocator(["occupied"], createId, "tree node"); + expect(allocateId()).toBe("copy"); + createId.mockClear(); + expect(allocateId).toThrow("createId did not produce a unique tree node id"); + expect(createId).toHaveBeenCalledTimes(100); + createId.mockReturnValue("next"); + expect(allocateId()).toBe("next"); + }); + + test.each(cases)("$name preserves the collision limit and document on failure", ({ initial, insert }) => { + const randomUUID = vi.fn(() => "collision"); + vi.stubGlobal("crypto", { randomUUID }); + try { + const document = createJSONDocument(initial); + expect(insert(document)).toBe(true); + const before = document.value; + randomUUID.mockClear(); + expect(() => insert(document)).toThrow("createId did not produce a unique"); + expect(randomUUID).toHaveBeenCalledTimes(100); + expect(document.value).toBe(before); + } finally { vi.unstubAllGlobals(); } + }); + test.each(cases)("$name does not reuse IDs across independent or recreated editors", ({ initial, pointer, insert }) => { const ids: JSONValue[] = []; for (let instance = 0; instance < 3; instance++) { diff --git a/packages/json-document-editing/tests/session-history.test.ts b/packages/json-document-editing/tests/session-history.test.ts index 9e81c7273..d436e4a4c 100644 --- a/packages/json-document-editing/tests/session-history.test.ts +++ b/packages/json-document-editing/tests/session-history.test.ts @@ -1,8 +1,76 @@ -import { createJSONDocument } from "@interactive-os/json-document"; -import { describe, expect, test } from "vitest"; +import { createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { describe, expect, test, vi } from "vitest"; import { createEditingSession } from "../src/session.js"; describe("selection-aware editing history", () => { + test("lowers aliased selection points while metadata and history remain detached", () => { + const document = createJSONDocument({ n: 0 }); + const point = { offset: 1 }; + const session = createEditingSession({ document, selection: { anchor: point, focus: point } }); + const retained = session.snapshot; + const after = { offset: 2 }; + const result = session.apply({ + operations: [{ op: "replace", path: "/n", value: 1 }], + selectionAfter: { anchor: after, focus: after }, origin: "edit", + }); + expect(result.ok).toBe(true); + point.offset = 9; + after.offset = 9; + expect(retained.selection).toEqual({ anchor: { offset: 1 }, focus: { offset: 1 } }); + expect(result.ok && result.change?.metadata?.editing).toMatchObject({ + selectionBefore: { anchor: { offset: 1 }, focus: { offset: 1 } }, + selectionAfter: { anchor: { offset: 2 }, focus: { offset: 2 } }, + }); + expect(session.undo()).toMatchObject({ ok: true, snapshot: { selection: retained.selection } }); + expect(session.redo()).toMatchObject({ ok: true, snapshot: { selection: { anchor: { offset: 2 } } } }); + }); + + test("ignored local history skips inverse reads but preserves canonical failures and redo", () => { + const inner = createJSONDocument({ n: 0, ignored: 0 }); + const at = vi.fn(inner.at); + const document = { ...inner, get value() { return inner.value; }, at }; + const session = createEditingSession({ document, selection: null }); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "record" }); + session.undo(); + at.mockClear(); + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/ignored", value: 1 }, + { op: "replace", path: "/ignored", value: 2 }, + ]; + expect(session.apply({ operations, selectionAfter: null, origin: "ignore", history: "ignore" }).ok).toBe(true); + expect(at).not.toHaveBeenCalled(); + const before = session.snapshot; + const invalid: JSONPatchOperation[] = [{ op: "remove", path: "/missing" }, { op: "test", path: "/n", value: 99 }]; + expect(session.apply({ operations: invalid, selectionAfter: null, origin: "ignore", history: "ignore" })) + .toEqual(inner.validatePatch(invalid)); + expect(session.snapshot).toEqual(before); + expect(session.redo().ok).toBe(true); + expect(inner.value).toEqual({ n: 1, ignored: 2 }); + }); + + test("preserves step order in a large mixed inverse and a long private history", () => { + const document = createJSONDocument({ source: { value: "kept" }, destination: "overwritten", values: Array(300).fill(0) }); + const session = createEditingSession({ document, selection: null }); + const initial = document.value; + const operations: JSONPatchOperation[] = [ + { op: "move", from: "/source", path: "/destination" }, + ...Array.from({ length: 300 }, (_, index): JSONPatchOperation => ({ op: "replace", path: `/values/${index}`, value: index + 1 })), + ]; + expect(session.apply({ operations, selectionAfter: null, origin: "batch" }).ok).toBe(true); + const changed = document.value; + expect(session.undo().ok).toBe(true); + expect(document.value).toEqual(initial); + expect(session.redo().ok).toBe(true); + expect(document.value).toEqual(changed); + for (let value = 1; value <= 200; value++) { + expect(session.apply({ operations: [{ op: "replace", path: "/values/0", value: value + 1 }], selectionAfter: null, origin: "history" }).ok).toBe(true); + } + for (let index = 0; index < 200; index++) expect(session.undo().ok).toBe(true); + expect(document.value).toEqual(changed); + for (let index = 0; index < 200; index++) expect(session.redo().ok).toBe(true); + expect(document.at("/values/0")).toMatchObject({ ok: true, value: 201 }); + }); + test.each([false, true])("reconciles external selection once before publication (observed=%s)", (observed) => { const document = createJSONDocument({ text: "long" }); let reconciles = 0; diff --git a/packages/json-document-react-hook-form/benchmarks/form.mjs b/packages/json-document-react-hook-form/benchmarks/form.mjs index f9e2517bb..20d58b430 100644 --- a/packages/json-document-react-hook-form/benchmarks/form.mjs +++ b/packages/json-document-react-hook-form/benchmarks/form.mjs @@ -23,11 +23,43 @@ console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${c const externalRows = []; const submitRows = []; +const rerenderRows = []; +const batchRows = []; for (const size of config.sizes) { const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) }; const middle = Math.floor(size / 2); console.log(`\nitems=${size}`); + const rerender = await measureAsync(config, "unchanged rerender", async () => { + const documentState = createJSONDocument(initial); + const hook = renderHook(() => useReactHookFormConnector(documentState)); + return async () => { + await act(async () => { hook.rerender(); }); + return hook.result.current.snapshot.value === documentState.value; + }; + }); + cleanup(); + rerenderRows.push({ size, ...rerender }); + + const count = Math.min(size, 5_000); + const batch = await measureAsync(config, `external ${count} leaf sync`, async () => { + const documentState = createJSONDocument(initial); + const hook = renderHook(() => useReactHookFormConnector(documentState)); + return async () => { + let committed; + await act(async () => { + committed = documentState.commit(Array.from({ length: count }, (_, index) => ({ + op: "replace", path: `/items/${index}/done`, value: true, + }))); + }); + const synced = hook.result.current.form.getValues(`items.${count - 1}.done`) === true; + hook.unmount(); + cleanup(); + return committed?.ok === true && synced; + }; + }); + batchRows.push({ size, ...batch }); + const external = await measureAsync(config, "external leaf sync", async () => { const documentState = createJSONDocument(initial); const hook = renderHook(() => useReactHookFormConnector(documentState)); @@ -63,3 +95,7 @@ console.log("\nexternal leaf sync"); reportScaling(externalRows); console.log("\nwhole form submit"); reportScaling(submitRows); +console.log("\nunchanged rerender"); +reportScaling(rerenderRows); +console.log("\nexternal batch sync"); +reportScaling(batchRows); diff --git a/packages/json-document-react-hook-form/src/index.ts b/packages/json-document-react-hook-form/src/index.ts index 71e477f1f..5f9910ca8 100644 --- a/packages/json-document-react-hook-form/src/index.ts +++ b/packages/json-document-react-hook-form/src/index.ts @@ -74,9 +74,10 @@ export function useJSONDocumentForm< changeSource?: Pick, ): JSONDocumentFormBinding { const snapshot = useEditingSnapshot(session); + const defaultValues = useMemo(() => cloneFormValues(snapshot.value), [snapshot.value]); const form = useForm({ ...options.form, - defaultValues: cloneFormValues(snapshot.value) as DefaultValues, + defaultValues: defaultValues as DefaultValues, }); const [result, setResult] = useState | null>(null); const canonicalValue = useRef(snapshot.value); @@ -159,7 +160,7 @@ function syncAppliedChange( } function syncPointers(operations: ReadonlyArray): string[] | null { - const pointers: string[] = []; + const pointers = new Set(); for (const operation of operations) { if (operation.op === "test") continue; if (operation.path === "") return null; @@ -169,18 +170,20 @@ function syncPointers(operations: ReadonlyArray): string[] | if (structural) segments.pop(); const pointer = buildPointer(segments); if (pointer === "") return null; - pointers.push(pointer); + pointers.add(pointer); if ((operation.op === "move" || operation.op === "copy") && operation.from !== "") { const from = parsePointer(operation.from); from.pop(); if (from.length === 0) return null; - pointers.push(buildPointer(from)); + pointers.add(buildPointer(from)); } } - return pointers.filter((pointer, index) => ( - pointers.indexOf(pointer) === index - && !pointers.some((other) => other !== pointer && pointer.startsWith(`${other}/`)) - )); + return [...pointers].filter((pointer) => { + for (let end = pointer.lastIndexOf("/"); end > 0; end = pointer.lastIndexOf("/", end - 1)) { + if (pointers.has(pointer.slice(0, end))) return false; + } + return true; + }); } function fieldPath(pointer: string): string | null { diff --git a/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx b/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx index c83664950..8e7b46668 100644 --- a/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx +++ b/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx @@ -1,11 +1,13 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { createJSONDocument, type JSONDocument } from "@interactive-os/json-document"; +import { createEditingSession } from "@interactive-os/json-document-editing"; +import { createFormControl } from "react-hook-form"; import { useReactConnector } from "@interactive-os/json-document-react"; import { createZodValidator } from "@interactive-os/json-document-zod"; import * as z from "zod/v4"; -import { useReactHookFormConnector } from "../src/index.js"; +import { useJSONDocumentForm, useReactHookFormConnector } from "../src/index.js"; interface ProfileForm { profile: { @@ -17,6 +19,98 @@ interface ProfileForm { afterEach(cleanup); describe("React Hook Form Connector", () => { + test("does not clone an unchanged snapshot on rerender or selection updates", () => { + const document = createJSONDocument({ items: Array.from({ length: 10_000 }, (_, id) => ({ id })) }); + const session = createEditingSession({ document, selection: null }); + const hook = renderHook(() => useJSONDocumentForm(session)); + const stringify = vi.spyOn(JSON, "stringify"); + try { + for (let index = 0; index < 10; index++) hook.rerender(); + act(() => { session.select(null); }); + expect(stringify.mock.calls.filter(([value]) => value === document.value)).toHaveLength(0); + act(() => { hook.result.current.form.setValue("items.0.id", 99); }); + expect(document.at("/items/0/id")).toMatchObject({ value: 0 }); + } finally { stringify.mockRestore(); } + }); + + test("initializes replaced form controls and follows session/document replacement", () => { + const first = createProfileDocument(); + const second = createJSONDocument({ profile: { title: "Second", role: "editor" } }); + const controls = [createFormControl().formControl, createFormControl().formControl]; + const hook = renderHook(({ document, formControl }) => useReactHookFormConnector(document, { + form: { formControl }, + }), { initialProps: { document: first, formControl: controls[0]! } }); + act(() => { hook.result.current.form.setValue("profile.title", "Local"); }); + hook.rerender({ document: first, formControl: controls[0]! }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Local"); + hook.rerender({ document: first, formControl: controls[1]! }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Draft"); + hook.rerender({ document: second, formControl: controls[1]! }); + expect(hook.result.current.form.getValues()).toEqual(second.value); + act(() => { first.commit([{ op: "replace", path: "/profile/title", value: "Old source" }]); }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Second"); + }); + + test("deduplicates a large pointer batch and retains unrelated drafts", () => { + const document = createJSONDocument({ + fields: Object.fromEntries(Array.from({ length: 1_000 }, (_, index) => [`field${index}`, 0])), + draft: "original", + }); + const session = createEditingSession({ document, selection: null }); + const source = { at: vi.fn(document.at), subscribe: document.subscribe }; + const hook = renderHook(() => useJSONDocumentForm(session, {}, source)); + act(() => { hook.result.current.form.setValue("draft", "local"); }); + const some = vi.spyOn(Array.prototype, "some"); + try { + act(() => { + document.commit(Array.from({ length: 2_000 }, (_, index) => ({ + op: "replace" as const, path: `/fields/field${index % 1_000}`, value: index, + }))); + }); + expect(some.mock.contexts.filter((value) => ( + Array.isArray(value) && value.length > 100 && typeof value[0] === "string" && value[0].startsWith("/fields/") + ))).toHaveLength(0); + } finally { some.mockRestore(); } + expect(source.at).toHaveBeenCalledTimes(1_000); + expect(hook.result.current.form.getValues("fields.field999")).toBe(1_999); + expect(hook.result.current.form.getValues("draft")).toBe("local"); + }); + + test("syncs ancestors, structural move/copy parents and escaped/root fallbacks", () => { + const document = createJSONDocument({ left: [{ n: 1 }], right: [{ n: 2 }], draft: "original", "a/b": { n: 0 } }); + const session = createEditingSession({ document, selection: null }); + const source = { at: vi.fn(document.at), subscribe: document.subscribe }; + const hook = renderHook(() => useJSONDocumentForm(session, {}, source)); + act(() => { hook.result.current.form.setValue("draft", "local"); }); + act(() => { + document.commit([ + { op: "replace", path: "/left/0/n", value: 3 }, + { op: "move", from: "/left/0", path: "/right/1" }, + { op: "copy", from: "/right/0", path: "/left/0" }, + ]); + }); + expect(source.at.mock.calls.map(([path]) => path)).toEqual(["/right", "/left"]); + expect(hook.result.current.form.getValues("right")).toEqual([{ n: 2 }, { n: 3 }]); + expect(hook.result.current.form.getValues("draft")).toBe("local"); + act(() => { document.commit([{ op: "replace", path: "/a~1b/n", value: 4 }]); }); + expect(hook.result.current.form.getValues()).toEqual(document.value); + source.at.mockClear(); + act(() => { document.commit([{ op: "replace", path: "", value: { title: "root" } }]); }); + expect(source.at).not.toHaveBeenCalled(); + expect(hook.result.current.form.getValues()).toEqual({ title: "root" }); + }); + + test("keeps JSON conversion for Date and omitted optional values in form payloads", async () => { + const document = createJSONDocument({ rows: [] }); + const hook = renderHook(() => useReactHookFormConnector<{ rows: Array<{ date: Date; optional?: string | undefined }> }>(document)); + act(() => { + hook.result.current.form.setValue("rows", [{ date: new Date("2026-01-01T00:00:00Z"), optional: undefined }]); + }); + await act(async () => { await hook.result.current.submit(); }); + expect(hook.result.current.result).toMatchObject({ ok: true }); + expect(document.value).toEqual({ rows: [{ date: "2026-01-01T00:00:00.000Z" }] }); + }); + test("keeps drafts local, then commits all submitted fields as one history entry", async () => { const document = createProfileDocument(); render(); diff --git a/packages/json-document-rich-text/src/diff.ts b/packages/json-document-rich-text/src/diff.ts index 8480943cc..133ab9f8a 100644 --- a/packages/json-document-rich-text/src/diff.ts +++ b/packages/json-document-rich-text/src/diff.ts @@ -1,6 +1,5 @@ import { buildPointer, parsePointer, type JSONPatchOperation, type Pointer } from "@interactive-os/json-document"; import { hasRichTextContent, isRichTextText, type RichTextDocument, type RichTextNode } from "./model.js"; -import { detachedValue } from "./path.js"; export function diffRichText( before: RichTextDocument, @@ -10,7 +9,7 @@ export function diffRichText( if (before === after) return []; const operations = diffNode(before, after, parsePointer(rootPointer)); return operations.length === 0 && before !== after - ? [{ op: "replace", path: rootPointer, value: detachedValue(after) }] + ? [{ op: "replace", path: rootPointer, value: after }] : operations; } @@ -21,7 +20,7 @@ function diffNode( ): JSONPatchOperation[] { if (before === after) return []; if (before.id !== after.id || before.type !== after.type) { - return [{ op: "replace", path: buildPointer(segments), value: detachedValue(after) }]; + return [{ op: "replace", path: buildPointer(segments), value: after }]; } const operations: JSONPatchOperation[] = []; if (isRichTextText(before) && isRichTextText(after)) { @@ -29,14 +28,14 @@ function diffNode( operations.push({ op: "replace", path: buildPointer([...segments, "text"]), value: after.text }); } if (JSON.stringify(before.marks) !== JSON.stringify(after.marks)) { - operations.push({ op: "replace", path: buildPointer([...segments, "marks"]), value: detachedValue(after.marks) }); + operations.push({ op: "replace", path: buildPointer([...segments, "marks"]), value: after.marks }); } return operations; } const beforeRecord = before as { readonly attrs?: import("@interactive-os/json-document").JSONValue }; const afterRecord = after as { readonly attrs?: import("@interactive-os/json-document").JSONValue }; if (JSON.stringify(beforeRecord.attrs) !== JSON.stringify(afterRecord.attrs) && afterRecord.attrs !== undefined) { - operations.push({ op: "replace", path: buildPointer([...segments, "attrs"]), value: detachedValue(afterRecord.attrs) }); + operations.push({ op: "replace", path: buildPointer([...segments, "attrs"]), value: afterRecord.attrs }); } if (!hasRichTextContent(before) || !hasRichTextContent(after)) return operations; operations.push(...diffContent(before.content, after.content, [...segments, "content"])); @@ -59,7 +58,7 @@ function diffContent( const sharedBefore = beforeIds.filter((id) => afterSet.has(id)); const sharedAfter = afterIds.filter((id) => beforeSet.has(id)); if (!sameIds(sharedBefore, sharedAfter)) { - return [{ op: "replace", path: buildPointer(segments), value: detachedValue(after) }]; + return [{ op: "replace", path: buildPointer(segments), value: after }]; } const operations: JSONPatchOperation[] = []; for (let index = before.length - 1; index >= 0; index -= 1) { @@ -69,7 +68,7 @@ function diffContent( const remaining = new Map(before.filter((node) => afterSet.has(node.id)).map((node) => [node.id, node])); after.forEach((node, index) => { if (!beforeSet.has(node.id)) { - operations.push({ op: "add", path: buildPointer([...segments, index]), value: detachedValue(node) }); + operations.push({ op: "add", path: buildPointer([...segments, index]), value: node }); return; } const previous = remaining.get(node.id); diff --git a/packages/json-document-rich-text/src/editor.ts b/packages/json-document-rich-text/src/editor.ts index 2bb584f6d..5006b16a5 100644 --- a/packages/json-document-rich-text/src/editor.ts +++ b/packages/json-document-rich-text/src/editor.ts @@ -1,6 +1,7 @@ import { buildPointer, - createJSONDocument, + applyPatch, + readPointer, isJSONValue, parsePointer, type JSONDocument, @@ -42,7 +43,6 @@ import { diffRichText } from "./diff.js"; import { containerContentSegments, contentSegments, - detachedValue, nodeAtPath, replaceContentAtPath, replaceNodeAtPath, @@ -182,14 +182,13 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd return pasteClipboard(intent.clipboard); }, apply(operations, applyOptions) { - const candidate = createJSONDocument(options.document.value); - const applied = candidate.commit(operations); + const applied = applyPatch(options.document.value, operations); if (!applied.ok) return applied; - const located = candidate.at(pointer); + const located = readPointer(applied.value, pointer); const validation = validateRichText(located.ok ? located.value : undefined, { schema }); if (!validation.ok) return validation; const nextSelection = asRichTextSelection(selectionFamily.reconcile(session.snapshot.selection, { - topology: richTextTopology(readRichTextDocument(candidate, pointer)), + topology: richTextTopology(readRichTextSnapshot(applied.value, pointer)), }).state); return session.apply({ operations, @@ -220,12 +219,14 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd ranges.forEach((range, rangeIndex) => { const anchor = range.anchor as Extract; const focus = range.focus as Extract; - grouped.set(anchor.nodeId, [...(grouped.get(anchor.nodeId) ?? []), { + const replacements = grouped.get(anchor.nodeId) ?? []; + grouped.set(anchor.nodeId, replacements); + replacements.push({ start: Math.min(anchor.offset, focus.offset), end: Math.max(anchor.offset, focus.offset), rangeIndex, affinity: focus.affinity, - }]); + }); }); const topology = richTextTopology(before); const operations: import("@interactive-os/json-document").JSONPatchOperation[] = []; @@ -234,7 +235,8 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const currentNode = located === null ? null : nodeAtPath(before, located.path); if (located === null || currentNode === null || !isRichTextText(currentNode)) return failure("rich-text.point-not-found"); let nextText = currentNode.text; - for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) { + replacements.sort((left, right) => right.start - left.start); + for (const replacement of replacements) { if (!validTextOffset(nextText, replacement.start) || !validTextOffset(nextText, replacement.end)) return failure("rich-text.invalid-offset"); nextText = nextText.slice(0, replacement.start) + text + nextText.slice(replacement.end); } @@ -242,10 +244,15 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const validation = validateRichTextNodeAt(before, located.path, nextNode, { schema }); if (!validation.ok) return failure(validation.code); operations.push({ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "text"]), value: nextText }); - for (const replacement of replacements) { - const shift = replacements - .filter((candidate) => candidate.start < replacement.start) - .reduce((total, candidate) => total + text.length - (candidate.end - candidate.start), 0); + let shift = 0; + let groupShift = 0; + for (let index = replacements.length - 1; index >= 0; index -= 1) { + const replacement = replacements[index]!; + if (replacement.start !== replacements[index + 1]?.start) { + shift += groupShift; + groupShift = 0; + } + groupShift += text.length - (replacement.end - replacement.start); const point: RichTextPoint = { kind: "text", nodeId, @@ -444,7 +451,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd : { id: node.id, type: "heading", attrs: { level: Number(attrs!.level) as 1 | 2 | 3 | 4 | 5 | 6 }, content: node.content }) as RichTextNode; const validation = validateRichTextNodeAt(current, located.path, nextNode, { schema }); if (!validation.ok) return failure(validation.code); - operations.push({ op: "replace", path: absolutePath(pointer, contentSegments(located.path)), value: detachedValue(nextNode) }); + operations.push({ op: "replace", path: absolutePath(pointer, contentSegments(located.path)), value: nextNode }); } if (operations.length === 0) return success(session.snapshot); return commitOperations(session.snapshot.selection, "rich-text.block.set-type", undefined, operations); @@ -545,7 +552,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd session.snapshot.selection, "rich-text.node.set-attrs", undefined, - [{ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "attrs"]), value: detachedValue(attrs) }], + [{ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "attrs"]), value: attrs }], ); } @@ -604,7 +611,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd collapsedAtPoint(caret), "rich-text.text.delete", "rich-text.typing", - [{ op: "replace", path: absolutePath(pointer, containerContentSegments(parentPath)), value: detachedValue(content) }], + [{ op: "replace", path: absolutePath(pointer, containerContentSegments(parentPath)), value: content }], { path: parentPath }, ); } @@ -684,19 +691,19 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd if (first === undefined) return failure("rich-text.point-not-found"); if (removeIndex >= 0 && removeIndex < index) { operations.push({ op: "remove", path: `${contentPath}/${removeIndex}` }); - operations.push({ op: "replace", path: `${contentPath}/${index - 1}`, value: detachedValue(first) }); + operations.push({ op: "replace", path: `${contentPath}/${index - 1}`, value: first }); for (let offset = 1; offset < replacements.length; offset += 1) { const added = replacements[offset]; if (added === undefined) continue; - operations.push({ op: "add", path: `${contentPath}/${index - 1 + offset}`, value: detachedValue(added) }); + operations.push({ op: "add", path: `${contentPath}/${index - 1 + offset}`, value: added }); } } else { if (removeIndex > index) operations.push({ op: "remove", path: `${contentPath}/${removeIndex}` }); - operations.push({ op: "replace", path: `${contentPath}/${index}`, value: detachedValue(first) }); + operations.push({ op: "replace", path: `${contentPath}/${index}`, value: first }); for (let offset = 1; offset < replacements.length; offset += 1) { const added = replacements[offset]; if (added === undefined) continue; - operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: detachedValue(added) }); + operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: added }); } } return commitOperations(selectionAfter, origin, undefined, operations); @@ -860,10 +867,6 @@ function collapsedAt(nodeId: string, offset: number): RichTextSelection { })); } -function detached(document: RichTextDocument): RichTextDocument { - return JSON.parse(JSON.stringify(document)) as RichTextDocument; -} - interface LocatedRichTextNode { readonly node: RichTextNode; readonly parent: (RichTextNode | RichTextDocument) & { readonly content: ReadonlyArray } | null; @@ -996,7 +999,11 @@ function groupIntervals(intervals: ReadonlyArray): ReadonlyArray<{ readonly intervals: ReadonlyArray<{ readonly from: number; readonly to: number }>; }> { const grouped = new Map>(); - for (const interval of intervals) grouped.set(interval.nodeId, [...(grouped.get(interval.nodeId) ?? []), { from: interval.from, to: interval.to }]); + for (const interval of intervals) { + const values = grouped.get(interval.nodeId) ?? []; + grouped.set(interval.nodeId, values); + values.push({ from: interval.from, to: interval.to }); + } return [...grouped].map(([nodeId, values]) => { const sorted = values.sort((left, right) => left.from - right.from || left.to - right.to); const merged: Array<{ from: number; to: number }> = []; @@ -1019,11 +1026,15 @@ function markedSegments( ): ReadonlyArray { const boundaries = [...new Set([0, node.text.length, ...intervals.flatMap((interval) => [interval.from, interval.to])])].sort((a, b) => a - b); const nodes: RichTextNode[] = []; + let intervalIndex = 0; for (let index = 0; index < boundaries.length - 1; index += 1) { const from = boundaries[index]!; const to = boundaries[index + 1]!; if (from === to) continue; - const selected = intervals.some((interval) => from >= interval.from && to <= interval.to); + // groupIntervals supplies sorted, disjoint ranges; each is visited once. + while (intervals[intervalIndex] && intervals[intervalIndex]!.to <= from) intervalIndex += 1; + const interval = intervals[intervalIndex]; + const selected = interval !== undefined && from >= interval.from && to <= interval.to; let marks = [...node.marks]; if (selected) { marks = marks.filter((candidate) => candidate.type !== mark.type); @@ -1115,19 +1126,15 @@ function removeSelectedValue( inputOwnership: "borrowed", }); if (!normalized.ok) return { ok: false, code: normalized.code }; + const order = logicalPointOrder(document); const ranges = selection.ranges.map((range) => { - const start = earlierPoint(document, range.anchor, range.focus); + const start = order(range.anchor) <= order(range.focus) ? range.anchor : range.focus; const point = mapPointAfterRemoval(document, normalized.value, start, intervals); return { anchor: point, focus: point }; }); return { ok: true, value: normalized.value, selection: { ...selection, ranges } as RichTextSelection }; } -function earlierPoint(document: RichTextDocument, left: RichTextPoint, right: RichTextPoint): RichTextPoint { - const order = logicalPointOrder(document); - return (order(left) <= order(right)) ? left : right; -} - function logicalPointOrder(document: RichTextDocument): (point: RichTextPoint) => number { const positions = new Map(); let sequence = 0; @@ -1418,10 +1425,10 @@ function siblingReplacementOps( const contentPath = absolutePath(rootPointer, containerContentSegments(parentPath)); const first = replacements[0]!; const operations: import("@interactive-os/json-document").JSONPatchOperation[] = [ - { op: "replace", path: `${contentPath}/${index}`, value: detachedValue(first) }, + { op: "replace", path: `${contentPath}/${index}`, value: first }, ]; for (let offset = 1; offset < replacements.length; offset += 1) { - operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: detachedValue(replacements[offset]!) }); + operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: replacements[offset]! }); } return operations; } @@ -1468,7 +1475,7 @@ function planInsertNode( operations: [{ op: "add", path: absolutePath(rootPointer, [...containerContentSegments(container.path), point.offset]), - value: detachedValue(node), + value: node, }], nodes: [node], parentPath: container.path, @@ -1482,7 +1489,7 @@ function planInsertNode( const contentPath = absolutePath(rootPointer, containerContentSegments(parentPath)); if (point.offset === 0) { return { - operations: [{ op: "add", path: `${contentPath}/${index}`, value: detachedValue(node) }], + operations: [{ op: "add", path: `${contentPath}/${index}`, value: node }], nodes: [node], parentPath, selection: pointAfterInsertedAt(parentIdFromPath(document, parentPath), index, node, point.affinity), @@ -1490,7 +1497,7 @@ function planInsertNode( } if (point.offset === located.node.text.length) { return { - operations: [{ op: "add", path: `${contentPath}/${index + 1}`, value: detachedValue(node) }], + operations: [{ op: "add", path: `${contentPath}/${index + 1}`, value: node }], nodes: [node], parentPath, selection: pointAfterInsertedAt(parentIdFromPath(document, parentPath), index + 1, node, point.affinity), @@ -1500,9 +1507,9 @@ function planInsertNode( const right = { ...located.node, id: createId(), text: located.node.text.slice(point.offset) }; return { operations: [ - { op: "replace", path: `${contentPath}/${index}`, value: detachedValue(left) }, - { op: "add", path: `${contentPath}/${index + 1}`, value: detachedValue(node) }, - { op: "add", path: `${contentPath}/${index + 2}`, value: detachedValue(right) }, + { op: "replace", path: `${contentPath}/${index}`, value: left }, + { op: "add", path: `${contentPath}/${index + 1}`, value: node }, + { op: "add", path: `${contentPath}/${index + 2}`, value: right }, ], nodes: [left, node, right], parentPath, diff --git a/packages/json-document-rich-text/src/path.ts b/packages/json-document-rich-text/src/path.ts index d0a3b0cd2..3cac3db72 100644 --- a/packages/json-document-rich-text/src/path.ts +++ b/packages/json-document-rich-text/src/path.ts @@ -1,4 +1,3 @@ -import type { JSONValue } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { hasRichTextContent, @@ -64,7 +63,3 @@ export function replaceContentAtPath( } return replaceNodeAtPath(document, path, { ...container, content } as RichTextNode); } - -export function detachedValue(value: Value): Value { - return JSON.parse(JSON.stringify(value)) as Value; -} diff --git a/packages/json-document-rich-text/tests/editor.test.ts b/packages/json-document-rich-text/tests/editor.test.ts index 561b45991..9da3656a1 100644 --- a/packages/json-document-rich-text/tests/editor.test.ts +++ b/packages/json-document-rich-text/tests/editor.test.ts @@ -32,6 +32,60 @@ const initial: RichTextDocument = { }; describe("Official Rich Text editor", () => { + it("preserves caret offsets for unsorted, coincident and overlapping replacements", () => { + const replacements = [{ start: 6, end: 6 }, { start: 1, end: 3 }, { start: 1, end: 1 }, { start: 4, end: 5 }]; + const document = createJSONDocument({ + ...initial, content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcdefgh", marks: [] }] }], + }); + const ranges = replacements.map(({ start, end }) => ({ anchor: point("t", start), focus: point("t", end) })); + const editor = createRichTextEditor({ document, selection: { kind: "range", ranges, primaryIndex: 2 } }); + const reconciled = editor.snapshot.selection; + const before = document.value; + const text = "XY"; + let expected = "abcdefgh"; + for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) { + expected = expected.slice(0, replacement.start) + text + expected.slice(replacement.end); + } + expect(editor.dispatch({ type: "text.insert", text }).ok).toBe(true); + expect(document.at("/content/0/content/0/text")).toMatchObject({ value: expected }); + expect(editor.snapshot.selection.ranges.map((range) => range.focus.offset)).toEqual(replacements.map((replacement) => ( + replacement.start + text.length + replacements.filter((other) => other.start < replacement.start) + .reduce((shift, other) => shift + text.length - (other.end - other.start), 0) + ))); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { selection: reconciled } }); + expect(document.value).toEqual(before); + }); + + it("marks the union of overlapping and separated ranges without changing unselected segments", () => { + const document = createJSONDocument({ + ...initial, content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcdefghij", marks: [] }] }], + }); + const ranges = [[6, 8], [1, 3], [2, 4]].map(([from, to]) => ({ anchor: point("t", from!), focus: point("t", to!) })); + const editor = createRichTextEditor({ document, createId: ids(), selection: { kind: "range", ranges, primaryIndex: 0 } }); + const before = document.value; + expect(editor.dispatch({ type: "mark.toggle", mark: { type: "strong" } }).ok).toBe(true); + const content = (document.value as RichTextDocument).content[0] as RichTextParagraph; + expect(content.content.map((node) => [(node as RichTextText).text, (node as RichTextText).marks])).toEqual([ + ["a", []], ["bcd", [{ type: "strong" }]], ["ef", []], ["gh", [{ type: "strong" }]], ["ij", []], + ]); + expect(editor.undo().ok).toBe(true); + expect(document.value).toEqual(before); + }); + + it("owns inserted payloads and retained change values after internal planning", () => { + const document = createJSONDocument(initial); + const editor = createRichTextEditor({ document }); + const node = { id: "external", type: "paragraph" as const, content: [{ id: "external-text", type: "text" as const, text: "safe", marks: [] }] }; + const inserted = editor.dispatch({ type: "node.insert", point: { kind: "child", nodeId: "document-1", offset: 1, affinity: "forward" }, node }); + expect(inserted.ok).toBe(true); + node.content[0]!.text = "poison"; + expect(document.at("/content/1/content/0/text")).toMatchObject({ value: "safe" }); + expect(inserted.ok && inserted.change?.applied[0]).toMatchObject({ value: { content: [{ text: "safe" }] } }); + expect(editor.undo().ok).toBe(true); + expect(editor.redo().ok).toBe(true); + expect(document.at("/content/1/content/0/text")).toMatchObject({ value: "safe" }); + }); + it("uses a child boundary for an empty first block and splits at both text boundaries canonically", () => { const empty = createJSONDocument({ profile: "urn:interactive-os:json-document:rich-text:1", diff --git a/packages/json-document-rich-text/tests/local-edit.test.ts b/packages/json-document-rich-text/tests/local-edit.test.ts index f6c3f07cf..57679e434 100644 --- a/packages/json-document-rich-text/tests/local-edit.test.ts +++ b/packages/json-document-rich-text/tests/local-edit.test.ts @@ -1,4 +1,4 @@ -import { createJSONDocument } from "@interactive-os/json-document"; +import { createJSONDocument, readPointer } from "@interactive-os/json-document"; import { describe, expect, it } from "vitest"; import { createRichTextBlockFixture, @@ -9,6 +9,29 @@ import { } from "../src/index.js"; describe("Official Rich Text local edit costs", () => { + it.each([1_000, 10_000])("reuses the nested snapshot topology across external leaf edits (%s blocks)", (size) => { + const document = createJSONDocument({ "a/b": createRichTextBlockFixture(size) }); + const editor = createRichTextEditor({ document, pointer: "#/a~1b" }); + const retained = editor.snapshot.value; + const topology = editor.topology; + const instrument = createRichTextInstrument(); + runWithRichTextInstrument(instrument, () => { + expect(editor.topology).toBe(topology); + expect(editor.snapshot.value).toBe(retained); + expect(editor.topology).toBe(topology); + }); + expect(instrument.snapshot().topologyCreates).toBe(0); + const release = editor.subscribe(() => {}); + runWithRichTextInstrument(instrument, () => { + expect(document.commit([{ op: "replace", path: "/a~1b/content/0/content/0/text", value: "external" }]).ok).toBe(true); + expect(editor.snapshot.value).toBe(document.value); + }); + expect(instrument.snapshot().topologyCreates).toBe(0); + expect(instrument.snapshot().topologyVisits).toBeLessThan(16); + expect(readPointer(retained, "/a~1b/content/0/content/0/text")).toMatchObject({ value: "x" }); + release(); + }); + it("indexes topology during editor create in the same walk as validation", () => { const size = 256; const instrument = createRichTextInstrument(); diff --git a/packages/json-document-rich-text/tests/schema.test.ts b/packages/json-document-rich-text/tests/schema.test.ts index ce22f79af..1953e360b 100644 --- a/packages/json-document-rich-text/tests/schema.test.ts +++ b/packages/json-document-rich-text/tests/schema.test.ts @@ -48,6 +48,25 @@ const canonical: RichTextDocument = { }; describe("Official Rich Text schema", () => { + it("keeps normalization values and patch payloads independently mutable", () => { + const input = { + profile: canonical.profile, id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [ + { id: "a", type: "text", text: "A", marks: [] }, + { id: "b", type: "text", text: "B", marks: [] }, + ] }], + }; + const normalized = normalizeRichText(input); + if (!normalized.ok) throw new Error(normalized.reason); + const operation = normalized.operations.find((operation) => operation.path === "/content/0/content"); + expect(operation).toMatchObject({ op: "replace", value: [{ text: "AB" }] }); + if (operation?.op !== "replace") throw new Error("missing normalization patch"); + (operation.value as Array<{ text: string }>)[0]!.text = "patch-only"; + expect(normalized.value.content[0]).toMatchObject({ content: [{ text: "AB" }] }); + input.content[0]!.content[0]!.text = "input-only"; + expect(normalized.value.content[0]).toMatchObject({ content: [{ text: "AB" }] }); + }); + it("rejects non-JSON extension attrs before normalization or history changes", () => { const schema = createRichTextSchema({ profile: "urn:example:json-attrs:1", nodes: { "com.example/data": { group: "block", atom: true, attrs: { value: { required: true, validate: () => true } }, content: null, allowedMarks: "none" }, diff --git a/site/src/routes/editing-demos/useClipboardLab.ts b/site/src/routes/editing-demos/useClipboardLab.ts index 73d986e79..4c3bd7d17 100644 --- a/site/src/routes/editing-demos/useClipboardLab.ts +++ b/site/src/routes/editing-demos/useClipboardLab.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { type BlockDocument, type DocumentClipboard } from "@interactive-os/json-document-editing"; +import { createEditingId, createEditingIdAllocator, type BlockDocument, type DocumentClipboard } from "@interactive-os/json-document-editing"; import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react"; const clipboardLabDocument: BlockDocument = { @@ -12,7 +12,12 @@ const clipboardLabDocument: BlockDocument = { /** Owns the Clipboard page's payload and copy/cut/paste command observation. */ export function useClipboardLab() { - const editor = useDocumentEditor(clipboardLabDocument); + const [createId] = useState(() => createEditingIdAllocator( + clipboardLabDocument.blocks.map((block) => block.id), + () => createEditingId("clipboard-block"), + "block", + )); + const editor = useDocumentEditor(clipboardLabDocument, { createId }); const [clipboard, setClipboard] = useState(null); const [lastCall, setLastCall] = useState("블록을 선택한 뒤 copy 또는 cut을 실행합니다."); const editing = useEditing({ diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 23d580896..1caa785d5 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -722,6 +722,11 @@ const registeredPublicUsages = [ symbol: "createEditingId", sourcePath: "packages/json-document-editing/src/identity.ts", }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createEditingIdAllocator", + sourcePath: "packages/json-document-editing/src/identity.ts", + }, { packageName: "@interactive-os/json-document-collaboration/editing", symbol: "createCollaborationEditingHistory", diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index a97a7c85c..c90d3d57a 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -1,8 +1,9 @@ -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, renderHook, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { DemoWorkbench } from "../../src/shared/demo-workbench/DemoWorkbench"; import { defineDemo } from "../../src/shared/demo-workbench/define-demo"; import { discoverDemoSources } from "../../src/shared/demo-workbench/demo-sources"; +import { useClipboardLab } from "../../src/routes/editing-demos/useClipboardLab"; afterEach(cleanup); @@ -54,6 +55,21 @@ describe("DemoWorkbench", () => { }); describe("Demo definition and source discovery", () => { + test("exercises and exposes the canonical ID allocator in Clipboard Usage", async () => { + const hook = renderHook(useClipboardLab); + act(() => { hook.result.current.copy(); }); + act(() => { hook.result.current.paste(); }); + act(() => { hook.result.current.paste(); }); + const value = hook.result.current.snapshot.value as { blocks: Array<{ id: string }> }; + expect(value.blocks).toHaveLength(5); + expect(new Set(value.blocks.map((block) => block.id)).size).toBe(5); + expect(value.blocks.filter((block) => block.id.startsWith("clipboard-block-"))).toHaveLength(2); + const sources = await discoverDemoSources("routes/editing-demos/ClipboardDemoRoute.tsx"); + const owner = sources.find((file) => file.path === "packages/json-document-editing/src/identity.ts"); + expect(owner?.referencePath).toMatch(/^\/docs\/api\//); + expect(await owner!.load()).toContain("export function createEditingIdAllocator"); + }); + test("Annotation Usage exposes the Hand, output, geometry, selection projection and Key owner", async () => { const sources = await discoverDemoSources("routes/annotation-demo/AnnotationDemoRoute.tsx"); for (const path of [