From ea9642bb6f307f3c296c66942588fafcd5dede7b 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: Mon, 7 Sep 2026 10:06:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=8E=B8=EC=A7=91=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20snapshot=C2=B7Pointer=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4=20(#7?= =?UTF-8?q?07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document-editing/src/session.ts | 35 +++++++++---- .../tests/session-history.test.ts | 45 ++++++++++++++++ packages/json-document-react/src/index.ts | 22 +++++--- .../tests/react-connector.test.tsx | 17 ++++++ .../src/application/document/protocol.ts | 7 ++- .../src/foundation/patch/track.ts | 52 ++++++++++++------- .../src/foundation/protocol/index.ts | 5 +- .../conformance/suites/pointer.ts | 3 ++ .../conformance/vectors/pointer.json | 26 ++++++++++ 9 files changed, 172 insertions(+), 40 deletions(-) diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 41eae0576..07ce963cb 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -1,5 +1,7 @@ import { + createJSONDocument, jsonEqual, + parentPointer, type JSONAppliedChange, type JSONDocument, type JSONPatchOperation, @@ -74,8 +76,9 @@ export function createEditingSession(options: { } function synchronizeExternalChange(): boolean { - if (observedValue === document.value) return false; - observedValue = document.value; + const latest = document.value; + if (jsonEqual(observedValue, latest)) return false; + observedValue = latest; undoStack = []; redoStack = []; activeHistoryGroup = undefined; @@ -136,7 +139,7 @@ export function createEditingSession(options: { undoStack = [...undoStack.slice(0, -1), { ...entry, forward: [...previous.forward, ...entry.forward], - inverse: previous.inverse, + inverse: [...entry.inverse, ...previous.inverse], selectionBefore: previous.selectionBefore, }]; } else { @@ -225,27 +228,37 @@ function invertOperations( document: JSONDocument, operations: ReadonlyArray, ): ReadonlyArray | null { + // Every inverse reads the state immediately before its forward operation. + // Keep single-operation edits on the original read port; a batch needs an + // isolated working document to resolve shifted indexes and overwritten values. + const working = operations.length > 1 ? createJSONDocument(document.value) : document; const inverse: JSONPatchOperation[] = []; - for (let index = operations.length - 1; index >= 0; index -= 1) { - const operation = operations[index]; - if (operation === undefined) return null; + for (const operation of operations) { if (operation.op === "replace") { - const located = document.at(operation.path); + const located = working.at(operation.path); if (!located.ok) return null; inverse.push({ op: "replace", path: operation.path, value: clone(located.value) }); } else if (operation.op === "remove") { - const located = document.at(operation.path); + const located = working.at(operation.path); if (!located.ok) return null; inverse.push({ op: "add", path: operation.path, value: clone(located.value) }); } else if (operation.op === "add") { - const path = appendedIndexPath(document, operation.path); + const path = appendedIndexPath(working, operation.path); if (path === null) return null; - inverse.push({ op: "remove", path }); + const parent = parentPointer(path); + const container = parent === null ? null : working.at(parent); + const previous = working.at(path); + inverse.push(previous.ok && (parent === null || (container?.ok && !Array.isArray(container.value))) + ? { op: "replace", path, value: clone(previous.value) } + : { op: "remove", path }); + } else if (operation.op === "test") { + // A successful precondition changes no value and needs no inverse. } else { return null; } + if (working !== document && !working.commit([operation]).ok) return null; } - return inverse; + return inverse.reverse(); } function appendedIndexPath(document: JSONDocument, path: string): string | null { diff --git a/packages/json-document-editing/tests/session-history.test.ts b/packages/json-document-editing/tests/session-history.test.ts index c1fa20d3f..dd6bee862 100644 --- a/packages/json-document-editing/tests/session-history.test.ts +++ b/packages/json-document-editing/tests/session-history.test.ts @@ -3,6 +3,51 @@ import { describe, expect, test } from "vitest"; import { createEditingSession } from "../src/session.js"; describe("selection-aware editing history", () => { + test.each([ + { name: "object add replaces an existing member", value: { title: "before" }, operations: [{ op: "add", path: "/title", value: "after" }] }, + { name: "successive removals use intermediate values", value: { items: ["a", "b", "c"] }, operations: [{ op: "remove", path: "/items/0" }, { op: "remove", path: "/items/0" }] }, + { name: "insert then replace follows the shifted index", value: { items: ["a", "b"] }, operations: [{ op: "add", path: "/items/0", value: "x" }, { op: "replace", path: "/items/1", value: "A" }] }, + { name: "root add restores the document", value: { title: "before" }, operations: [{ op: "add", path: "", value: { title: "after" } }] }, + ] as const)("round trips $name", ({ value, operations }) => { + const document = createJSONDocument(value); + const session = createEditingSession({ document, selection: null }); + expect(session.apply({ operations, selectionAfter: null, origin: "edit" }).ok).toBe(true); + const after = document.value; + expect(session.undo().ok).toBe(true); + expect(document.value).toEqual(value); + expect(session.redo().ok).toBe(true); + expect(document.value).toEqual(after); + }); + + test("a history group restores changes to every affected path", () => { + const document = createJSONDocument({ left: 0, right: 0 }); + const session = createEditingSession({ document, selection: null }); + for (const path of ["/left", "/right"]) { + session.apply({ operations: [{ op: "replace", path, value: 1 }], selectionAfter: null, origin: "edit", historyGroup: "both" }); + } + expect(session.undo().ok).toBe(true); + expect(document.value).toEqual({ left: 0, right: 0 }); + expect(session.redo().ok).toBe(true); + expect(document.value).toEqual({ left: 1, right: 1 }); + }); + + test("fresh snapshot copies preserve local history until the value changes", () => { + const inner = createJSONDocument({ title: "before" }); + const document = { ...inner, get value() { return structuredClone(inner.value); } }; + const session = createEditingSession({ document, selection: null }); + const revisions: number[] = []; + const unsubscribe = session.subscribe((snapshot) => revisions.push(snapshot.revision)); + session.apply({ operations: [{ op: "replace", path: "/title", value: "after" }], selectionAfter: null, origin: "edit" }); + expect(session.snapshot.canUndo).toBe(true); + expect(session.snapshot.revision).toBe(1); + expect(session.undo().ok).toBe(true); + expect(inner.value).toEqual({ title: "before" }); + inner.commit([{ op: "replace", path: "/title", value: "external" }]); + expect(session.snapshot.canRedo).toBe(false); + expect(revisions).toEqual([1, 2, 3]); + unsubscribe(); + }); + test("publishes external document changes and invalidates local history", () => { const document = createJSONDocument({ title: "Draft" }); const session = createEditingSession<{ readonly current: string | null }>({ diff --git a/packages/json-document-react/src/index.ts b/packages/json-document-react/src/index.ts index ea7bbe691..f3befe462 100644 --- a/packages/json-document-react/src/index.ts +++ b/packages/json-document-react/src/index.ts @@ -1,5 +1,5 @@ -import { useCallback, useState, useSyncExternalStore } from "react"; -import type { JSONDocument, JSONValue } from "@interactive-os/json-document"; +import { useMemo, useState, useSyncExternalStore } from "react"; +import { jsonEqual, type JSONDocument, type JSONValue } from "@interactive-os/json-document"; import { createDocumentEditor, type BlockDocument, @@ -68,12 +68,18 @@ export { } from "./use-anchored-floating-position.js"; export function useJSONDocumentValue(document: JSONDocument): JSONValue { - const subscribe = useCallback( - (notify: () => void) => document.subscribe(() => notify()), - [document], - ); - const getSnapshot = useCallback(() => document.value, [document]); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const store = useMemo(() => { + let current = document.value; + return { + subscribe: (notify: () => void) => document.subscribe(() => notify()), + getSnapshot() { + const latest = document.value; + if (!jsonEqual(current, latest)) current = latest; + return current; + }, + }; + }, [document]); + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); } /** Official React Connector entry point for a JSONDocument subscription. */ diff --git a/packages/json-document-react/tests/react-connector.test.tsx b/packages/json-document-react/tests/react-connector.test.tsx index b89005e5d..47dbeea65 100644 --- a/packages/json-document-react/tests/react-connector.test.tsx +++ b/packages/json-document-react/tests/react-connector.test.tsx @@ -13,6 +13,23 @@ import { afterEach(cleanup); describe("React Connector", () => { + test("accepts copied document snapshots without repeated renders", () => { + const inner = createJSONDocument({ title: "Draft" }); + const document = { ...inner, get value() { return structuredClone(inner.value); } }; + let renders = 0; + function View() { + renders += 1; + const value = useJSONDocumentValue(document) as { title: string }; + return {value.title}; + } + render(); + expect(screen.getByText("Draft")).toBeTruthy(); + expect(renders).toBe(1); + act(() => { inner.commit([{ op: "replace", path: "/title", value: "Ready" }]); }); + expect(screen.getByText("Ready")).toBeTruthy(); + expect(renders).toBe(2); + }); + test("composes Document textarea caret, click count, input, and cursor restoration", () => { const caretRanges: unknown[] = []; const inputs: unknown[] = []; diff --git a/packages/json-document/src/application/document/protocol.ts b/packages/json-document/src/application/document/protocol.ts index 0324b3eb9..b0aaa7034 100644 --- a/packages/json-document/src/application/document/protocol.ts +++ b/packages/json-document/src/application/document/protocol.ts @@ -12,6 +12,7 @@ import { import type { JSONPatchOperation, JSONPatchResult, + JSONValue, Pointer, } from "./contract.js"; @@ -56,9 +57,13 @@ export function appendSegment( return appendSegmentInternal(pointer, segment); } +/** Tracks a location through applied patches using the pre-patch value. + * Omitting `before` retains the legacy numeric-segment-as-array interpretation. + */ export function trackPointer( pointer: Pointer, applied: ReadonlyArray, + before?: JSONValue, ): Pointer | null { - return trackPointerInternal(pointer, applied); + return trackPointerInternal(pointer, applied, before); } diff --git a/packages/json-document/src/foundation/patch/track.ts b/packages/json-document/src/foundation/patch/track.ts index a81ccc94d..ec7529970 100644 --- a/packages/json-document/src/foundation/patch/track.ts +++ b/packages/json-document/src/foundation/patch/track.ts @@ -5,11 +5,14 @@ import { buildPointer, isPrefix, + readAt, tryParsePointer, type Pointer, } from "../pointer/core.js"; import { parseArrayIndex } from "../pointer/array-index.js"; import type { JSONPatchOperation } from "./contract.js"; +import type { JSONValue } from "../protocol/contract.js"; +import { applyOpRaw } from "./apply.js"; function isArrayIndex(seg: string): boolean { return parseArrayIndex(seg) !== null; @@ -22,12 +25,16 @@ function isArrayIndex(seg: string): boolean { // remove 의 경우 delta = -1 (pivot > remove 위치는 한 칸 당겨짐). // // `at` 의 마지막 segment 가 array index 가 아니거나 "-" 이면 영향 없음. -function shiftArraySibling(at: string[], target: string[], delta: 1 | -1): string[] | null { +function shiftArraySibling(at: string[], target: string[], delta: 1 | -1, before?: JSONValue): string[] | null { if (at.length === 0) return null; const pivotSeg = at[at.length - 1]!; if (pivotSeg === "-") return null; if (!isArrayIndex(pivotSeg)) return null; const parent = at.slice(0, at.length - 1); + if (before !== undefined) { + const container = readAt(before, parent); + if (!container.ok || !Array.isArray(container.value)) return null; + } if (target.length < at.length) return null; for (let i = 0; i < parent.length; i++) { if (parent[i] !== target[i]) return null; @@ -45,7 +52,7 @@ function shiftArraySibling(at: string[], target: string[], delta: 1 | -1): strin // 한 op 가 한 pointer 에 어떤 영향을 주는가. // null = pointer 자체가 cascading drop 됨. -function trackOne(pointer: Pointer, op: JSONPatchOperation): Pointer | null { +function trackOne(pointer: Pointer, op: JSONPatchOperation, before?: JSONValue): Pointer | null { const target = tryParsePointer(pointer); if (target === null) return null; @@ -56,7 +63,12 @@ function trackOne(pointer: Pointer, op: JSONPatchOperation): Pointer | null { case "add": { const at = tryParsePointer(op.path); if (at === null) return null; - const shifted = shiftArraySibling(at, target, 1); + if (before !== undefined) { + const parent = readAt(before, at.slice(0, -1)); + if ((at.length === 0 || (parent.ok && !Array.isArray(parent.value))) + && isPrefix(at, target) && at.length < target.length) return null; + } + const shifted = shiftArraySibling(at, target, 1, before); return shifted ? buildPointer(shifted) : pointer; } @@ -65,7 +77,7 @@ function trackOne(pointer: Pointer, op: JSONPatchOperation): Pointer | null { if (at === null) return null; // 동일 또는 자손이면 drop if (isPrefix(at, target)) return null; - const shifted = shiftArraySibling(at, target, -1); + const shifted = shiftArraySibling(at, target, -1, before); return shifted ? buildPointer(shifted) : pointer; } @@ -92,14 +104,16 @@ function trackOne(pointer: Pointer, op: JSONPatchOperation): Pointer | null { return buildPointer([...to, ...tail]); } // 그 외: remove(from) 적용 후 add(to) 적용으로 합성 - const afterRemove = trackOne(pointer, { op: "remove", path: op.from }); + const afterRemove = trackOne(pointer, { op: "remove", path: op.from }, before); if (afterRemove === null) return null; - return trackOne(afterRemove, { op: "add", path: op.path, value: null }); + const removed = before === undefined ? undefined : applyOpRaw(before, { op: "remove", path: op.from }); + if (removed && "error" in removed) return null; + return trackOne(afterRemove, { op: "add", path: op.path, value: null }, removed?.state as JSONValue | undefined); } case "copy": { // copy 는 add 와 같은 영향 (target 위치에 새 노드) - return trackOne(pointer, { op: "add", path: op.path, value: null }); + return trackOne(pointer, { op: "add", path: op.path, value: null }, before); } } } @@ -107,23 +121,25 @@ function trackOne(pointer: Pointer, op: JSONPatchOperation): Pointer | null { export function trackPointer( pointer: Pointer, applied: ReadonlyArray, + before?: JSONValue, ): Pointer | null { - return trackPointerFrom(pointer, applied, 0); -} - -function trackPointerFrom( - pointer: Pointer, - applied: ReadonlyArray, - startIndex: number, -): Pointer | null { + const segments = tryParsePointer(pointer); + if (segments === null || (before !== undefined && !readAt(before, segments).ok)) return null; let cur: Pointer | null = pointer; - for (let index = startIndex; index < applied.length; index += 1) { + let state = before; + for (let index = 0; index < applied.length; index += 1) { if (cur === null) return null; const op = applied[index]!; - cur = trackOne(cur, op); + cur = trackOne(cur, op, state); + if (state !== undefined) { + const result = applyOpRaw(state, op); + if ("error" in result) return null; + state = result.state as JSONValue; + } // 방어: `/-` 가 결과 pointer 에 누출되면 broken — null 반환. // applyPatch 의 applied 는 normalizeOp 으로 이미 concrete index. 이 가드는 hand-built ops 보호용. - if (cur !== null && (cur === "-" || cur.endsWith("/-"))) return null; + if (cur !== null && (cur === "-" || cur.endsWith("/-")) + && (state === undefined || !readAt(state, tryParsePointer(cur)!).ok)) return null; } return cur; } diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index fad63f210..9a03c4271 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -1,6 +1,6 @@ import { trackPointer as trackPointerInternal } from "../patch/track.js"; import type { Pointer } from "../pointer/core.js"; -import type { JSONPatchOperation } from "./contract.js"; +import type { JSONPatchOperation, JSONValue } from "./contract.js"; export { applyOwnedProtocolPatch, @@ -40,6 +40,7 @@ export { parseArrayIndex } from "../pointer/array-index.js"; export function trackPointer( pointer: Pointer, applied: ReadonlyArray, + before?: JSONValue, ): Pointer | null { - return trackPointerInternal(pointer, applied); + return trackPointerInternal(pointer, applied, before); } diff --git a/standards/json-document-v3/conformance/suites/pointer.ts b/standards/json-document-v3/conformance/suites/pointer.ts index 1ccc4dbf7..9028d8c3e 100644 --- a/standards/json-document-v3/conformance/suites/pointer.ts +++ b/standards/json-document-v3/conformance/suites/pointer.ts @@ -15,6 +15,7 @@ export interface PointerHarness { trackPointer( pointer: string, applied: ReadonlyArray, + before?: JSONValue, ): string | null; } @@ -49,6 +50,7 @@ interface PointerManifest { readonly id: string; readonly pointer: string; readonly applied: ReadonlyArray; + readonly before?: JSONValue; readonly expect: string | null; }>; } @@ -99,6 +101,7 @@ export function runPointerConformance(harness: PointerHarness): void { expect(harness.trackPointer( vector.pointer, cloneJSON(vector.applied), + vector.before === undefined ? undefined : cloneJSON(vector.before), )).toBe(vector.expect); }); } diff --git a/standards/json-document-v3/conformance/vectors/pointer.json b/standards/json-document-v3/conformance/vectors/pointer.json index 8f45c72f7..65ffda741 100644 --- a/standards/json-document-v3/conformance/vectors/pointer.json +++ b/standards/json-document-v3/conformance/vectors/pointer.json @@ -33,6 +33,32 @@ { "id": "parent-nested", "pointer": "/items/0/title", "expect": "/items/0" } ], "track": [ + { + "id": "track-numeric-object-member", + "before": { "items": { "0": "a", "1": "b" } }, + "pointer": "/items/1", + "applied": [{ "op": "add", "path": "/items/0", "value": "A" }], + "expect": "/items/1" + }, + { + "id": "track-object-add-replaces-descendant", + "before": { "item": { "title": "a" } }, + "pointer": "/item/title", + "applied": [{ "op": "add", "path": "/item", "value": { "title": "b" } }], + "expect": null + }, + { + "id": "track-container-type-changes-in-batch", + "before": { "items": { "0": "a", "1": "b" } }, + "pointer": "/items/1", + "applied": [ + { "op": "move", "from": "/items", "path": "/archive" }, + { "op": "add", "path": "/items", "value": [] }, + { "op": "move", "from": "/archive/1", "path": "/items/0" }, + { "op": "add", "path": "/items/0", "value": "x" } + ], + "expect": "/items/1" + }, { "id": "track-array-add", "pointer": "/items/2/title", From e78c035d000eab6459bd3bb82409325941dd43ad 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: Mon, 7 Sep 2026 10:06:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=98=91=EC=97=85=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=B6=94=EC=A0=81=EA=B3=BC=20=EA=B8=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=9D=B4=ED=9B=84=20=ED=8E=B8=EC=A7=91=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=ED=95=9C=EB=8B=A4=20(#707)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../json-document-collaboration/README.md | 15 ++ .../benchmarks/runtime.mjs | 10 + .../src/document-patch.ts | 183 +++++++++++++++--- .../src/document-runtime.ts | 1 + .../src/history-runtime.ts | 7 +- .../src/materialize.ts | 73 +++---- .../src/replica-runtime.ts | 8 +- .../src/text-runtime.ts | 8 +- .../json-document-collaboration/src/tree.ts | 14 ++ .../tests/unit/document-tracking.test.ts | 51 +++++ .../tests/unit/long-history.test.ts | 36 ++++ 11 files changed, 337 insertions(+), 69 deletions(-) create mode 100644 packages/json-document-collaboration/tests/unit/document-tracking.test.ts create mode 100644 packages/json-document-collaboration/tests/unit/long-history.test.ts diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index aec64726f..71d26cf7c 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -1,5 +1,20 @@ # @interactive-os/json-document-collaboration +Remote `document.subscribe` notifications compile visible tree identities into +ordered JSON Patch moves, insertions, and removals. Consumers can use +`trackPointer(pointer, change.applied, before)` with the previous snapshot to +follow array reorders, object renames, and cross-container moves. A batch may use +temporary transfer locations to preserve identities through swaps; only the +final document is published. Root replacement still invalidates descendants, +while moving a surviving container to root retains its descendant addresses. +JSON-equal transitions remain notification-free under the JSONDocument contract. + +Data-only causal append reuses the previous materialization. Reordered histories +and history controls still replay from the epoch base. Causal ancestry uses actor +frontiers rather than recursive dependency walks. `benchmarks/runtime.mjs` measures +both remote history ingest and the first subsequent edit by a new actor; no wire +or checkpoint format changed. + Transport-free causal collaboration engine for the six-member `@interactive-os/json-document` JSON Document contract. diff --git a/packages/json-document-collaboration/benchmarks/runtime.mjs b/packages/json-document-collaboration/benchmarks/runtime.mjs index 11e15073a..bd4fabbab 100644 --- a/packages/json-document-collaboration/benchmarks/runtime.mjs +++ b/packages/json-document-collaboration/benchmarks/runtime.mjs @@ -45,6 +45,7 @@ seed.document.commit([{ op: "replace", path: "/value", value: 1 }]); const first = seed.replica.exportBundle().changes[0]; if (first === undefined || first.ops[0]?.kind !== "set") throw new Error("ledger seed failed"); const ledgerRows = []; +const commitRows = []; console.log("\nledger replay"); for (const size of ledgerSizes) { const changes = Array.from({ length: size }, (_, index) => ({ @@ -58,5 +59,14 @@ for (const size of ledgerSizes) { return () => receiver.replica.ingest(bundle).ok; }); ledgerRows.push({ size, ...result }); + const commit = measure(config, `${size} prior changes -> new actor commit`, () => { + const receiver = createCollaborationRuntime({ value: 0 }, { ...runtimeOptions, actorId: "ledger-receiver" }); + if (!receiver.replica.ingest(bundle).ok) throw new Error("ledger ingest failed"); + return () => receiver.document.commit([{ op: "replace", path: "/value", value: 2 }]).ok + && receiver.document.value.value === 2; + }); + commitRows.push({ size, ...commit }); } reportScaling(ledgerRows); +console.log("\ncontinued editing after remote history"); +reportScaling(commitRows); diff --git a/packages/json-document-collaboration/src/document-patch.ts b/packages/json-document-collaboration/src/document-patch.ts index aa84aa83c..70e27301c 100644 --- a/packages/json-document-collaboration/src/document-patch.ts +++ b/packages/json-document-collaboration/src/document-patch.ts @@ -1,43 +1,166 @@ -import { buildPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; -import { jsonEqual } from "@interactive-os/json-document"; +import { buildPointer, jsonEqual, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; +import { visibleMemberEntries, type TreeState } from "./tree.js"; -export function patchBetweenValues( - before: JSONValue, - after: JSONValue, -): ReadonlyArray { - const operations = diff(before, after, []); - return operations.length === 0 && !jsonEqual(before, after) - ? [{ op: "replace", path: "", value: after }] - : operations; +interface VisibleMember { + readonly id: string; + readonly value: JSONValue; + readonly container: string | undefined; + parent?: VisibleMember; + key: string; + children: VisibleMember[]; } -function diff( +/** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */ +export function patchBetweenTrees( before: JSONValue, after: JSONValue, - segments: ReadonlyArray, -): JSONPatchOperation[] { - if (before === after || jsonEqual(before, after)) return []; - if (isRecord(before) && isRecord(after)) { - const operations: JSONPatchOperation[] = []; - for (const key of Object.keys(after)) { - if (!Object.prototype.hasOwnProperty.call(before, key)) { - operations.push({ op: "add", path: buildPointer([...segments, key]), value: after[key]! }); - continue; + beforeTree: TreeState, + afterTree: TreeState, +): ReadonlyArray { + const current = new Map(); + const desired = new Map(); + let root = snapshot(beforeTree, beforeTree.root, before, "", current); + const target = snapshot(afterTree, afterTree.root, after, "", desired); + const operations: JSONPatchOperation[] = []; + if (root.container !== target.container && target.container !== undefined) { + const source = [...current.values()].find((member) => member.container === target.container); + if (source !== undefined) { + operations.push({ op: "move", from: pointer(source), path: "" }); + detach(source); + root = source; + } + } + // Root replacement invalidates descendants, just as a local root replace does. + if (root.container !== target.container || root.container === undefined) { + return jsonEqual(before, after) ? [] : [{ op: "replace", path: "", value: after }]; + } + let staging: VisibleMember | undefined; + + function remove(node: VisibleMember): void { + operations.push({ op: "remove", path: pointer(node) }); + detach(node); + } + + function move(node: VisibleMember, parent: VisibleMember, key: string): void { + const from = pointer(node); + detach(node); + // RFC 6902 resolves the destination after removing the source. + const path = buildPointer([...segments(parent), key]); + insert(node, parent, key); + operations.push({ op: "move", from, path }); + } + + function retain(node: VisibleMember): boolean { + return desired.has(node.id) || node.children.some(retain); + } + + function vacate(node: VisibleMember): void { + if (!retain(node)) { remove(node); return; } + 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: [] }; + insert(staging, root, key); + operations.push({ op: "add", path: pointer(staging), value: [] }); + } + move(node, staging, String(staging.children.length)); + } + + function reconcile(node: VisibleMember, wanted: VisibleMember): void { + if (node.container !== wanted.container || node.container === undefined) { + if (jsonEqual(node.value, wanted.value) && node.container === wanted.container) return; + // Rescue members moved out of a replaced container before dropping it. + 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 }; + if (node.parent !== undefined) { + const parent = node.parent; + const index = parent.children.indexOf(node); + replacement.parent = parent; + parent.children[index] = replacement; + } + current.set(wanted.id, replacement); + node = replacement; + if (wanted.container === undefined) return; + } + for (const [index, child] of wanted.children.entries()) { + const key = Array.isArray(wanted.value) ? String(index) : child.key; + let existing = current.get(child.id); + if (existing !== undefined && !attached(existing, root)) existing = undefined; + const occupant = Array.isArray(node.value) + ? node.children[index] + : node.children.find((entry) => entry.key === 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: [] }; + insert(existing, node, key); + current.set(child.id, existing); + operations.push({ op: "add", path: pointer(existing), value }); + } else if (existing.parent !== node || occupant !== existing) { + move(existing, node, key); } - operations.push(...diff(before[key]!, after[key]!, [...segments, key])); + reconcile(existing, child); } - for (const key of Object.keys(before)) { - if (Object.prototype.hasOwnProperty.call(after, key)) continue; - operations.push({ op: "remove", path: buildPointer([...segments, key]) }); + const wantedIds = new Set(wanted.children.map((child) => child.id)); + for (const child of [...node.children]) { + if (child !== staging && !wantedIds.has(child.id)) vacate(child); } - return operations; } - if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) { - return before.flatMap((value, index) => diff(value, after[index]!, [...segments, index])); + + reconcile(root, target); + if (staging !== undefined) remove(staging); + return operations; +} + +function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, members: Map): VisibleMember { + const reference = tree.members.get(id)!.node; + const node: VisibleMember = { + id, + value, + container: reference.kind === "container" ? reference.containerId : undefined, + key, + children: [], + }; + members.set(node.id, node); + if (value !== null && typeof value === "object") { + for (const [key, childId] of visibleMemberEntries(tree, id)) { + const child = Array.isArray(value) ? value[Number(key)]! : (value as Readonly>)[key]!; + const member = snapshot(tree, childId, child, key, members); + member.parent = node; + node.children.push(member); + } } - return [{ op: "replace", path: buildPointer(segments), value: after }]; + return node; +} + +function attached(node: VisibleMember, root: VisibleMember): boolean { + while (node.parent !== undefined) node = node.parent; + return node === root; +} + +function segments(node: VisibleMember): string[] { + const result: string[] = []; + while (node.parent !== undefined) { + result.push(Array.isArray(node.parent.value) ? String(node.parent.children.indexOf(node)) : node.key); + node = node.parent; + } + return result.reverse(); +} + +function pointer(node: VisibleMember): string { return buildPointer(segments(node)); } + +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); + delete node.parent; } -function isRecord(value: JSONValue): value is { readonly [key: string]: JSONValue } { - return typeof value === "object" && value !== null && !Array.isArray(value); +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); } diff --git a/packages/json-document-collaboration/src/document-runtime.ts b/packages/json-document-collaboration/src/document-runtime.ts index ef86064ba..78709cfed 100644 --- a/packages/json-document-collaboration/src/document-runtime.ts +++ b/packages/json-document-collaboration/src/document-runtime.ts @@ -106,6 +106,7 @@ export function createDocumentRuntime(state: RuntimeState): JSONDocument { state.initialTree, nextGraph.ordered, state.materializeValidation, + { ordered: state.graph.ordered, materialized: state.materialized }, ); if (!jsonEqual(nextMaterialized.value, patched.value)) { return failure( diff --git a/packages/json-document-collaboration/src/history-runtime.ts b/packages/json-document-collaboration/src/history-runtime.ts index cbe1a99a5..f31dcc279 100644 --- a/packages/json-document-collaboration/src/history-runtime.ts +++ b/packages/json-document-collaboration/src/history-runtime.ts @@ -8,7 +8,7 @@ import { prepareGraph, type PreparedGraph, } from "./change.js"; -import { patchBetweenValues } from "./document-patch.js"; +import { patchBetweenTrees } from "./document-patch.js"; import { jsonEqual } from "@interactive-os/json-document"; import { historyOperationFor, @@ -212,6 +212,7 @@ export function createHistory(state: RuntimeState): History { const prepared = prepareHistoryChange(direction); if (!prepared.ok) return prepared; + const previousTree = state.materialized.tree; assignCausalState(state, { known: prepared.value.known, graph: prepared.value.graph, @@ -221,9 +222,11 @@ export function createHistory(state: RuntimeState): History { let documentChange = undefined; if (prepared.value.didChangeDocument) { - const documentCommit = state.documentStore.commit(patchBetweenValues( + const documentCommit = state.documentStore.commit(patchBetweenTrees( state.documentStore.value, state.materialized.value, + previousTree, + state.materialized.tree, )); if (!documentCommit.ok) { throw new Error( diff --git a/packages/json-document-collaboration/src/materialize.ts b/packages/json-document-collaboration/src/materialize.ts index 73d584c9b..ffcca1a0e 100644 --- a/packages/json-document-collaboration/src/materialize.ts +++ b/packages/json-document-collaboration/src/materialize.ts @@ -45,6 +45,10 @@ export function materializeChanges( initialTree: TreeState, ordered: ReadonlyArray, validate: ((candidate: JSONValue) => JSONPatchValidationResult) | undefined, + previous?: { + readonly ordered: ReadonlyArray; + readonly materialized: MaterializedDocument; + }, ): MaterializedDocument { const isAncestor = createAncestry(ordered); const changes = new Map( @@ -54,12 +58,20 @@ export function materializeChanges( const appliedUndoTargets = new Map(); const appliedHistoryKeys = new Set(); const historySuppressed: SuppressedChange[] = []; + // Only a data-only, unchanged prefix can be reused. Reordering or any history + // control may change earlier acceptance decisions and must replay from base. + const prefix = previous !== undefined + && previous.ordered.length <= ordered.length + && previous.ordered.every((change, index) => change === ordered[index]) + && ordered.every((change) => classifyHistoryChange(change).kind === "none") + ? previous : undefined; let replay = replayDataChanges( initialTree, ordered, disabledByTarget, validate, isAncestor, + prefix, ); for (const change of ordered) { @@ -292,33 +304,22 @@ export function validateCandidate( function createAncestry( ordered: ReadonlyArray, ): (left: ChangeId, right: ChangeId) => boolean { - const changes = new Map( - ordered.map((change) => [changeIdKey(change.changeId), change]), - ); - const cache = new Map(); - - return (left: ChangeId, right: ChangeId): boolean => { - const leftKey = changeIdKey(left); - const rightKey = changeIdKey(right); - if (leftKey === rightKey) return false; - if (left.actorId === right.actorId) return left.counter < right.counter; - const pair = `${leftKey.length}:${leftKey}${rightKey}`; - const cached = cache.get(pair); - if (cached !== undefined) return cached; - - const seen = new Set(); - const visit = (currentKey: string): boolean => { - if (currentKey === leftKey) return true; - if (seen.has(currentKey)) return false; - seen.add(currentKey); - const current = changes.get(currentKey); - if (current === undefined) return false; - return current.deps.some((dependency) => visit(changeIdKey(dependency))); - }; - const result = visit(rightKey); - cache.set(pair, result); - return result; - }; + // The graph has a topological order and each actor has one contiguous chain. + // Actor frontiers answer reachability without recursive, per-pair graph walks. + const frontiers = new Map>(); + for (const change of ordered) { + const frontier = new Map(); + for (const dependency of change.deps) { + for (const [actor, counter] of frontiers.get(changeIdKey(dependency)) ?? []) { + frontier.set(actor, Math.max(frontier.get(actor) ?? 0, counter)); + } + frontier.set(dependency.actorId, Math.max(frontier.get(dependency.actorId) ?? 0, dependency.counter)); + } + frontiers.set(changeIdKey(change.changeId), frontier); + } + return (left, right) => left.actorId === right.actorId + ? left.counter < right.counter + : (frontiers.get(changeIdKey(right))?.get(left.actorId) ?? 0) >= left.counter; } function freezeConflict( @@ -380,14 +381,20 @@ function replayDataChanges( disabledByTarget: ReadonlyMap, validate: ((candidate: JSONValue) => JSONPatchValidationResult) | undefined, isAncestor: (left: ChangeId, right: ChangeId) => boolean, + prefix?: { + readonly ordered: ReadonlyArray; + readonly materialized: MaterializedDocument; + }, ): DataReplay { - let tree = cloneTree(initialTree); + const baseTree = prefix?.materialized.tree ?? initialTree; + let tree = cloneTree(baseTree); let projected: Extract, { readonly ok: true }> | null = null; let firstDataChange = true; - const suppressed: SuppressedChange[] = []; - const appliedKeys = new Set(); + const suppressed: SuppressedChange[] = [...(prefix?.materialized.suppressed ?? [])]; + const appliedKeys = new Set(prefix?.materialized.history.appliedKeys); for (const [order, change] of ordered.entries()) { + if (order < (prefix?.ordered.length ?? 0)) continue; const classified = classifyHistoryChange(change); if (classified.kind !== "none") continue; const key = changeIdKey(change.changeId); @@ -397,7 +404,7 @@ function replayDataChanges( firstDataChange = false; const applied = applySemanticChange(candidate, change, order); if (!applied.ok) { - if (candidate === tree) tree = cloneTree(initialTree); + if (candidate === tree) tree = cloneTree(baseTree); suppressed.push(freezeSuppressed( change.changeId, applied.code, @@ -415,7 +422,7 @@ function replayDataChanges( const materializedDocument = projectTree(candidate, isAncestor); if (!materializedDocument.ok) { - if (candidate === tree) tree = cloneTree(initialTree); + if (candidate === tree) tree = cloneTree(baseTree); suppressed.push(freezeSuppressed( change.changeId, materializedDocument.code, @@ -426,7 +433,7 @@ function replayDataChanges( const validation = validateCandidate(validate, materializedDocument.value); if (!validation.ok) { - if (candidate === tree) tree = cloneTree(initialTree); + if (candidate === tree) tree = cloneTree(baseTree); suppressed.push(freezeSuppressed( change.changeId, validation.code, diff --git a/packages/json-document-collaboration/src/replica-runtime.ts b/packages/json-document-collaboration/src/replica-runtime.ts index 9091c5474..4ac98f3ff 100644 --- a/packages/json-document-collaboration/src/replica-runtime.ts +++ b/packages/json-document-collaboration/src/replica-runtime.ts @@ -13,7 +13,7 @@ import { prepareGraph, unauthorizedChange, } from "./change.js"; -import { patchBetweenValues } from "./document-patch.js"; +import { patchBetweenTrees } from "./document-patch.js"; import { jsonEqual } from "@interactive-os/json-document"; import { materializeChanges } from "./materialize.js"; import { assignCausalState, type RuntimeState } from "./runtime-state.js"; @@ -141,9 +141,11 @@ export function createReplicaRuntime(state: RuntimeState): CollaborationReplica state.initialTree, nextGraph.ordered, state.materializeValidation, + { ordered: state.graph.ordered, materialized: state.materialized }, ); const changed = !jsonEqual(state.documentStore.value, nextMaterialized.value); + const previousTree = state.materialized.tree; assignCausalState(state, { known: nextKnown, graph: nextGraph, @@ -160,9 +162,11 @@ export function createReplicaRuntime(state: RuntimeState): CollaborationReplica let documentChange = undefined; if (changed) { - const documentCommit = state.documentStore.commit(patchBetweenValues( + const documentCommit = state.documentStore.commit(patchBetweenTrees( state.documentStore.value, state.materialized.value, + previousTree, + state.materialized.tree, )); if (!documentCommit.ok) { throw new Error( diff --git a/packages/json-document-collaboration/src/text-runtime.ts b/packages/json-document-collaboration/src/text-runtime.ts index 230563a93..e28a7954e 100644 --- a/packages/json-document-collaboration/src/text-runtime.ts +++ b/packages/json-document-collaboration/src/text-runtime.ts @@ -10,7 +10,7 @@ import { freezeLocalChange, prepareGraph, } from "./change.js"; -import { patchBetweenValues } from "./document-patch.js"; +import { patchBetweenTrees } from "./document-patch.js"; import { jsonEqual } from "@interactive-os/json-document"; import { materializeChanges } from "./materialize.js"; import { @@ -297,6 +297,7 @@ export function createText(state: RuntimeState): Text { state.initialTree, nextGraph.ordered, state.materializeValidation, + { ordered: state.graph.ordered, materialized: state.materialized }, ); const changeKey = changeIdKey(changeId); if (!nextMaterialized.history.appliedKeys.has(changeKey)) { @@ -314,6 +315,7 @@ export function createText(state: RuntimeState): Text { state.documentStore.value, nextMaterialized.value, ); + const previousTree = state.materialized.tree; assignCausalState(state, { known: nextKnown, graph: nextGraph, @@ -323,9 +325,11 @@ export function createText(state: RuntimeState): Text { let documentChange = undefined; if (didChangeDocument) { - const documentCommit = state.documentStore.commit(patchBetweenValues( + const documentCommit = state.documentStore.commit(patchBetweenTrees( state.documentStore.value, state.materialized.value, + previousTree, + state.materialized.tree, ), { ...(metadataProbe.change.metadata === undefined ? {} diff --git a/packages/json-document-collaboration/src/tree.ts b/packages/json-document-collaboration/src/tree.ts index bbd90839b..0366b3e66 100644 --- a/packages/json-document-collaboration/src/tree.ts +++ b/packages/json-document-collaboration/src/tree.ts @@ -439,6 +439,20 @@ export function arrayPositionId(member: TreeMember): PositionId | null { : null; } +/** Visible children keyed by their projected JSON segment, without repeated path scans. */ +export function visibleMemberEntries(tree: TreeState, memberId: MemberId): ReadonlyArray { + const member = tree.members.get(memberId); + if (member?.node.kind !== "container") return []; + const container = tree.containers.get(member.node.containerId); + if (container === undefined) return []; + if (container.kind === "array") { + return visibleArrayMembers(tree, container).map((child, index) => [String(index), child.id]); + } + return [...objectGroups(tree, container)].map(([key, members]) => [ + key, [...members].sort(compareMemberPlacements).at(-1)!.id, + ]); +} + function applySemanticOperation( tree: TreeState, operation: SemanticOperation, diff --git a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts new file mode 100644 index 000000000..fea4862b3 --- /dev/null +++ b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { trackPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; +import { createCollaborationRuntime } from "../../src/index.js"; + +const options = { epochId: "tracking/v1", ruleset: { id: "tracking", digest: "v1" } }; + +describe("remote structural notification", () => { + 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" }, + { name: "transfer key collision", initial: { __json_document_transfer__: 1, 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 container to root", initial: { item: { label: "a" }, discarded: true }, pointer: "/item/label", patch: [{ op: "move", from: "/item", path: "" }], expected: "/label" }, + { name: "array reorder with equal labels", initial: { items: [{ label: "same", n: 1 }, { label: "same", n: 2 }] }, pointer: "/items/0/label", patch: [{ op: "move", from: "/items/0", path: "/items/1" }], expected: "/items/1/label" }, + { name: "array insert", initial: { items: ["a", "b"] }, pointer: "/items/1", patch: [{ op: "add", path: "/items/0", value: "x" }], expected: "/items/2" }, + { name: "array delete", initial: { items: ["a", "b"] }, pointer: "/items/1", patch: [{ op: "remove", path: "/items/0" }], expected: "/items/0" }, + { name: "cross-container move", initial: { left: [{ label: "a" }], right: [] }, pointer: "/left/0/label", patch: [{ op: "move", from: "/left/0", path: "/right/0" }], expected: "/right/0/label" }, + { name: "object rename", initial: { old: { label: "a" } }, pointer: "/old/label", patch: [{ op: "move", from: "/old", path: "/new" }], expected: "/new/label" }, + { 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" }, + ] 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" }); + let tracked: string | null = pointer; + let before: JSONValue = remote.document.value; + remote.document.subscribe((change) => { + tracked = tracked === null ? null : trackPointer(tracked, change.applied, before); + before = remote.document.value; + }); + expect(local.document.commit(patch as readonly JSONPatchOperation[]).ok).toBe(true); + expect(remote.replica.ingest(local.replica.exportBundle()).ok).toBe(true); + expect(remote.document.value).toEqual(local.document.value); + expect(tracked).toBe(expected); + }); + + test("a local leaf edit still follows an incoming concurrent move", () => { + const initial = { items: [{ label: "a" }, { label: "b" }] }; + const local = createCollaborationRuntime(initial, { ...options, actorId: "local" }); + const remote = createCollaborationRuntime(initial, { ...options, actorId: "remote" }); + local.document.commit([{ op: "replace", path: "/items/0/label", value: "local" }]); + remote.document.commit([{ op: "move", from: "/items/0", path: "/items/1" }]); + const before = local.document.value; + let tracked: string | null = "/items/0/label"; + local.document.subscribe((change) => { tracked = trackPointer("/items/0/label", change.applied, before); }); + expect(local.replica.ingest(remote.replica.exportBundle()).ok).toBe(true); + expect(tracked).toBe("/items/1/label"); + expect(local.document.at(tracked!)).toMatchObject({ ok: true, value: "local" }); + expect(remote.replica.ingest(local.replica.exportBundle()).ok).toBe(true); + expect(remote.document.value).toEqual(local.document.value); + }); +}); diff --git a/packages/json-document-collaboration/tests/unit/long-history.test.ts b/packages/json-document-collaboration/tests/unit/long-history.test.ts new file mode 100644 index 000000000..1ed31f9f4 --- /dev/null +++ b/packages/json-document-collaboration/tests/unit/long-history.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { createCollaborationRuntime, restoreCollaborationRuntime, type CollaborationBundle } from "../../src/index.js"; + +const options = { epochId: "long-history/v1", ruleset: { id: "long-history", digest: "v1" } }; + +describe("editing after remote history", () => { + test("a new actor can commit after a long causal chain and restore the same state", () => { + const author = createCollaborationRuntime({ value: 0 }, { ...options, actorId: "author" }); + author.document.commit([{ op: "replace", path: "/value", value: 1 }]); + const first = author.replica.exportBundle().changes[0]!; + const operation = first.ops[0]!; + if (operation.kind !== "set") throw new Error("expected scalar set"); + const bundle: CollaborationBundle = { + epoch: author.replica.epoch, + changes: Array.from({ length: 6000 }, (_, index) => ({ + changeId: { actorId: "author", counter: index + 1 }, + deps: index === 0 ? [] : [{ actorId: "author", counter: index }], + ops: [{ ...operation, value: (index + 1) % 2 }], + })), + }; + const receiver = createCollaborationRuntime({ value: 0 }, { ...options, actorId: "receiver" }); + expect(receiver.replica.ingest(bundle).ok).toBe(true); + for (const value of [2, 3]) { + expect(receiver.document.commit([{ op: "replace", path: "/value", value }]).ok).toBe(true); + } + expect(receiver.document.value).toEqual({ value: 3 }); + const restored = restoreCollaborationRuntime(receiver.replica.exportCheckpoint(), { actorId: "receiver", ruleset: options.ruleset }); + expect(restored.ok).toBe(true); + if (!restored.ok) return; + expect(restored.runtime.document.value).toEqual(receiver.document.value); + expect(restored.runtime.replica.status()).toEqual(receiver.replica.status()); + expect(restored.runtime.document.commit([{ op: "replace", path: "/value", value: 4 }]).ok).toBe(true); + expect(receiver.replica.ingest(restored.runtime.replica.exportBundle()).ok).toBe(true); + expect(receiver.document.value).toEqual({ value: 4 }); + }, 30_000); +}); From 688a889439ec20b510ae46c1a17b4be271dd2c02 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: Mon, 7 Sep 2026 10:06:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EB=8F=85=EB=A6=BD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=9D=98=20=EC=A0=84=EC=B2=B4=20=EC=A0=81=ED=95=A9?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EA=B3=B5=EA=B0=9C=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=A6=9D=EA=B1=B0=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#707)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference/json-document.md | 2 +- docs/public/api.md | 24 +++- docs/public/llms.txt | 4 +- package-lock.json | 13 +- package.json | 1 + packages/json-document-editing/README.md | 6 + packages/json-document-react/README.md | 3 + packages/json-document/README.md | 4 +- .../connectors/react/ReactConnectorLab.tsx | 28 +++- .../src/shared/demo-workbench/demo-sources.ts | 7 + site/tests/unit/react-connector-demo.test.tsx | 20 +++ standards/json-document-v3/evaluate.mjs | 33 +++-- .../independent/conformance.test.ts | 9 +- .../independent/json-document.ts | 133 ++++-------------- .../independent/query-types.ts | 68 +++++++++ standards/json-document-v3/profile.md | 17 ++- 16 files changed, 231 insertions(+), 141 deletions(-) create mode 100644 site/tests/unit/react-connector-demo.test.tsx create mode 100644 standards/json-document-v3/implementations/independent/query-types.ts diff --git a/docs/api-reference/json-document.md b/docs/api-reference/json-document.md index d5e0e2e21..12eb5e48c 100644 --- a/docs/api-reference/json-document.md +++ b/docs/api-reference/json-document.md @@ -182,7 +182,7 @@ type ReadResult = ## `trackPointer` ```ts -trackPointer(pointer: Pointer, applied: ReadonlyArray): Pointer | null +trackPointer(pointer: Pointer, applied: ReadonlyArray, before?: JSONValue): Pointer | null ``` ## `tryParsePointer` diff --git a/docs/public/api.md b/docs/public/api.md index 677272864..81b750313 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -117,9 +117,23 @@ function asPointer(path: string): Pointer | null { `null`을 돌려줍니다. `appendSegment`는 Pointer에 segment를 하나 추가하고, `parentPointer`는 부모 위치를 돌려줍니다. -`trackPointer(pointer, operations)`는 patch가 적용된 뒤 같은 값이 이동한 -위치를 계산합니다. 값이 제거됐거나 더 이상 한 위치로 추적되지 않으면 -`null`입니다. +`trackPointer(pointer, change.applied, before)`는 commit 직전 snapshot을 문맥으로 +사용하여 patch 이후 위치를 계산합니다. 각 operation의 중간 상태에서 객체 key와 +배열 index를 구별합니다. 값이 제거되거나 상위 값의 교체로 위치를 잃으면 `null`입니다. +같은 위치의 `replace`는 유지되며 교체된 값의 자손은 해제됩니다. + +```ts +import { createJSONDocument, trackPointer } from "@interactive-os/json-document"; + +const document = createJSONDocument({ items: { "0": "a", "1": "b" } }); +const before = document.value; +const result = document.commit([{ op: "add", path: "/items/0", value: "A" }]); +if (result.ok) trackPointer("/items/1", result.change.applied, before); // /items/1 +``` + +기존 두 인자 호출도 호환됩니다. 다만 문맥이 없으면 숫자 segment를 배열 index로 +간주하는 이전 동작이 유지되므로, 숫자 객체 key를 포함할 수 있는 일반 JSON에는 +세 인자 호출을 사용합니다. `operations`에는 commit이 돌려준 concrete `change.applied`를 전달합니다. JSON Pointer의 array segment를 index로 해석해야 하는 adapter는 정본 `parseArrayIndex`를 사용합니다. 선행 0, 음수, 안전하지 않은 정수는 @@ -284,13 +298,13 @@ type Failure = { ## 공개 export -Package root는 다음 21개 symbol을 공개합니다. +Package root는 다음 23개 symbol을 공개합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - trackPointer, tryParsePointer + jsonEqual, parseArrayIndex, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 19549b473..497b6a361 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -23,12 +23,12 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 21개 symbol이다. +공개 Root는 정확히 다음 23개 symbol이다. ```txt values appendSegment, applyPatch, buildPointer, createJSONDocument - parentPointer, parsePointer, trackPointer, tryParsePointer + jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer types JSONAppliedChange, JSONPatchValidationResult diff --git a/package-lock.json b/package-lock.json index 231ec5689..1a8195c6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,8 @@ "site" ], "devDependencies": { - "@playwright/test": "^1.60.0" + "@playwright/test": "^1.60.0", + "jsonpath-js": "0.3.1" }, "optionalDependencies": { "@esbuild/linux-x64": "0.27.7", @@ -3720,6 +3721,16 @@ "node": ">=6" } }, + "node_modules/jsonpath-js": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/jsonpath-js/-/jsonpath-js-0.3.1.tgz", + "integrity": "sha512-sm5eHsv1XgtMJl3y8eKpFSB9gft1rD4D7Tm+COVxMzXStnz84xFOsqfMBj6aMBS48NPt0qPKt6k/wREPpONn2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "devOptional": true, diff --git a/package.json b/package.json index 64ba8ce4f..5879f3a3c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "site:evaluate:live": "node site/scripts/evaluate-live.mjs" }, "devDependencies": { + "jsonpath-js": "0.3.1", "@playwright/test": "^1.60.0" }, "optionalDependencies": { diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 8f113781e..09d36a48f 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -1,5 +1,11 @@ # @interactive-os/json-document-editing +`EditingSession` observes snapshots by JSON value, not reference identity. +Fresh-copy JSONDocument implementations retain local history until an actual +external value change. Undo reverses each operation against its sequential +pre-state, including object `add` replacement and array index shifts. A +`historyGroup` composes all grouped inverse operations, including different paths. + Headless editing transactions, selection publication, clipboard coordination, and history for `@interactive-os/json-document`. Structural selection state and semantic interaction contracts come from `@interactive-os/json-document-selection`. diff --git a/packages/json-document-react/README.md b/packages/json-document-react/README.md index 0eb005528..7d8eafc1f 100644 --- a/packages/json-document-react/README.md +++ b/packages/json-document-react/README.md @@ -31,6 +31,9 @@ function DocumentView() { `useReactConnector(document)` is the official stateful Connector entry point and connects the six-member `JSONDocument` directly to React. `useJSONDocumentValue` remains the lower-level document-value hook. +Both accept conforming implementations that return a fresh, isolated JSON +snapshot on every read. The Connector stabilizes equal values for React's +external-store contract; document implementations need not promise reference identity. `useEditingSnapshot` accepts the structural snapshot/subscription surface shared by `EditingSession` and `DocumentEditor`. `useEditing` adds the shared selection loop. `getIsSelected` is the object diff --git a/packages/json-document/README.md b/packages/json-document/README.md index 65a7e784b..f42aa210e 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -106,13 +106,13 @@ Initial value와 patch payload, metadata, exposed document value/change는 docum ## 공개 root -Root는 21개 public symbol만 공개합니다. +Root는 23개 public symbol만 공개합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - trackPointer, tryParsePointer + jsonEqual, parseArrayIndex, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation diff --git a/site/src/routes/connectors/react/ReactConnectorLab.tsx b/site/src/routes/connectors/react/ReactConnectorLab.tsx index f783ba002..c982c7003 100644 --- a/site/src/routes/connectors/react/ReactConnectorLab.tsx +++ b/site/src/routes/connectors/react/ReactConnectorLab.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { Plus, Redo2, Undo2 } from "lucide-react"; -import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { createJSONDocument, trackPointer, type JSONValue } from "@interactive-os/json-document"; import { documentSelectionFocus, type BlockDocument } from "@interactive-os/json-document-editing"; import { DocumentTextControl, @@ -28,10 +28,36 @@ export function ReactConnectorLab() { + ); } +function PointerTrackingLab() { + const [shape, setShape] = useState<"array" | "object">("array"); + const before = shape === "array" ? { items: ["a", "b"] } : { items: { "0": "a", "1": "b" } }; + const document = createJSONDocument(before); + const committed = document.commit([{ op: "add", path: "/items/0", value: "A" }]); + const tracked = committed.ok ? trackPointer("/items/1", committed.change.applied, before) : null; + + return ( +
+

Track a position through a patch

+

The previous snapshot distinguishes array insertion from numeric object-key replacement.

+
+ setShape("array")}>Array insertion + setShape("object")}>Numeric object key +
+

