From 2d2dc2c3a9c0ffa21321d14cd8225ac85e33e386 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, 14 Jul 2026 17:16:06 +0900 Subject: [PATCH] refactor(json-document): harden state publication boundaries --- docs/changelog.md | 27 + docs/public/api.md | 23 +- docs/standard/core-standard.md | 9 + docs/standard/json-document-spec.md | 17 + packages/json-document/README.md | 5 + .../domain/document/clipboard/clipboard.ts | 7 +- .../src/domain/document/clipboard/paste.ts | 6 +- .../src/domain/document/create.ts | 20 +- .../src/domain/document/editing/actions.ts | 10 +- .../src/domain/document/history/undoRedo.ts | 36 +- .../src/domain/document/selection/create.ts | 17 +- .../src/domain/document/selection/runtime.ts | 13 +- .../src/domain/document/state/json.ts | 163 ++++- .../src/domain/document/state/ops.ts | 29 +- .../src/domain/document/state/ownership.ts | 282 ++++++++ .../src/domain/document/state/patch.ts | 357 +++++----- .../src/domain/schema/model/schema.ts | 11 +- .../src/domain/schema/validation/patch.ts | 9 +- .../src/foundation/patch/apply.ts | 58 +- .../src/foundation/patch/index.ts | 1 + .../src/foundation/patch/schema.ts | 56 +- .../src/foundation/patch/trusted.ts | 30 +- .../src/foundation/pointer/index.ts | 30 +- .../document/capability/integration.test.ts | 23 +- .../document/create-json-document.test.ts | 90 +-- .../json-document/tests/public/error.test.ts | 40 ++ .../tests/public/json-patch/rfc6902.test.ts | 137 ++++ .../public/json-pointer/uri-fragment.test.ts | 16 + .../tests/public/jsonpath/cts.test.ts | 2 +- .../regression/json-state-invariants.test.ts | 648 ++++++++++++++++++ scripts/benchmark-core.mjs | 143 +++- 31 files changed, 1970 insertions(+), 345 deletions(-) create mode 100644 packages/json-document/src/domain/document/state/ownership.ts create mode 100644 packages/json-document/tests/regression/json-state-invariants.test.ts diff --git a/docs/changelog.md b/docs/changelog.md index 71ff801e..fe8fcec8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -30,9 +30,36 @@ All notable changes to this project are documented here. - Changed the causal patch inbox to commit each ready direct or materialized envelope once through `doc.commit`, including a rebased `selectionAfter`, and to advance its applied ledger and causal frontier only after success. +- Changed document publication to expose transitively immutable snapshots and + immutable canonical patch records. Mutable ingress values are owned before + commit; pre-frozen `trustedInitial` snapshots retain the identity-preserving + zero-copy path, while only statically known-JSON schemas skip the JSON scan. + +### Fixed + +- Rejected non-JSON Zod outputs instead of admitting a poisoned document. + Construction throws a `TypeError`; load/reset failures follow strict/onError + execution policy. +- Restored parsed transform outputs correctly from `reset()` and treated an + explicit `reset(null)` as a real JSON root value. +- Rejected undefined RFC 6901 tilde escapes, encoded URI fragment pointers as + UTF-8, and limited RFC 6902 `path`/`from` to JSON Pointer string form rather + than URI fragment form. +- Prevented retained payload/operation references and early subscribers from + mutating published state, `lastPatch`, or undo/redo records. +- Isolated schema validation candidates so mutating custom checks cannot change + live state, failed previews, or capability probes. +- Kept publication-local patch records and revision order across reentrant + subscribers, and stopped test-only/no-op patches from creating stale history. ### Performance +- Added separate mutable and pre-frozen `trustedInitial` benchmark cases and + separate commit-only versus commit-plus-snapshot measurements. Immutable + snapshots are materialized on first public read when needed; an already + immutable base carries provenance through COW changes by sealing path-local + containers or identity-diffed structural changes. Repeated reads reuse the + ownership cache. - Indexed explicit `baseRevision` materialization directly into the dense journal suffix, replaced full-ledger dependency lookup with id-to-revision lookup, and removed duplicate/unneeded patch copies. diff --git a/docs/public/api.md b/docs/public/api.md index d7adc482..af7e7eac 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -60,7 +60,7 @@ function Editor() { | 작업 | 진입점 | 알아야 하는 규칙 | | --- | --- | --- | -| 현재 값 읽기 | `doc.value` | schema-valid JSON 값입니다. | +| 현재 값 읽기 | `doc.value` | schema-valid, transitively immutable JSON snapshot입니다. | | 한 위치 읽기 | `doc.at(pointer)` | raw value가 아니라 `ReadResult`를 반환합니다. | | 하위 항목 나열 | `doc.entries(pointer)` | object, record, array entry를 Pointer와 함께 돌려줍니다. | | 여러 위치 찾기 | `doc.find(jsonPath)`, `doc.query(jsonPath)` | JSONPath는 변경 언어가 아닙니다. 결과 Pointer로 patch를 만듭니다. | @@ -85,6 +85,18 @@ doc.subscribe((patch, metadata) => { }); ``` +`doc.value`와 `doc.at`/`doc.entries`가 가리키는 document subtree는 같은 immutable +snapshot입니다. 값을 직접 수정하지 말고 `patch`, `commit`, high-level edit API를 +사용합니다. Document는 accepted initial/load/patch payload를 소유하므로 caller가 원본 +객체를 나중에 바꿔도 published state나 history가 바뀌지 않습니다. + +`trustedInitial: true`는 initial schema parse를 생략합니다. Mutable input은 안전하게 +clone하고, public snapshot으로 처음 노출될 때 freeze합니다. 이미 deep-freeze된 root는 +같은 identity로 재사용합니다. Schema output이 정적으로 JSON임을 판별할 수 있을 때만 +JSON scan까지 생략되어 생성 비용이 O(1)이 됩니다. Custom/refined schema는 frozen root도 +JSON boundary를 한 번 검사합니다. Frozen root를 전달할 때 caller는 전체 graph가 +schema-valid JSON이고 transitively immutable임을 보증합니다. + 읽기는 document에 직접 둡니다. ```ts @@ -109,10 +121,11 @@ doc.canReplace("", nextDocument); 앱 adapter에서 plain string path를 받으면 json-document 경계에서 한 번 확인합니다. ```ts -import { tryParsePointer, type Pointer } from "@interactive-os/json-document"; +import { buildPointer, tryParsePointer, type Pointer } from "@interactive-os/json-document"; function asPointer(path: string): Pointer | null { - return tryParsePointer(path) === null ? null : path as Pointer; + const segments = tryParsePointer(path); + return segments === null ? null : buildPointer(segments); } ``` @@ -136,7 +149,9 @@ doc.commit([ ], { label: "rename" }); ``` -Patch의 `path`와 `from`은 JSON Pointer입니다. JSONPath를 patch에 직접 넣지 않습니다. +Patch의 `path`와 `from`은 JSON Pointer의 JSON string 표현인 `""` 또는 `/...`입니다. +URI fragment 표현인 `#`/`#/...`는 `$ref`나 URL adapter에서 parse한 뒤 위 예제처럼 +canonical Pointer로 바꿉니다. JSONPath를 patch에 직접 넣지 않습니다. 구조 편집 후 앱이 정확한 다음 선택 위치를 알고 있으면 `selectionAfter`를 같이 넘깁니다. 기본 편집은 document가 selection을 자동 복구하지만, group, unwrap, bulk edit처럼 결과 focus가 command 의미에 속하는 경우에는 command가 최종 selection을 명시합니다. diff --git a/docs/standard/core-standard.md b/docs/standard/core-standard.md index 02fb8f4e..9dec297b 100644 --- a/docs/standard/core-standard.md +++ b/docs/standard/core-standard.md @@ -54,6 +54,10 @@ React 적합성은 별도로 주장할 수 있으며, `@interactive-os/json-docu document state는 RFC 8259와 ECMA-404의 JSON data여야 한다. +구현체가 publish한 document state snapshot은 transitively immutable해야 한다. +Caller가 initial, load, patch payload reference를 나중에 수정해도 state, history, +subscriber가 관측하는 patch record가 바뀌면 안 된다. + patch operation, clipboard payload, selection snapshot, history metadata는 명시적인 trusted boundary API를 사용하지 않는 한 JSON-serializable이어야 한다. @@ -82,6 +86,11 @@ schema는 document가 받아들일 수 있는 shape를 정의한다. 초기 document 생성은 caller가 explicit trusted-initial boundary를 사용하지 않는 한 초기 값을 검증해야 한다. +trusted-initial boundary는 schema parse를 생략한다는 caller assertion이다. JSON state +요건을 해제하지 않으며, zero-copy reuse를 요구하려면 caller가 transitively immutable +snapshot을 제공해야 한다. O(1) 생성은 schema output을 정적으로 JSON으로 판별할 수 있는 +경우에만 보장하며, custom/refined schema는 frozen snapshot도 JSON boundary를 검사한다. + mutation API는 성공 결과를 commit하기 전에 결과 document를 schema로 검증해야 한다. diff --git a/docs/standard/json-document-spec.md b/docs/standard/json-document-spec.md index 020f691a..77bda9b5 100644 --- a/docs/standard/json-document-spec.md +++ b/docs/standard/json-document-spec.md @@ -39,6 +39,7 @@ UI rendering, DOM event mapping, visual selection drawing, system clipboard acce - Query output은 Pointer다. - JSONPath는 patch target이 아니다. - State, patch operation, selection snapshot, clipboard payload, history metadata는 JSON-serializable이어야 한다. +- Published State와 applied patch record는 immutable snapshot이어야 하며 retained caller reference나 subscriber가 이를 바꿀 수 없어야 한다. ## 2. 공개 Entry Point @@ -295,6 +296,7 @@ doc.query("$.items[*].id"); ``` JSONPath는 검색 언어다. `find`는 편집 feature verb이고, `query`는 같은 JSONPath engine을 노출하는 lower-level read primitive다. Mutation input은 JSON Pointer `path`와 `from`을 가진 JSON Patch operation으로 유지한다. +RFC 6902 `path`와 `from`은 URI fragment가 아니라 JSON Pointer의 JSON string 표현인 `""` 또는 `/...`를 사용한다. URL이나 `$ref`에서 받은 `#`/`#/...`는 adapter가 parse한 뒤 canonical string 표현으로 변환한다. ```ts const found = doc.find("$.items[?(@.done==false)]"); @@ -385,6 +387,21 @@ History metadata는 앱이나 adapter가 document change에 붙이는 주석이 큰 문서의 hot path는 document facade인 `doc.patch`, `doc.commit`, `doc.canPatch`에 둔다. 공개 `applyPatch`는 외부 JSON 경계라서 입력 state 전체의 JSON 안전성을 확인한다. `applyPatchToTrustedState`는 호출자가 이미 state JSON 경계를 소유할 때 쓰는 pure core opt-in이다. Operation value와 schema validation은 여전히 실행되며 구조만 가진 schema는 document facade와 같은 trusted fast path를 사용할 수 있다. +Document facade는 accepted payload를 ingress에서 소유한다. 아직 immutable provenance가 +없는 COW state는 `value`, read result, EditOk처럼 외부로 처음 노출될 때 container graph를 +한 번 freeze한다. 이미 publish되었거나 신뢰된 immutable base의 단일 mutation 및 +replace-only batch는 변경 경로의 COW container만 seal한다. 구조가 이동하는 batch는 +immutable base와의 identity diff로 상속 subtree를 건너뛰고 새 container만 freeze한다. +같은 snapshot의 반복 읽기는 ownership cache를 재사용한다. 따라서 input/commit hot path와 render/read +snapshot materialization 비용을 각각 측정할 수 있다. `trustedInitial: true`의 mutable +value는 clone fallback을 사용하며, pre-frozen root는 caller의 transitive immutability +assertion으로 같은 identity를 재사용할 수 있다. Schema output이 정적으로 JSON임을 +판별할 수 있을 때만 JSON scan도 생략되어 O(1) 생성이 된다. Custom/refined schema는 +frozen root도 JSON boundary를 한 번 검사한다. 이 경로에서 caller는 schema-valid JSON +output도 함께 보증한다. Pure +`applyPatch*` helper는 state owner가 아니므로 이 runtime ownership 정책의 +대상이 아니다. + 빠른 document path는 신뢰된 document state와 구조만 가진 Zod schema에서만 적용된다. 대상 schema는 refinement, transform, check가 없는 object, array, record, scalar validator다. 지원 edit는 non-root `replace` batch(독립 경로와 순차 ancestor/descendant overlap 포함), array `add`/`remove`/`copy`/`move`, same-array `add`/`remove` batch다. Leading `test` assertion 뒤에 이 edit들이 오는 guarded batch도 assertion을 먼저 확인한 뒤 같은 mutation fast path를 사용한다. Overlapping `replace`의 history inverse도 operation별 이전 값과 역순을 유지하면서 같은 private copy-on-write 순회 구현을 사용한다. `refine`, `superRefine`, transform, check가 있으면 의도적으로 전체 루트 schema 검증으로 돌아간다. ```sh diff --git a/packages/json-document/README.md b/packages/json-document/README.md index eda84ae5..2bf61897 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -7,6 +7,11 @@ Zod schema로 보호되는 JSON state를 읽고, 바꾸고, 선택하고, 복사 아닙니다. Form, CMS block, kanban board, outliner, settings editor처럼 서로 다른 제품이 반복해서 구현하는 document editing core를 제공합니다. +Published `doc.value`는 transitively immutable JSON snapshot입니다. Document가 +accepted input과 patch record를 소유하므로 caller-held payload나 subscriber가 +state/history를 notification 없이 바꿀 수 없습니다. 모든 변경은 document mutation +API를 통과합니다. + ```txt schema -> document -> pointer/query -> can* -> change -> result ``` diff --git a/packages/json-document/src/domain/document/clipboard/clipboard.ts b/packages/json-document/src/domain/document/clipboard/clipboard.ts index 6399efbe..7de7eea4 100644 --- a/packages/json-document/src/domain/document/clipboard/clipboard.ts +++ b/packages/json-document/src/domain/document/clipboard/clipboard.ts @@ -57,7 +57,6 @@ interface CreateClipboardOptions { ) => PreviewedDocumentPatchResult; getSelectionSource?: () => SelectionSource | null; getSelectionTarget?: () => Pointer | null; - getAppliedPatch?: () => ReadonlyArray; getStateJsonTrusted?: () => boolean; onChange?: () => void; } @@ -75,7 +74,6 @@ export function createClipboard( applyPreviewedPatch, getSelectionSource, getSelectionTarget, - getAppliedPatch, getStateJsonTrusted, onChange, } = args; @@ -89,7 +87,6 @@ export function createClipboard( previewTrustedValuesPatch, applyPreviewedPatch, getSelectionTarget, - getAppliedPatch, }); const setBuffer = (next: ClipboardBuffer | null): void => { @@ -170,14 +167,14 @@ export function createClipboard( if (!result.ok) return result; const publication: PreviewedDocumentPatchResult = applyPreviewedPatch ? applyPreviewedPatch(result.prepared, result.patch) - : { status: "applied", result: ops.patch(result.patch) }; + : { status: "applied", result: ops.patch(result.patch), applied: result.patch }; if (publication.status === "stale") continue; const patchResult = publication.result; const applyResult: ClipboardCutResult> = patchResult.ok ? { ok: true, value: getState(), - applied: getAppliedPatch?.() ?? result.patch, + applied: publication.applied, target: null, payload: result.payload, source: result.source, diff --git a/packages/json-document/src/domain/document/clipboard/paste.ts b/packages/json-document/src/domain/document/clipboard/paste.ts index eee31624..a0c224c3 100644 --- a/packages/json-document/src/domain/document/clipboard/paste.ts +++ b/packages/json-document/src/domain/document/clipboard/paste.ts @@ -33,7 +33,6 @@ interface CreateClipboardPasteRuntimeOptions { operations: ReadonlyArray, ) => PreviewedDocumentPatchResult) | undefined; getSelectionTarget?: (() => Pointer | null) | undefined; - getAppliedPatch?: (() => ReadonlyArray) | undefined; } export interface ClipboardPasteRuntime { @@ -70,7 +69,6 @@ export function createClipboardPasteRuntime( previewTrustedValuesPatch, applyPreviewedPatch, getSelectionTarget, - getAppliedPatch, } = options; function pasteExecutionOptions( @@ -132,7 +130,7 @@ export function createClipboardPasteRuntime( if (!result.ok) return result; const publication: PreviewedDocumentPatchResult = applyPreviewedPatch ? applyPreviewedPatch(result.prepared, result.patch) - : { status: "applied", result: ops.patch(result.patch) }; + : { status: "applied", result: ops.patch(result.patch), applied: result.patch }; if (publication.status === "stale") continue; const patchResult = publication.result; if (!patchResult.ok) { @@ -142,7 +140,7 @@ export function createClipboardPasteRuntime( reason: patchResult.reason ?? patchResult.code, }; } - const applied = getAppliedPatch?.() ?? result.patch; + const applied = publication.applied; // target: 첫 착지 slot (applied 는 정규화된 concrete op — `/-` 없음). const first = applied[0]; return { diff --git a/packages/json-document/src/domain/document/create.ts b/packages/json-document/src/domain/document/create.ts index 066fcaa6..79d85404 100644 --- a/packages/json-document/src/domain/document/create.ts +++ b/packages/json-document/src/domain/document/create.ts @@ -135,7 +135,6 @@ export function createDocumentRuntime( onChange: options.onChange, }); const selectionState = selectionRuntime.state; - const syncLastPatch = (): void => { patchState.lastPatch = rawOps.lastApplied; }; const mutation = createDocumentMutationRuntime({ schema: zodSchema, rawOps, @@ -148,7 +147,6 @@ export function createDocumentRuntime( rawOps, historyState, selection: selectionRuntime.access, - syncLastPatch, }); const ops = createDocumentStateOps({ @@ -157,19 +155,18 @@ export function createDocumentRuntime( historyState, patchState, snapSelection: selectionRuntime.access.snapSelection, - syncLastPatch, + selectionAfterForApplied: mutation.selectionAfterForApplied, }); const insertPasteRuntime = createClipboardPasteRuntime({ schema: zodSchema, - getState: () => rawOps.state, + getState: () => rawOps.snapshot, getRevision: () => rawOps.revision, ops, - previewPatch: rawOps.previewPatch, + previewPatch: rawOps.previewOwnedPatch, previewTrustedValuesPatch: rawOps.previewTrustedValuesPatch, applyPreviewedPatch: mutation.applyPreviewedDocumentPatch, getSelectionTarget: () => selectionState?.primaryPointer ?? null, - getAppliedPatch: () => patchState.lastPatch, }); const insertRuntime = { insertPayload( @@ -200,15 +197,14 @@ export function createDocumentRuntime( }); const clipboardOptions = { schema: zodSchema, - getState: () => rawOps.state, + getState: () => rawOps.snapshot, getRevision: () => rawOps.revision, ops, - previewPatch: rawOps.previewPatch, + previewPatch: rawOps.previewOwnedPatch, previewTrustedValuesPatch: rawOps.previewTrustedValuesPatch, applyPreviewedPatch: mutation.applyPreviewedDocumentPatch, getSelectionSource: () => selectionState?.selectedSource ?? null, getSelectionTarget: () => selectionState?.primaryPointer ?? null, - getAppliedPatch: () => patchState.lastPatch, getStateJsonTrusted: () => rawOps.stateJsonTrusted, }; const clipboard = createClipboard(options.onChange === undefined ? clipboardOptions : { ...clipboardOptions, onChange: options.onChange }); @@ -224,17 +220,17 @@ export function createDocumentRuntime( reason: `${direction} failed to apply history entry`, }; }; - const read = createDocumentRead(zodSchema, () => rawOps.state); + const read = createDocumentRead(zodSchema, () => rawOps.snapshot); const schemaState = createSchemaState(zodSchema); const edit = createDocumentEditActions({ - getState: () => rawOps.state, + getState: () => rawOps.snapshot, selection: selectionState, mutation, insertRuntime, }); return { - get value() { return rawOps.state; }, + get value() { return rawOps.snapshot; }, get lastPatch() { return [...patchState.lastPatch]; }, get selection() { return selectionRuntime.enabled ? selectionState : undefined; }, history, diff --git a/packages/json-document/src/domain/document/editing/actions.ts b/packages/json-document/src/domain/document/editing/actions.ts index 35713627..800503bd 100644 --- a/packages/json-document/src/domain/document/editing/actions.ts +++ b/packages/json-document/src/domain/document/editing/actions.ts @@ -4,6 +4,7 @@ import type { SelectionSource } from "../../selection/read.js"; import type { DuplicateError as DomainDuplicateError, DuplicateOpts } from "../../editing/duplicate.js"; import type { CapabilityResult } from "../capabilities/result.js"; import type { SelectionState } from "../selection/create.js"; +import type { DetailedDocumentPatchResult } from "../state/patch.js"; import type { JSONDocumentInsertOptions, JSONDocumentInsertTarget, @@ -43,8 +44,7 @@ export type JSONDocumentDuplicateResult = | Extract; interface DocumentEditMutation { - patch(operations: ReadonlyArray): JSONResult; - lastApplied(): ReadonlyArray; + applyDocumentPatchDetailed(operations: ReadonlyArray): DetailedDocumentPatchResult; duplicate(source: Pointer, options?: JSONDocumentDuplicateOptions): JSONDocumentDuplicateResult; } @@ -83,9 +83,9 @@ export function createDocumentEditActions( // 계획 실행 + 통일 성공 shape 구성 (#219 EditOk). const applyPlan = (plan: ReturnType): JSONDocumentEditResult => { if (!plan.ok) return plan; - const r = mutation.patch(plan.operations); - if (!r.ok) return r; - return { ok: true, value: getState(), applied: mutation.lastApplied(), target: plan.target }; + const execution = mutation.applyDocumentPatchDetailed(plan.operations); + if (!execution.result.ok) return execution.result; + return { ok: true, value: getState(), applied: execution.applied, target: plan.target }; }; function insert(target: JSONDocumentInsertTarget, value: unknown, options?: JSONDocumentInsertOptions): JSONDocumentEditResult; diff --git a/packages/json-document/src/domain/document/history/undoRedo.ts b/packages/json-document/src/domain/document/history/undoRedo.ts index 78ee7529..2b1972f0 100644 --- a/packages/json-document/src/domain/document/history/undoRedo.ts +++ b/packages/json-document/src/domain/document/history/undoRedo.ts @@ -28,6 +28,7 @@ export interface DocumentHistoryRuntimeState { isRestoring: boolean; activeHistoryMetadata: HistoryTransactionOptions | undefined; activeTransactionStartDepth: number | undefined; + activeTransactionToken: object | undefined; } export function createDocumentHistoryRuntimeState(): DocumentHistoryRuntimeState { @@ -36,6 +37,7 @@ export function createDocumentHistoryRuntimeState(): DocumentHistoryRuntimeState isRestoring: false, activeHistoryMetadata: undefined, activeTransactionStartDepth: undefined, + activeTransactionToken: undefined, }; } @@ -61,7 +63,6 @@ interface CreateDocumentHistoryRuntimeInput { rawOps: TrustedJSONStateOps; historyState: DocumentHistoryRuntimeState; selection: SelectionRuntimeAccess; - syncLastPatch: () => void; } export function createDocumentHistoryRuntime( @@ -75,7 +76,7 @@ export function createDocumentHistoryRuntime( canRedo: () => boolean; }; } { - const { rawOps, historyState, selection, syncLastPatch } = input; + const { rawOps, historyState, selection } = input; const restore = (direction: "undo" | "redo"): boolean => { const restoreStack = direction === "undo" ? historyState.stack.undo : historyState.stack.redo; @@ -95,7 +96,6 @@ export function createDocumentHistoryRuntime( : rawOps.trustedApply(plan.state as T, plan.patch); if (!result.ok) return false; if (direction === "redo") restoreStack[restoreStack.length - 1] = plan.entry; - syncLastPatch(); } catch { return false; } finally { @@ -129,17 +129,6 @@ export function createDocumentHistoryRuntime( return true; }; - const mergeTransactionEntries = (depthBefore: number): void => { - if (historyDepth(historyState.stack) <= depthBefore + 1) return; - const start = historyState.stack.undoStart + depthBefore; - const end = historyState.stack.undo.length; - if (start < historyState.stack.undoStart || end - start <= 1) return; - const merged = mergeTransactionHistoryRange(historyState.stack.undo, start, end); - if (merged === null) return; - historyState.stack.undo[start] = merged; - historyState.stack.undo.length = start + 1; - }; - const withHistoryMetadata = (metadata: HistoryTransactionOptions | undefined, fn: () => void): void => { const previous = historyState.activeHistoryMetadata; historyState.activeHistoryMetadata = metadata === undefined ? previous : { ...previous, ...metadata }; @@ -159,13 +148,16 @@ export function createDocumentHistoryRuntime( const metadata = typeof optionsOrFn === "function" ? undefined : optionsOrFn; const depthBefore = historyDepth(historyState.stack); const previousTransactionStartDepth = historyState.activeTransactionStartDepth; + const previousTransactionToken = historyState.activeTransactionToken; historyState.activeTransactionStartDepth = previousTransactionStartDepth ?? depthBefore; + historyState.activeTransactionToken = previousTransactionToken ?? {}; try { withHistoryMetadata(metadata, fn); } finally { historyState.activeTransactionStartDepth = previousTransactionStartDepth; + historyState.activeTransactionToken = previousTransactionToken; } - mergeTransactionEntries(depthBefore); + mergeDocumentHistoryEntriesSince(historyState, depthBefore); }; const history: JSONDocumentHistory = { @@ -183,6 +175,20 @@ export function createDocumentHistoryRuntime( return { history, historyControls }; } +export function mergeDocumentHistoryEntriesSince( + historyState: DocumentHistoryRuntimeState, + depthBefore: number, +): void { + if (historyDepth(historyState.stack) <= depthBefore + 1) return; + const start = historyState.stack.undoStart + depthBefore; + const end = historyState.stack.undo.length; + if (start < historyState.stack.undoStart || end - start <= 1) return; + const merged = mergeTransactionHistoryRange(historyState.stack.undo, start, end); + if (merged === null) return; + historyState.stack.undo[start] = merged; + historyState.stack.undo.length = start + 1; +} + function mergeTransactionHistoryRange( entries: ReadonlyArray, start: number, diff --git a/packages/json-document/src/domain/document/selection/create.ts b/packages/json-document/src/domain/document/selection/create.ts index 714cee92..5fa74830 100644 --- a/packages/json-document/src/domain/document/selection/create.ts +++ b/packages/json-document/src/domain/document/selection/create.ts @@ -2,6 +2,7 @@ // React hook and JSONDocument use this same implementation. import type { JSONStateOps } from "../state/ops.js"; +import type { JSONPatchOperation } from "../../../foundation/patch/index.js"; import type { Pointer } from "../../../foundation/pointer/index.js"; import { jsonEqual } from "../../../foundation/json/index.js"; import { @@ -86,6 +87,10 @@ interface CreateSelectionOptions extends SelectionOptions { interface InternalCreateSelectionOptions extends CreateSelectionOptions { applyMetadataSelectionAfter?: boolean; + onPatchSelection?: ( + applied: ReadonlyArray, + selection: SelectionSnap, + ) => void; } type SelectionChangeListener = ( @@ -199,14 +204,19 @@ export function createSelection( } }; const hasObservers = (): boolean => options.onChange !== undefined || listeners.size > 0; - const setSnap = (next: SelectionSnap): void => { + const setSnap = (next: SelectionSnap, beforeEmit?: (selection: SelectionSnap) => void): void => { if (!hasObservers()) { snap = next; + beforeEmit?.(selectionSnapshot(snap)); return; } const previous = selectionSnapshot(snap); - if (sameSelectionSnapshot(previous, next)) return; + if (sameSelectionSnapshot(previous, next)) { + beforeEmit?.(selectionSnapshot(snap)); + return; + } snap = next; + beforeEmit?.(selectionSnapshot(snap)); emit(previous); }; const dispatch = (action: SelectionAction): void => { @@ -221,7 +231,8 @@ export function createSelection( ops.subscribe((applied, metadata) => { setSnap(applyMetadataSelectionAfter && metadata?.selectionAfter ? restoreSelection(metadata.selectionAfter, mode, ops.state) - : applySelectionAutoRules(snap, applied, ops.state, mode)); + : applySelectionAutoRules(snap, applied, ops.state, mode), + (selection) => (options as InternalCreateSelectionOptions).onPatchSelection?.(applied, selection)); }); return { diff --git a/packages/json-document/src/domain/document/selection/runtime.ts b/packages/json-document/src/domain/document/selection/runtime.ts index a74f9553..c3b63e58 100644 --- a/packages/json-document/src/domain/document/selection/runtime.ts +++ b/packages/json-document/src/domain/document/selection/runtime.ts @@ -4,6 +4,7 @@ import { type SelectionSnap, } from "../../selection/snap.js"; import type { JSONStateOps } from "../state/ops.js"; +import type { JSONPatchOperation } from "../../../foundation/patch/index.js"; import { createSelection, type SelectionOptions, type SelectionState } from "./create.js"; export interface DocumentSelectionRuntime { @@ -18,6 +19,7 @@ export interface SelectionRuntimeAccess { selectionMode: SelectionMode; snapSelection: () => SelectionSnap; restoreSelection: (selection: SelectionSnap) => void; + selectionAfterForApplied: (applied: ReadonlyArray) => SelectionSnap | undefined; } interface CreateDocumentSelectionRuntimeInput { @@ -30,10 +32,18 @@ export function createDocumentSelectionRuntime( input: CreateDocumentSelectionRuntimeInput, ): DocumentSelectionRuntime { const selectionEnabled = input.selection !== undefined && input.selection !== false; + const selectionAfterByApplied = new WeakMap(); const selectionOptions: SelectionOptions = typeof input.selection === "object" ? input.selection : {}; - const createSelectionOptions: SelectionOptions & { onChange?: () => void; applyMetadataSelectionAfter: true } = { + const createSelectionOptions: SelectionOptions & { + onChange?: () => void; + applyMetadataSelectionAfter: true; + onPatchSelection: (applied: ReadonlyArray, selection: SelectionSnap) => void; + } = { ...selectionOptions, applyMetadataSelectionAfter: true, + onPatchSelection: (applied, selection) => { + selectionAfterByApplied.set(applied as object, selection); + }, }; if (input.onChange !== undefined) createSelectionOptions.onChange = input.onChange; @@ -48,6 +58,7 @@ export function createDocumentSelectionRuntime( selectionMode: selectionOptions.mode ?? "single", snapSelection, restoreSelection: (selection) => { selectionState?.restore(selection); }, + selectionAfterForApplied: (applied) => selectionAfterByApplied.get(applied as object), }, ref: selectionState ? { get current() { return selectionState; } } : undefined, }; diff --git a/packages/json-document/src/domain/document/state/json.ts b/packages/json-document/src/domain/document/state/json.ts index c38acce7..385a564c 100644 --- a/packages/json-document/src/domain/document/state/json.ts +++ b/packages/json-document/src/domain/document/state/json.ts @@ -7,6 +7,7 @@ import { applyPatch, applySingleTrustedValuePatchToTrustedState, applyPatchToTrustedState as applyPatchToTrustedStateCore, + validatePatchOperations, } from "../../../foundation/patch/index.js"; import { applyAcceptedPatch } from "../../../foundation/patch/index.js"; import type { @@ -24,6 +25,7 @@ import type { JSONStateOps, } from "./ops.js"; import type { JSONChangeMetadata } from "../history/metadata.js"; +import { createJSONStateOwnership } from "./ownership.js"; type JSONChangeListener = ( applied: ReadonlyArray, @@ -36,15 +38,21 @@ interface CreateJSONStateOptions extends ErrorPolicy { } export interface TrustedJSONStateOps extends JSONStateOps { - readonly lastApplied: ReadonlyArray; + readonly snapshot: T; readonly revision: number; readonly stateJsonTrusted: boolean; + sequenceForApplied(applied: ReadonlyArray): number | undefined; + executePatch( + operations: ReadonlyArray, + metadata?: JSONChangeMetadata, + ): JSONStatePatchExecution; previewPatch(operations: ReadonlyArray): PreparedJSONStateChange; + previewOwnedPatch(operations: ReadonlyArray): PreparedJSONStateChange; previewTrustedValuesPatch(operations: ReadonlyArray): PreparedJSONStateChange; publishPreparedPatch( change: PreparedJSONStateChange, metadata?: JSONChangeMetadata, - ): PreparedJSONStatePublication | { status: "stale" }; + ): PreparedJSONStatePublication | { status: "stale" }; applyTrustedPatch(operations: ReadonlyArray, metadata?: JSONChangeMetadata): JSONResult; trustedApply(state: T, applied: ReadonlyArray, metadata?: JSONChangeMetadata): JSONResult; } @@ -55,12 +63,22 @@ export interface PreparedJSONStateChange { readonly state: T; readonly result: JSONResult; readonly applied: ReadonlyArray; + readonly valuesOwned: boolean; } -export interface PreparedJSONStatePublication { +export interface PreparedJSONStatePublication { readonly status: "published"; + readonly sequence: number; readonly revision: number; readonly changed: boolean; + readonly before: T; + readonly state: T; + readonly applied: ReadonlyArray; +} + +export interface JSONStatePatchExecution { + readonly result: JSONResult; + readonly publication: PreparedJSONStatePublication | null; } const ROOT_REPLACE = (value: unknown): JSONPatchOperation => ({ op: "replace", path: "", value }); @@ -72,6 +90,7 @@ export function createJSONState( initial: z.input | z.output, options: CreateJSONStateOptions = {}, ): TrustedJSONStateOps> { + const ownership = createJSONStateOwnership(); const schemaOutputJsonTrusted = schemaOutputIsKnownJson(schema); let state: z.output; if (options.trustedInitial === true) { @@ -81,38 +100,66 @@ export function createJSONState( if (!parsed.success) throw parsed.error; state = parsed.data as z.output; } - let stateJsonTrusted = schemaOutputJsonTrusted || jsonSerializableError(state) === null; + const trustedFrozenRoot = options.trustedInitial === true + && state !== null + && typeof state === "object" + && Object.isFrozen(state); + const initialJsonError = schemaOutputJsonTrusted + && (options.trustedInitial !== true || trustedFrozenRoot) + ? null + : jsonSerializableError(state); + if (initialJsonError !== null) { + throw new TypeError(`Initial document value is not JSON-serializable: ${initialJsonError}`); + } + if (options.trustedInitial === true) { + state = ownership.ownTrustedInitial(state); + } else { + state = ownership.ownParsedState(state, !schemaOutputJsonTrusted); + } + let stateJsonTrusted = true; const initialState = state; const policy: ErrorPolicy = options; const listeners = new Set(); - let lastApplied: ReadonlyArray = []; + const sequenceByApplied = new WeakMap(); let revision = 0; + let publicationSequence = 0; const notify = ( applied: ReadonlyArray, metadata?: JSONChangeMetadata, + sequence = ++publicationSequence, ): void => { if (applied.length === 0) return; - lastApplied = applied; - options.onChange?.(); + sequenceByApplied.set(applied as object, sequence); for (const listener of listeners) listener(applied, metadata); + options.onChange?.(); }; - const dispatch = ( + const executePatch = ( label: JSONPatchOperation | "patch", operations: ReadonlyArray, metadata?: JSONChangeMetadata, - ): JSONResult => { + ): JSONStatePatchExecution> => { for (let attempt = 0; attempt < MAX_PREPARE_RETRIES; attempt += 1) { - const prepared = preparePatch(operations); + const prepared = prepareOwnedPatch(operations); if (prepared.baseRevision !== revision || prepared.before !== state) continue; - if (!prepared.result.ok) return handleResult(policy, label, prepared.result); + if (!prepared.result.ok) { + return { + result: handleResult(policy, label, prepared.result), + publication: null, + }; + } const publication = publishPreparedPatch(prepared, metadata); if (publication.status === "stale") continue; - return prepared.result; + return { result: prepared.result, publication }; } throw new Error(STALE_PREPARED_CHANGE_MESSAGE); }; + const dispatch = ( + label: JSONPatchOperation | "patch", + operations: ReadonlyArray, + metadata?: JSONChangeMetadata, + ): JSONResult => executePatch(label, operations, metadata).result; const dispatchTrusted = ( operations: ReadonlyArray, metadata?: JSONChangeMetadata, @@ -121,6 +168,8 @@ export function createJSONState( const applied = applyAcceptedPatch(before, operations); if (!applied.result.ok) return handleResult(policy, "patch", applied.result); if (applied.state === before) return applied.result; + ownership.freeze(applied.applied); + ownership.freezePatchResult(before, applied.state, applied.applied); stateJsonTrusted = stateJsonTrusted ? true : schemaOutputJsonTrusted || jsonSerializableError(applied.state) === null; @@ -135,6 +184,7 @@ export function createJSONState( metadata?: JSONChangeMetadata, ): JSONResult => { if (next === state) return { ok: true }; + ownership.freeze(applied); stateJsonTrusted = true; state = next; revision += 1; @@ -147,6 +197,7 @@ export function createJSONState( sourceJsonTrusted: boolean, operations: ReadonlyArray, valuesTrusted: boolean, + valuesOwned: boolean, ): PreparedJSONStateChange> => { const applied = !sourceJsonTrusted ? applyPatch(schema, from, operations) @@ -161,6 +212,7 @@ export function createJSONState( state: applied.state as z.output, result: applied.result, applied: applied.applied, + valuesOwned, }; }; const preparePatch = ( @@ -169,38 +221,81 @@ export function createJSONState( const from = state; const baseRevision = revision; const sourceJsonTrusted = stateJsonTrusted; - return preparePatchFrom(from, baseRevision, sourceJsonTrusted, operations, false); + return preparePatchFrom(from, baseRevision, sourceJsonTrusted, operations, false, false); + }; + const prepareOwnedPatch = ( + operations: ReadonlyArray, + ): PreparedJSONStateChange> => { + if (!Array.isArray(operations) || validatePatchOperations(operations) !== null) { + return preparePatch(operations); + } + const owned = ownership.ownPatch(operations); + const from = state; + const baseRevision = revision; + const sourceJsonTrusted = stateJsonTrusted; + return preparePatchFrom( + from, + baseRevision, + sourceJsonTrusted, + owned.operations, + owned.valuesTrusted, + owned.valuesTrusted, + ); }; const prepareTrustedValuesPatch = ( operations: ReadonlyArray, ): PreparedJSONStateChange> => { + if (!Array.isArray(operations) || validatePatchOperations(operations) !== null) { + return preparePatch(operations); + } + const owned = ownership.ownPatch(operations, true); const from = state; const baseRevision = revision; const sourceJsonTrusted = stateJsonTrusted; - return preparePatchFrom(from, baseRevision, sourceJsonTrusted, operations, true); + return preparePatchFrom(from, baseRevision, sourceJsonTrusted, owned.operations, true, true); }; const publishPreparedPatch = ( prepared: PreparedJSONStateChange>, metadata?: JSONChangeMetadata, - ): PreparedJSONStatePublication | { status: "stale" } => { + ): PreparedJSONStatePublication> | { status: "stale" } => { if (prepared.baseRevision !== revision || prepared.before !== state) return { status: "stale" }; + const sequence = ++publicationSequence; if (!prepared.result.ok || prepared.state === prepared.before) { - const publication: PreparedJSONStatePublication = { + const publication: PreparedJSONStatePublication> = { status: "published", + sequence, revision, changed: false, + before: prepared.before, + state: prepared.before, + applied: [], }; return publication; } + let next = prepared.state; + let applied = prepared.applied; + if (!prepared.valuesOwned) { + const owned = ownership.ownPatch(applied, true); + const replayed = applyAcceptedPatch(prepared.before, owned.operations); + if (!replayed.result.ok) throw new Error("prepared patch could not be replayed"); + next = replayed.state as z.output; + applied = replayed.applied; + } + ownership.freeze(applied); + ownership.freezePatchResult(prepared.before, next, applied); stateJsonTrusted = true; - state = prepared.state; + state = next; revision += 1; - const publication: PreparedJSONStatePublication = { + const publication: PreparedJSONStatePublication> = { status: "published", + sequence, revision, changed: true, + before: prepared.before, + state: next, + applied, }; - notify(prepared.applied, metadata); + notify(applied, metadata, sequence); return publication; }; @@ -215,10 +310,27 @@ export function createJSONState( reason: JSON.stringify(parsed.error.issues), }); } - state = parsed.data as z.output; - stateJsonTrusted = schemaOutputJsonTrusted || jsonSerializableError(state) === null; + const next = parsed.data as z.output; + const nextJsonError = schemaOutputJsonTrusted ? null : jsonSerializableError(next); + if (nextJsonError !== null) { + return handleResult(policy, label, { + ok: false, + code: "not_serializable", + reason: nextJsonError, + }); + } + state = ownership.ownParsedState(next, !schemaOutputJsonTrusted); + stateJsonTrusted = true; + revision += 1; + notify(ownership.ownPatch([ROOT_REPLACE(state)]).operations); + return { ok: true }; + }; + + const restoreInitial = (): JSONResult => { + state = initialState; + stateJsonTrusted = true; revision += 1; - notify([ROOT_REPLACE(state)]); + notify(ownership.ownPatch([ROOT_REPLACE(state)]).operations); return { ok: true }; }; @@ -244,21 +356,24 @@ export function createJSONState( return handleResult(policy, op, result.result); }, patch: (operations, metadata) => dispatch("patch", operations, metadata), + executePatch: (operations, metadata) => executePatch("patch", operations, metadata), previewPatch: preparePatch, + previewOwnedPatch: prepareOwnedPatch, previewTrustedValuesPatch: prepareTrustedValuesPatch, publishPreparedPatch, applyTrustedPatch: (operations, metadata) => dispatchTrusted(operations, metadata), trustedApply: (next, applied, metadata) => applyTrustedState(next, applied, metadata), load: (value) => replaceRoot("load", value), - reset: (value) => replaceRoot("reset", value ?? initialState), + reset: (value) => value === undefined ? restoreInitial() : replaceRoot("reset", value), subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, get state() { return state; }, - get lastApplied() { return lastApplied; }, + get snapshot() { return ownership.freeze(state); }, get revision() { return revision; }, get stateJsonTrusted() { return stateJsonTrusted; }, + sequenceForApplied: (applied) => sequenceByApplied.get(applied as object), }; return ops; } diff --git a/packages/json-document/src/domain/document/state/ops.ts b/packages/json-document/src/domain/document/state/ops.ts index 2896c0cc..24e42857 100644 --- a/packages/json-document/src/domain/document/state/ops.ts +++ b/packages/json-document/src/domain/document/state/ops.ts @@ -35,7 +35,6 @@ interface DocumentMutationOps { applyDocumentPatch( operations: ReadonlyArray, metadata?: JSONChangeMetadata, - operationsOwned?: boolean, ): JSONResult; } @@ -45,26 +44,32 @@ interface CreateDocumentStateOpsInput { historyState: DocumentHistoryRuntimeState; patchState: DocumentPatchRuntimeState; snapSelection: () => SelectionSnap; - syncLastPatch: () => void; + selectionAfterForApplied: (applied: ReadonlyArray) => SelectionSnap | undefined; } export function createDocumentStateOps( input: CreateDocumentStateOpsInput, ): JSONStateOps { - const { rawOps, mutation, historyState, patchState, snapSelection, syncLastPatch } = input; + const { + rawOps, + mutation, + historyState, + patchState, + snapSelection, + selectionAfterForApplied, + } = input; return { - add: (path, value) => mutation.applyDocumentPatch([{ op: "add", path, value }], undefined, true), - remove: (path) => mutation.applyDocumentPatch([{ op: "remove", path }], undefined, true), - replace: (path, value) => mutation.applyDocumentPatch([{ op: "replace", path, value }], undefined, true), - move: (from, path) => mutation.applyDocumentPatch([{ op: "move", from, path }], undefined, true), - copy: (from, path) => mutation.applyDocumentPatch([{ op: "copy", from, path }], undefined, true), + add: (path, value) => mutation.applyDocumentPatch([{ op: "add", path, value }]), + remove: (path) => mutation.applyDocumentPatch([{ op: "remove", path }]), + replace: (path, value) => mutation.applyDocumentPatch([{ op: "replace", path, value }]), + move: (from, path) => mutation.applyDocumentPatch([{ op: "move", from, path }]), + copy: (from, path) => mutation.applyDocumentPatch([{ op: "copy", from, path }]), test: rawOps.test, patch: mutation.applyDocumentPatch, load(value, loadOptions?: { preserveHistory?: boolean }) { const r = rawOps.load(value); if (r.ok) { - syncLastPatch(); if (loadOptions?.preserveHistory !== true) resetDocumentHistoryRuntimeState(historyState); } return r; @@ -72,7 +77,6 @@ export function createDocumentStateOps( reset(value) { const r = rawOps.reset(value); if (r.ok) { - syncLastPatch(); resetDocumentHistoryRuntimeState(historyState); } return r; @@ -80,10 +84,11 @@ export function createDocumentStateOps( subscribe(listener) { patchState.documentSubscriberCount += 1; const unsubscribe = rawOps.subscribe((applied, metadata) => { - patchState.lastPatch = applied; listener(applied, { ...metadata, - selectionAfter: metadata?.selectionAfter ?? snapSelection(), + selectionAfter: metadata?.selectionAfter + ?? selectionAfterForApplied(applied) + ?? snapSelection(), }); }); let subscribed = true; diff --git a/packages/json-document/src/domain/document/state/ownership.ts b/packages/json-document/src/domain/document/state/ownership.ts new file mode 100644 index 00000000..50c7e6f7 --- /dev/null +++ b/packages/json-document/src/domain/document/state/ownership.ts @@ -0,0 +1,282 @@ +import { + cloneJsonSerializable, + cloneTrustedPlainJson, +} from "../../../foundation/json/index.js"; +import type { JSONPatchOperation } from "../../../foundation/patch/index.js"; +import { readAt, tryParsePointer } from "../../../foundation/pointer/index.js"; + +export interface OwnedJSONPatch { + readonly operations: ReadonlyArray; + readonly valuesTrusted: boolean; +} + +type OwnedJSONValue = + | { ok: true; value: T } + | { ok: false; reason: string }; + +export interface JSONStateOwnership { + freeze(value: T): T; + freezePatchResult(before: T, next: T, operations: ReadonlyArray): T; + ownParsedState(value: T, clone: boolean): T; + ownTrustedInitial(value: T): T; + ownPatch(operations: ReadonlyArray, trustValues?: boolean): OwnedJSONPatch; +} + +export function createJSONStateOwnership(): JSONStateOwnership { + const owned = new WeakSet(); + + const freezeGraph = (value: T): T => { + if (value === null || typeof value !== "object") return value; + const stack: object[] = [value]; + while (stack.length > 0) { + const current = stack.pop()!; + if (owned.has(current)) continue; + owned.add(current); + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index += 1) { + const child = current[index]; + if (child !== null && typeof child === "object" && !owned.has(child)) { + stack.push(child); + } + } + } else { + for (const key of Object.keys(current)) { + const child = (current as Record)[key]; + if (child !== null && typeof child === "object" && !owned.has(child)) { + stack.push(child); + } + } + } + if (!Object.isFrozen(current)) Object.freeze(current); + } + return value; + }; + const freeze = (value: T): T => freezeGraph(value); + const freezeFully = (value: T): T => freezeGraph(value); + + const freezePathContainers = ( + value: unknown, + pointer: string, + sealed: WeakSet, + ): void => { + const segments = tryParsePointer(pointer); + if (segments === null) return; + let current = value; + if (current !== null && typeof current === "object" && !sealed.has(current)) { + Object.freeze(current); + sealed.add(current); + } + for (const segment of segments) { + if (current === null || typeof current !== "object") return; + if (Array.isArray(current)) { + if (!/^(?:0|[1-9][0-9]*)$/.test(segment)) return; + const index = Number(segment); + if (index >= current.length) return; + current = current[index]; + } else { + if (!Object.prototype.hasOwnProperty.call(current, segment)) return; + current = (current as Record)[segment]; + } + if (current !== null && typeof current === "object" && !sealed.has(current)) { + Object.freeze(current); + sealed.add(current); + } + } + }; + + const valueAt = (value: unknown, pointer: string): { ok: true; value: unknown } | { ok: false } => { + const segments = tryParsePointer(pointer); + return segments === null ? { ok: false } : readAt(value, segments); + }; + + const freezeChangedGraph = ( + before: object, + next: object, + operations: ReadonlyArray, + ): void => { + const trustedSources = new WeakSet(); + for (const operation of operations) { + if (operation.op !== "move") continue; + const source = valueAt(before, operation.from); + if (source.ok && source.value !== null && typeof source.value === "object") { + trustedSources.add(source.value); + } + } + + const stack: Array<{ current: object; base: unknown }> = [{ current: next, base: before }]; + while (stack.length > 0) { + const { current, base } = stack.pop()!; + if (owned.has(current)) continue; + if (current === base || trustedSources.has(current)) { + continue; + } + + owned.add(current); + let directBaseChildren: WeakSet | undefined; + const isDirectBaseChild = (child: object): boolean => { + if (base === null || typeof base !== "object") return false; + if (directBaseChildren === undefined) { + directBaseChildren = new WeakSet(); + if (Array.isArray(base)) { + for (const candidate of base) { + if (candidate !== null && typeof candidate === "object") { + directBaseChildren.add(candidate); + } + } + } else { + for (const key of Object.keys(base)) { + const candidate = (base as Record)[key]; + if (candidate !== null && typeof candidate === "object") { + directBaseChildren.add(candidate); + } + } + } + } + return directBaseChildren.has(child); + }; + const schedule = (child: unknown, baseChild: unknown): void => { + if (child === null || typeof child !== "object" || owned.has(child)) return; + if (child === baseChild || trustedSources.has(child) || isDirectBaseChild(child)) { + return; + } + stack.push({ current: child, base: baseChild }); + }; + + if (Array.isArray(current)) { + const baseArray = Array.isArray(base) ? base : undefined; + for (let index = 0; index < current.length; index += 1) { + schedule(current[index], baseArray?.[index]); + } + } else { + const baseRecord = base !== null && typeof base === "object" && !Array.isArray(base) + ? base as Record + : undefined; + for (const key of Object.keys(current)) { + schedule( + (current as Record)[key], + baseRecord && Object.prototype.hasOwnProperty.call(baseRecord, key) + ? baseRecord[key] + : undefined, + ); + } + } + if (!Object.isFrozen(current)) Object.freeze(current); + } + owned.add(next); + }; + + const freezePatchResult = ( + before: T, + next: T, + operations: ReadonlyArray, + ): T => { + const mutations = operations.filter((operation) => operation.op !== "test"); + if ( + before === next + || before === null + || typeof before !== "object" + || next === null + || typeof next !== "object" + || !owned.has(before) + || mutations.length === 0 + ) { + return next; + } + + if (mutations.length > 1 && !mutations.every((operation) => operation.op === "replace")) { + freezeChangedGraph(before, next, mutations); + return next; + } + + // A published/frozen base and owned patch values make unchanged subtrees + // transitively immutable. Freeze only the COW containers on changed paths. + // Copy creates a new subtree; a move that no longer aliases its original + // source can also contain containers created by an earlier batch operation. + for (const operation of mutations) { + if (operation.op !== "copy" && operation.op !== "move") continue; + const target = valueAt(next, operation.path); + if (!target.ok) continue; + if (operation.op === "copy") { + freezeGraph(target.value); + continue; + } + const source = valueAt(before, operation.from); + if (!source.ok || source.value !== target.value) freezeGraph(target.value); + } + const sealed = new WeakSet(); + for (const operation of mutations) { + freezePathContainers(next, operation.path, sealed); + if (operation.op === "move") freezePathContainers(next, operation.from, sealed); + } + Object.freeze(next); + owned.add(next); + return next; + }; + + const ownParsedState = (value: T, clone: boolean): T => + clone ? cloneTrustedPlainJson(value) : value; + + const ownTrustedInitial = (value: T): T => { + if (value !== null && typeof value === "object" && Object.isFrozen(value)) { + // trustedInitial의 frozen root는 transitive immutability에 대한 caller assertion이다. + owned.add(value); + return value; + } + return cloneTrustedPlainJson(value); + }; + + const ownPatchValue = (value: unknown, trustValue: boolean): OwnedJSONValue => { + if (value !== null && typeof value === "object" && owned.has(value)) { + return { ok: true, value }; + } + if (trustValue) { + return { ok: true, value: freezeFully(cloneTrustedPlainJson(value)) }; + } + const cloned = cloneJsonSerializable(value); + return cloned.ok + ? { ok: true, value: freezeFully(cloned.value) } + : cloned; + }; + + const ownPatch = ( + operations: ReadonlyArray, + trustValues = false, + ): OwnedJSONPatch => { + const next = new Array(operations.length); + let valuesTrusted = true; + for (let index = 0; index < operations.length; index += 1) { + if (!(index in operations)) continue; + const operation = operations[index]!; + let ownedOperation: JSONPatchOperation; + switch (operation.op) { + case "add": + case "replace": + case "test": { + const value = ownPatchValue(operation.value, trustValues); + if (!value.ok) valuesTrusted = false; + ownedOperation = { + op: operation.op, + path: operation.path, + value: value.ok ? value.value : operation.value, + }; + break; + } + case "remove": + ownedOperation = { op: "remove", path: operation.path }; + break; + case "move": + case "copy": + ownedOperation = { op: operation.op, from: operation.from, path: operation.path }; + break; + } + owned.add(ownedOperation); + Object.freeze(ownedOperation); + next[index] = ownedOperation; + } + owned.add(next); + Object.freeze(next); + return { operations: next, valuesTrusted }; + }; + + return { freeze, freezePatchResult, ownParsedState, ownTrustedInitial, ownPatch }; +} diff --git a/packages/json-document/src/domain/document/state/patch.ts b/packages/json-document/src/domain/document/state/patch.ts index 0a29bb6a..e7307e22 100644 --- a/packages/json-document/src/domain/document/state/patch.ts +++ b/packages/json-document/src/domain/document/state/patch.ts @@ -7,7 +7,7 @@ import type { import type { Pointer } from "../../../foundation/pointer/index.js"; import { commitMutable, historyDepth } from "../../../foundation/history/index.js"; import { duplicate as duplicateVerb } from "../../editing/duplicate.js"; -import { restoreSelectionTarget } from "../../selection/snap.js"; +import { restoreSelectionTarget, sameSelectionSnap } from "../../selection/snap.js"; import type { SelectionSnap } from "../../selection/snap.js"; import type { HistoryTransactionOptions, @@ -23,8 +23,11 @@ import { planDocumentHistoryRecord } from "../history/restore.js"; import type { DocumentHistoryRuntimeState, } from "../history/undoRedo.js"; +import { mergeDocumentHistoryEntriesSince } from "../history/undoRedo.js"; import type { + JSONStatePatchExecution, PreparedJSONStateChange, + PreparedJSONStatePublication, TrustedJSONStateOps, } from "./json.js"; import type { SelectionRuntimeAccess } from "../selection/runtime.js"; @@ -32,17 +35,24 @@ import type { SelectionRuntimeAccess } from "../selection/runtime.js"; export type JSONPatchInput = JSONPatchOperation | ReadonlyArray; export type PreviewedDocumentPatchResult = - | { status: "applied"; result: JSONResult } + | { status: "applied"; result: JSONResult; applied: ReadonlyArray } | { status: "stale" }; +export interface DetailedDocumentPatchResult { + readonly result: JSONResult; + readonly applied: ReadonlyArray; +} + export interface DocumentPatchRuntimeState { lastPatch: ReadonlyArray; + lastPatchSequence: number; documentSubscriberCount: number; } export function createDocumentPatchRuntimeState(): DocumentPatchRuntimeState { return { lastPatch: [], + lastPatchSequence: 0, documentSubscriberCount: 0, }; } @@ -57,6 +67,9 @@ interface CreateDocumentMutationRuntimeInput { } interface DocumentChangeHistoryRecord { + sequence: number; + revision: number; + transactionToken: object | undefined; before: unknown; after: unknown; operations: ReadonlyArray; @@ -70,6 +83,18 @@ export function createDocumentMutationRuntime( input: CreateDocumentMutationRuntimeInput, ) { const { schema, rawOps, historyLimit, historyState, patchState, selection } = input; + const pendingHistory: DocumentChangeHistoryRecord[] = []; + let mutationDepth = 0; + + // Selection subscribes before this runtime is created. This observer therefore + // captures the recovered selection and canonical patch before public listeners + // can reenter the document. + rawOps.subscribe((applied) => { + const sequence = rawOps.sequenceForApplied(applied); + if (sequence !== undefined && sequence < patchState.lastPatchSequence) return; + patchState.lastPatch = applied; + if (sequence !== undefined) patchState.lastPatchSequence = sequence; + }); const recordHistory = ( before: z.output, @@ -111,87 +136,100 @@ export function createDocumentMutationRuntime( || historyState.activeHistoryMetadata !== undefined || metadata !== undefined || (selection.selectionEnabled && patchState.documentSubscriberCount > 0); - const historyRecord = ( - record: boolean, - before: unknown, - after: unknown, - operations: ReadonlyArray, - selectionBefore: SelectionSnap, - selectionAfter: SelectionSnap, - metadata: JSONChangeMetadata | undefined, - operationsOwned = false, - ): DocumentChangeHistoryRecord | null => { - if (!record) return null; - return { - before, - after, - operations, - selectionBefore, - selectionAfter, - metadata, - operationsOwned, + const flushPendingHistory = (): void => { + if (pendingHistory.length === 0) return; + pendingHistory.sort((left, right) => left.sequence - right.sequence); + const records = pendingHistory.splice(0, pendingHistory.length); + let transactionToken: object | undefined; + let transactionStartDepth: number | undefined; + const mergeTransaction = (): void => { + if (transactionStartDepth !== undefined) { + mergeDocumentHistoryEntriesSince(historyState, transactionStartDepth); + } + transactionToken = undefined; + transactionStartDepth = undefined; }; + for (const history of records) { + if (history.transactionToken !== transactionToken) { + mergeTransaction(); + transactionToken = history.transactionToken; + if (transactionToken !== undefined) transactionStartDepth = historyDepth(historyState.stack); + } + recordHistory( + history.before as z.output, + history.after as z.output, + history.operations, + history.selectionBefore, + history.selectionAfter, + history.metadata, + history.operationsOwned, + ); + } + mergeTransaction(); }; - const applyDocumentChangeResult = ( - result: JSONResult, - operationCount: number, - applied: ReadonlyArray, - history: DocumentChangeHistoryRecord | null, - ): void => { - if (!result.ok) return; - patchState.lastPatch = operationCount === 0 ? [] : applied; - if (history === null) return; - recordHistory( - history.before as z.output, - history.after as z.output, - history.operations, - history.selectionBefore, - history.selectionAfter, - history.metadata, - history.operationsOwned, - ); + const withinMutation = (run: () => T): T => { + mutationDepth += 1; + try { + return run(); + } finally { + mutationDepth -= 1; + if (mutationDepth === 0) flushPendingHistory(); + } }; - - const applyDocumentPatch = ( + const queuePublication = ( + publication: PreparedJSONStatePublication>, + record: boolean, + selectionBefore: SelectionSnap | undefined, + metadata: JSONChangeMetadata | undefined, + selectionAfterOverride?: SelectionSnap, + ): ReadonlyArray => { + if (!publication.changed) return []; + if (record && selectionBefore !== undefined) { + pendingHistory.push({ + sequence: publication.sequence, + revision: publication.revision, + transactionToken: historyState.activeTransactionToken, + before: publication.before, + after: publication.state, + operations: publication.applied, + selectionBefore, + selectionAfter: selectionAfterOverride + ?? selection.selectionAfterForApplied(publication.applied) + ?? selection.snapSelection(), + metadata, + operationsOwned: true, + }); + } + return publication.applied; + }; + const executeDocumentPatch = ( operations: ReadonlyArray, metadata?: JSONChangeMetadata, - operationsOwned = false, - ): JSONResult => { + ): DetailedDocumentPatchResult => withinMutation(() => { const record = shouldRecordHistory(operations.length); const captureMetadata = shouldCaptureMetadata(record, metadata); - if (!captureMetadata) { - const r = rawOps.patch(operations); - applyDocumentChangeResult(r, operations.length, rawOps.lastApplied, null); - return r; - } - - const before = record ? rawOps.state : undefined; - const selectionBefore = selection.snapSelection(); - const changeMetadata = buildChangeMetadata( - historyState.activeHistoryMetadata, - metadata, - selectionBefore, - selection.selectionEnabled, - ); - const r = rawOps.patch(operations, changeMetadata); - const selectionAfter = selection.snapSelection(); - applyDocumentChangeResult( - r, - operations.length, - rawOps.lastApplied, - historyRecord( - record, - before, - rawOps.state, - operations, - selectionBefore, - selectionAfter, - changeMetadata, - operationsOwned, - ), + const selectionBefore = captureMetadata ? selection.snapSelection() : undefined; + const changeMetadata = selectionBefore === undefined + ? undefined + : buildChangeMetadata( + historyState.activeHistoryMetadata, + metadata, + selectionBefore, + selection.selectionEnabled, + ); + const execution: JSONStatePatchExecution> = rawOps.executePatch( + operations, + changeMetadata, ); - return r; - }; + const applied = execution.publication === null + ? [] + : queuePublication(execution.publication, record, selectionBefore, changeMetadata); + return { result: execution.result, applied }; + }); + const applyDocumentPatch = ( + operations: ReadonlyArray, + metadata?: JSONChangeMetadata, + ): JSONResult => executeDocumentPatch(operations, metadata).result; const applyPreviewedDocumentPatch = ( candidate: ApplyResult, @@ -199,55 +237,33 @@ export function createDocumentMutationRuntime( metadata?: JSONChangeMetadata, ): PreviewedDocumentPatchResult => { if (!isPreparedJSONStateChange>(candidate) || !candidate.result.ok) { - return { status: "applied", result: applyDocumentPatch(operations, metadata) }; + const execution = executeDocumentPatch(operations, metadata); + return { status: "applied", result: execution.result, applied: execution.applied }; } - const prepared = candidate; - const record = shouldRecordHistory(operations.length); - const captureMetadata = shouldCaptureMetadata(record, metadata); - if (!captureMetadata) { - const publication = rawOps.publishPreparedPatch(prepared); + return withinMutation(() => { + const prepared = candidate; + const record = shouldRecordHistory(operations.length); + const captureMetadata = shouldCaptureMetadata(record, metadata); + const selectionBefore = captureMetadata ? selection.snapSelection() : undefined; + const changeMetadata = selectionBefore === undefined + ? undefined + : buildChangeMetadata( + historyState.activeHistoryMetadata, + metadata, + selectionBefore, + selection.selectionEnabled, + ); + const publication = rawOps.publishPreparedPatch(prepared, changeMetadata); if (publication.status === "stale") return publication; - applyDocumentChangeResult( - prepared.result, - prepared.applied.length, - publication.changed ? prepared.applied : rawOps.lastApplied, - null, - ); - return { status: "applied", result: prepared.result }; - } - - const before = record ? rawOps.state : undefined; - const selectionBefore = selection.snapSelection(); - const changeMetadata = buildChangeMetadata( - historyState.activeHistoryMetadata, - metadata, - selectionBefore, - selection.selectionEnabled, - ); - const publication = rawOps.publishPreparedPatch(prepared, changeMetadata); - if (publication.status === "stale") return publication; - const selectionAfter = selection.snapSelection(); - applyDocumentChangeResult( - prepared.result, - prepared.applied.length, - rawOps.lastApplied, - historyRecord( - record, - before, - prepared.state, - operations, - selectionBefore, - selectionAfter, - changeMetadata, - ), - ); - return { status: "applied", result: prepared.result }; + const applied = queuePublication(publication, record, selectionBefore, changeMetadata); + return { status: "applied", result: prepared.result, applied }; + }); }; const patch = (operations: JSONPatchInput, metadata?: JSONChangeMetadata): JSONResult => { return Array.isArray(operations) - ? applyDocumentPatch(operations, metadata, false) - : applyDocumentPatch([operations as JSONPatchOperation], metadata, true); + ? applyDocumentPatch(operations, metadata) + : applyDocumentPatch([operations as JSONPatchOperation], metadata); }; const commit = ( @@ -256,42 +272,58 @@ export function createDocumentMutationRuntime( ): JSONResult => { const metadata = commitOptions === undefined ? undefined : compactHistoryMetadata(commitOptions); if (commitOptions?.selectionAfter === undefined) return applyDocumentPatch(operations, metadata); - for (let attempt = 0; attempt < 8; attempt += 1) { - const before = rawOps.state; - const selectionBefore = selection.snapSelection(); - const predicted = rawOps.previewPatch(operations); - if (predicted.baseRevision !== rawOps.revision || predicted.before !== rawOps.state) continue; - if (!predicted.result.ok) return patch(operations, metadata); - const selectionAfter = restoreSelectionTarget(commitOptions.selectionAfter, selection.selectionMode, predicted.state); - const directMetadata: JSONChangeMetadata = metadata === undefined - ? { selectionAfter } - : { ...metadata, selectionAfter }; - const changeMetadata = buildChangeMetadata( - historyState.activeHistoryMetadata, - directMetadata, - selectionBefore, - selection.selectionEnabled, - ); - const publication = rawOps.publishPreparedPatch(predicted, changeMetadata); - if (publication.status === "stale") continue; - selection.restoreSelection(selectionAfter); - applyDocumentChangeResult( - predicted.result, - operations.length, - rawOps.lastApplied, - historyRecord( - shouldRecordHistory(operations.length), - before, - predicted.state, - operations, + const selectionTarget = commitOptions.selectionAfter; + return withinMutation(() => { + for (let attempt = 0; attempt < 8; attempt += 1) { + const selectionBefore = selection.snapSelection(); + const predicted = rawOps.previewOwnedPatch(operations); + if (predicted.baseRevision !== rawOps.revision || predicted.before !== rawOps.state) continue; + if (!predicted.result.ok) return patch(operations, metadata); + const selectionAfter = restoreSelectionTarget(selectionTarget, selection.selectionMode, predicted.state); + const directMetadata: JSONChangeMetadata = metadata === undefined + ? { selectionAfter } + : { ...metadata, selectionAfter }; + const changeMetadata = buildChangeMetadata( + historyState.activeHistoryMetadata, + directMetadata, selectionBefore, - selectionAfter, - changeMetadata, - ), - ); - return predicted.result; - } - throw new Error("state changed repeatedly while preparing commit"); + selection.selectionEnabled, + ); + const publication = rawOps.publishPreparedPatch(predicted, changeMetadata); + if (publication.status === "stale") continue; + selection.restoreSelection(selectionAfter); + const record = shouldRecordHistory(operations.length); + if ( + !publication.changed + && record + && selection.selectionEnabled + && !sameSelectionSnap(selectionBefore, selectionAfter) + ) { + pendingHistory.push({ + sequence: publication.sequence, + revision: publication.revision, + transactionToken: historyState.activeTransactionToken, + before: publication.before, + after: publication.state, + operations: [], + selectionBefore, + selectionAfter, + metadata: changeMetadata, + operationsOwned: true, + }); + } else { + queuePublication( + publication, + record, + selectionBefore, + changeMetadata, + selectionAfter, + ); + } + return predicted.result; + } + throw new Error("state changed repeatedly while preparing commit"); + }); }; const duplicate = ( @@ -301,7 +333,7 @@ export function createDocumentMutationRuntime( for (let attempt = 0; attempt < 8; attempt += 1) { const baseRevision = rawOps.revision; const planned = duplicateVerb(schema, rawOps.state, source, duplicateOptions, { - previewPatch: rawOps.previewPatch, + previewPatch: rawOps.previewOwnedPatch, trustedPayload: rawOps.stateJsonTrusted, }); if (rawOps.revision !== baseRevision) continue; @@ -312,8 +344,8 @@ export function createDocumentMutationRuntime( return r.ok ? { ok: true, - value: rawOps.state, - applied: patchState.lastPatch, + value: rawOps.snapshot, + applied: publication.applied, target: planned.duplicatedTo, duplicatedTo: planned.duplicatedTo, } @@ -322,9 +354,16 @@ export function createDocumentMutationRuntime( throw new Error("state changed repeatedly while preparing duplicate"); }; - const lastApplied = (): ReadonlyArray => patchState.lastPatch; - - return { applyDocumentPatch, applyPreviewedDocumentPatch, patch, commit, duplicate, lastApplied }; + return { + applyDocumentPatch, + applyDocumentPatchDetailed: executeDocumentPatch, + applyPreviewedDocumentPatch, + patch, + commit, + duplicate, + selectionAfterForApplied: (applied: ReadonlyArray) => + selection.selectionAfterForApplied(applied), + }; } function isPreparedJSONStateChange( diff --git a/packages/json-document/src/domain/schema/model/schema.ts b/packages/json-document/src/domain/schema/model/schema.ts index 803643cf..d853b49c 100644 --- a/packages/json-document/src/domain/schema/model/schema.ts +++ b/packages/json-document/src/domain/schema/model/schema.ts @@ -102,7 +102,7 @@ export function schemaOutputIsKnownJson(schema: z.ZodType, seen?: WeakSet { + if (check === null || typeof check !== "object") return true; + const kind = (check as { _zod?: { def?: { check?: unknown } } })._zod?.def?.check; + return kind === "custom" || kind === "overwrite" || typeof kind !== "string"; + }); +} + export function prefixIssues(path: Pointer, issues: z.ZodError["issues"]): z.ZodError["issues"] { const prefix = parsePointer(path).map((segment) => numericSegment(segment) ?? segment); return issues.map((issue) => ({ ...issue, path: [...prefix, ...issue.path] })); diff --git a/packages/json-document/src/domain/schema/validation/patch.ts b/packages/json-document/src/domain/schema/validation/patch.ts index 33614ce9..f966eabe 100644 --- a/packages/json-document/src/domain/schema/validation/patch.ts +++ b/packages/json-document/src/domain/schema/validation/patch.ts @@ -1,7 +1,7 @@ import type * as z from "zod"; import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; import { applyAcceptedPatch, applyTrustedPatch } from "../../../foundation/patch/index.js"; -import { validateOperationShape } from "../../../foundation/patch/index.js"; +import { validateOperationShape, validatePatchOperations } from "../../../foundation/patch/index.js"; import { applyPatchToTrustedState as applyPatchToTrustedStateCore } from "../../../foundation/patch/index.js"; import type { Pointer } from "../../../foundation/pointer/index.js"; import { @@ -139,8 +139,11 @@ export function applyPatchToTrustedState( state: z.output, ops: ReadonlyArray, ): ApplyResult { - return applyPatchWithLocalSchemaValidation(schema, state, ops) - ?? applyPatchToTrustedStateCore(schema, state, ops); + if (Array.isArray(ops) && validatePatchOperations(ops) === null) { + const local = applyPatchWithLocalSchemaValidation(schema, state, ops); + if (local !== null) return local; + } + return applyPatchToTrustedStateCore(schema, state, ops); } export function applySameArrayPatchWithLocalSchemaValidation( diff --git a/packages/json-document/src/foundation/patch/apply.ts b/packages/json-document/src/foundation/patch/apply.ts index dd99bf31..22483355 100644 --- a/packages/json-document/src/foundation/patch/apply.ts +++ b/packages/json-document/src/foundation/patch/apply.ts @@ -1,6 +1,6 @@ // applyOpRaw — RFC 6902 6 op 의 raw 적용 (schema 검증 없음). public 노출은 patch.ts. -import { isPrefix, type Pointer } from "../pointer/index.js"; +import { isPrefix, parsePointer, type Pointer } from "../pointer/index.js"; import { cloneJson } from "../json/clone.js"; import { jsonSerializableError } from "../json/serializable.js"; import type { ErrorCode, JSONPatchOperation } from "./contract.js"; @@ -42,6 +42,60 @@ export function validateOperationShape(op: JSONPatchOperation): { error: ErrorCo } } +type PatchPointerError = { error: "invalid_pointer"; reason: string; pointer: Pointer }; +type PatchValidationError = { error: ErrorCode; reason: string; pointer?: Pointer }; + +export function validateOperationPointers(op: JSONPatchOperation): PatchPointerError | null { + const pathError = validatePatchPointer(op.path, "path"); + if (pathError !== null) return pathError; + if (op.op !== "move" && op.op !== "copy") return null; + return validatePatchPointer(op.from, "from"); +} + +export function validatePatchOperations( + operations: ReadonlyArray, +): PatchValidationError | null { + for (let index = 0; index < operations.length; index += 1) { + if (!(index in operations)) { + return { error: "invalid_pointer", reason: `op[${index}]: op must be object` }; + } + const operation = operations[index]!; + const shape = validateOperationShape(operation); + if (shape !== null) { + return { error: shape.error, reason: `op[${index}]: ${shape.reason}` }; + } + const pointerError = validateOperationPointers(operation); + if (pointerError !== null) { + return { + error: pointerError.error, + reason: `op[${index}]: ${pointerError.reason}`, + pointer: pointerError.pointer, + }; + } + } + return null; +} + +function validatePatchPointer(pointer: Pointer, field: "path" | "from"): PatchPointerError | null { + if (pointer[0] === "#") { + return { + error: "invalid_pointer", + reason: `JSON Patch ${field} must use JSON Pointer string representation, not a URI fragment`, + pointer, + }; + } + try { + parsePointer(pointer); + return null; + } catch (error) { + return { + error: "invalid_pointer", + reason: error instanceof Error ? error.message : `Invalid JSON Patch ${field}`, + pointer, + }; + } +} + function validateSerializableOpValue(op: JSONPatchOperation): { error: ErrorCode; reason: string } | null { if (op.op !== "add" && op.op !== "replace" && op.op !== "test") return null; const reason = jsonSerializableError(op.value); @@ -51,6 +105,8 @@ function validateSerializableOpValue(op: JSONPatchOperation): { error: ErrorCode export function applyOpRaw(state: unknown, op: JSONPatchOperation): RawResult { const shape = validateOperationShape(op); if (shape) return shape; + const pointerError = validateOperationPointers(op); + if (pointerError) return pointerError; const serializable = validateSerializableOpValue(op); if (serializable) return serializable; diff --git a/packages/json-document/src/foundation/patch/index.ts b/packages/json-document/src/foundation/patch/index.ts index ee3101eb..7a1cb96b 100644 --- a/packages/json-document/src/foundation/patch/index.ts +++ b/packages/json-document/src/foundation/patch/index.ts @@ -1,5 +1,6 @@ export { applyOpRaw, + validatePatchOperations, validateOperationShape, } from "./apply.js"; export type { RawResult } from "./apply.js"; diff --git a/packages/json-document/src/foundation/patch/schema.ts b/packages/json-document/src/foundation/patch/schema.ts index 31a90e2a..013233e1 100644 --- a/packages/json-document/src/foundation/patch/schema.ts +++ b/packages/json-document/src/foundation/patch/schema.ts @@ -1,6 +1,12 @@ import type * as z from "zod"; +import { cloneJsonSerializable } from "../json/clone.js"; import { jsonSerializableError } from "../json/serializable.js"; -import { applyOpRaw, validateOperationShape } from "./apply.js"; +import { + applyOpRaw, + validateOperationPointers, + validateOperationShape, + validatePatchOperations, +} from "./apply.js"; import { normalizeAppliedOp, normalizeOp } from "./container.js"; import { applyFastPatchStrategies, publicTrustedStateStrategies } from "./fast/apply.js"; import { fail, ok, zodIssuesReason } from "./result.js"; @@ -16,12 +22,16 @@ export function applyOperation( if (stateJsonErr) return { state, result: fail("not_serializable", stateJsonErr), applied: [] }; const shape = validateOperationShape(op); if (shape) return { state, result: fail(shape.error, shape.reason), applied: [] }; + const pointerError = validateOperationPointers(op); + if (pointerError) { + return { state, result: fail(pointerError.error, pointerError.reason, pointerError.pointer), applied: [] }; + } const normalized = normalizeOp(op, state); const r = applyOpRaw(state, normalized); if ("error" in r) return { state, result: fail(r.error, r.reason, r.pointer), applied: [] }; const appliedOp = normalizeAppliedOp(normalized, r.state); if (normalized.op === "test") return { state, result: ok, applied: [appliedOp] }; - const parsed = schema.safeParse(r.state); + const parsed = safeParseIsolated(schema, r.state); if (!parsed.success) return { state, result: fail("schema_violation", zodIssuesReason(parsed.error)), applied: [] }; return { state: r.state as z.output, result: ok, applied: [appliedOp] }; } @@ -42,11 +52,14 @@ export function applyPatchToTrustedState( ops: ReadonlyArray, ): ApplyResult { if (!Array.isArray(ops)) return { state, result: fail("invalid_pointer", "patch must be an array"), applied: [] }; - const fast = applyFastPatchStrategies(state, ops, publicTrustedStateStrategies, false); - if (fast !== null) { - const parsed = schema.safeParse(fast.state); - if (!parsed.success) return { state, result: fail("schema_violation", zodIssuesReason(parsed.error)), applied: [] }; - return { state: fast.state as z.output, result: ok, applied: fast.applied }; + const validation = validatePatchOperations(ops); + if (validation === null) { + const fast = applyFastPatchStrategies(state, ops, publicTrustedStateStrategies, false); + if (fast !== null) { + const parsed = safeParseIsolated(schema, fast.state); + if (!parsed.success) return { state, result: fail("schema_violation", zodIssuesReason(parsed.error)), applied: [] }; + return { state: fast.state as z.output, result: ok, applied: fast.applied }; + } } let cur: unknown = state; @@ -55,6 +68,18 @@ export function applyPatchToTrustedState( if (!(i in ops)) return { state, result: fail("invalid_pointer", `op[${i}]: op must be object`), applied: [] }; const shape = validateOperationShape(ops[i]!); if (shape) return { state, result: fail(shape.error, `op[${i}]: ${shape.reason}`), applied: [] }; + const pointerError = validateOperationPointers(ops[i]!); + if (pointerError) { + return { + state, + result: fail( + pointerError.error, + `op[${i}]: ${pointerError.reason}`, + pointerError.pointer, + ), + applied: [], + }; + } const n = normalizeOp(ops[i]!, cur); const r = applyOpRaw(cur, n); if ("error" in r) { @@ -63,7 +88,7 @@ export function applyPatchToTrustedState( normalized.push(normalizeAppliedOp(n, r.state)); cur = r.state; } - const parsed = schema.safeParse(cur); + const parsed = safeParseIsolated(schema, cur); if (!parsed.success) return { state, result: fail("schema_violation", zodIssuesReason(parsed.error)), applied: [] }; return { state: cur as z.output, result: ok, applied: normalized }; } @@ -81,6 +106,14 @@ export function applySingleTrustedValuePatchToTrustedState, result: ok, applied: [normalized] }; } + +function safeParseIsolated(schema: S, value: unknown) { + const cloned = cloneJsonSerializable(value); + return schema.safeParse(cloned.ok ? cloned.value : value); +} diff --git a/packages/json-document/src/foundation/patch/trusted.ts b/packages/json-document/src/foundation/patch/trusted.ts index db778c1e..b62d9d96 100644 --- a/packages/json-document/src/foundation/patch/trusted.ts +++ b/packages/json-document/src/foundation/patch/trusted.ts @@ -1,5 +1,5 @@ import { jsonSerializableError } from "../json/serializable.js"; -import { applyOpRaw, validateOperationShape } from "./apply.js"; +import { applyOpRaw, validateOperationPointers, validateOperationShape } from "./apply.js"; import { normalizeAppliedOp, normalizeOp } from "./container.js"; import { acceptedStrategies, applyFastPatchStrategies, trustedStrategies } from "./fast/apply.js"; import { fail, ok } from "./result.js"; @@ -29,6 +29,18 @@ export function applyTrustedPatch( if (!(i in ops)) return { state, result: fail("invalid_pointer", `op[${i}]: op must be object`), applied: [] }; const shape = validateOperationShape(ops[i]!); if (shape) return { state, result: fail(shape.error, `op[${i}]: ${shape.reason}`), applied: [] }; + const pointerError = validateOperationPointers(ops[i]!); + if (pointerError) { + return { + state, + result: fail( + pointerError.error, + `op[${i}]: ${pointerError.reason}`, + pointerError.pointer, + ), + applied: [], + }; + } const n = normalizeOp(ops[i]!, cur); const r = applyOpRaw(cur, n); if ("error" in r) { @@ -70,6 +82,14 @@ function applySingleTrustedValuePatch( const shape = validateOperationShape(op); if (shape) return { state, result: fail(shape.error, `op[0]: ${shape.reason}`), applied: [] }; + const pointerError = validateOperationPointers(op); + if (pointerError) { + return { + state, + result: fail(pointerError.error, `op[0]: ${pointerError.reason}`, pointerError.pointer), + applied: [], + }; + } if (!valuesTrusted && jsonSerializableError(op.value) !== null) return null; const normalized = op.op === "add" && op.path.endsWith("/-") ? normalizeOp(op, state) : op; @@ -90,6 +110,14 @@ function applyAcceptedSingleTrustedValuePatch( if (op === null || typeof op !== "object" || (op.op !== "add" && op.op !== "replace") || typeof op.path !== "string" || !("value" in op)) { return null; } + const pointerError = validateOperationPointers(op); + if (pointerError) { + return { + state, + result: fail(pointerError.error, `op[0]: ${pointerError.reason}`, pointerError.pointer), + applied: [], + }; + } const normalized = op.op === "add" && op.path.endsWith("/-") ? normalizeOp(op, state) : op; if (normalized.op !== "add" && normalized.op !== "replace") return null; const applied = applyTrustedValueMutation(state, normalized); diff --git a/packages/json-document/src/foundation/pointer/index.ts b/packages/json-document/src/foundation/pointer/index.ts index 2d5f68f0..fb799e03 100644 --- a/packages/json-document/src/foundation/pointer/index.ts +++ b/packages/json-document/src/foundation/pointer/index.ts @@ -31,9 +31,16 @@ export function buildPointer( } function parsePointerSegments(body: string): string[] { - return body.includes("~") - ? body.split("/").map(unescapeSegment) - : body.split("/"); + if (!body.includes("~")) return body.split("/"); + return body.split("/").map((segment) => { + for (let index = segment.indexOf("~"); index !== -1; index = segment.indexOf("~", index + 2)) { + const escaped = segment[index + 1]; + if (escaped !== "0" && escaped !== "1") { + throw new PointerSyntaxError(`Invalid JSON Pointer escape in segment: ${JSON.stringify(segment)}`); + } + } + return unescapeSegment(segment); + }); } export function parsePointer(pointer: Pointer): string[] { @@ -43,8 +50,9 @@ export function parsePointer(pointer: Pointer): string[] { if (pointer[1] !== "/") { throw new PointerSyntaxError(`JSON Pointer URI fragment must be '#' or start with '#/': ${JSON.stringify(pointer)}`); } + let decoded: string; try { - return parsePointerSegments(decodeURIComponent(pointer.slice(2))); + decoded = decodeURIComponent(pointer.slice(2)); } catch (error) { throw new PointerSyntaxError( error instanceof Error @@ -52,6 +60,7 @@ export function parsePointer(pointer: Pointer): string[] { : "Invalid JSON Pointer URI fragment encoding", ); } + return parsePointerSegments(decoded); } if (pointer[0] !== "/") { throw new PointerSyntaxError(`JSON Pointer must be empty or start with '/': ${JSON.stringify(pointer)}`); @@ -70,16 +79,19 @@ export function tryParsePointer(pointer: Pointer): string[] | null { } } if (pointer[0] !== "/") return null; - return parsePointerSegments(pointer.slice(1)); + try { + return parsePointerSegments(pointer.slice(1)); + } catch { + return null; + } } // RFC 3986 + 6901 §6: fragment 안에서 안전하지 않은 문자 percent-encode. // JSON Pointer 자체의 escape (~0, ~1) 는 이미 처리됐으므로 fragment 의 추가 제약만. function encodePointerForFragment(s: string): string { - // %, " ", '"', '<', '>', '\\', '^', '`', '{', '|', '}' 등을 인코딩. - return s.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@/?]/g, (c) => - "%" + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"), - ); + // encodeURI는 Unicode를 UTF-8 octet으로 바꾼 뒤 percent-encode한다. + // Fragment delimiter인 #만 JSON Pointer 본문에 남지 않도록 추가 인코드한다. + return encodeURI(s).replace(/#/g, "%23"); } export class PointerSyntaxError extends Error { diff --git a/packages/json-document/tests/document/capability/integration.test.ts b/packages/json-document/tests/document/capability/integration.test.ts index d7ba193d..47d02eb1 100644 --- a/packages/json-document/tests/document/capability/integration.test.ts +++ b/packages/json-document/tests/document/capability/integration.test.ts @@ -42,17 +42,20 @@ describe("JSONDocument can* interface", () => { }); test("canFind validates query syntax without traversing the document", () => { - const doc = createJSONDocument(z.any(), { - items: [ - Object.defineProperty({ id: "a" }, "computed", { - get: () => { - throw new Error("canFind should not read values"); - }, - enumerable: true, - configurable: true, - }), - ], + const QuerySchema = z.object({ + items: z.array(z.object({ id: z.string(), computed: z.string() })), }); + const item = Object.freeze(Object.defineProperty({ id: "a" }, "computed", { + get: () => { + throw new Error("canFind should not read values"); + }, + enumerable: true, + configurable: true, + })) as { id: string; computed: string }; + const trusted: z.output = { items: [item] }; + Object.freeze(trusted.items); + Object.freeze(trusted); + const doc = createJSONDocument(QuerySchema, trusted, { trustedInitial: true }); expect(doc.canFind("$..computed")).toEqual({ ok: true }); }); diff --git a/packages/json-document/tests/document/create-json-document.test.ts b/packages/json-document/tests/document/create-json-document.test.ts index db1693e1..0998fa75 100644 --- a/packages/json-document/tests/document/create-json-document.test.ts +++ b/packages/json-document/tests/document/create-json-document.test.ts @@ -631,38 +631,25 @@ describe("createJSONDocument public interface", () => { expect(doc.clipboard.hasData).toBe(false); }); - test("optional undefined schema output keeps the document JSON guard", () => { + test("rejects optional undefined schema output at the document boundary", () => { const OptionalSchema = z.object({ item: z.object({ maybe: z.string().optional(), }), }); - const doc = createJSONDocument(OptionalSchema, { + expect(() => createJSONDocument(OptionalSchema, { item: { maybe: undefined }, - }); - - expect(doc.canPatch({ op: "replace", path: "/item/maybe", value: "next" })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + })).toThrow("/item/maybe: undefined is not JSON"); }); - test("record and catchall unknown schema outputs keep the document JSON guard", () => { + test("rejects non-JSON record and catchall outputs at creation", () => { const RecordSchema = z.record(z.string(), z.unknown()); - const recordDoc = createJSONDocument(RecordSchema, { item: () => "bad" }); - - expect(recordDoc.canPatch({ op: "replace", path: "/item", value: "next" })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + expect(() => createJSONDocument(RecordSchema, { item: () => "bad" })) + .toThrow("/item: function is not JSON"); const CatchallSchema = z.object({}).catchall(z.unknown()); - const catchallDoc = createJSONDocument(CatchallSchema, { item: () => "bad" }); - - expect(catchallDoc.canPatch({ op: "replace", path: "/item", value: "next" })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + expect(() => createJSONDocument(CatchallSchema, { item: () => "bad" })) + .toThrow("/item: function is not JSON"); }); test("known-JSON record and catchall schema outputs drop prototype keys before trust", () => { @@ -713,16 +700,12 @@ describe("createJSONDocument public interface", () => { expect(readonlyDoc.patch({ op: "replace", path: "/item/id", value: "b" })).toEqual({ ok: true }); }); - test("default schema output keeps the document JSON guard", () => { + test("rejects a non-JSON default output at creation", () => { const DefaultSchema = z.object({ value: z.number().default(() => Number.NaN), }); - const doc = createJSONDocument(DefaultSchema, {}); - - expect(doc.canPatch({ op: "replace", path: "/value", value: 1 })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + expect(() => createJSONDocument(DefaultSchema, {})) + .toThrow("/value: non-finite number"); }); test("known-JSON lazy recursive schema outputs can be trusted", () => { @@ -742,7 +725,7 @@ describe("createJSONDocument public interface", () => { expect(doc.patch({ op: "replace", path: "/children/0/id", value: "next" })).toEqual({ ok: true }); }); - test("lazy recursive schema with unknown output keeps the document JSON guard", () => { + test("rejects non-JSON output from a lazy recursive schema at creation", () => { interface BadNode { value: unknown; children: BadNode[]; @@ -751,15 +734,10 @@ describe("createJSONDocument public interface", () => { value: z.any(), children: z.array(BadTree), })); - const doc = createJSONDocument(BadTree, { + expect(() => createJSONDocument(BadTree, { value: () => "bad", children: [], - }); - - expect(doc.canPatch({ op: "replace", path: "/value", value: 1 })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + })).toThrow("/value: function is not JSON"); }); test("mutual lazy recursion does not cache an unsafe branch as JSON", () => { @@ -778,21 +756,16 @@ describe("createJSONDocument public interface", () => { a: A.nullable(), })); - createJSONDocument(A, { + expect(() => createJSONDocument(A, { b: { a: null }, bad: () => "bad", - }); - const doc = createJSONDocument(B, { + })).toThrow("/bad: function is not JSON"); + expect(() => createJSONDocument(B, { a: { b: { a: null }, bad: () => "bad", }, - }); - - expect(doc.canPatch({ op: "replace", path: "/a/b/a", value: null })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + })).toThrow("/a/bad: function is not JSON"); }); test("known-JSON nonoptional, prefault, and pipe schema outputs can be trusted", () => { @@ -816,24 +789,18 @@ describe("createJSONDocument public interface", () => { expect(pipeDoc.patch({ op: "replace", path: "/value", value: "b" })).toEqual({ ok: true }); }); - test("catch and transform outputs keep the document JSON guard", () => { + test("rejects non-JSON catch and transform outputs at creation", () => { const CatchSchema = z.object({ value: z.number().catch(() => Number.NaN), }); - const catchDoc = createJSONDocument(CatchSchema, { value: "bad" } as never); - expect(catchDoc.canPatch({ op: "replace", path: "/value", value: 1 })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + expect(() => createJSONDocument(CatchSchema, { value: "bad" } as never)) + .toThrow("/value: non-finite number"); const TransformSchema = z.object({ value: z.string().transform(() => () => "bad"), }); - const transformDoc = createJSONDocument(TransformSchema, { value: "bad" }); - expect(transformDoc.canPatch({ op: "replace", path: "/value", value: 1 })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + expect(() => createJSONDocument(TransformSchema, { value: "bad" })) + .toThrow("/value: function is not JSON"); }); test("known-JSON intersection schema outputs can be trusted", () => { @@ -849,20 +816,15 @@ describe("createJSONDocument public interface", () => { expect(doc.patch({ op: "replace", path: "/value", value: 2 })).toEqual({ ok: true }); }); - test("intersection schema with unknown output keeps the document JSON guard", () => { + test("rejects non-JSON intersection output at creation", () => { const IntersectionSchema = z.intersection( z.object({ id: z.string() }), z.object({ value: z.any() }), ); - const doc = createJSONDocument(IntersectionSchema, { + expect(() => createJSONDocument(IntersectionSchema, { id: "a", value: () => "bad", - }); - - expect(doc.canPatch({ op: "replace", path: "/id", value: "b" })).toMatchObject({ - ok: false, - code: "not_serializable", - }); + })).toThrow("/value: function is not JSON"); }); test("clipboard write rejects invalid sources before payload cloning", () => { diff --git a/packages/json-document/tests/public/error.test.ts b/packages/json-document/tests/public/error.test.ts index 5671dd4f..ab76af50 100644 --- a/packages/json-document/tests/public/error.test.ts +++ b/packages/json-document/tests/public/error.test.ts @@ -118,6 +118,46 @@ describe("JSONDocumentError", () => { expect(onError).not.toHaveBeenCalled(); }); + test("non-JSON initial outputs throw TypeError without onError", () => { + const onError = vi.fn(); + + expect(() => createJSONDocument( + z.object({ value: z.unknown() }), + { value: new Date("2026-07-14T00:00:00.000Z") }, + { onError }, + )).toThrow(TypeError); + expect(onError).not.toHaveBeenCalled(); + }); + + test("load and reset route non-JSON outputs through the execution error policy", () => { + const onError = vi.fn(); + const doc = createJSONDocument(z.object({ value: z.unknown() }), { value: "ok" }, { + strict: false, + onError, + }); + + expect(doc.load({ value: new Date("2026-07-14T00:00:00.000Z") })).toMatchObject({ + ok: false, + code: "not_serializable", + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + name: "JSONDocumentError", + op: "load", + result: { ok: false, code: "not_serializable" }, + }); + + const strictDoc = createJSONDocument(z.object({ value: z.unknown() }), { value: "ok" }, { + strict: true, + onError, + }); + expect(() => strictDoc.reset({ value: new Date("2026-07-14T00:00:00.000Z") })) + .toThrow(JSONDocumentError); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError.mock.calls[1]?.[0]).toMatchObject({ op: "reset" }); + expect(strictDoc.value).toEqual({ value: "ok" }); + }); + test("default strict policy returns document execution failures unless strict overrides", () => { const doc = createJSONDocument(z.object({ name: z.string() }), { name: "ok" }); diff --git a/packages/json-document/tests/public/json-patch/rfc6902.test.ts b/packages/json-document/tests/public/json-patch/rfc6902.test.ts index e6e98050..c1584291 100644 --- a/packages/json-document/tests/public/json-patch/rfc6902.test.ts +++ b/packages/json-document/tests/public/json-patch/rfc6902.test.ts @@ -8,6 +8,7 @@ import { applyPatch, applyPatchToTrustedState, buildPointer, + createJSONDocument, parsePointer, type JSONPatchOperation, } from "@interactive-os/json-document"; @@ -45,6 +46,90 @@ describe("RFC 6901 — JSON Pointer", () => { expect(r.result.ok).toBe(false); if (!r.result.ok) expect(r.result.code).toBe("invalid_pointer"); }); + + it("rejects URI fragment representation in JSON Patch paths", () => { + const initial = { x: 1 }; + const r = applyOperation(Any, initial, { op: "replace", path: "#/x", value: 2 }); + + expect(r.result).toEqual({ + ok: false, + code: "invalid_pointer", + reason: "JSON Patch path must use JSON Pointer string representation, not a URI fragment", + pointer: "#/x", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + + it("rejects URI fragment paths before trusted-state optimizers", () => { + const Schema = z.object({ x: z.number() }); + const initial = { x: 1 }; + const r = applyPatchToTrustedState(Schema, initial, [ + { op: "replace", path: "#/x", value: 2 }, + ]); + + expect(r.result).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "#/x", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + + it("rejects URI fragment representation in JSON Patch from", () => { + const initial = { a: 1, b: 2 }; + const r = applyPatch(Any, initial, [ + { op: "move", from: "#/a", path: "/b" }, + ]); + + expect(r.result).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "#/a", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + + it("rejects undefined tilde escapes in patch pointers", () => { + const initial = { "a~2b": 1 }; + const r = applyOperation(Any, initial, { op: "replace", path: "/a~2b", value: 2 }); + + expect(r.result).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "/a~2b", + }); + expect(r.state).toBe(initial); + }); + + it("returns invalid_pointer for malformed append parents instead of throwing", () => { + const Schema = z.object({ items: z.array(z.number()) }); + const initial = { items: [1] }; + const operation = { op: "add", path: "/items~2/-", value: 2 } as const; + + for (const result of [ + applyPatch(Schema, initial, [operation]), + applyPatchToTrustedState(Schema, initial, [operation]), + ]) { + expect(result.result).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "/items~2/-", + }); + expect(result.state).toBe(initial); + expect(result.applied).toEqual([]); + } + + const doc = createJSONDocument(Schema, initial); + expect(doc.patch(operation)).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "/items~2/-", + }); + expect(doc.value).toEqual(initial); + }); }); describe("RFC 6902 — add", () => { @@ -278,6 +363,58 @@ describe("RFC 6902 — batch atomicity (G8)", () => { expect(r.state).toBe(initial); }); + it("rejects fragment paths before a fast batch can partially apply", () => { + const initial = { a: 1, b: 2 }; + const r = applyPatch(Any, initial, [ + { op: "replace", path: "/a", value: 10 }, + { op: "replace", path: "#/b", value: 20 }, + ]); + + expect(r.result).toEqual({ + ok: false, + code: "invalid_pointer", + reason: "op[1]: JSON Patch path must use JSON Pointer string representation, not a URI fragment", + pointer: "#/b", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + + it("reports an earlier operation failure before a later malformed pointer", () => { + const initial = { a: 1, b: 2 }; + const r = applyPatch(Any, initial, [ + { op: "test", path: "/a", value: 999 }, + { op: "replace", path: "#/b", value: 20 }, + ]); + + expect(r.result).toEqual({ + ok: false, + code: "test_failed", + reason: "op[0]: value mismatch", + pointer: "/a", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + + it("preserves operation failure order through trusted-state optimizers", () => { + const Schema = z.object({ a: z.number(), b: z.number() }); + const initial = { a: 1, b: 2 }; + const r = applyPatchToTrustedState(Schema, initial, [ + { op: "test", path: "/a", value: 999 }, + { op: "replace", path: "#/b", value: 20 }, + ]); + + expect(r.result).toEqual({ + ok: false, + code: "test_failed", + reason: "op[0]: value mismatch", + pointer: "/a", + }); + expect(r.state).toBe(initial); + expect(r.applied).toEqual([]); + }); + it("rolls back on mid-batch failure", () => { const initial = { a: 1, b: 2 }; const ops: JSONPatchOperation[] = [ diff --git a/packages/json-document/tests/public/json-pointer/uri-fragment.test.ts b/packages/json-document/tests/public/json-pointer/uri-fragment.test.ts index aa097f3e..ff95a534 100644 --- a/packages/json-document/tests/public/json-pointer/uri-fragment.test.ts +++ b/packages/json-document/tests/public/json-pointer/uri-fragment.test.ts @@ -52,6 +52,14 @@ describe("RFC 6901 §6 — URI fragment 형 Pointer", () => { } }); + test("URI fragment는 Unicode segment를 UTF-8 octet으로 인코딩한다", () => { + const segments = ["한글", "😀", "slash/tilde~"]; + const fragment = "#/%ED%95%9C%EA%B8%80/%F0%9F%98%80/slash~1tilde~0"; + + expect(buildPointer(segments, { uriFragment: true })).toBe(fragment); + expect(parsePointer(fragment)).toEqual(segments); + }); + test("# 단독 = 빈 fragment = root", () => { expect(parsePointer("#")).toEqual([]); }); @@ -69,4 +77,12 @@ describe("RFC 6901 §6 — URI fragment 형 Pointer", () => { expect(tryParsePointer("foo")).toBeNull(); expect(tryParsePointer("#/foo")).toEqual(["foo"]); }); + + test("정의되지 않은 tilde escape를 거부한다", () => { + for (const pointer of ["/~", "/~2", "/a~2b", "#/~2"]) { + expect(() => parsePointer(pointer)).toThrow("Invalid JSON Pointer escape"); + expect(tryParsePointer(pointer)).toBeNull(); + } + expect(parsePointer("/~01")).toEqual(["~1"]); + }); }); diff --git a/packages/json-document/tests/public/jsonpath/cts.test.ts b/packages/json-document/tests/public/jsonpath/cts.test.ts index 817a8b43..ee35f572 100644 --- a/packages/json-document/tests/public/jsonpath/cts.test.ts +++ b/packages/json-document/tests/public/jsonpath/cts.test.ts @@ -38,7 +38,7 @@ describe("RFC 9535 JSONPath CTS — jsonpath-standard vendor", () => { let passed = 0; for (const c of suite.tests) { - const doc = createJSONDocument(Any, c.document); + const doc = createJSONDocument(Any, "document" in c ? c.document : null); const queried = doc.query(c.selector); if (c.invalid_selector) { invalidSelectors += 1; diff --git a/packages/json-document/tests/regression/json-state-invariants.test.ts b/packages/json-document/tests/regression/json-state-invariants.test.ts new file mode 100644 index 00000000..fac0ca56 --- /dev/null +++ b/packages/json-document/tests/regression/json-state-invariants.test.ts @@ -0,0 +1,648 @@ +import { describe, expect, test, vi } from "vitest"; +import * as z from "zod"; + +import { + applyPatch, + createJSONDocument, + type JSONPatchOperation, +} from "@interactive-os/json-document"; + +describe("JSON document state invariants", () => { + test("rejects a non-JSON schema output before creating a document", () => { + const Schema = z.object({ createdAt: z.date() }); + + expect(() => createJSONDocument(Schema, { + createdAt: new Date("2026-07-14T00:00:00.000Z"), + })).toThrow("Initial document value is not JSON-serializable: /createdAt: non-plain object (Date)"); + }); + + test("trustedInitial skips schema parsing but not the JSON state boundary", () => { + expect(() => createJSONDocument( + z.date(), + new Date("2026-07-14T00:00:00.000Z"), + { trustedInitial: true }, + )).toThrow("Initial document value is not JSON-serializable: : non-plain object (Date)"); + }); + + test("checks a mutable trustedInitial against the JSON boundary for known output schemas", () => { + expect(() => createJSONDocument( + z.object({ value: z.string() }), + { value: () => "not JSON" } as never, + { trustedInitial: true }, + )).toThrow("Initial document value is not JSON-serializable: /value: function is not JSON"); + }); + + test("rejects non-JSON output produced by a schema overwrite", () => { + const Schema = z.number().overwrite(() => Number.NaN); + + expect(() => createJSONDocument(Schema, 1)).toThrow( + "Initial document value is not JSON-serializable", + ); + }); + + test("rejects non-JSON output produced by a mutating custom check", () => { + const Schema = z.object({ value: z.unknown() }).superRefine((value) => { + value.value = () => "not JSON"; + }); + + expect(() => createJSONDocument(Schema, { value: "initial" })).toThrow( + "Initial document value is not JSON-serializable", + ); + }); + + test("isolates mutating custom schema checks from patch candidates", () => { + const Schema = z.any().superRefine((value) => { + if (value?.trigger === true) value.poison = () => "not JSON"; + }); + const doc = createJSONDocument(Schema, { + trigger: false, + nested: { value: 1 }, + }); + void doc.value; + + expect(doc.patch({ op: "replace", path: "/trigger", value: true })).toEqual({ ok: true }); + expect(doc.value).toEqual({ trigger: true, nested: { value: 1 } }); + expect("poison" in doc.value).toBe(false); + + const rootResult = doc.patch({ + op: "replace", + path: "", + value: { trigger: true, nested: { value: 2 } }, + }); + expect(rootResult).toEqual({ ok: true }); + expect(doc.value).toEqual({ trigger: true, nested: { value: 2 } }); + }); + + test("keeps failed custom validation atomic before and after snapshot materialization", () => { + const Schema = z.any().superRefine((value, context) => { + if (value?.trigger !== true) return; + value.nested.value = 99; + context.addIssue({ code: "custom", message: "reject" }); + }); + + for (const materialize of [false, true]) { + const doc = createJSONDocument(Schema, { + trigger: false, + nested: { value: 1 }, + }); + if (materialize) void doc.value; + + expect(doc.patch({ op: "replace", path: "/trigger", value: true })).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(doc.value).toEqual({ trigger: false, nested: { value: 1 } }); + expect(doc.lastPatch).toEqual([]); + } + }); + + test("keeps capability probes pure when custom schema checks mutate their input", () => { + const Schema = z.custom>((value) => { + if (value !== null && typeof value === "object") { + const record = value as Record; + if (record.trigger === true) { + (record.nested as Record).poison = () => "not JSON"; + } + } + return true; + }); + const initial = { trigger: false, nested: { value: 1 } }; + const doc = createJSONDocument(Schema, initial); + + expect(doc.canPatch({ op: "replace", path: "/trigger", value: true })).toEqual({ ok: true }); + expect(doc.value).toEqual(initial); + expect(doc.lastPatch).toEqual([]); + }); + + test("isolates custom schema checks in the public pure patch helper", () => { + const Schema = z.any().superRefine((value) => { + if (value?.trigger === true) value.nested.poison = () => "not JSON"; + }); + const initial = { trigger: false, nested: { value: 1 } }; + + const result = applyPatch(Schema, initial, [ + { op: "replace", path: "/trigger", value: true }, + ]); + + expect(result.result).toEqual({ ok: true }); + expect(result.state).toEqual({ trigger: true, nested: { value: 1 } }); + expect(initial).toEqual({ trigger: false, nested: { value: 1 } }); + }); + + test("rejects a non-JSON load atomically", () => { + const doc = createJSONDocument(z.object({ value: z.unknown() }), { value: "initial" }); + const listener = vi.fn(); + doc.subscribe(listener); + + expect(doc.load({ value: new Date("2026-07-14T00:00:00.000Z") })).toEqual({ + ok: false, + code: "not_serializable", + reason: "/value: non-plain object (Date)", + }); + expect(doc.value).toEqual({ value: "initial" }); + expect(listener).not.toHaveBeenCalled(); + }); + + test("reset treats null as an explicit JSON root value", () => { + const doc = createJSONDocument(z.union([z.string(), z.null()]), "initial"); + expect(doc.load("changed")).toEqual({ ok: true }); + + expect(doc.reset(null)).toEqual({ ok: true }); + expect(doc.value).toBeNull(); + }); + + test("exposes the document value as an immutable snapshot", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ id: z.string() })) }), + { items: [{ id: "a" }] }, + ); + + expect(Object.isFrozen(doc.value)).toBe(true); + expect(Object.isFrozen(doc.value.items)).toBe(true); + expect(Object.isFrozen(doc.value.items[0])).toBe(true); + expect(() => { + doc.value.items[0]!.id = "corrupt"; + }).toThrow(TypeError); + expect(doc.value).toEqual({ items: [{ id: "a" }] }); + }); + + test("deep-freezes children below a schema-produced shallow readonly node", () => { + const doc = createJSONDocument( + z.object({ + item: z.object({ + nested: z.object({ value: z.number() }), + }).readonly(), + }), + { item: { nested: { value: 1 } } }, + ); + + const snapshot = doc.value; + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.item)).toBe(true); + expect(Object.isFrozen(snapshot.item.nested)).toBe(true); + expect(() => { + snapshot.item.nested.value = 99; + }).toThrow(TypeError); + expect(doc.value.item.nested.value).toBe(1); + }); + + test("owns patch payloads without freezing the caller's object", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ id: z.string() })) }), + { items: [] }, + ); + const payload = { id: "a" }; + + expect(doc.patch({ op: "add", path: "/items/-", value: payload })).toEqual({ ok: true }); + expect(Object.isFrozen(payload)).toBe(false); + expect(doc.value.items[0]).not.toBe(payload); + + payload.id = "external mutation"; + expect(doc.value).toEqual({ items: [{ id: "a" }] }); + expect(Object.isFrozen(doc.value.items[0])).toBe(true); + }); + + test("owns operation records used by lastPatch and history", () => { + const doc = createJSONDocument( + z.object({ a: z.object({ value: z.number() }), b: z.object({ value: z.number() }) }), + { a: { value: 1 }, b: { value: 10 } }, + { history: 10 }, + ); + const operation: JSONPatchOperation = { op: "replace", path: "/a/value", value: 2 }; + + expect(doc.patch(operation)).toEqual({ ok: true }); + operation.path = "/b/value"; + operation.value = 99; + + expect(doc.lastPatch).toEqual([{ op: "replace", path: "/a/value", value: 2 }]); + expect(Object.isFrozen(doc.lastPatch[0])).toBe(true); + expect(doc.history.undo()).toBe(true); + expect(doc.history.redo()).toBe(true); + expect(doc.value).toEqual({ a: { value: 2 }, b: { value: 10 } }); + }); + + test("isolates later subscribers from mutation attempts by an earlier subscriber", () => { + const doc = createJSONDocument(z.object({ item: z.object({ value: z.number() }) }), { + item: { value: 1 }, + }); + const observed = vi.fn(); + doc.subscribe((applied) => { + expect(Object.isFrozen(applied)).toBe(true); + expect(Object.isFrozen(applied[0])).toBe(true); + try { + (applied as JSONPatchOperation[])[0] = { op: "remove", path: "/item" }; + } catch { + // Expected for the immutable publication record. + } + try { + (applied[0] as Extract).path = "/missing"; + } catch { + // Expected for the immutable publication record. + } + }); + doc.subscribe(observed); + + expect(doc.patch({ op: "replace", path: "/item/value", value: 2 })).toEqual({ ok: true }); + expect(observed.mock.calls[0]?.[0]).toEqual([ + { op: "replace", path: "/item/value", value: 2 }, + ]); + expect(doc.value).toEqual({ item: { value: 2 } }); + }); + + test("owns and freezes values accepted by load", () => { + const doc = createJSONDocument(z.object({ data: z.unknown() }), { data: { value: 1 } }); + const loaded = { data: { value: 2 } }; + + expect(doc.load(loaded)).toEqual({ ok: true }); + expect(Object.isFrozen(loaded)).toBe(false); + expect(Object.isFrozen(loaded.data)).toBe(false); + loaded.data.value = 99; + + expect(doc.value).toEqual({ data: { value: 2 } }); + expect(Object.isFrozen(doc.value)).toBe(true); + expect(Object.isFrozen(doc.value.data)).toBe(true); + }); + + test("reset without a value restores the parsed initial snapshot", () => { + const doc = createJSONDocument(z.string().transform((value) => value.length), "initial"); + expect(doc.value).toBe(7); + expect(doc.load("next")).toEqual({ ok: true }); + expect(doc.value).toBe(4); + + expect(doc.reset()).toEqual({ ok: true }); + expect(doc.value).toBe(7); + }); + + test("owns commit operations before preview and history recording", () => { + const Schema = z.object({ item: z.object({ label: z.string() }) }); + const doc = createJSONDocument(Schema, { item: { label: "before" } }, { history: 10 }); + const payload = { label: "after" }; + const operations: JSONPatchOperation[] = [{ op: "replace", path: "/item", value: payload }]; + + expect(doc.commit(operations)).toEqual({ ok: true }); + expect(Object.isFrozen(payload)).toBe(false); + payload.label = "mutated payload"; + operations[0] = { op: "remove", path: "/item" }; + + expect(doc.history.undo()).toBe(true); + expect(doc.history.redo()).toBe(true); + expect(doc.value).toEqual({ item: { label: "after" } }); + }); + + test("owns values published through the high-level insert path", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ id: z.string() })) }), + { items: [] }, + ); + const payload = { id: "a" }; + + expect(doc.insert("/items/-", payload)).toMatchObject({ ok: true }); + expect(Object.isFrozen(payload)).toBe(false); + payload.id = "external mutation"; + + expect(doc.value).toEqual({ items: [{ id: "a" }] }); + expect(Object.isFrozen(doc.value.items[0])).toBe(true); + }); + + test("clones a mutable trustedInitial value but reuses a pre-frozen snapshot", () => { + const Schema = z.object({ item: z.object({ id: z.string() }) }); + const mutable = { item: { id: "mutable" } }; + const mutableDoc = createJSONDocument(Schema, mutable, { trustedInitial: true }); + + expect(mutableDoc.value).not.toBe(mutable); + expect(Object.isFrozen(mutable)).toBe(false); + mutable.item.id = "external mutation"; + expect(mutableDoc.value).toEqual({ item: { id: "mutable" } }); + + const frozenItem = Object.freeze({ id: "frozen" }); + const frozen = Object.freeze({ item: frozenItem }); + const frozenDoc = createJSONDocument(Schema, frozen, { trustedInitial: true }); + expect(frozenDoc.value).toBe(frozen); + }); + + test("preserves deep immutability when patching a frozen trustedInitial snapshot", () => { + const Item = z.object({ id: z.string(), meta: z.object({ label: z.string() }) }); + const Schema = z.object({ items: z.array(Item) }); + const first = { + id: "a", + meta: { label: "before" }, + }; + const second = { + id: "b", + meta: { label: "untouched" }, + }; + const initial = { items: [first, second] }; + Object.freeze(first.meta); + Object.freeze(first); + Object.freeze(second.meta); + Object.freeze(second); + Object.freeze(initial.items); + Object.freeze(initial); + const doc = createJSONDocument(Schema, initial, { trustedInitial: true }); + + expect(doc.patch({ + op: "replace", + path: "/items/0/meta/label", + value: "after", + })).toEqual({ ok: true }); + + const snapshot = doc.value; + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.items)).toBe(true); + expect(Object.isFrozen(snapshot.items[0])).toBe(true); + expect(Object.isFrozen(snapshot.items[0]!.meta)).toBe(true); + expect(snapshot.items[1]).toBe(second); + expect(() => { + snapshot.items[0]!.meta.label = "corrupt"; + }).toThrow(TypeError); + expect(doc.value.items[0]!.meta.label).toBe("after"); + }); + + test("document patch rejects URI fragment paths without changing state", () => { + const doc = createJSONDocument(z.object({ value: z.number() }), { value: 1 }); + + expect(doc.patch({ op: "replace", path: "#/value", value: 2 })).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "#/value", + }); + expect(doc.value).toEqual({ value: 1 }); + expect(doc.lastPatch).toEqual([]); + }); + + test("freezes cloned subtrees produced by copy operations", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ meta: z.object({ label: z.string() }) })) }), + { items: [{ meta: { label: "a" } }] }, + ); + + expect(doc.patch({ op: "copy", from: "/items/0", path: "/items/-" })).toEqual({ ok: true }); + expect(doc.value.items[1]).not.toBe(doc.value.items[0]); + expect(Object.isFrozen(doc.value.items[1])).toBe(true); + expect(Object.isFrozen(doc.value.items[1]!.meta)).toBe(true); + }); + + test("keeps a copied subtree immutable when the same batch moves it", () => { + const Node = z.object({ meta: z.object({ label: z.string() }) }); + const doc = createJSONDocument(z.object({ + source: Node, + temporary: Node.optional(), + destination: Node.optional(), + }), { + source: { meta: { label: "a" } }, + }); + + expect(doc.patch([ + { op: "copy", from: "/source", path: "/temporary" }, + { op: "move", from: "/temporary", path: "/destination" }, + ])).toEqual({ ok: true }); + expect(Object.isFrozen(doc.value.destination)).toBe(true); + expect(Object.isFrozen(doc.value.destination!.meta)).toBe(true); + }); + + test("keeps a copied subtree immutable when a later array insert shifts it", () => { + const Node = z.object({ meta: z.object({ label: z.string() }) }); + const doc = createJSONDocument(z.object({ + source: Node, + items: z.array(Node), + }), { + source: { meta: { label: "copied" } }, + items: [], + }); + void doc.value; + + expect(doc.patch([ + { op: "copy", from: "/source", path: "/items/0" }, + { op: "add", path: "/items/0", value: { meta: { label: "inserted" } } }, + ])).toEqual({ ok: true }); + expect(doc.value.items.map((item) => item.meta.label)).toEqual(["inserted", "copied"]); + expect(Object.isFrozen(doc.value.items[1])).toBe(true); + expect(Object.isFrozen(doc.value.items[1]!.meta)).toBe(true); + expect(() => { + doc.value.items[1]!.meta.label = "corrupt"; + }).toThrow(TypeError); + }); + + test("records reentrant subscriber patches in publication order", () => { + const doc = createJSONDocument( + z.object({ value: z.number() }), + { value: 1 }, + { history: 10 }, + ); + let reentered = false; + doc.subscribe((applied) => { + if (reentered || applied[0]?.path !== "/value") return; + reentered = true; + expect(doc.patch({ op: "replace", path: "/value", value: 3 })).toEqual({ ok: true }); + }); + + expect(doc.patch({ op: "replace", path: "/value", value: 2 })).toEqual({ ok: true }); + expect(doc.value).toEqual({ value: 3 }); + expect(doc.history.undoDepth).toBe(2); + + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ value: 2 }); + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ value: 1 }); + }); + + test("preserves a history transaction opened by a reentrant subscriber", () => { + const doc = createJSONDocument( + z.object({ a: z.number(), b: z.number(), c: z.number() }), + { a: 1, b: 1, c: 1 }, + { history: 10 }, + ); + let reentered = false; + doc.subscribe((applied) => { + if (reentered || applied[0]?.path !== "/a") return; + reentered = true; + doc.history.transaction(() => { + expect(doc.patch({ op: "replace", path: "/b", value: 2 })).toEqual({ ok: true }); + expect(doc.patch({ op: "replace", path: "/c", value: 2 })).toEqual({ ok: true }); + }); + }); + + expect(doc.patch({ op: "replace", path: "/a", value: 2 })).toEqual({ ok: true }); + expect(doc.value).toEqual({ a: 2, b: 2, c: 2 }); + expect(doc.history.undoDepth).toBe(2); + + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ a: 2, b: 1, c: 1 }); + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ a: 1, b: 1, c: 1 }); + }); + + test("keeps each subscriber event's selection metadata publication-local", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ id: z.string() })) }), + { items: [{ id: "a" }, { id: "b" }] }, + { selection: { initial: ["/items/1"] } }, + ); + let reentered = false; + doc.subscribe((applied) => { + if (reentered || applied[0]?.path !== "/items/0/id") return; + reentered = true; + expect(doc.patch({ op: "remove", path: "/items/0" })).toEqual({ ok: true }); + }); + const outerMetadata = vi.fn(); + doc.subscribe((applied, metadata) => { + if (applied[0]?.path === "/items/0/id") outerMetadata(metadata); + }); + + expect(doc.patch({ op: "replace", path: "/items/0/id", value: "A" })).toEqual({ ok: true }); + + expect(doc.selection?.selectedPointers).toEqual(["/items/0"]); + expect(outerMetadata).toHaveBeenCalledWith(expect.objectContaining({ + selectionBefore: expect.objectContaining({ selectedPointers: ["/items/1"] }), + selectionAfter: expect.objectContaining({ selectedPointers: ["/items/1"] }), + })); + }); + + test("captures selection metadata before selection callbacks can reenter", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ id: z.string() })) }), + { items: [{ id: "a" }, { id: "b" }, { id: "c" }] }, + { selection: { initial: ["/items/2"] } }, + ); + let reentered = false; + doc.selection?.subscribe(() => { + if (reentered) return; + reentered = true; + expect(doc.patch({ op: "remove", path: "/items/1" })).toEqual({ ok: true }); + }); + const observed: Array> = []; + doc.subscribe((_applied, metadata) => { + observed.push(metadata?.selectionAfter?.selectedPointers ?? []); + }); + + expect(doc.patch({ op: "remove", path: "/items/0" })).toEqual({ ok: true }); + + expect(observed).toEqual([["/items/0"], ["/items/1"]]); + expect(doc.selection?.selectedPointers).toEqual(["/items/0"]); + expect(doc.lastPatch).toEqual([{ op: "remove", path: "/items/1" }]); + }); + + test("returns the outer edit's canonical patch when a subscriber reenters", () => { + const doc = createJSONDocument( + z.object({ outer: z.number(), inner: z.number() }), + { outer: 1, inner: 1 }, + ); + let reentered = false; + doc.subscribe((applied) => { + if (reentered || applied[0]?.path !== "/outer") return; + reentered = true; + expect(doc.patch({ op: "replace", path: "/inner", value: 2 })).toEqual({ ok: true }); + }); + + const result = doc.replace("/outer", 2); + + expect(result).toMatchObject({ + ok: true, + applied: [{ op: "replace", path: "/outer", value: 2 }], + target: "/outer", + value: { outer: 2, inner: 2 }, + }); + expect(doc.lastPatch).toEqual([{ op: "replace", path: "/inner", value: 2 }]); + }); + + test("keeps previewed insert results and history publication-local under reentry", () => { + const doc = createJSONDocument( + z.object({ + items: z.array(z.object({ id: z.string() })), + marker: z.number(), + }), + { items: [], marker: 0 }, + { history: 10 }, + ); + let reentered = false; + doc.subscribe((applied) => { + if (reentered || applied[0]?.path !== "/items/0") return; + reentered = true; + expect(doc.patch({ op: "replace", path: "/marker", value: 1 })).toEqual({ ok: true }); + }); + + const result = doc.insert("/items/0", { id: "a" }); + + expect(result).toMatchObject({ + ok: true, + applied: [{ op: "add", path: "/items/0", value: { id: "a" } }], + target: "/items/0", + value: { items: [{ id: "a" }], marker: 1 }, + }); + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ items: [{ id: "a" }], marker: 0 }); + expect(doc.history.undo()).toBe(true); + expect(doc.value).toEqual({ items: [], marker: 0 }); + }); + + test("does not publish or record a successful test-only patch", () => { + const doc = createJSONDocument( + z.object({ value: z.number() }), + { value: 1 }, + { history: 10 }, + ); + const listener = vi.fn(); + doc.subscribe(listener); + expect(doc.patch({ op: "replace", path: "/value", value: 2 })).toEqual({ ok: true }); + const previousLastPatch = doc.lastPatch; + const previousUndoDepth = doc.history.undoDepth; + listener.mockClear(); + + expect(doc.patch({ op: "test", path: "/value", value: 2 })).toEqual({ ok: true }); + + expect(doc.lastPatch).toEqual(previousLastPatch); + expect(doc.history.undoDepth).toBe(previousUndoDepth); + expect(listener).not.toHaveBeenCalled(); + }); + + test("records an explicit selection-only commit as one undoable history step", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.string()) }), + { items: ["a", "b"] }, + { + history: 10, + selection: { initial: ["/items/0"] }, + }, + ); + + expect(doc.commit( + [{ op: "test", path: "/items/0", value: "a" }], + { selectionAfter: "/items/1" }, + )).toEqual({ ok: true }); + expect(doc.selection?.selectedPointers).toEqual(["/items/1"]); + expect(doc.history.undoDepth).toBe(1); + expect(doc.lastPatch).toEqual([]); + + expect(doc.history.undo()).toBe(true); + expect(doc.selection?.selectedPointers).toEqual(["/items/0"]); + expect(doc.history.redo()).toBe(true); + expect(doc.selection?.selectedPointers).toEqual(["/items/1"]); + }); + + test("ignores selectionAfter on a no-op commit when selection is disabled", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.string()) }), + { items: ["a", "b"] }, + { history: 10 }, + ); + + expect(doc.commit( + [{ op: "test", path: "/items/0", value: "a" }], + { selectionAfter: "/items/1" }, + )).toEqual({ ok: true }); + expect(doc.selection).toBeUndefined(); + expect(doc.history.undoDepth).toBe(0); + expect(doc.lastPatch).toEqual([]); + }); + + test("deep-freezes a subtree copied to the document root", () => { + const doc = createJSONDocument(z.unknown(), { + source: { meta: { label: "a" } }, + }); + + expect(doc.patch({ op: "copy", from: "/source", path: "" })).toEqual({ ok: true }); + const value = doc.value as { meta: { label: string } }; + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.meta)).toBe(true); + }); +}); diff --git a/scripts/benchmark-core.mjs b/scripts/benchmark-core.mjs index ceb28eba..6b884f27 100644 --- a/scripts/benchmark-core.mjs +++ b/scripts/benchmark-core.mjs @@ -96,6 +96,12 @@ const batchSize = envNumber("PERF_BATCH", 1000); const individualCount = envNumber("PERF_INDIVIDUAL", 100); const jsonpathRepeats = envNumber("PERF_JSONPATH_REPEATS", 10000); const rounds = envNumber("PERF_ROUNDS", 5); +const leafCommitP50BudgetMs = envNumber("PERF_LEAF_COMMIT_P50_BUDGET_MS", 0.75); +const leafSnapshotP50BudgetMs = envNumber("PERF_LEAF_SNAPSHOT_P50_BUDGET_MS", 4); +const frozenTrustedSnapshotP50BudgetMs = envNumber("PERF_FROZEN_TRUSTED_SNAPSHOT_P50_BUDGET_MS", 4); +const copyBatchP50BudgetPerThousandMs = envNumber("PERF_COPY_BATCH_P50_BUDGET_PER_1000_MS", 15); +const frozenTrustedCopySnapshotP50BudgetPerThousandMs = envNumber("PERF_FROZEN_TRUSTED_COPY_SNAPSHOT_P50_BUDGET_PER_1000_MS", 15); +const warmRootReplaceP50BudgetPerThousandMs = envNumber("PERF_WARM_ROOT_REPLACE_P50_BUDGET_PER_1000_MS", 20); const overlappingReplaceRounds = Math.max(15, rounds); const forceGc = process.env.PERF_GC === "1"; const runtimeGc = typeof globalThis.gc === "function" ? globalThis.gc.bind(globalThis) : null; @@ -108,6 +114,7 @@ if (forceGc) { for (const size of sizes) { const state = Schema.parse(makeState(size)); + const frozenTrustedState = deepFreezeJson(Schema.parse(makeState(size))); const optionalItemsState = OptionalItemsSchema.parse(state); const nestedState = NestedSchema.parse({ wrapper: { items: state.items } }); const escapedSelectionState = EscapedSelectionSchema.parse({ @@ -195,6 +202,11 @@ for (const size of sizes) { })), ]; const copyMoveCount = Math.min(individualCount, size); + const copyBatchOps = Array.from({ length: Math.min(batchSize, size) }, () => ({ + op: "copy", + from: "/items/0", + path: "/items/-", + })); const copyMoveOps = [ ...Array.from({ length: Math.floor(copyMoveCount / 2) }, (_, index) => ({ op: "copy", @@ -217,10 +229,14 @@ for (const size of sizes) { const doc = createJSONDocument(Schema, state, { history: 0 }); return { ok: doc.value.items.length === size }; }); - bench("createJSONDocument init trustedInitial history=0", Math.max(3, Math.ceil(rounds / 2)), () => { + bench("createJSONDocument init mutable trustedInitial history=0", Math.max(3, Math.ceil(rounds / 2)), () => { const doc = createJSONDocument(Schema, state, { history: 0, trustedInitial: true }); return { ok: doc.value.items.length === size }; }); + bench("createJSONDocument init frozen trustedInitial history=0", Math.max(3, Math.ceil(rounds / 2)), () => { + const doc = createJSONDocument(Schema, frozenTrustedState, { history: 0, trustedInitial: true }); + return { ok: doc.value === frozenTrustedState }; + }); bench("createJSONDocument init history=100", Math.max(3, Math.ceil(rounds / 2)), () => { const doc = createJSONDocument(Schema, state, { history: 100 }); return { ok: doc.value.items.length === size }; @@ -250,12 +266,52 @@ for (const size of sizes) { { const doc = createJSONDocument(Schema, state, { history: 100 }); - bench("doc.patch single leaf + history", rounds, (index) => + const stats = bench("doc.patch single leaf + history", rounds, (index) => doc.patch({ op: "replace", path: `/items/${middle}/done`, value: index % 2 === 0, })); + assertP50Budget("leaf commit", stats, leafCommitP50BudgetMs); + } + + { + const doc = createJSONDocument(Schema, state, { history: 100 }); + void doc.value; + const stats = bench("doc.patch single leaf + history + snapshot", rounds, (index) => { + const result = doc.patch({ + op: "replace", + path: `/items/${middle}/done`, + value: index % 2 === 0, + }); + void doc.value; + return result; + }); + assertP50Budget("leaf commit + snapshot", stats, leafSnapshotP50BudgetMs); + } + + { + let doc; + const stats = benchWithSetup( + "doc.patch frozen trustedInitial leaf + first snapshot", + rounds, + () => { + doc = createJSONDocument(Schema, frozenTrustedState, { + history: 100, + trustedInitial: true, + }); + }, + () => { + const result = doc.patch({ + op: "replace", + path: `/items/${middle}/done`, + value: true, + }); + void doc.value; + return result; + }, + ); + assertP50Budget("frozen trustedInitial leaf + first snapshot", stats, frozenTrustedSnapshotP50BudgetMs); } { @@ -952,6 +1008,41 @@ for (const size of sizes) { }); } + { + const doc = createJSONDocument(Schema, state, { history: 0 }); + const stats = bench(`doc.patch copy array batch ${copyBatchOps.length} history=0`, Math.max(3, Math.ceil(rounds / 2)), () => + doc.patch(copyBatchOps)); + assertP50Budget( + `copy batch ${copyBatchOps.length}`, + stats, + copyBatchP50BudgetPerThousandMs * Math.max(1, copyBatchOps.length / 1000), + ); + } + + { + let doc; + const stats = benchWithSetup( + `doc.patch frozen trustedInitial copy batch ${copyBatchOps.length} + first snapshot`, + Math.max(3, Math.ceil(rounds / 2)), + () => { + doc = createJSONDocument(Schema, frozenTrustedState, { + history: 0, + trustedInitial: true, + }); + }, + () => { + const result = doc.patch(copyBatchOps); + void doc.value; + return result; + }, + ); + assertP50Budget( + `frozen trustedInitial copy batch ${copyBatchOps.length} + first snapshot`, + stats, + frozenTrustedCopySnapshotP50BudgetPerThousandMs * Math.max(1, copyBatchOps.length / 1000), + ); + } + { const doc = createJSONDocument(Schema, state, { history: 0 }); bench(`doc.patch copy/move array batch ${copyMoveOps.length} history=0`, Math.max(3, Math.ceil(rounds / 2)), () => @@ -1042,6 +1133,14 @@ for (const size of sizes) { path: `/k${index}/meta/rank`, value: rootReplaceCount + index, })); + const wideRootKeyCount = envNumber("PERF_WIDE_ROOT_KEYS", Math.max(...sizes)); + const wideRootState = makeRootObjectState(wideRootKeyCount); + const wideRootReplaceCount = Math.min(batchSize, wideRootKeyCount); + const wideRootReplaceOps = Array.from({ length: wideRootReplaceCount }, (_, index) => ({ + op: "replace", + path: `/k${index}`, + value: makeRootObjectValue(wideRootKeyCount + index), + })); console.log(`\nroot keys=${rootReplaceCount}`); bench(`createJSONDocument root record init history=0`, Math.max(3, Math.ceil(rounds / 2)), () => { const doc = createJSONDocument(RootRecord, rootState, { history: 0 }); @@ -1064,6 +1163,23 @@ for (const size of sizes) { bench(`doc.patch root object replace batch ${rootReplaceCount} history=0`, Math.max(3, Math.ceil(rounds / 2)), () => doc.patch(rootReplaceOps)); } + { + let doc; + const stats = benchWithSetup( + `doc.patch warm wide root ${wideRootKeyCount} replace batch ${wideRootReplaceCount}`, + Math.max(3, Math.ceil(rounds / 2)), + () => { + doc = createJSONDocument(RootRecord, wideRootState, { history: 0 }); + void doc.value; + }, + () => doc.patch(wideRootReplaceOps), + ); + assertP50Budget( + `warm wide root replace batch ${wideRootReplaceCount}`, + stats, + warmRootReplaceP50BudgetPerThousandMs * Math.max(1, wideRootReplaceCount / 1000), + ); + } { const doc = createJSONDocument(RootRecord, rootState, { history: 100 }); bench(`doc.patch root object replace batch ${rootReplaceCount} history=100`, Math.max(3, Math.ceil(rounds / 2)), () => @@ -1301,6 +1417,21 @@ function makeRecursiveState(size) { }; } +function deepFreezeJson(value) { + if (value === null || typeof value !== "object") return value; + const stack = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (Object.isFrozen(current)) continue; + for (const key of Object.keys(current)) { + const child = current[key]; + if (child !== null && typeof child === "object") stack.push(child); + } + Object.freeze(current); + } + return value; +} + function makeRootObjectState(size) { return Object.fromEntries( Array.from({ length: size }, (_, index) => [`k${index}`, makeRootObjectValue(index)]), @@ -1359,6 +1490,13 @@ function bench(label, sampleCount, fn) { const { avg, min, p50, p90, max } = sampleStats(samples); const ok = resultOk(last); console.log(`${label}: avg=${avg.toFixed(2)}ms min=${min.toFixed(2)}ms p50=${p50.toFixed(2)}ms p90=${p90.toFixed(2)}ms max=${max.toFixed(2)}ms ok=${ok}`); + return { avg, min, p50, p90, max, ok }; +} + +function assertP50Budget(label, stats, limitMs) { + const pass = stats.p50 <= limitMs; + console.log(`budget ${label}: p50=${stats.p50.toFixed(2)}ms limit=${limitMs.toFixed(2)}ms pass=${pass}`); + if (!pass) process.exitCode = 1; } function benchWithSetup(label, sampleCount, setup, fn) { @@ -1374,6 +1512,7 @@ function benchWithSetup(label, sampleCount, setup, fn) { const { avg, min, p50, p90, max } = sampleStats(samples); const ok = resultOk(last); console.log(`${label}: avg=${avg.toFixed(2)}ms min=${min.toFixed(2)}ms p50=${p50.toFixed(2)}ms p90=${p90.toFixed(2)}ms max=${max.toFixed(2)}ms ok=${ok}`); + return { avg, min, p50, p90, max, ok }; } function benchHeapRetained(label, sampleCount, fn) {