Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 19 additions & 4 deletions docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 만듭니다. |
Expand All @@ -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
Expand All @@ -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);
}
```

Expand All @@ -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을 명시합니다.

Expand Down
9 changes: 9 additions & 0 deletions docs/standard/core-standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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이어야
한다.
Expand Down Expand Up @@ -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로
검증해야 한다.

Expand Down
17 changes: 17 additions & 0 deletions docs/standard/json-document-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)]");
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/json-document/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ interface CreateClipboardOptions<S extends z.ZodType> {
) => PreviewedDocumentPatchResult;
getSelectionSource?: () => SelectionSource | null;
getSelectionTarget?: () => Pointer | null;
getAppliedPatch?: () => ReadonlyArray<JSONPatchOperation>;
getStateJsonTrusted?: () => boolean;
onChange?: () => void;
}
Expand All @@ -75,7 +74,6 @@ export function createClipboard<S extends z.ZodType>(
applyPreviewedPatch,
getSelectionSource,
getSelectionTarget,
getAppliedPatch,
getStateJsonTrusted,
onChange,
} = args;
Expand All @@ -89,7 +87,6 @@ export function createClipboard<S extends z.ZodType>(
previewTrustedValuesPatch,
applyPreviewedPatch,
getSelectionTarget,
getAppliedPatch,
});

const setBuffer = (next: ClipboardBuffer | null): void => {
Expand Down Expand Up @@ -170,14 +167,14 @@ export function createClipboard<S extends z.ZodType>(
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<z.output<S>> = patchResult.ok
? {
ok: true,
value: getState(),
applied: getAppliedPatch?.() ?? result.patch,
applied: publication.applied,
target: null,
payload: result.payload,
source: result.source,
Expand Down
6 changes: 2 additions & 4 deletions packages/json-document/src/domain/document/clipboard/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ interface CreateClipboardPasteRuntimeOptions<S extends z.ZodType> {
operations: ReadonlyArray<JSONPatchOperation>,
) => PreviewedDocumentPatchResult) | undefined;
getSelectionTarget?: (() => Pointer | null) | undefined;
getAppliedPatch?: (() => ReadonlyArray<JSONPatchOperation>) | undefined;
}

export interface ClipboardPasteRuntime<T> {
Expand Down Expand Up @@ -70,7 +69,6 @@ export function createClipboardPasteRuntime<S extends z.ZodType>(
previewTrustedValuesPatch,
applyPreviewedPatch,
getSelectionTarget,
getAppliedPatch,
} = options;

function pasteExecutionOptions(
Expand Down Expand Up @@ -132,7 +130,7 @@ export function createClipboardPasteRuntime<S extends z.ZodType>(
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) {
Expand All @@ -142,7 +140,7 @@ export function createClipboardPasteRuntime<S extends z.ZodType>(
reason: patchResult.reason ?? patchResult.code,
};
}
const applied = getAppliedPatch?.() ?? result.patch;
const applied = publication.applied;
// target: 첫 착지 slot (applied 는 정규화된 concrete op — `/-` 없음).
const first = applied[0];
return {
Expand Down
20 changes: 8 additions & 12 deletions packages/json-document/src/domain/document/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export function createDocumentRuntime<S extends z.ZodType>(
onChange: options.onChange,
});
const selectionState = selectionRuntime.state;
const syncLastPatch = (): void => { patchState.lastPatch = rawOps.lastApplied; };
const mutation = createDocumentMutationRuntime({
schema: zodSchema,
rawOps,
Expand All @@ -148,7 +147,6 @@ export function createDocumentRuntime<S extends z.ZodType>(
rawOps,
historyState,
selection: selectionRuntime.access,
syncLastPatch,
});

const ops = createDocumentStateOps({
Expand All @@ -157,19 +155,18 @@ export function createDocumentRuntime<S extends z.ZodType>(
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(
Expand Down Expand Up @@ -200,15 +197,14 @@ export function createDocumentRuntime<S extends z.ZodType>(
});
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 });
Expand All @@ -224,17 +220,17 @@ export function createDocumentRuntime<S extends z.ZodType>(
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,
Expand Down
10 changes: 5 additions & 5 deletions packages/json-document/src/domain/document/editing/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,8 +44,7 @@ export type JSONDocumentDuplicateResult<T> =
| Extract<JSONResult, { ok: false }>;

interface DocumentEditMutation<T> {
patch(operations: ReadonlyArray<JSONPatchOperation>): JSONResult;
lastApplied(): ReadonlyArray<JSONPatchOperation>;
applyDocumentPatchDetailed(operations: ReadonlyArray<JSONPatchOperation>): DetailedDocumentPatchResult;
duplicate(source: Pointer, options?: JSONDocumentDuplicateOptions): JSONDocumentDuplicateResult<T>;
}

Expand Down Expand Up @@ -83,9 +83,9 @@ export function createDocumentEditActions<T>(
// 계획 실행 + 통일 성공 shape 구성 (#219 EditOk).
const applyPlan = (plan: ReturnType<typeof planDocumentReplace>): JSONDocumentEditResult<T> => {
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<T>;
Expand Down
Loading