Tracked address: {tracked ?? "removed"}

+ +
+ ); +} + function JSONDocumentSubscriptionLab() { const [document] = useState(() => createJSONDocument({ title: "Connector draft", count: 0 })); const value = useReactConnector(document) as { readonly title: string; readonly count: number }; diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index f5ca2055d..2434b026f 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,4 +1,5 @@ import type { CodeLanguage } from "../ui/code-tokens"; +import pointerTrackingSource from "../../../../packages/json-document/src/foundation/patch/track.ts?raw"; import editingObservationSource from "../../../../packages/json-document-react/src/editing-observation.ts?raw"; import calendarEditingSource from "../../../../packages/json-document-editing/src/calendar.ts?raw"; import calendarAllDayPointerSource from "../../../../packages/json-document-editing/src/calendar-allday-pointer.ts?raw"; @@ -162,6 +163,7 @@ const excludedSources = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); const registeredUsageSources = new Map([ + ["packages/json-document/src/foundation/patch/track.ts", pointerTrackingSource], ["packages/json-document-editing/src/calendar.ts", calendarEditingSource], ["packages/json-document-editing/src/calendar-allday-pointer.ts", calendarAllDayPointerSource], ["packages/json-document-editing/src/calendar-month-pointer.ts", calendarMonthPointerSource], @@ -613,6 +615,11 @@ const registeredPublicUsages = [ symbol: "createJSONDocument", sourcePath: "packages/json-document/src/application/document/create.ts", }, + { + packageName: "@interactive-os/json-document", + symbol: "trackPointer", + sourcePath: "packages/json-document/src/foundation/patch/track.ts", + }, { packageName: "@interactive-os/json-document-selection", symbol: "collapsedRangeSelection", diff --git a/site/tests/unit/react-connector-demo.test.tsx b/site/tests/unit/react-connector-demo.test.tsx new file mode 100644 index 000000000..87ed01632 --- /dev/null +++ b/site/tests/unit/react-connector-demo.test.tsx @@ -0,0 +1,20 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { ReactConnectorLab } from "../../src/routes/connectors/react/ReactConnectorLab"; +import { discoverDemoSources } from "../../src/shared/demo-workbench/demo-sources"; + +afterEach(cleanup); + +describe("React Connector public Usage", () => { + test("executes context-aware tracking and exposes its canonical owner", async () => { + render(); + expect(screen.getByRole("button", { name: "Array insertion" }).textContent).toBe("Array insertion"); + expect(screen.getByTestId("tracked-pointer").textContent).toBe("/items/2"); + fireEvent.click(screen.getByRole("button", { name: "Numeric object key" })); + expect(screen.getByTestId("tracked-pointer").textContent).toBe("/items/1"); + fireEvent.click(screen.getByRole("button", { name: "Array insertion" })); + expect(screen.getByTestId("tracked-pointer").textContent).toBe("/items/2"); + const sources = await discoverDemoSources("routes/connectors/react/ReactConnectorDemoRoute.tsx"); + expect(sources.map((source) => source.path)).toContain("packages/json-document/src/foundation/patch/track.ts"); + }); +}); diff --git a/standards/json-document-v3/evaluate.mjs b/standards/json-document-v3/evaluate.mjs index bc649141b..cb9b3d1f6 100644 --- a/standards/json-document-v3/evaluate.mjs +++ b/standards/json-document-v3/evaluate.mjs @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -159,6 +159,11 @@ equal("v3 peer dependencies", manifest.package?.peerDependencies, []); equal("public contract entrypoints", Object.keys(packageContract), ["root"]); const sourceExports = publicExports(rootSource); +const exportCount = sourceExports.values.length + sourceExports.types.length; +requirePattern("profile export count", profile, new RegExp(`root entrypoint 하나와 ${exportCount}개 symbol`)); +requirePattern("profile runtime export count", profile, new RegExp(`values\\s+${sourceExports.values.length}\\b`)); +requirePattern("profile type export count", profile, new RegExp(`types\\s+${sourceExports.types.length}\\b`)); +requirePattern("profile total export count", profile, new RegExp(`total\\s+${exportCount}\\b`)); equal("root source values", sourceExports.values, [...packageContract.root.values].sort()); equal("root source types", sourceExports.types, [...packageContract.root.types].sort()); equal( @@ -215,7 +220,6 @@ for (const word of ["MUST", "SHOULD", "MAY"]) { } for (const pattern of [ /stateless JSON Patch -> JSON Document -> host adapter/, - /root entrypoint 하나와 21개 symbol/, /runtime dependency와 peer dependency가 없다/, /제거된 `\/session`과\s*`\/react` implementation은 export가 아니며 production build와 tarball에\s*포함하지 않는다/, ]) { @@ -242,9 +246,6 @@ const jsonPathBinding = read(manifest.conformance.jsonPathBinding); const foundationVectors = json(manifest.conformance.foundationVectors); const pressureVectors = json(manifest.conformance.pressureVectors); const pressureSuite = read(manifest.conformance.pressureSuite); -const independentJSONDocumentImplementation = read( - manifest.conformance.independentJSONDocumentImplementation, -); const independentJSONDocumentBinding = read( manifest.conformance.independentJSONDocumentBinding, ); @@ -444,22 +445,20 @@ assertGenericSuite( pressureSuite, /JSONDocumentHarness[\s\S]*runPressureConformance/, ); -if ( - /@interactive-os\/json-document|\/src\//.test( - independentJSONDocumentImplementation, - ) -) { - fail("independent JSON Document: reference package or private source import leaked."); -} -if ( - /@interactive-os\/json-document|\/src\//.test(independentJSONDocumentBinding) -) { - fail("independent JSON Document binding must not import the reference implementation."); +const independentDirectory = dirname(manifest.conformance.independentJSONDocumentImplementation); +for (const file of readdirSync(join(repoRoot, independentDirectory), { recursive: true })) { + if (!file.endsWith(".ts")) continue; + if (/@interactive-os\/json-document|\/src\//.test(read(join(independentDirectory, file)))) { + fail(`independent implementation ${file}: reference or private source import leaked.`); + } } for (const pattern of [ /createIndependentJSONDocument/, /runJSONDocumentConformance\(independentHarness\)/, /runPressureConformance\(independentHarness\)/, + /runJSONPathConformance\(/, + /runRFC6902Conformance\(independentPatchHarness\)/, + /runProtocolConformance\(independentPatchHarness\)/, ]) { requirePattern("independent JSON Document binding", independentJSONDocumentBinding, pattern); } @@ -512,6 +511,6 @@ if (failures.length > 0) { process.exitCode = 1; } else { console.log( - "json-document standardization ok: 1 entrypoint, 21 exports, 6 JSON Document members, 0 runtime peers", + `json-document standardization ok: 1 entrypoint, ${exportCount} exports, 6 JSON Document members, 0 runtime peers`, ); } diff --git a/standards/json-document-v3/implementations/independent/conformance.test.ts b/standards/json-document-v3/implementations/independent/conformance.test.ts index 9b7767535..0391ff08f 100644 --- a/standards/json-document-v3/implementations/independent/conformance.test.ts +++ b/standards/json-document-v3/implementations/independent/conformance.test.ts @@ -5,7 +5,10 @@ import { runJSONDocumentConformance, type JSONDocumentHarness, } from "../../conformance/suites/json-document.js"; -import { createIndependentJSONDocument } from "./json-document.js"; +import { applyIndependentPatch, createIndependentJSONDocument } from "./json-document.js"; +import { runProtocolConformance } from "../../conformance/suites/protocol.js"; +import { runJSONPathConformance } from "../../conformance/suites/jsonpath.js"; +import { runRFC6902Conformance } from "../../conformance/suites/rfc6902.js"; const independentHarness: JSONDocumentHarness = { create: createIndependentJSONDocument, @@ -13,3 +16,7 @@ const independentHarness: JSONDocumentHarness = { runJSONDocumentConformance(independentHarness); runPressureConformance(independentHarness); +runJSONPathConformance({ create: (initial) => createIndependentJSONDocument("json", initial) }); +const independentPatchHarness = { applyPatch: applyIndependentPatch }; +runRFC6902Conformance(independentPatchHarness); +runProtocolConformance(independentPatchHarness); diff --git a/standards/json-document-v3/implementations/independent/json-document.ts b/standards/json-document-v3/implementations/independent/json-document.ts index 8c4ab5918..be4b31ccf 100644 --- a/standards/json-document-v3/implementations/independent/json-document.ts +++ b/standards/json-document-v3/implementations/independent/json-document.ts @@ -1,3 +1,6 @@ +import { JSONPathJS } from "jsonpath-js"; +import { validateQueryTypes } from "./query-types.js"; +import type { ProtocolPatchResult } from "../../conformance/suites/protocol.js"; import type { JSONPatchOperation, JSONValue, @@ -22,6 +25,21 @@ interface OperationResult { readonly applied: JSONPatchOperation; } +export function applyIndependentPatch(initial: unknown, operations: ReadonlyArray): ProtocolPatchResult { + try { + let value = cloneJSON(initial); + const applied: JSONPatchOperation[] = []; + for (const operation of operations) { + const result = applyOperation(value, operation); + value = result.value; + applied.push(cloneJSON(result.applied) as unknown as JSONPatchOperation); + } + return Object.freeze({ ok: true, value: freezeJSON(value), change: createChange(applied, undefined) }); + } catch (error) { + return failureFrom(error); + } +} + export function createIndependentJSONDocument( validation: JSONDocumentValidation, initial: JSONValue, @@ -38,23 +56,16 @@ export function createIndependentJSONDocument( operations: ReadonlyArray, ): PreparedCommit | JSONDocumentFailure => { try { - let value = cloneJSON(state); - const applied: JSONPatchOperation[] = []; - for (const operation of operations) { - const result = applyOperation(value, operation); - value = result.value; - applied.push( - cloneJSON(result.applied) as unknown as JSONPatchOperation, - ); - } - value = freezeJSON(value); + const patched = applyIndependentPatch(state, operations); + if (!patched.ok) return patched; + const value = patched.value; evaluatingValidation = true; try { accept(validation, value); } finally { evaluatingValidation = false; } - return { value, applied }; + return { value, applied: patched.change.applied }; } catch (error) { return failureFrom(error); } @@ -519,101 +530,11 @@ function parseArrayIndex(segment: string): number | null { } function queryPointers(value: JSONValue, query: string): string[] { - const tokens = parseQuery(query); - let matches: Array<{ readonly value: JSONValue; readonly path: string }> = [ - { value, path: "" }, - ]; - for (const token of tokens) { - const next: Array<{ readonly value: JSONValue; readonly path: string }> = []; - for (const match of matches) { - if (token === "*") { - if (Array.isArray(match.value)) { - for (let index = 0; index < match.value.length; index += 1) { - next.push({ - value: match.value[index] as JSONValue, - path: appendPointer(match.path, String(index)), - }); - } - } else if (isRecord(match.value)) { - for (const key of Object.keys(match.value)) { - next.push({ - value: match.value[key] as JSONValue, - path: appendPointer(match.path, key), - }); - } - } - continue; - } - if (Array.isArray(match.value)) { - const index = parseArrayIndex(token); - if (index !== null && index < match.value.length) { - next.push({ - value: match.value[index] as JSONValue, - path: appendPointer(match.path, token), - }); - } - } else if ( - isRecord(match.value) - && Object.prototype.hasOwnProperty.call(match.value, token) - ) { - next.push({ - value: match.value[token] as JSONValue, - path: appendPointer(match.path, token), - }); - } - } - matches = next; - } - return matches.map((match) => match.path); -} - -function parseQuery(query: string): string[] { - if (query === "$") return []; - if (!query.startsWith("$")) { - throw new IndependentError("invalid_query", "JSONPath must start with '$'"); - } - - const tokens: string[] = []; - let index = 1; - while (index < query.length) { - if (query[index] === ".") { - const start = ++index; - while ( - index < query.length - && query[index] !== "." - && query[index] !== "[" - ) { - index += 1; - } - if (index === start) { - throw new IndependentError("invalid_query", "empty member name"); - } - tokens.push(query.slice(start, index)); - continue; - } - if (query[index] === "[") { - const close = query.indexOf("]", index + 1); - if (close === -1) { - throw new IndependentError("invalid_query", "unclosed selector"); - } - const selector = query.slice(index + 1, close); - if (selector === "*") { - tokens.push("*"); - } else if (/^(0|[1-9]\d*)$/.test(selector)) { - tokens.push(selector); - } else { - const quoted = /^(["'])(.*)\1$/.exec(selector); - if (quoted === null) { - throw new IndependentError("invalid_query", "unsupported selector"); - } - tokens.push(quoted[2] as string); - } - index = close + 1; - continue; - } - throw new IndependentError("invalid_query", "unexpected JSONPath token"); - } - return tokens; + const expression = new JSONPathJS(query); + validateQueryTypes(expression.rootNode); + return expression.pathSegments(value as Parameters[0]).map(({ segments }) => ( + segments.reduce((pointer, segment) => appendPointer(pointer, String(segment)), "") + )); } function createChange( diff --git a/standards/json-document-v3/implementations/independent/query-types.ts b/standards/json-document-v3/implementations/independent/query-types.ts new file mode 100644 index 000000000..ff34be858 --- /dev/null +++ b/standards/json-document-v3/implementations/independent/query-types.ts @@ -0,0 +1,68 @@ +import type { JSONPathJS } from "jsonpath-js"; + +type Query = JSONPathJS["rootNode"]; +type Segment = Query["segments"][number]; +type Filter = Extract[number], { type: "FilterSelector" }>; +type Expression = Filter["expr"]; +type FunctionExpression = Extract["query"], { type: "FunctionExpr" }>; +type Node = Expression | FunctionExpression["args"][number]; +type ExpressionType = "value" | "logical" | "nodes" | "singular"; + +const functions: Readonly> = { + length: { args: ["value"], result: "value" }, + count: { args: ["nodes"], result: "value" }, + value: { args: ["nodes"], result: "value" }, + match: { args: ["value", "value"], result: "logical" }, + search: { args: ["value", "value"], result: "logical" }, +}; + +/** RFC 9535 §2.4 static typing, independent of document contents/evaluation. */ +export function validateQueryTypes(query: Query): void { + expressionType(query); +} + +function requireType(actual: ExpressionType, expected: ExpressionType): void { + if (actual === expected || actual === "singular" + || (expected === "logical" && actual === "nodes")) return; + throw new SyntaxError(`JSONPath ${actual} expression cannot be used as ${expected}`); +} + +function expressionType(node: Node): ExpressionType { + switch (node.type) { + case "Literal": return "value"; + case "Root": + case "CurrentNode": { + for (const segment of node.segments) { + const selectors = Array.isArray(segment) ? segment : segment.selectors; + for (const selector of selectors) { + if (selector.type === "FilterSelector") requireType(expressionType(selector.expr), "logical"); + } + } + return node.segments.every((segment) => Array.isArray(segment) && segment.length === 1 + && ["NameSelector", "MemberNameShorthand", "IndexSelector"].includes(segment[0]!.type)) + ? "singular" : "nodes"; + } + case "TestExpr": + requireType(expressionType(node.query), "logical"); + return "logical"; + case "ComparisonExpr": + requireType(expressionType(node.left), "value"); + requireType(expressionType(node.right), "value"); + return "logical"; + case "LogicalBinary": + requireType(expressionType(node.left), "logical"); + requireType(expressionType(node.right), "logical"); + return "logical"; + case "LogicalUnary": + requireType(expressionType(node.expr), "logical"); + return "logical"; + case "FunctionExpr": { + const signature = Object.hasOwn(functions, node.name) ? functions[node.name] : undefined; + if (signature === undefined || node.args.length !== signature.args.length) { + throw new SyntaxError(`invalid JSONPath function signature: ${node.name}`); + } + node.args.forEach((arg, index) => requireType(expressionType(arg), signature.args[index]!)); + return signature.result; + } + } +} diff --git a/standards/json-document-v3/profile.md b/standards/json-document-v3/profile.md index af623cb8c..493e05434 100644 --- a/standards/json-document-v3/profile.md +++ b/standards/json-document-v3/profile.md @@ -86,7 +86,7 @@ JSON이 아니거나 validation에 거부되면 TypeScript reference binding은 | JD3-HOST-001 | rendering, DOM focus, geometry, keyboard policy, system clipboard, filesystem, network, formula engine, CRDT와 OT는 host 또는 extension이 소유해야 하며 Core JSON Document의 필수 data나 member가 되어서는 안 된다. | | JD3-CONFORMANCE-001 | conformance는 public factory 또는 injected harness만 사용하는 machine-readable black-box vector로 성공, 실패, atomicity, immutability, probe/commit parity, change notification을 검증해야 한다. private source path, provider object, 특정 dist layout을 요구하면 안 된다. | | JD3-CONFORMANCE-002 | 이 profile을 stable이라고 선언하려면 같은 suite가 reference implementation과 최소 한 개의 독립 구현을 통과하고 form, table/data-grid, outliner/tree, rich text, storage/collaboration의 다섯 pressure vertical에서 같은 제약이 확인되어야 한다. | -| JD3-BINDING-001 | Kernel package export와 TypeScript declaration은 언어별 binding contract이며 보편 protocol과 별도로 versioning해야 한다. v3 Kernel package는 root entrypoint와 21개 Kernel symbol만 공개하고 runtime·peer dependency 없이 빌드되어야 한다. public JSON Document declaration은 application-owned structural contract여야 하고 removed session, framework binding, implementation runtime alias나 private declaration path를 노출하면 안 된다. Framework와 schema integration은 독립 Connector package에서 versioning할 수 있다. | +| JD3-BINDING-001 | Kernel package export와 TypeScript declaration은 언어별 binding contract이며 보편 protocol과 별도로 versioning해야 한다. v3 Kernel package는 root entrypoint와 23개 Kernel symbol만 공개하고 runtime·peer dependency 없이 빌드되어야 한다. public JSON Document declaration은 application-owned structural contract여야 하고 removed session, framework binding, implementation runtime alias나 private declaration path를 노출하면 안 된다. Framework와 schema integration은 독립 Connector package에서 versioning할 수 있다. | ## Result 초안 @@ -171,7 +171,7 @@ conformance corpus의 public-root binding을 서로 분리한다. | `standards/json-document-v3/conformance/vectors/pressure.json` | form, table/data-grid, outliner/tree, rich text, storage/collaboration 시나리오 | | `standards/json-document-v3/conformance/suites/pressure.ts` | 여섯 member만으로 다섯 vertical을 실행하는 injected runner | | `standards/json-document-v3/implementations/independent/json-document.ts` | reference runtime을 import하지 않는 독립 6-member test implementation | -| `standards/json-document-v3/implementations/independent/conformance.test.ts` | 독립 구현에 JSON Document과 pressure suite를 함께 주입하는 binding | +| `standards/json-document-v3/implementations/independent/conformance.test.ts` | 독립 구현에 JSON Document, pressure, protocol, RFC 6902, RFC 9535 전체 suite를 주입하는 binding | | `packages/json-document-collaboration/tests/conformance/json-document.test.ts` | collaboration public root에 같은 두 suite를 주입하는 추가 binding | suite가 export하는 structural type은 test harness 내부 계약이며 package public @@ -187,6 +187,13 @@ reference와 독립 구현을 모두 통과하며, collaboration public binding 다섯 vertical을 통과한다. collaboration 구현은 Core protocol을 조합하므로 독립 구현 수에는 포함하지 않는다. +독립 구현은 reference parser/evaluator를 재사용하지 않는다. JSONPath는 test-only +`jsonpath-js@0.3.1`의 public parser/evaluator와 독립적인 RFC 9535 §2.4 함수 +타입 검사로 구성한다. document가 비어 있어도 잘못된 함수 식을 거절해야 한다. +전체 703개 CTS와 RFC 6902 실행 대상 110개를 reference와 독립 구현 모두에 +적용한다. 이는 저장소 내부의 두 구현에 대한 적합성 증거이며, 외부 팀의 +독립 채택·상호운용 실험이 이루어졌다는 주장은 아니다. + ## Durability primitive boundary Core의 JSON equality는 하나의 equality leaf가 소유한다. canonical array index @@ -205,13 +212,13 @@ array-property 분류만 공통 leaf에 두고, parity test가 untrusted boundar ## Package binding -`@interactive-os/json-document`는 root entrypoint 하나와 21개 symbol을 +`@interactive-os/json-document`는 root entrypoint 하나와 23개 symbol을 공개한다. `JSONDocument`의 canonical member는 여섯 개다. ```txt -values 8 +values 10 types 13 -total 21 +total 23 ``` 패키지는 runtime dependency와 peer dependency가 없다. 제거된 `/session`과