From a4fa60d2a54fd0d4f23b389c5197a63e4c7d1d7e 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: Tue, 8 Sep 2026 15:24:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20JSON=20=EC=A3=BC=EC=86=8C=EC=99=80?= =?UTF-8?q?=20=EA=B0=92=20=EA=B2=80=EC=A6=9D=EC=9D=84=20Core=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference/json-document.md | 10 ++++++ docs/public/api.md | 14 ++++++-- docs/public/llms.txt | 5 +-- package-lock.json | 2 ++ .../json-document-editing/src/clipboard.ts | 6 ---- .../json-document-editing/src/database.ts | 8 +++-- packages/json-document-editing/src/sheet.ts | 8 +++-- .../tests/clipboard-surface.test.ts | 28 ++++++++++++++++ .../tests/database-editor.test.ts | 23 +++++++++++++ .../tests/sheet-editor.test.ts | 23 +++++++++++++ .../json-document-rich-text-react/README.md | 5 +++ .../package.json | 2 ++ .../src/render-store.ts | 15 ++++----- .../tests/render-locality.test.tsx | 28 +++++++++++++++- .../tests/render.test.tsx | 23 +++++++++++++ .../tsconfig.json | 1 + .../src/editor-validation.ts | 7 ++-- .../json-document-rich-text/src/editor.ts | 6 ++-- .../json-document-rich-text/src/validation.ts | 12 ++----- .../tests/schema.test.ts | 33 ++++++++++++++++++- .../src/database-document.ts | 12 ++----- packages/json-document-zod/src/index.ts | 19 +++-------- packages/json-document/README.md | 28 ++++++++++++++-- packages/json-document/public-contract.json | 2 ++ .../src/application/document/index.ts | 2 ++ .../src/application/document/protocol.ts | 13 ++++++++ .../src/domain/json-document/create.ts | 16 ++------- .../src/domain/json-document/index.ts | 3 +- .../src/foundation/json/index.ts | 1 + .../src/foundation/json/serializable.ts | 6 ++++ .../src/foundation/jsonpath/evaluate.ts | 11 ++++--- .../src/foundation/jsonpath/fast.ts | 13 ++++---- .../src/foundation/jsonpath/simple.ts | 13 ++++---- .../src/foundation/jsonpath/support.ts | 4 --- .../src/foundation/protocol/index.ts | 2 ++ .../src/foundation/protocol/read.ts | 21 ++++++++++++ .../tests/conformance/json-primitives.test.ts | 32 +++++++++++++++++- .../json-document/tests/package/smoke.mjs | 4 +-- .../connectors/react/ReactConnectorLab.tsx | 30 ++++++++++++++++- .../src/shared/demo-workbench/demo-sources.ts | 14 ++++++++ site/tests/unit/demo-workbench.test.tsx | 2 ++ .../unit/public-document-contract.test.ts | 3 +- site/tests/unit/react-connector-demo.test.tsx | 19 +++++++++++ standards/json-document-v3/profile.md | 13 +++++--- 44 files changed, 430 insertions(+), 112 deletions(-) create mode 100644 packages/json-document/src/foundation/protocol/read.ts diff --git a/docs/api-reference/json-document.md b/docs/api-reference/json-document.md index 12eb5e48c..fb35e1126 100644 --- a/docs/api-reference/json-document.md +++ b/docs/api-reference/json-document.md @@ -26,6 +26,11 @@ buildPointer(segments: ReadonlyArray, options?: { readonly uriF ```ts createJSONDocument(initial: unknown, options?: JSONDocumentOptions): JSONDocument ``` +## `isJSONValue` + +```ts +isJSONValue(value: unknown): value is JSONValue +``` ## `JSONAppliedChange` ```ts @@ -167,6 +172,11 @@ type QueryResult = readonly reason?: string; }; ``` +## `readPointer` + +```ts +readPointer(value: JSONValue, pointer: Pointer): ReadResult +``` ## `ReadResult` ```ts diff --git a/docs/public/api.md b/docs/public/api.md index 81b750313..786fc770a 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -158,6 +158,14 @@ import { jsonEqual } from "@interactive-os/json-document"; jsonEqual({ title: "Draft", tags: [] }, { tags: [], title: "Draft" }); // true ``` +## 문서 없이 JSON 값 검증·조회하기 + +Snapshot 조회와 일반 JSON 값 검증에는 `readPointer(value, pointer)`와 +`isJSONValue(value)`를 사용합니다. 두 함수는 값을 복제하거나 정규화하지 +않습니다. 주소 조회는 `document.at`과 같은 문법·실패 결과를 사용하고 원본 +참조를 반환합니다. 상세 제약과 예제는 [Core package 문서](https://github.com/developer-1px/json-document/blob/main/packages/json-document/README.md)의 +순수 core 항목에서 확인할 수 있습니다. + ## 문서 없이 patch 적용하기 `applyPatch(value, operations)`는 document 상태를 만들지 않고 RFC 6902 @@ -287,6 +295,8 @@ type Failure = { | --- | --- | --- | | 현재 값 | `document.value` | `JSONValue` | | 한 위치 읽기 | `document.at(pointer)` | `ReadResult` | +| snapshot에서 한 위치 읽기 | `readPointer(value, pointer)` | `ReadResult` | +| JSON 값 검사 | `isJSONValue(value)` | boolean/type guard | | 여러 위치 찾기 | `document.query(jsonPath)` | `QueryResult` | | patch 검사 | `document.validatePatch(operations)` | `JSONPatchValidationResult` | | 상태 변경 | `document.commit(operations, options?)` | `JSONDocumentCommitResult` | @@ -298,13 +308,13 @@ type Failure = { ## 공개 export -Package root는 다음 23개 symbol을 공개합니다. +Package root는 다음 25개 symbol을 공개합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - jsonEqual, parseArrayIndex, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 497b6a361..9e822d892 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -23,12 +23,13 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 23개 symbol이다. +공개 Root는 정확히 다음 25개 symbol이다. ```txt values appendSegment, applyPatch, buildPointer, createJSONDocument - jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer + readPointer, trackPointer, tryParsePointer types JSONAppliedChange, JSONPatchValidationResult diff --git a/package-lock.json b/package-lock.json index 0f02d8174..99a805454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6851,6 +6851,7 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-web": "*", @@ -6863,6 +6864,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-react": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0", diff --git a/packages/json-document-editing/src/clipboard.ts b/packages/json-document-editing/src/clipboard.ts index 567162eb2..63e8933ca 100644 --- a/packages/json-document-editing/src/clipboard.ts +++ b/packages/json-document-editing/src/clipboard.ts @@ -19,9 +19,3 @@ export function cutEditingClipboard( export function isClipboardRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } - -export function isClipboardJSONValue(value: unknown): boolean { - if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true; - if (Array.isArray(value)) return value.every(isClipboardJSONValue); - return isClipboardRecord(value) && Object.values(value).every(isClipboardJSONValue); -} diff --git a/packages/json-document-editing/src/database.ts b/packages/json-document-editing/src/database.ts index 666da66f9..19c330654 100644 --- a/packages/json-document-editing/src/database.ts +++ b/packages/json-document-editing/src/database.ts @@ -1,5 +1,6 @@ import { buildPointer, + isJSONValue, jsonEqual, type JSONPatchOperation, type JSONValue, @@ -13,7 +14,7 @@ import { import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; import { reconcileRangeSelection } from "./range-selection.js"; -import { isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; +import { isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds } from "./topology.js"; import { acceptsDatabaseValue, defaultDatabaseValue } from "./database-property-value.js"; import { assertDatabaseDocument, assertDatabaseView } from "./database-validation.js"; @@ -121,10 +122,10 @@ export interface DatabaseClipboard extends Record { export const databaseClipboardFormat = { mimeType: "application/vnd.interactive-os.database+json" as const, parse(value: unknown): DatabaseClipboard | null { - if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; + if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null; const width = value.cells[0].length; - return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue)) + return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width) ? value as DatabaseClipboard : null; }, }; @@ -341,6 +342,7 @@ function paste( topology?: DatabaseTopology, index?: DatabaseIndex, ): EditingResult { + if (!isJSONValue(clipboard)) return failure("clipboard.invalid"); const focus = session.snapshot.selection.focus; if (focus === null) return failure("selection.empty"); if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) { diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 1407bf562..a055e9042 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -1,5 +1,6 @@ import { buildPointer, + isJSONValue, type JSONPatchOperation, type JSONValue, } from "@interactive-os/json-document"; @@ -12,7 +13,7 @@ import { import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; -import { cutEditingClipboard, isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; +import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds, type GridTopology } from "./topology.js"; import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js"; import { @@ -74,10 +75,10 @@ export interface SheetClipboard extends Record { export const sheetClipboardFormat = { mimeType: "application/vnd.interactive-os.sheet+json" as const, parse(value: unknown): SheetClipboard | null { - if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; + if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null; const width = value.cells[0].length; - return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue)) + return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width) ? value as SheetClipboard : null; }, }; @@ -304,6 +305,7 @@ function paste( topology?: SheetTopology, index?: SheetIndex, ): EditingResult { + if (!isJSONValue(clipboard)) return failure("clipboard.invalid"); const focus = session.snapshot.selection.focus; if (focus === null) return failure("selection.empty"); if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) { diff --git a/packages/json-document-editing/tests/clipboard-surface.test.ts b/packages/json-document-editing/tests/clipboard-surface.test.ts index 2932ed872..b2af9b921 100644 --- a/packages/json-document-editing/tests/clipboard-surface.test.ts +++ b/packages/json-document-editing/tests/clipboard-surface.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { applyPatch, type JSONValue } from "@interactive-os/json-document"; import { createDatabaseEditor, createDocumentEditor, @@ -6,8 +7,35 @@ import { createOrderEditor, createSheetEditor, createTreeEditor, + sheetClipboardFormat, + databaseClipboardFormat, } from "../src/index.js"; +describe.each([sheetClipboardFormat, databaseClipboardFormat])("$mimeType JSON boundary", (format) => { + const cycle: unknown[] = []; + cycle.push(cycle); + const invalidValues = [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]; + + test.each(invalidValues.map((value, index) => ({ value, index })))("rejects non-JSON cell $index as Core does", ({ value }) => { + expect(applyPatch(null, [{ op: "replace", path: "", value: value as JSONValue }]).ok).toBe(false); + expect(format.parse({ type: format.mimeType, cells: [[value]], text: "x" })).toBeNull(); + }); + + test("rejects holes in either matrix dimension and does not invoke cell accessors", () => { + let reads = 0; + const cell = Object.defineProperty({}, "value", { enumerable: true, get: () => { reads++; return 1; } }); + for (const cells of [Array(1), [Array(1)], [[cell]]]) { + expect(format.parse({ type: format.mimeType, cells, text: "x" })).toBeNull(); + } + expect(reads).toBe(0); + }); + + test("preserves valid nested JSON without normalization", () => { + const payload = { type: format.mimeType, cells: [[{ "a/b~": [1, true, null] }]], text: "x" }; + expect(format.parse(payload)).toBe(payload); + }); +}); + describe("editing clipboard surface", () => { test("every domain editor copies a structured payload and a text projection", () => { const document = createDocumentEditor({ blocks: [{ id: "a", text: "A" }] }); diff --git a/packages/json-document-editing/tests/database-editor.test.ts b/packages/json-document-editing/tests/database-editor.test.ts index 734d3b7d1..613cd6dcd 100644 --- a/packages/json-document-editing/tests/database-editor.test.ts +++ b/packages/json-document-editing/tests/database-editor.test.ts @@ -1,3 +1,4 @@ +import type { JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { acceptsDatabaseValue, @@ -36,6 +37,28 @@ const initial: DatabaseDocument = { }; describe("Database editor", () => { + + test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => { + const editor = createDatabaseEditor(initial); + expect(editor.dispatch({ type: "cell.commit", recordId: "r1", propertyId: "score", value: 9 }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) { + expect(editor.dispatch({ + type: "clipboard.paste", + clipboard: { type: "application/vnd.interactive-os.database+json", cells: [[value as JSONValue]], text: "x" }, + })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.snapshot.canRedo).toBe(true); + expect(editor.redo().ok).toBe(true); + unsubscribe(); + }); test("owns the canonical property value semantics", () => { const [, , score, status, done] = initial.schema.properties; expect(defaultDatabaseValue(score!)).toBe(0); diff --git a/packages/json-document-editing/tests/sheet-editor.test.ts b/packages/json-document-editing/tests/sheet-editor.test.ts index 84c9efb17..a25b3ca6a 100644 --- a/packages/json-document-editing/tests/sheet-editor.test.ts +++ b/packages/json-document-editing/tests/sheet-editor.test.ts @@ -1,3 +1,4 @@ +import type { JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { createSheetEditor, type SheetDocument } from "../src/index.js"; @@ -15,6 +16,28 @@ const initial: SheetDocument = { }; describe("sheet editing vertical slice", () => { + + test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => { + const editor = createSheetEditor(initial); + expect(editor.dispatch({ type: "cell.commit", rowId: "r1", columnId: "score", value: 9 }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) { + expect(editor.dispatch({ + type: "clipboard.paste", + clipboard: { type: "application/vnd.interactive-os.sheet+json", cells: [[value as JSONValue]], text: "x" }, + })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.snapshot.canRedo).toBe(true); + expect(editor.redo().ok).toBe(true); + unsubscribe(); + }); test("selects a rectangular range and copies row-major JSON with TSV", () => { const editor = createSheetEditor(initial); diff --git a/packages/json-document-rich-text-react/README.md b/packages/json-document-rich-text-react/README.md index 45b863d5d..65c3b7626 100644 --- a/packages/json-document-rich-text-react/README.md +++ b/packages/json-document-rich-text-react/README.md @@ -6,6 +6,11 @@ scope whose copy text comes from the canonical Rich Text model projection. Official React renderer and `contenteditable` surface for the json-document Rich Text v1 profile. +An editor's `pointer` may bind the root, a nested JSON Pointer, or its URI +fragment form. The surface reads that snapshot through Core's `readPointer` +without cloning the document. Escaped keys and fragment addresses retain the +same rendering, change observation, and history behavior as ordinary pointers. + `RichTextRenderer` renders canonical semantic HTML. `RichTextEditorSurface` connects that rendering to the official editor, DOM Selection, `beforeinput`, IME, Clipboard, and history integration. ```tsx diff --git a/packages/json-document-rich-text-react/package.json b/packages/json-document-rich-text-react/package.json index 09f3af62c..65e5fd1b5 100644 --- a/packages/json-document-rich-text-react/package.json +++ b/packages/json-document-rich-text-react/package.json @@ -29,12 +29,14 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-react": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0", "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-web": "*", diff --git a/packages/json-document-rich-text-react/src/render-store.ts b/packages/json-document-rich-text-react/src/render-store.ts index c2ae9222e..3303777cd 100644 --- a/packages/json-document-rich-text-react/src/render-store.ts +++ b/packages/json-document-rich-text-react/src/render-store.ts @@ -1,3 +1,4 @@ +import { buildPointer, parsePointer, readPointer, type JSONValue } from "@interactive-os/json-document"; import { appliedOperationsFor, hasRichTextContent, @@ -22,7 +23,7 @@ export interface RichTextRenderStore { } export function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore { - const pointer = editor.pointer ?? ""; + const pointer = buildPointer(parsePointer(editor.pointer ?? "")); let document = documentAtPointer(editor.snapshot.value, pointer); let blockIds: ReadonlyArray = document.content.map((node) => node.id); let placeholderBlockId: string | null = null; @@ -231,14 +232,10 @@ function relativeOperations( }); } -function documentAtPointer(value: unknown, pointer: string): RichTextDocument { - if (pointer === "") return value as RichTextDocument; - let current = value; - for (const segment of pointer.slice(1).split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))) { - if (current === null || typeof current !== "object") throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`); - current = (current as Readonly>)[segment]; - } - return current as RichTextDocument; +function documentAtPointer(value: JSONValue, pointer: string): RichTextDocument { + const result = readPointer(value, pointer); + if (!result.ok) throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`); + return result.value as RichTextDocument; } function contentStructureChanged( diff --git a/packages/json-document-rich-text-react/tests/render-locality.test.tsx b/packages/json-document-rich-text-react/tests/render-locality.test.tsx index f147f77b5..457d153b4 100644 --- a/packages/json-document-rich-text-react/tests/render-locality.test.tsx +++ b/packages/json-document-rich-text-react/tests/render-locality.test.tsx @@ -2,7 +2,7 @@ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -import { createJSONDocument } from "@interactive-os/json-document"; +import { buildPointer, createJSONDocument } from "@interactive-os/json-document"; import { createRichTextBlockFixture, createRichTextEditor, @@ -20,6 +20,32 @@ import { import { createRichTextRenderStore } from "../src/render-store.js"; describe("Rich Text React locality", () => { + it.each([false, true])("observes nested edits, history, and external changes (fragment: %s)", (uriFragment) => { + const key = "a/b~ #한"; + const value = createRichTextBlockFixture(3, { idPrefix: "nested" }); + const document = createJSONDocument({ [key]: value }); + const pointer = buildPointer([key], { uriFragment }); + const editor = createRichTextEditor({ document, pointer, selection: collapsed("nested-text-1", 1) }); + const store = createRichTextRenderStore(editor); + let changed = 0; + const unsubscribe = store.subscribeNode("nested-text-1", () => { changed++; }); + const untouched = store.getNode("nested-0"); + expect(editor.dispatch({ type: "text.insert", text: "y" }).ok).toBe(true); + expect(changed).toBe(1); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" }); + expect(store.getNode("nested-0")).toBe(untouched); + expect(lastRenderStoreBlockScan()).toBe(1); + expect(editor.undo().ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "x" }); + expect(editor.redo().ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" }); + expect(document.commit([{ op: "replace", path: buildPointer([key, "content", 1, "content", 0, "text"]), value: "remote" }]).ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "remote" }); + expect(store.getNode("nested-0")).toBe(untouched); + expect(editor.pointer).toBe(pointer); + unsubscribe(); + }); + it("catches up after disconnected structural and leaf edits", () => { const editor = createRichTextEditor({ document: createJSONDocument(createRichTextBlockFixture(3, { idPrefix: "offline" })), diff --git a/packages/json-document-rich-text-react/tests/render.test.tsx b/packages/json-document-rich-text-react/tests/render.test.tsx index 9367e425b..ccba76092 100644 --- a/packages/json-document-rich-text-react/tests/render.test.tsx +++ b/packages/json-document-rich-text-react/tests/render.test.tsx @@ -1,9 +1,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { buildPointer, createJSONDocument } from "@interactive-os/json-document"; import * as richTextReact from "../src/index.js"; import { RichTextEditorSurface, RichTextRenderer } from "../src/index.js"; import { createRichTextSchema, + createRichTextEditor, + createRichTextBlockFixture, richTextSchemaV1, type RichTextDocument, type RichTextEditor, @@ -18,6 +21,26 @@ describe("public surface", () => { }); describe("RichTextRenderer", () => { + it.each([false, true])("renders the same nested document through an escaped pointer (fragment: %s)", (uriFragment) => { + const key = "a/b~ #한"; + const value = createRichTextBlockFixture(2, { idPrefix: "nested" }); + const editor = createRichTextEditor({ + document: createJSONDocument({ [key]: value }), + pointer: buildPointer([key], { uriFragment }), + }); + const html = renderToStaticMarkup(); + expect(html).toContain('data-rich-text-node-id="nested-0"'); + expect(html).toContain('data-rich-text-node-id="nested-1"'); + }); + + it("renders the root URI fragment", () => { + const editor = createRichTextEditor({ + document: createJSONDocument(createRichTextBlockFixture(1, { idPrefix: "root" })), + pointer: "#", + }); + expect(renderToStaticMarkup()).toContain('data-rich-text-node-id="root-0"'); + }); + it("renders every official container with DOM mapping identifiers", () => { const html = renderToStaticMarkup(, schema: RichTextSchema): ReturnType { diff --git a/packages/json-document-rich-text/src/editor.ts b/packages/json-document-rich-text/src/editor.ts index f7cebe8ab..2bb584f6d 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, + isJSONValue, parsePointer, type JSONDocument, type JSONPatchOperation, @@ -113,10 +114,11 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const initialValidation = indexValidatedRichText(initial, schema); if (!initialValidation.ok) throw new TypeError(initialValidation.reason); const initialTopology = richTextTopology(initial); + const patchPointer = buildPointer(parsePointer(pointer)); let previousDocument = initial; function observeChange(change: import("@interactive-os/json-document").JSONAppliedChange): void { const next = readRichTextDocument(options.document, pointer); - seedRichTextTopology(previousDocument, next, change.applied, pointer); + seedRichTextTopology(previousDocument, next, change.applied, patchPointer); rememberAppliedOperations(next, change.applied); previousDocument = next; } @@ -304,7 +306,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd } function pasteClipboard(clipboard: RichTextClipboard): EditingResult { - if (clipboard.type !== RICH_TEXT_CLIPBOARD_MIME || clipboard.slice.profile !== schema.profile) { + if (!isJSONValue(clipboard) || clipboard.type !== RICH_TEXT_CLIPBOARD_MIME || clipboard.slice.profile !== schema.profile) { return failure("rich-text.clipboard-invalid"); } const ranges = session.snapshot.selection.ranges; diff --git a/packages/json-document-rich-text/src/validation.ts b/packages/json-document-rich-text/src/validation.ts index 23666bcd6..5c306cac4 100644 --- a/packages/json-document-rich-text/src/validation.ts +++ b/packages/json-document-rich-text/src/validation.ts @@ -1,4 +1,4 @@ -import type { JSONValue, Pointer } from "@interactive-os/json-document"; +import { isJSONValue, type Pointer } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { RICH_TEXT_PROFILE_V1, @@ -192,13 +192,14 @@ function validateAttrs( const names = Object.keys(specs); if (names.length === 0) return "attrs" in owner ? fail("rich-text.schema-violation", "Unexpected attrs.", `${pointer}/attrs`) : { ok: true }; if (!isJSONObject(owner.attrs)) return fail("rich-text.schema-violation", "Required attrs object is missing.", `${pointer}/attrs`); + if (!isJSONValue(owner.attrs)) return fail("rich-text.schema-violation", "Attrs must contain JSON values.", `${pointer}/attrs`); for (const [name, spec] of Object.entries(specs)) { const value = owner.attrs[name]; if (value === undefined) { if (spec.required && spec.default === undefined) return fail("rich-text.schema-violation", `Missing attr ${name}.`, `${pointer}/attrs/${name}`); continue; } - if (!isJSONValue(value) || !spec.validate(value)) return fail("rich-text.schema-violation", `Invalid attr ${name}.`, `${pointer}/attrs/${name}`); + if (!spec.validate(value)) return fail("rich-text.schema-violation", `Invalid attr ${name}.`, `${pointer}/attrs/${name}`); } for (const name of Object.keys(owner.attrs)) if (specs[name] === undefined) return fail("rich-text.schema-violation", `Unknown attr ${name}.`, `${pointer}/attrs/${name}`); return { ok: true }; @@ -218,11 +219,4 @@ function isJSONObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isJSONValue(value: unknown): value is JSONValue { - if (value === null || typeof value === "string" || typeof value === "boolean") return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJSONValue); - return isJSONObject(value) && Object.values(value).every(isJSONValue); -} - export { RICH_TEXT_PROFILE_V1 }; diff --git a/packages/json-document-rich-text/tests/schema.test.ts b/packages/json-document-rich-text/tests/schema.test.ts index 7b2a50041..ce22f79af 100644 --- a/packages/json-document-rich-text/tests/schema.test.ts +++ b/packages/json-document-rich-text/tests/schema.test.ts @@ -1,7 +1,8 @@ -import { createJSONDocument } from "@interactive-os/json-document"; +import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; import { describe, expect, it } from "vitest"; import { createRichTextSchema, + createRichTextEditor, createRichTextTopology, normalizeRichText, richTextSchemaV1, @@ -47,6 +48,36 @@ const canonical: RichTextDocument = { }; describe("Official Rich Text schema", () => { + 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" }, + } }); + const document = createJSONDocument({ profile: schema.profile, id: "doc", type: "doc", content: [ + { id: "data", type: "com.example/data", attrs: { value: null } }, + ] }); + const editor = createRichTextEditor({ document, schema }); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle]) { + const attrs = { value: value as JSONValue }; + expect(editor.dispatch({ type: "node.set-attrs", nodeId: "data", attrs })) + .toMatchObject({ ok: false, code: "rich-text.schema-violation" }); + expect(editor.dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.rich-text+json", text: "", html: "", + slice: { profile: schema.profile, openStart: 0, openEnd: 0, content: [{ id: "copy", type: "com.example/data", attrs }] }, + } })).toMatchObject({ ok: false, code: "rich-text.clipboard-invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.dispatch({ type: "node.set-attrs", nodeId: "data", attrs: { value: { nested: [1, null] } } }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + expect(editor.snapshot.value).toEqual(before.value); + unsubscribe(); + }); + it("reports a schema failure when local validation cannot resolve the parent type", () => { const nodes = Object.fromEntries(Object.entries(richTextSchemaV1.nodes).filter(([type]) => type !== "blockquote")); const options = { schema: { ...richTextSchemaV1, nodes } }; diff --git a/packages/json-document-zod/src/database-document.ts b/packages/json-document-zod/src/database-document.ts index 16c807a21..422e7866a 100644 --- a/packages/json-document-zod/src/database-document.ts +++ b/packages/json-document-zod/src/database-document.ts @@ -1,4 +1,4 @@ -import type { JSONValue } from "@interactive-os/json-document"; +import { buildPointer, type JSONValue } from "@interactive-os/json-document"; import { acceptsDatabaseValue, defaultDatabaseValue, @@ -76,7 +76,7 @@ export function databaseDocumentFromZod( return failure( "schema_violation", issue?.message ?? "Record failed Zod validation.", - issue === undefined ? `/${index}` : recordPointer(index, issue.path), + issue === undefined ? `/${index}` : buildPointer([index, ...issue.path.map(String)]), ); } @@ -228,14 +228,6 @@ function displayName(key: string): string { return key.length === 0 ? key : `${key[0]!.toUpperCase()}${key.slice(1)}`; } -function recordPointer(index: number, path: ReadonlyArray): string { - return `/${[index, ...path].map((segment) => escapePointerToken(String(segment))).join("/")}`; -} - -function escapePointerToken(token: string): string { - return token.replace(/~/g, "~0").replace(/\//g, "~1"); -} - function failure( code: string, reason?: string, diff --git a/packages/json-document-zod/src/index.ts b/packages/json-document-zod/src/index.ts index 208f1c162..2fb3abd3d 100644 --- a/packages/json-document-zod/src/index.ts +++ b/packages/json-document-zod/src/index.ts @@ -1,6 +1,7 @@ -import type { - JSONPatchValidationResult, - JSONValue, +import { + buildPointer, + type JSONPatchValidationResult, + type JSONValue, } from "@interactive-os/json-document"; import type { ZodType } from "zod/v4"; @@ -37,17 +38,7 @@ export function createZodValidator( ok: false, code, reason: issue.message, - pointer: issuePathToPointer(issue.path), + pointer: buildPointer(issue.path.map(String)), }; }; } - -function issuePathToPointer(path: ReadonlyArray): string { - return path.length === 0 - ? "" - : `/${path.map((segment) => escapePointerToken(String(segment))).join("/")}`; -} - -function escapePointerToken(token: string): string { - return token.replace(/~/g, "~0").replace(/\//g, "~1"); -} diff --git a/packages/json-document/README.md b/packages/json-document/README.md index f42aa210e..d4247e27b 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는 23개 public symbol만 공개합니다. +Root의 공개 계약은 `public-contract.json`으로 검사합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - jsonEqual, parseArrayIndex, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation @@ -129,6 +129,30 @@ history와 clipboard는 optional editing companion이 조합하고, framework bi ## 순수 core +`isJSONValue(value: unknown): value is JSONValue`는 Core의 JSON tree 제약을 +검사합니다. 값을 복제하거나 정규화하지 않습니다. 유한하지 않은 숫자, 희소 배열, +접근자·symbol 속성, 비표준 객체, 순환 또는 공유 객체 참조는 거절합니다. +도메인 schema의 추가 조건은 각 도메인이 검사합니다. + +`readPointer(value: JSONValue, pointer: Pointer): ReadResult`는 `document.at`과 +동일한 주소 해석을 값에 직접 적용합니다. 일반 Pointer와 URI fragment를 지원하고, +실패는 `invalid_pointer` 또는 `path_not_found`로 반환합니다. 입력은 이미 유효한 +JSON이어야 하며, 반환한 값은 원본 참조입니다. 입력을 복제·동결하거나 소유하지 +않으므로 immutable snapshot을 읽을 때도 참조 동일성이 유지됩니다. + +```ts +import { isJSONValue, readPointer } from "@interactive-os/json-document"; + +const input: unknown = { "a/b~": [{ title: "Draft" }] }; +if (isJSONValue(input)) { + const result = readPointer(input, "#/a~1b~0/0/title"); + // { ok: true, path: "#/a~1b~0/0/title", value: "Draft" } +} +``` + +실행 가능한 Usage와 구현 source는 site의 `/connectors/react`에서 확인할 수 +있습니다. 이 stateless API는 `JSONDocument`의 여섯 멤버를 늘리지 않습니다. + `applyPatch`는 schema, session, UI 없이 ordered atomic JSON Patch를 적용합니다. ```ts diff --git a/packages/json-document/public-contract.json b/packages/json-document/public-contract.json index 114acf97f..4f10136e4 100644 --- a/packages/json-document/public-contract.json +++ b/packages/json-document/public-contract.json @@ -5,10 +5,12 @@ "applyPatch", "buildPointer", "createJSONDocument", + "isJSONValue", "jsonEqual", "parentPointer", "parseArrayIndex", "parsePointer", + "readPointer", "trackPointer", "tryParsePointer" ], diff --git a/packages/json-document/src/application/document/index.ts b/packages/json-document/src/application/document/index.ts index d8ceb0dac..43703a62a 100644 --- a/packages/json-document/src/application/document/index.ts +++ b/packages/json-document/src/application/document/index.ts @@ -3,10 +3,12 @@ export { appendSegment, applyPatch, buildPointer, + isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer, + readPointer, trackPointer, tryParsePointer, } from "./protocol.js"; diff --git a/packages/json-document/src/application/document/protocol.ts b/packages/json-document/src/application/document/protocol.ts index b0aaa7034..98bfc9672 100644 --- a/packages/json-document/src/application/document/protocol.ts +++ b/packages/json-document/src/application/document/protocol.ts @@ -8,12 +8,15 @@ import { trackPointer as trackPointerInternal, tryParsePointer as tryParsePointerInternal, jsonEqual as jsonEqualInternal, + isJSONValue as isJSONValueInternal, + readPointer as readPointerInternal, } from "../../domain/json-document/index.js"; import type { JSONPatchOperation, JSONPatchResult, JSONValue, Pointer, + ReadResult, } from "./contract.js"; export function applyPatch( @@ -35,6 +38,16 @@ export function jsonEqual(left: unknown, right: unknown): boolean { return jsonEqualInternal(left, right); } +/** Tests Core's JSON tree constraints without cloning or normalizing the input. */ +export function isJSONValue(value: unknown): value is JSONValue { + return isJSONValueInternal(value); +} + +/** Reads a JSON value by Pointer, preserving the selected value's identity. */ +export function readPointer(value: JSONValue, pointer: Pointer): ReadResult { + return readPointerInternal(value, pointer); +} + export function tryParsePointer(pointer: Pointer): string[] | null { return tryParsePointerInternal(pointer); } diff --git a/packages/json-document/src/domain/json-document/create.ts b/packages/json-document/src/domain/json-document/create.ts index d3dabc355..62b6c0f48 100644 --- a/packages/json-document/src/domain/json-document/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -7,6 +7,7 @@ import { parsePointer, queryJSONPath, readAt, + readPointer, type JSONAppliedChange, type JSONPatchValidationResult, type JSONChangeMetadata, @@ -80,20 +81,7 @@ export function createJSONDocumentState( return state; }, at(pointer: string): ReadResult { - let segments: string[]; - try { - segments = parsePointer(pointer); - } catch (error) { - return failure( - "invalid_pointer", - error instanceof Error ? error.message : "invalid pointer", - pointer, - ); - } - const result = readAt(state, segments); - return result.ok - ? Object.freeze({ ok: true, path: pointer, value: result.value as JSONValue }) - : failure("path_not_found", `path not found: ${pointer}`, pointer); + return readPointer(state, pointer); }, query(jsonPath: string): QueryResult { try { diff --git a/packages/json-document/src/domain/json-document/index.ts b/packages/json-document/src/domain/json-document/index.ts index 1d6bd538d..6268f6538 100644 --- a/packages/json-document/src/domain/json-document/index.ts +++ b/packages/json-document/src/domain/json-document/index.ts @@ -2,7 +2,7 @@ export { createJSONDocumentState, } from "./create.js"; -export { jsonEqual } from "../../foundation/json/index.js"; +export { isJSONValue, jsonEqual } from "../../foundation/json/index.js"; export { appendSegment, @@ -11,6 +11,7 @@ export { parentPointer, parseArrayIndex, parsePointer, + readPointer, trackPointer, tryParsePointer, } from "../../foundation/protocol/index.js"; diff --git a/packages/json-document/src/foundation/json/index.ts b/packages/json-document/src/foundation/json/index.ts index 48314fe75..4d8a17e7a 100644 --- a/packages/json-document/src/foundation/json/index.ts +++ b/packages/json-document/src/foundation/json/index.ts @@ -1,3 +1,4 @@ export { cloneJsonSerializable } from "./clone.js"; export { jsonEqual } from "./equal.js"; +export { isJSONValue } from "./serializable.js"; export { cloneTrustedPlainJson } from "./trusted-clone.js"; diff --git a/packages/json-document/src/foundation/json/serializable.ts b/packages/json-document/src/foundation/json/serializable.ts index f2b031093..d5e6dcd59 100644 --- a/packages/json-document/src/foundation/json/serializable.ts +++ b/packages/json-document/src/foundation/json/serializable.ts @@ -1,5 +1,11 @@ import { buildPointer } from "../pointer/core.js"; import { isJsonArrayIndexKey } from "./classification.js"; +import type { JSONValue } from "../protocol/contract.js"; + +/** Tests the Core JSON value boundary without invoking getters or normalizing data. */ +export function isJSONValue(value: unknown): value is JSONValue { + return jsonSerializableErrorFast(value) === null; +} export function jsonSerializableError(value: unknown): string | null { return jsonSerializableErrorFast(value) === null ? null : jsonSerializableErrorDetailed(value); diff --git a/packages/json-document/src/foundation/jsonpath/evaluate.ts b/packages/json-document/src/foundation/jsonpath/evaluate.ts index 61e80fab2..f8bdfe8ef 100644 --- a/packages/json-document/src/foundation/jsonpath/evaluate.ts +++ b/packages/json-document/src/foundation/jsonpath/evaluate.ts @@ -1,11 +1,12 @@ // foundation/jsonpath/evaluate — Query AST + JSON 입력 → Match[] (Pointer + value). // RFC 9535 §2 의 normalized 의미. Pointer 는 RFC 6901. +import { appendSegment } from "../pointer/core.js"; import type { Query, Segment, Selector, FilterExpr, Comparable, FilterQuery, FunctionExpr, Match } from "./ast.js"; import { jsonEqual } from "../json/equal.js"; import { evaluateArrayRegexFilter, evaluateArrayWildcardField } from "./fast.js"; import { evaluateSimpleQuery, evaluateSinglePathQuery } from "./simple.js"; -import { compiledRegex, escapeSeg, normalizeSliceIndex, objectHasOwn } from "./support.js"; +import { compiledRegex, normalizeSliceIndex, objectHasOwn } from "./support.js"; /** root JSON 입력에 query 적용 → matches. 결과 순서: RFC 9535 정합 (DFS). */ export function evaluate(query: Query, root: unknown): Match[] { @@ -54,7 +55,7 @@ function visitDescendants(m: Match, cb: (n: Match) => void): void { } } else { for (const k of Object.keys(m.value as Record)) { - visitDescendants({ pointer: m.pointer + "/" + escapeSeg(k), value: (m.value as Record)[k] }, cb); + visitDescendants({ pointer: appendSegment(m.pointer, k), value: (m.value as Record)[k] }, cb); } } } @@ -64,7 +65,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { if (m.value === null || typeof m.value !== "object" || Array.isArray(m.value)) return []; const obj = m.value as Record; if (!objectHasOwn.call(obj, sel.name)) return []; - return [{ pointer: m.pointer + "/" + escapeSeg(sel.name), value: obj[sel.name] }]; + return [{ pointer: appendSegment(m.pointer, sel.name), value: obj[sel.name] }]; } if (sel.kind === "index") { if (!Array.isArray(m.value)) return []; @@ -91,7 +92,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { return m.value.map((v, i) => ({ pointer: m.pointer + "/" + i, value: v })); } const obj = m.value as Record; - return Object.keys(obj).map((k) => ({ pointer: m.pointer + "/" + escapeSeg(k), value: obj[k] })); + return Object.keys(obj).map((k) => ({ pointer: appendSegment(m.pointer, k), value: obj[k] })); } if (sel.kind === "filter") { if (m.value === null || typeof m.value !== "object") return []; @@ -104,7 +105,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { } else { const obj = m.value as Record; for (const k of Object.keys(obj)) { - const cm = { pointer: m.pointer + "/" + escapeSeg(k), value: obj[k] }; + const cm = { pointer: appendSegment(m.pointer, k), value: obj[k] }; if (evalFilter(sel.expr, cm, root)) out.push(cm); } } diff --git a/packages/json-document/src/foundation/jsonpath/fast.ts b/packages/json-document/src/foundation/jsonpath/fast.ts index 1b3f1ca2c..4309163cf 100644 --- a/packages/json-document/src/foundation/jsonpath/fast.ts +++ b/packages/json-document/src/foundation/jsonpath/fast.ts @@ -1,5 +1,6 @@ +import { appendSegment } from "../pointer/core.js"; import type { FilterExpr, Match, Query } from "./ast.js"; -import { compiledRegex, escapeSeg, objectHasOwn, plainRegexLiteral } from "./support.js"; +import { compiledRegex, objectHasOwn, plainRegexLiteral } from "./support.js"; interface ArrayWildcardFieldQuery { arrayName: string; @@ -16,8 +17,8 @@ export function evaluateArrayWildcardField(query: Query, root: unknown): Match[] const array = rootObject[simple.arrayName]; if (!Array.isArray(array)) return null; - const rootPointer = "/" + escapeSeg(simple.arrayName); - const fieldPointer = "/" + escapeSeg(simple.fieldName); + const rootPointer = appendSegment("", simple.arrayName); + const fieldPointer = appendSegment("", simple.fieldName); const matches = new Array(array.length); let matchCount = 0; for (let index = 0; index < array.length; index += 1) { @@ -70,7 +71,7 @@ export function evaluateArrayRegexFilter(query: Query, root: unknown): Match[] | const regex = literal === null ? compiledRegex(filter.pattern, filter.full) : null; if (literal === null && regex === null) return []; - const arrayPointer = "/" + escapeSeg(arraySelector.name); + const arrayPointer = appendSegment("", arraySelector.name); const matches = new Array(array.length); let matchCount = 0; for (let index = 0; index < array.length; index += 1) { @@ -122,8 +123,8 @@ export function matchArrayWildcardFieldPointers(query: Query, root: unknown): st const array = rootObject[simple.arrayName]; if (!Array.isArray(array)) return null; - const rootPointer = "/" + escapeSeg(simple.arrayName); - const fieldPointer = "/" + escapeSeg(simple.fieldName); + const rootPointer = appendSegment("", simple.arrayName); + const fieldPointer = appendSegment("", simple.fieldName); const pointers = new Array(array.length); let pointerCount = 0; for (let index = 0; index < array.length; index += 1) { diff --git a/packages/json-document/src/foundation/jsonpath/simple.ts b/packages/json-document/src/foundation/jsonpath/simple.ts index 0caa7456c..ca200b953 100644 --- a/packages/json-document/src/foundation/jsonpath/simple.ts +++ b/packages/json-document/src/foundation/jsonpath/simple.ts @@ -1,6 +1,7 @@ +import { appendSegment } from "../pointer/core.js"; import type { Match, Query, Selector } from "./ast.js"; import { matchArrayWildcardFieldPointers } from "./fast.js"; -import { escapeSeg, normalizeSliceIndex, objectHasOwn } from "./support.js"; +import { normalizeSliceIndex, objectHasOwn } from "./support.js"; export function evaluateSinglePathQuery(query: Query, root: unknown): Match[] | null { if (query.segments.length === 0) return [{ pointer: "", value: root }]; @@ -17,7 +18,7 @@ export function evaluateSinglePathQuery(query: Query, root: unknown): Match[] | const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return []; value = object[selector.name]; - pointer += "/" + escapeSeg(selector.name); + pointer = appendSegment(pointer, selector.name); continue; } @@ -100,7 +101,7 @@ function applySimpleSelector( const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return true; nextValues?.push(object[selector.name]); - nextPointers.push(pointer + "/" + escapeSeg(selector.name)); + nextPointers.push(appendSegment(pointer, selector.name)); return true; } case "index": { @@ -144,7 +145,7 @@ function applySimpleSelector( for (let index = 0; index < keys.length; index += 1) { const key = keys[index]!; nextValues?.push(object[key]); - nextPointers.push(pointer + "/" + escapeSeg(key)); + nextPointers.push(appendSegment(pointer, key)); } return true; } @@ -165,7 +166,7 @@ function applySimpleMatchSelector( const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return true; next.push({ - pointer: match.pointer + "/" + escapeSeg(selector.name), + pointer: appendSegment(match.pointer, selector.name), value: object[selector.name], }); return true; @@ -209,7 +210,7 @@ function applySimpleMatchSelector( const keys = Object.keys(object); for (let index = 0; index < keys.length; index += 1) { const key = keys[index]!; - next.push({ pointer: match.pointer + "/" + escapeSeg(key), value: object[key] }); + next.push({ pointer: appendSegment(match.pointer, key), value: object[key] }); } return true; } diff --git a/packages/json-document/src/foundation/jsonpath/support.ts b/packages/json-document/src/foundation/jsonpath/support.ts index c3a043ecd..d38c217ec 100644 --- a/packages/json-document/src/foundation/jsonpath/support.ts +++ b/packages/json-document/src/foundation/jsonpath/support.ts @@ -3,10 +3,6 @@ const regexCache = new Map(); export const objectHasOwn = Object.prototype.hasOwnProperty; -export function escapeSeg(s: string): string { - return s.replace(/~/g, "~0").replace(/\//g, "~1"); -} - export function plainRegexLiteral(pattern: string): string | null { for (let index = 0; index < pattern.length; index += 1) { switch (pattern[index]) { diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index 9a03c4271..21e0b4172 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -2,6 +2,8 @@ import { trackPointer as trackPointerInternal } from "../patch/track.js"; import type { Pointer } from "../pointer/core.js"; import type { JSONPatchOperation, JSONValue } from "./contract.js"; +export { readPointer } from "./read.js"; + export { applyOwnedProtocolPatch, applyProtocolPatch, diff --git a/packages/json-document/src/foundation/protocol/read.ts b/packages/json-document/src/foundation/protocol/read.ts new file mode 100644 index 000000000..78e4bf5a8 --- /dev/null +++ b/packages/json-document/src/foundation/protocol/read.ts @@ -0,0 +1,21 @@ +import { parsePointer, readAt, type Pointer } from "../pointer/core.js"; +import type { JSONValue, ReadResult } from "./contract.js"; + +/** Resolves a pointer without cloning, freezing, or taking ownership of the value. */ +export function readPointer(value: JSONValue, pointer: Pointer): ReadResult { + let segments: string[]; + try { + segments = parsePointer(pointer); + } catch (error) { + return Object.freeze({ + ok: false, + code: "invalid_pointer", + reason: error instanceof Error ? error.message : "invalid pointer", + pointer, + }); + } + const result = readAt(value, segments); + return result.ok + ? Object.freeze({ ok: true, path: pointer, value: result.value as JSONValue }) + : Object.freeze({ ok: false, code: "path_not_found", reason: `path not found: ${pointer}`, pointer }); +} diff --git a/packages/json-document/tests/conformance/json-primitives.test.ts b/packages/json-document/tests/conformance/json-primitives.test.ts index 7086e63fb..aa89bd52f 100644 --- a/packages/json-document/tests/conformance/json-primitives.test.ts +++ b/packages/json-document/tests/conformance/json-primitives.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it } from "vitest"; -import { jsonEqual, parseArrayIndex } from "@interactive-os/json-document"; +import { applyPatch, createJSONDocument, isJSONValue, jsonEqual, parseArrayIndex, readPointer } from "@interactive-os/json-document"; describe("canonical JSON primitives", () => { + it("uses the same JSON tree boundary as Core without normalizing or invoking accessors", () => { + const cycle: unknown[] = []; + cycle.push(cycle); + const shared = {}; + let reads = 0; + const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => { reads++; return 1; } }); + const candidates: unknown[] = [ + null, true, 1, "", [], { "a/b~": [1, null] }, Object.create(null), + undefined, NaN, Infinity, new Date(0), Array(1), cycle, [shared, shared], + { value: undefined }, accessor, { [Symbol("key")]: 1 }, + ]; + for (const candidate of candidates) { + expect(isJSONValue(candidate)).toBe(applyPatch(candidate, []).ok); + } + expect(reads).toBe(0); + expect(isJSONValue({ nested: { title: "Draft" } })).toBe(true); + }); + + it("reads the same locations and failures as document.at while borrowing the value", () => { + const value = { "a/b~": [{ title: "Draft" }], "": true }; + const document = createJSONDocument(value); + for (const pointer of ["", "#", "/", "/a~1b~0/0", "#/a~1b~0/0/title", "/a~1b~0/01", "/missing", "/toString", "/bad~2", "#/%ZZ"]) { + expect(readPointer(value, pointer)).toEqual(document.at(pointer)); + } + const selected = readPointer(value, "#/a~1b~0/0"); + expect(selected.ok && selected.value).toBe(value["a/b~"][0]); + expect(Object.isFrozen(value)).toBe(false); + expect(Object.isFrozen(value["a/b~"][0])).toBe(false); + }); + it("compares JSON values without depending on object key order", () => { expect(jsonEqual({ alpha: 1, beta: [true] }, { beta: [true], alpha: 1 })).toBe(true); expect(jsonEqual({ alpha: 1 }, { alpha: 2 })).toBe(false); diff --git a/packages/json-document/tests/package/smoke.mjs b/packages/json-document/tests/package/smoke.mjs index fa2eb1e75..5474e172e 100644 --- a/packages/json-document/tests/package/smoke.mjs +++ b/packages/json-document/tests/package/smoke.mjs @@ -123,8 +123,8 @@ try { if (packageJson.dependencies !== undefined) { throw new Error("The v3 kernel must not publish runtime dependencies."); } - if (rootValueExports.length !== 10 || rootTypeExports.length !== 13) { - throw new Error("The root contract must contain exactly 10 values and 13 types."); + if (rootValueExports.length !== 12 || rootTypeExports.length !== 13) { + throw new Error("The root contract must contain exactly 12 values and 13 types."); } const packResult = JSON.parse(run( diff --git a/site/src/routes/connectors/react/ReactConnectorLab.tsx b/site/src/routes/connectors/react/ReactConnectorLab.tsx index c982c7003..35fc0a242 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, trackPointer, type JSONValue } from "@interactive-os/json-document"; +import { createJSONDocument, isJSONValue, readPointer, trackPointer, type JSONValue } from "@interactive-os/json-document"; import { documentSelectionFocus, type BlockDocument } from "@interactive-os/json-document-editing"; import { DocumentTextControl, @@ -29,10 +29,38 @@ export function ReactConnectorLab() { + ); } +function JSONValueLab() { + const samples = [ + { label: "Nested value", value: { "a/b~": [{ title: "Draft" }] }, pointer: "#/a~1b~0/0/title" }, + { label: "Non-finite number", value: { score: NaN }, pointer: "/score" }, + { label: "Sparse array", value: Array(1), pointer: "/0" }, + ]; + const [index, setIndex] = useState(0); + const sample = samples[index]!; + const input: unknown = sample.value; + const valid = isJSONValue(input); + const result = valid ? readPointer(input, sample.pointer) : null; + return ( +
+

Read a JSON snapshot

+

Validate incoming values, then resolve an address without copying the snapshot.

+
+ {samples.map((item, index) => setIndex(index)}>{item.label})} +
+

{valid ? "Valid JSON value" : "Not a JSON value"}

+ +
+ ); +} + function PointerTrackingLab() { const [shape, setShape] = useState<"array" | "object">("array"); const before = shape === "array" ? { items: ["a", "b"] } : { items: { "0": "a", "1": "b" } }; diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 754b4cc0b..fbe9575f8 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -105,6 +105,8 @@ import richTextReactSurfaceSource from "../../../../packages/json-document-rich- import richTextRenderStoreSource from "../../../../packages/json-document-rich-text-react/src/render-store.ts?raw"; import uiFileSizeSource from "../../../../packages/json-document-file-intake/src/file-size.ts?raw"; import coreDocumentSource from "../../../../packages/json-document/src/application/document/create.ts?raw"; +import corePointerReadSource from "../../../../packages/json-document/src/foundation/protocol/read.ts?raw"; +import coreJSONValidationSource from "../../../../packages/json-document/src/foundation/json/serializable.ts?raw"; import selectionRangeSource from "../../../../packages/json-document-selection/src/range/index.ts?raw"; import selectionMaterializedRangeSource from "../../../../packages/json-document-selection/src/range/materialized.ts?raw"; import contentEditableReactSource from "../../../../packages/json-document-contenteditable/src/content-editable.tsx?raw"; @@ -283,6 +285,8 @@ const registeredUsageSources = new Map([ ["packages/json-document-rich-text-react/src/render-store.ts", richTextRenderStoreSource], ["packages/json-document-file-intake/src/file-size.ts", uiFileSizeSource], ["packages/json-document/src/application/document/create.ts", coreDocumentSource], + ["packages/json-document/src/foundation/protocol/read.ts", corePointerReadSource], + ["packages/json-document/src/foundation/json/serializable.ts", coreJSONValidationSource], ["packages/json-document-selection/src/range/index.ts", selectionRangeSource], ["packages/json-document-selection/src/range/materialized.ts", selectionMaterializedRangeSource], ["packages/json-document-contenteditable/src/content-editable.tsx", contentEditableReactSource], @@ -657,6 +661,16 @@ const registeredPublicUsages = [ symbol: "createJSONDocument", sourcePath: "packages/json-document/src/application/document/create.ts", }, + { + packageName: "@interactive-os/json-document", + symbol: "readPointer", + sourcePath: "packages/json-document/src/foundation/protocol/read.ts", + }, + { + packageName: "@interactive-os/json-document", + symbol: "isJSONValue", + sourcePath: "packages/json-document/src/foundation/json/serializable.ts", + }, { packageName: "@interactive-os/json-document", symbol: "trackPointer", diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 17d30ff52..43b6019cf 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -136,6 +136,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-ui-primitives-react/src/toolbar.tsx", "packages/json-document-web/src/clipboard.ts", "packages/json-document-editing/src/database.ts", + "packages/json-document/src/foundation/json/serializable.ts", "packages/json-document-editing/src/database-property-value.ts", "packages/json-document-editing/src/topology.ts", "packages/json-document-web/src/grid-cell.ts", @@ -179,6 +180,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-editing/src/sheet.ts", + "packages/json-document/src/foundation/json/serializable.ts", "packages/json-document-react/src/use-grid-editing.ts", "packages/json-document-editing/src/topology.ts", "packages/json-document-web/src/grid-cell.ts", diff --git a/site/tests/unit/public-document-contract.test.ts b/site/tests/unit/public-document-contract.test.ts index 94c48818c..ded713e74 100644 --- a/site/tests/unit/public-document-contract.test.ts +++ b/site/tests/unit/public-document-contract.test.ts @@ -30,7 +30,8 @@ describe("public documentation contract", () => { }); test("accepts the exact published llms source against the canonical Core contract", () => { - expect(symbolCount).toBe(23); + expect(symbolCount).toBe(25); + for (const symbol of publicContract.root.values) expect(llms).toContain(symbol); expect(findings(llms)).toEqual([]); }); diff --git a/site/tests/unit/react-connector-demo.test.tsx b/site/tests/unit/react-connector-demo.test.tsx index 87ed01632..a1350fa78 100644 --- a/site/tests/unit/react-connector-demo.test.tsx +++ b/site/tests/unit/react-connector-demo.test.tsx @@ -6,6 +6,25 @@ import { discoverDemoSources } from "../../src/shared/demo-workbench/demo-source afterEach(cleanup); describe("React Connector public Usage", () => { + test("validates JSON and reads fragment addresses through registered Core owners", async () => { + render(); + expect(screen.getByTestId("json-value-valid").textContent).toBe("Valid JSON value"); + expect(JSON.parse(screen.getByTestId("json-value-read").textContent!)).toMatchObject({ ok: true, value: "Draft" }); + for (const name of ["Non-finite number", "Sparse array"]) { + fireEvent.click(screen.getByRole("button", { name })); + expect(screen.getByTestId("json-value-valid").textContent).toBe("Not a JSON value"); + expect(JSON.parse(screen.getByTestId("json-value-read").textContent!)).toBeNull(); + } + fireEvent.click(screen.getByRole("button", { name: "Nested value" })); + expect(JSON.parse(screen.getByTestId("json-value-read").textContent!)).toMatchObject({ ok: true, value: "Draft" }); + const sources = await discoverDemoSources("routes/connectors/react/ReactConnectorDemoRoute.tsx"); + for (const path of ["packages/json-document/src/foundation/protocol/read.ts", "packages/json-document/src/foundation/json/serializable.ts"]) { + const owner = sources.find((source) => source.path === path); + expect(owner?.referencePath).toBe("/docs/api/json-document"); + expect(await owner!.load()).toContain("export function"); + } + }); + test("executes context-aware tracking and exposes its canonical owner", async () => { render(); expect(screen.getByRole("button", { name: "Array insertion" }).textContent).toBe("Array insertion"); diff --git a/standards/json-document-v3/profile.md b/standards/json-document-v3/profile.md index 493e05434..2f2ff894c 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와 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할 수 있다. | +| JD3-BINDING-001 | Kernel package export와 TypeScript declaration은 언어별 binding contract이며 보편 protocol과 별도로 versioning해야 한다. v3 Kernel package는 root entrypoint와 25개 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 초안 @@ -212,15 +212,20 @@ array-property 분류만 공통 leaf에 두고, parity test가 untrusted boundar ## Package binding -`@interactive-os/json-document`는 root entrypoint 하나와 23개 symbol을 +`@interactive-os/json-document`는 root entrypoint 하나와 25개 symbol을 공개한다. `JSONDocument`의 canonical member는 여섯 개다. ```txt -values 10 +values 12 types 13 -total 23 +total 25 ``` +`isJSONValue`와 `readPointer`는 JSON 값 검증과 주소 조회의 stateless TypeScript +binding이다. 기존 Core 규칙을 소비자에게 제공하며 document member, wire 형식, +JSON 유효성 또는 domain schema를 추가하지 않는다. 공개 목록은 package의 +`public-contract.json`이 소유한다. + 패키지는 runtime dependency와 peer dependency가 없다. 제거된 `/session`과 `/react` implementation은 export가 아니며 production build와 tarball에 포함하지 않는다. 별도 `JSON Document` public type은 외부 구현에서 같은 From dd39608a8abbcc2c6fbc6d1cd7b5af68baa3b9c0 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: Tue, 8 Sep 2026 16:18:51 +0900 Subject: [PATCH 2/2] test(site): include canonical JSON validator in source tabs --- site/tests/browser/demo-workbench.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/tests/browser/demo-workbench.spec.ts b/site/tests/browser/demo-workbench.spec.ts index 61fd40431..952991f5a 100644 --- a/site/tests/browser/demo-workbench.spec.ts +++ b/site/tests/browser/demo-workbench.spec.ts @@ -64,6 +64,7 @@ test("shows every demo-owned database file as a source tab", async ({ page }) => "toolbar.tsx", "clipboard.ts", "database.ts", + "serializable.ts", "database-property-value.ts", "topology.ts", "grid-cell.ts",