Skip to content

Core API Reference

유용태 edited this page Jun 18, 2026 · 6 revisions

json-document Core API Reference

@interactive-os/json-document core 공개 API는 1.0부터 안정화된 공개 표면이다.

이 문서는 제품 개발자가 자주 쓰는 순서대로 API를 설명하는 레퍼런스다. 마지막에는 public entrypoint에서 import할 수 있는 runtime/type export 이름을 모두 적는다.

1.x에서는 기존 이름, overload/call shape, result 구조, 에러 코드, 명령 의미론, 원자성, selection 의미론, clipboard 기본값, strict 동작의 하위 호환성을 유지한다.

json-document의 core는 UI framework, persistence, collaboration, network sync를 직접 소유하지 않는다. 이 package는 schema를 통과한 JSON state와 그 state에 대한 편집 명령, 선택 상태, history, clipboard, pointer utility를 제공하는 headless document layer다. 제품은 이 layer 위에 editor, form, CMS, automation, review UI를 얹는다.

이 문서의 예시는 사용 빈도가 높은 순서로 배치되어 있다. 앞쪽은 제품 코드가 매일 호출하는 API이고, 뒤쪽은 adapter나 extension package가 공개 경계에서 의존하는 API다.

문서 위치는 위에서 아래로 제품 개발자에게 가까운 API에서 adapter와 low-level primitive에 가까운 API 순서로 배치한다.

상세 method/function 문서:

1. 공개 import

제품 코드는 이 두 entrypoint만 공개 API로 사용한다.

import {
  createJSONDocument,
  type JSONDocument,
} from "@interactive-os/json-document";

import { useJSONDocument } from "@interactive-os/json-document/react";
  • core: @interactive-os/json-document
  • React adapter: @interactive-os/json-document/react

내부 source path import는 공개 API가 아니다.

설계 의도: root entrypoint는 framework와 무관한 core만 제공한다. React hook은 별도 entrypoint에 둔다. 이렇게 나누면 React를 쓰지 않는 runtime, test runner, server-side pipeline, 향후 adapter가 같은 core 공개 표면을 공유할 수 있다.

내부 source path를 import하지 않는 이유는 구현 폴더가 책임 단위로 계속 정리될 수 있어야 하기 때문이다. 외부 제품이 의존할 수 있는 경계는 package entrypoint다.

2. 문서 만들기

가장 흔한 시작점은 createJSONDocument다.

import { z } from "zod";
import { createJSONDocument } from "@interactive-os/json-document";

const Card = z.object({
  id: z.string(),
  title: z.string().min(1),
  done: z.boolean(),
});

const doc = createJSONDocument(Card, {
  id: "card-1",
  title: "Draft",
  done: false,
}, {
  history: 100,
  selection: true,
});

공개 시그니처:

function createJSONDocument<S extends z.ZodType>(
  schema: S,
  initial: z.output<S>,
  options: JSONDocumentOptions & { trustedInitial: true },
): JSONDocument<z.output<S>>;

function createJSONDocument<S extends z.ZodType>(
  schema: S,
  initial: z.input<S>,
  options?: JSONDocumentOptions & { trustedInitial?: false | undefined },
): JSONDocument<z.output<S>>;
interface JSONDocumentOptions {
  strict?: boolean | undefined;
  onError?: (error: JSONDocumentError) => void;
  trustedInitial?: boolean | undefined;
  history?: number;
  selection?: boolean | SelectionOptions;
  onChange?: () => void;
}

trustedInitial: true는 초기값 schema parse를 건너뛴다. 호출자가 이미 검증 경계를 소유할 때만 쓴다.

이 trusted/untrusted 초기값 overload는 1.0 public signature contract의 일부다. Schema input과 output이 다른 Zod transform을 쓰는 제품은 이 구분에 의존할 수 있다.

설계 의도: document는 항상 Zod schema를 기준으로 만들어진다. 제품이 다루는 값은 검증되지 않은 JSON이 아니라 schema를 통과한 z.output<S> 상태다. 초기값이 외부 입력이면 기본 parse 경계를 통과하고, 이미 검증된 내부 snapshot이면 trustedInitial: true로 비용을 줄일 수 있다.

history, selection, onChange는 optional이다. 단순 JSON state manager로 쓸 때는 꺼둘 수 있고, editor나 CMS처럼 사용자 조작을 추적해야 할 때만 켠다.

3. React

React에서는 같은 document 공개 표면을 hook으로 받는다.

import { useJSONDocument } from "@interactive-os/json-document/react";

function Editor() {
  const doc = useJSONDocument(Card, initial, {
    history: 100,
    selection: true,
  });

  return <button onClick={() => doc.replace("/title", "Ready")} />;
}

React API:

function useJSONDocument<S extends z.ZodType>(
  schema: S,
  initial: z.output<S>,
  options: JSONDocumentOptions & { trustedInitial: true },
): JSONDocument<z.output<S>>;

function useJSONDocument<S extends z.ZodType>(
  schema: S,
  initial: z.input<S>,
  options?: JSONDocumentOptions & { trustedInitial?: false | undefined },
): JSONDocument<z.output<S>>;

React API는 @interactive-os/json-document/react에만 있다. Root package는 headless다.

설계 의도: React adapter는 core document 공개 표면을 React lifecycle에 맞춰 제공하는 얇은 entrypoint다. React 제품에서도 command, result, schema, selection 의미는 core와 동일하게 유지된다.

root package가 React를 import하지 않기 때문에 core는 non-React app, package extension, test, server-side utility에서도 같은 방식으로 쓸 수 있다.

4. 현재 값 읽기

doc.value는 현재 schema를 통과한 JSON 상태다.

doc.value;
doc.at("/title");
doc.exists("/title");
doc.entries("");

읽기 result:

type ReadResult =
  | { ok: true; path: Pointer; value: unknown }
  | { ok: false; code: "invalid_pointer" | "path_not_found"; reason?: string; pointer: Pointer };

type EntryKind = "root" | "object" | "array" | "record" | "primitive";

interface ReadEntry {
  key: string;
  path: Pointer;
  value: unknown;
}

type EntriesResult =
  | {
      ok: true;
      path: Pointer;
      kind: EntryKind;
      entries: ReadonlyArray<ReadEntry>;
    }
  | { ok: false; code: "invalid_pointer" | "path_not_found"; reason?: string; pointer: Pointer };

설계 의도: 읽기 API는 throw보다 result 객체를 선호한다. UI는 pointer가 잘못됐는지, path가 없는지, 현재 값이 무엇인지 같은 상태를 렌더링 흐름 안에서 안전하게 분기할 수 있어야 한다.

entries("")가 root를 다룰 수 있는 것은 root document도 하나의 addressable node로 보기 때문이다. Root pointer는 빈 문자열 ""이고, object/array/record/primitive를 같은 entry model로 순회할 수 있다.

5. 검색하기

검색은 JSONPath를 받는다. 검색 결과는 JSON Pointer 목록이다.

const found = doc.find("$..cards[?(@.done==false)]");

if (found.ok) {
  for (const pointer of found.pointers) {
    doc.replace(`${pointer}/done`, true);
  }
}

검색 result:

type QueryResult =
  | { ok: true; query: string; pointers: Pointer[] }
  | { ok: false; code: "invalid_query"; reason?: string };

JSONPath는 변경 target이 아니다. 변경은 JSON Pointer로 한다.

설계 의도: JSONPath는 탐색 언어이고 JSON Pointer는 편집 주소다. 검색 결과를 pointer로 돌려주는 이유는 이후 replace, delete, move, commit 같은 mutation API가 모두 pointer를 기준으로 동작하기 때문이다.

제품 코드는 JSONPath를 저장된 query, filter, automation rule에 사용할 수 있다. 실제 변경 단계에서는 항상 pointer로 내려와야 하므로 query syntax가 mutation contract가 되지 않는다.

6. 먼저 물어보고 실행하기

제품 UI는 대부분 can* -> command -> result 흐름을 쓴다.

const can = doc.canReplace("/title", "Ready");

if (can.ok) {
  doc.replace("/title", "Ready");
} else {
  can.code;
  can.reason;
  can.violations;
}

can*는 boolean이 아니다. 실패 이유를 가진 JSONCapabilityResult를 반환한다.

type JSONCapabilityResult =
  | { ok: true }
  | {
      ok: false;
      code: CapabilityErrorCode;
      reason?: string;
      pointer?: Pointer;
      violations?: ReadonlyArray<CapabilityViolation>;
    };

interface CapabilityViolation {
  path: string;
  message: string;
}

설계 의도: can* API는 버튼 disabled 상태, menu 노출 여부, command palette, shortcut handling 같은 UI affordance를 위해 있다. 실패도 boolean 하나로 줄이지 않고 code, pointer, violations를 포함해 사용자가 왜 실행할 수 없는지 보여줄 수 있게 한다.

can*는 실행을 대체하지 않는다. 사용자가 클릭하기 전 state가 바뀔 수 있으므로 command는 항상 다시 검증하고 result를 반환한다.

7. 값 바꾸기

가장 많이 쓰는 편집 verb는 insert, replace, delete, move, duplicate다.

doc.insert("/items/-", { id: "new", title: "New" });
doc.replace("/title", "Ready");
doc.delete("/items/0");
doc.move("/items/0", "/items/2");
doc.duplicate("/items/0", {
  rekey: { fields: ["id"], strategy: "suffix" },
});

선택이 켜져 있으면 일부 overload는 현재 selection을 기본 source로 쓴다.

doc.replace("Ready");
doc.delete();
doc.move("/items/2");
doc.duplicate();

성공한 편집은 schema 검증을 통과한 뒤 commit된다. 실패하면 state, selection, clipboard, history가 부분적으로 바뀌지 않는다.

설계 의도: 이 verb들은 JSON Patch보다 제품 행동에 가깝다. 앱은 "선택한 카드 복제", "현재 제목 교체", "선택 영역 삭제" 같은 사용자 의도를 command로 표현하고, core는 이를 검증 가능한 patch로 낮춘다.

편집은 원자적으로 적용된다. schema 위반, pointer 실패, rekey 실패가 발생하면 document state뿐 아니라 selection, clipboard, history도 중간 상태로 남지 않는다.

8. 여러 변경을 한 번에 적용하기

알고 있는 여러 변경은 commit 또는 patch에 operation 배열로 넘긴다.

doc.commit([
  { op: "replace", path: "/title", value: "Ready" },
  { op: "replace", path: "/done", value: true },
], {
  label: "complete card",
  origin: "keyboard",
});

Patch type:

type JSONPatchInput = JSONPatchOperation | ReadonlyArray<JSONPatchOperation>;

type JSONPatchOperation =
  | { op: "add"; path: Pointer; value: unknown }
  | { op: "remove"; path: Pointer }
  | { op: "replace"; path: Pointer; value: unknown }
  | { op: "move"; from: Pointer; path: Pointer }
  | { op: "copy"; from: Pointer; path: Pointer }
  | { op: "test"; path: Pointer; value: unknown };

Result:

type JSONResult =
  | { ok: true }
  | {
      ok: false;
      code: ErrorCode;
      reason?: string;
      pointer?: Pointer;
    };

Semantic contract에서 고정하는 것은 reason 문구가 아니라 result branch와 stable code다. invalid_pointer, schema_violation, empty_stack, empty_clipboard, not_serializable 같은 code는 제품 분기와 localization의 기준이다. 자세한 고정 범위는 1.0 Semantic Contract를 본다.

설계 의도: 여러 operation은 하나의 사용자 의도일 수 있다. 예를 들어 checkbox 완료 처리는 title, status, timestamp를 동시에 바꾸지만 history에는 하나의 command로 남아야 한다. commit은 이런 묶음을 명시하는 API다.

operation 배열은 순서대로 평가되지만 전체 결과는 하나다. 중간 operation이 실패하면 이후 operation까지 포함한 전체 변경은 적용되지 않는다.

9. JSONDocument<T> 전체 표면

제품이 가장 많이 의존하는 중심 interface다.

interface JSONDocument<T> {
  readonly value: T;
  readonly lastPatch: ReadonlyArray<JSONPatchOperation>;
  readonly selection: SelectionState | undefined;
  readonly history: JSONDocumentHistory;
  readonly clipboard: ClipboardState<T>;
  readonly schema: SchemaState;

  patch(operations: JSONPatchInput, metadata?: JSONChangeMetadata): JSONResult;
  commit(
    operations: ReadonlyArray<JSONPatchOperation>,
    options?: JSONDocumentCommitOptions,
  ): JSONResult;

  find(jsonpath: string): QueryResult;

  insert(path: Pointer, value: unknown): JSONDocumentEditResult;
  insert(value: unknown): JSONDocumentEditResult;

  replace(path: Pointer, value: unknown): JSONDocumentEditResult;
  replace(value: unknown): JSONDocumentEditResult;

  delete(source?: SelectionSource): JSONDocumentEditResult;

  move(source: Pointer, target: Pointer): JSONDocumentEditResult;
  move(target: Pointer): JSONDocumentEditResult;

  duplicate(
    source: Pointer,
    options?: JSONDocumentDuplicateOptions,
  ): JSONDocumentDuplicateResult<T>;
  duplicate(options?: JSONDocumentDuplicateOptions): JSONDocumentDuplicateResult<T>;

  copy(source?: SelectionSource, options?: ClipboardCopyOptions): ClipboardCopyResult;
  cut(source?: SelectionSource, options?: ClipboardCutOptions): ClipboardCutResult<T>;
  paste(
    target?: JSONDocumentPasteTarget,
    options?: JSONDocumentPasteOptions,
  ): ClipboardPasteResult<T>;

  undo(): JSONCapabilityResult;
  redo(): JSONCapabilityResult;

  load(value: unknown, options?: { preserveHistory?: boolean }): JSONResult;
  reset(value?: unknown): JSONResult;

  subscribe(listener: (
    applied: ReadonlyArray<JSONPatchOperation>,
    metadata?: JSONChangeMetadata,
  ) => void): () => void;

  at(path: Pointer): ReadResult;
  exists(path: Pointer): boolean;
  query(jsonpath: string): QueryResult;
  entries(path: Pointer): EntriesResult;

  canPatch(operations: JSONPatchInput): JSONCapabilityResult;
  canFind(jsonpath: string): JSONCapabilityResult;
  canInsert(value: unknown): JSONCapabilityResult;
  canInsert(path: Pointer, value: unknown): JSONCapabilityResult;
  canReplace(value: unknown): JSONCapabilityResult;
  canReplace(path: Pointer, value: unknown): JSONCapabilityResult;
  canDelete(source?: SelectionSource): JSONCapabilityResult;
  canMove(target: Pointer): JSONCapabilityResult;
  canMove(source: Pointer, target: Pointer): JSONCapabilityResult;
  canDuplicate(source: Pointer, options?: JSONDocumentDuplicateOptions): JSONCapabilityResult;
  canDuplicate(options?: JSONDocumentDuplicateOptions): JSONCapabilityResult;
  canCopy(source?: SelectionSource): JSONCapabilityResult;
  canCut(source?: SelectionSource): JSONCapabilityResult;
  canPaste(target?: JSONDocumentPasteTarget, options?: JSONDocumentPasteOptions): JSONCapabilityResult;
  canUndo(): JSONCapabilityResult;
  canRedo(): JSONCapabilityResult;
}
type JSONDocumentEditResult =
  | JSONResult
  | Extract<JSONCapabilityResult, { ok: false }>;

설계 의도: JSONDocument<T>는 제품 코드가 붙잡는 유일한 runtime object다. state, command, selection, history, clipboard, schema query가 같은 instance에 모여 있는 이유는 사용자 조작 하나가 이 상태들을 함께 바꾸기 때문이다.

이 interface는 headless다. rendering, persistence, remote sync, permission check, analytics는 제품이나 adapter가 소유한다. core는 변경 가능한 JSON document의 일관성과 public command contract만 책임진다.

10. 실패 code

앱은 reason 문자열이 아니라 code로 분기한다.

ErrorCode:

"invalid_pointer"
"path_not_found"
"move_into_self"
"schema_violation"
"test_failed"
"not_serializable"

추가 capability code:

"preflight_failed"
"discriminator_mismatch"
"rekey_failed"
"missing_new_key"
"key_conflict"
"empty_selection"
"empty_scope"
"empty_match"
"cursor_boundary"
"syntax_error"
"empty_stack"
"apply_failed"
"empty_clipboard"
"missing_length"
"multi_pointer_range"
"overlapping_ranges"
"not_string"
"point_not_in_order"

reason은 진단 문구다. 정확한 문구는 호환성 보장 대상이 아니다.

설계 의도: code는 제품 로직과 localization이 의존하는 값이다. reason은 개발자 진단, logging, debug UI를 위한 문구라서 앱의 조건문 기준으로 쓰지 않는다.

실패는 가능한 한 throw가 아니라 result로 전달된다. 사용자가 만든 pointer, paste payload, selection 상태처럼 정상적인 제품 흐름에서 실패할 수 있는 입력은 recoverable failure로 다룬다.

11. 선택 상태

Selection은 DOM focus가 아니라 JSON document 위의 headless 주소 상태다.

doc.selection?.selectRanges(["/items/0", "/items/1"]);
doc.copy(doc.selection?.selectedSource ?? []);

Selection 기본 타입:

type SelectionMode = "single" | "multiple" | "extended";
type SelectionEdge = "before" | "after";
type SelectionAffinity = "forward" | "backward";
type SelectionType = "None" | "Caret" | "Range";
type SelectionSource = Pointer | ReadonlyArray<Pointer>;

type JSONValue =
  | string
  | number
  | boolean
  | null
  | { readonly [key: string]: JSONValue }
  | ReadonlyArray<JSONValue>;

type SelectionContext = JSONValue;

interface SelectionPointObject {
  path: Pointer;
  offset?: number;
  edge?: SelectionEdge;
  affinity?: SelectionAffinity;
}

type SelectionPoint = Pointer | SelectionPointObject;

interface SelectionRange {
  anchor: SelectionPoint;
  focus: SelectionPoint;
}

type SelectionRangeInput = SelectionPoint | SelectionRange;

interface SelectionSnap {
  selectedPointers: ReadonlyArray<Pointer>;
  selectionRanges: ReadonlyArray<SelectionRange>;
  primaryIndex: number;
  anchor: SelectionPoint | null;
  focus: SelectionPoint | null;
  context?: SelectionContext | undefined;
}

interface SelectionOptions {
  mode?: SelectionMode;
  initial?: ReadonlyArray<SelectionRangeInput>;
  context?: SelectionContext;
}

SelectionState 표면:

interface SelectionState extends SelectionSnap {
  readonly rangeCount: number;
  readonly selectedCount: number;
  readonly hasSelection: boolean;
  readonly isCollapsed: boolean;
  readonly type: SelectionType;
  readonly primaryRange: SelectionRange | null;
  readonly anchorPointer: Pointer | null;
  readonly focusPointer: Pointer | null;
  readonly selectedSource: SelectionSource | null;
  readonly primaryPointer: Pointer | null;
  readonly caret: SelectionPoint | null;
  readonly caretPointer: Pointer | null;
  readonly context: SelectionContext | undefined;

  collapse(point: SelectionPoint): void;
  setBaseAndExtent(anchor: SelectionPoint, focus: SelectionPoint): void;
  extend(point: SelectionPoint): void;
  addRange(pointOrRange: SelectionPoint | SelectionRange): void;
  removeRange(pointOrRangeOrIndex: SelectionPoint | SelectionRange | number): void;
  toggleRange(pointOrRange: SelectionPoint | SelectionRange): void;
  togglePointer(pointer: Pointer): void;

  moveCursor(direction: SelectionCursorDirection, options?: SelectionCursorOptions): SelectionCursorResult;
  extendCursor(direction: SelectionCursorDirection, options?: SelectionCursorOptions): SelectionCursorResult;
  resolveCursor(direction: SelectionCursorDirection, options?: SelectionCursorOptions): SelectionCursorResult;

  orderPrimaryRange(options?: SelectionOrderOptions): SelectionRangeOrderResult;
  orderRanges(options?: SelectionOrderOptions): SelectionRangesOrderResult;
  spansForPointer(pointer: Pointer, options?: SelectionSpanOptions): SelectionPointerSpansResult;

  textEdits(replacement: string, options?: SelectionTextEditOptions): SelectionTextEditsResult;
  textPatch(replacement: string, options?: SelectionTextEditOptions): ReplaceSelectionTextResult;
  deleteText(options?: SelectionTextDeleteOptions): DeleteSelectionTextResult;

  selectScope(options?: SelectionScopeOptions): SelectionScopeResult;
  resolveScope(options?: SelectionScopeOptions): SelectionScopeTarget;

  selectRanges(
    ranges: ReadonlyArray<SelectionRangeInput>,
    anchor?: SelectionPoint | null,
    focus?: SelectionPoint | null,
    primaryIndex?: number,
  ): void;

  setContext(context: SelectionContext): void;
  clearContext(): void;
  empty(): void;
  isSelected(pointer: Pointer): boolean;
  snapshot(): SelectionSnap;
  toJSON(): SelectionSnap;
  restore(snapshot: SelectionSnap): void;
  subscribe(listener: (snapshot: SelectionSnap, previous: SelectionSnap) => void): () => void;
}

설계 의도: selection은 DOM selection을 추상화한 것이 아니라 JSON document 위의 address state다. 같은 document를 list editor, form editor, outline, review UI가 서로 다른 방식으로 렌더링해도 selection 의미는 pointer와 range로 유지된다.

selection을 optional로 둔 이유는 모든 JSON document가 editor는 아니기 때문이다. automation이나 server-side transform은 selection 없이도 core를 쓸 수 있고, editor 제품은 selection을 켜서 copy, cut, delete, move 같은 command의 기본 source로 사용할 수 있다.

12. 복사, 잘라내기, 붙여넣기

Clipboard는 browser clipboard가 아니라 document instance가 가진 headless buffer다.

doc.copy(["/items/0", "/items/1"]);
doc.paste("/items/-");

doc.paste("/items/-", {
  payload: { id: "external", title: "External" },
});

Clipboard 표면:

type ClipboardSource = SelectionSource;

interface JSONDocumentPasteOptions {
  payload?: unknown;
  rekey?: {
    fields: string[];
    strategy:
      | "suffix"
      | "uuid"
      | ((value: unknown, context: {
          field: string;
          existing: ReadonlySet<string>;
          attempt: number;
        }) => string);
  };
  spread?: boolean;
  trustedPayload?: boolean;
}

type JSONDocumentPasteTarget =
  | Pointer
  | { before: Pointer }
  | { after: Pointer }
  | { replace: Pointer };

interface ClipboardWriteOptions {
  source?: Pointer | null;
  sources?: ReadonlyArray<Pointer> | null;
  trustedPayload?: boolean;
  clonePayload?: boolean;
}

interface ClipboardReadOptions {
  clonePayload?: boolean;
}

interface ClipboardCopyOptions {
  clonePayload?: boolean;
}

interface ClipboardCutOptions {
  clonePayload?: boolean;
}

interface ClipboardReadOk {
  ok: true;
  payload: unknown;
  source: Pointer | null;
  sources: ReadonlyArray<Pointer> | null;
}

interface ClipboardEmpty {
  ok: false;
  code: "empty_clipboard";
  reason: string;
}

type ClipboardReadResult = ClipboardReadOk | ClipboardEmpty;

interface ClipboardMutationOk<T> {
  ok: true;
  value: T;
  applied: ReadonlyArray<JSONPatchOperation>;
}

interface ClipboardCutOk<T> extends ClipboardMutationOk<T> {
  payload: unknown;
  source: Pointer;
  sources: ReadonlyArray<Pointer>;
}

interface ClipboardState<T> {
  readonly hasData: boolean;
  readonly source: Pointer | null;
  readonly sources: ReadonlyArray<Pointer> | null;

  read(options?: ClipboardReadOptions): ClipboardReadResult;
  write(payload: unknown, options?: ClipboardWriteOptions): JSONResult;
  clear(): void;

  copy(source?: ClipboardSource, options?: ClipboardCopyOptions): ClipboardCopyResult;
  cut(source?: ClipboardSource, options?: ClipboardCutOptions): ClipboardCutResult<T>;
  paste(target?: JSONDocumentPasteTarget, options?: JSONDocumentPasteOptions): ClipboardPasteResult<T>;
}

여러 source를 copy한 뒤 array insert target에 paste하면 기본으로 펼쳐진다. 직접 payload를 넣은 paste는 spread: true를 명시해야 펼쳐진다.

설계 의도: core clipboard는 browser clipboard adapter가 아니다. browser clipboard, native clipboard, cross-tab sync는 외부 adapter가 붙일 수 있고, core는 document instance 안에서 구조화된 JSON payload와 source pointer metadata를 보존한다.

copy로 만든 multi-source payload와 직접 전달한 external payload를 구분하는 이유는 안전한 기본값 때문이다. document 안에서 복사한 array item들은 붙여넣을 때 자연스럽게 펼칠 수 있지만, 외부 payload는 호출자가 spread: true를 명시해야 의도치 않은 구조 변경을 피할 수 있다.

13. Undo, redo, history

제품 command는 보통 top-level doc.undo()doc.redo()를 쓴다.

doc.undo();
doc.redo();
doc.history.transaction({ label: "bulk edit" }, () => {
  doc.replace("/title", "Ready");
  doc.replace("/done", true);
});

History 표면:

interface HistoryTransactionOptions {
  label?: string;
  origin?: "keyboard" | "pointer" | "programmatic" | string;
  mergeKey?: string;
}

interface JSONChangeMetadata extends HistoryTransactionOptions {
  selectionBefore?: SelectionSnap;
  selectionAfter?: SelectionSnap;
}

interface JSONDocumentCommitOptions extends HistoryTransactionOptions {
  selection?: SelectionSnap;
}

interface JSONDocumentHistory {
  readonly canUndo: boolean;
  readonly canRedo: boolean;
  readonly undoDepth: number;
  readonly redoDepth: number;

  undo(): boolean;
  redo(): boolean;
  mergeLast(options?: { mergeKey?: string }): boolean;
  transaction(fn: () => void): void;
  transaction(options: HistoryTransactionOptions, fn: () => void): void;
}

doc.undo()doc.redo()JSONCapabilityResult를 반환한다. doc.history.undo()doc.history.redo()는 lower-level control이라 boolean을 반환한다.

설계 의도: history는 patch log만이 아니라 사용자 의도 단위의 복구 모델이다. label, origin, mergeKey는 keyboard repeat, pointer drag, programmatic update처럼 서로 다른 입력 흐름을 제품이 같은 history UX로 묶기 위한 metadata다.

top-level doc.undo()는 제품 command 공개 표면과 같은 result style을 따른다. lower-level doc.history는 adapter나 고급 제어를 위해 boolean API를 유지한다.

14. Schema 확인

Form, import review, paste guard는 doc.schema를 쓴다.

doc.schema.kind("/title");
doc.schema.describe("/items/-", "insert");
doc.schema.accepts("/title", "Ready");

Schema 표면:

interface SchemaState {
  at(path: Pointer, mode?: SchemaPathMode): SchemaQueryResult;
  kind(path: Pointer, mode?: SchemaPathMode): SchemaKindResult;
  accepts(path: Pointer, value: unknown, mode?: SchemaPathMode): JSONCapabilityResult;
  describe(path: Pointer, mode?: SchemaPathMode): SchemaDescriptionResult;
}

type SchemaPathMode = "value" | "insert";

type SchemaKind =
  | "unknown"
  | "string"
  | "number"
  | "boolean"
  | "null"
  | "literal"
  | "enum"
  | "object"
  | "array"
  | "record"
  | "union"
  | "discriminatedUnion"
  | "optional"
  | "nullable"
  | "any";

interface SchemaDescription {
  kind: SchemaKind;
  jsonSchema: unknown;
  keys?: string[];
  elementKind?: SchemaKind;
  valueKind?: SchemaKind;
  discriminator?: string;
  allowed?: unknown[];
}

type SchemaErrorCode = "invalid_pointer" | "path_not_found";

interface SchemaErrorResult {
  ok: false;
  code: SchemaErrorCode;
  reason?: string;
  pointer: Pointer;
}

type SchemaQueryResult =
  | {
      ok: true;
      path: Pointer;
      mode: SchemaPathMode;
      kind: SchemaKind;
      description: SchemaDescription;
    }
  | SchemaErrorResult;

type SchemaKindResult =
  | {
      ok: true;
      path: Pointer;
      mode: SchemaPathMode;
      kind: SchemaKind;
    }
  | SchemaErrorResult;

type SchemaDescriptionResult =
  | {
      ok: true;
      path: Pointer;
      mode: SchemaPathMode;
      description: SchemaDescription;
    }
  | SchemaErrorResult;

schema.accepts의 violation path는 schema-slot 기준이다. mutation preflight와 execution failure는 document-result 기준이다.

설계 의도: doc.schema는 form generator가 아니라 runtime affordance API다. 제품은 어떤 path가 string인지, array item을 넣을 수 있는지, paste payload가 해당 slot에 들어갈 수 있는지 정도를 빠르게 물어볼 수 있다.

value mode와 insert mode를 나눈 이유는 기존 값의 schema와 새 값을 넣을 위치의 schema가 다를 수 있기 때문이다. 예를 들어 /items/0은 현재 item을 가리키고, /items/-는 새 item이 들어갈 slot을 가리킨다.

15. JSON Pointer helper

Adapter가 사용자 입력 path를 받을 때는 먼저 Pointer로 확인한다.

const segments = tryParsePointer(input);
if (segments === null) return;

const path = buildPointer(["items", 0, "title"]);

Pointer API:

type Pointer = string;

function parsePointer(pointer: Pointer): string[];
function tryParsePointer(pointer: Pointer): string[] | null;
function buildPointer(
  segments: ReadonlyArray<string | number>,
  options?: { uriFragment?: boolean },
): Pointer;

function escapeSegment(segment: string): string;
function unescapeSegment(segment: string): string;

function parentPointer(pointer: Pointer): Pointer | null;
function lastSegment(pointer: Pointer): string | null;
function lastSegmentIndex(pointer: Pointer): number | null;
function appendSegment(pointer: Pointer, segment: string | number): Pointer;
function withLastSegment(pointer: Pointer, segment: string | number): Pointer | null;

class PointerSyntaxError extends Error {}

Root pointer는 빈 문자열 ""이다.

설계 의도: pointer는 core의 공통 주소 형식이다. editor selection, JSON Patch, schema query, clipboard source, tracking helper가 모두 같은 주소 체계를 쓰면 adapter 사이에서 state를 옮겨도 의미가 유지된다.

문자열 조합으로 pointer를 직접 만들면 escaping bug가 쉽게 생긴다. buildPointer, appendSegment, escapeSegment를 쓰면 /, ~가 들어간 key도 같은 규칙으로 처리된다.

16. Pointer tracking과 sibling range

Selection, annotation, id resolver adapter는 patch 이후 pointer를 추적할 수 있다.

const nextPointer = trackPointer("/items/0", doc.lastPatch);

Tracking API:

function trackPointer(
  pointer: Pointer,
  applied: ReadonlyArray<JSONPatchOperation>,
): Pointer | null;

Sibling item 선택을 parent와 index run으로 정규화할 때는 resolveSiblingRange를 쓴다.

type SiblingRangeErrorCode =
  | "empty_selection"
  | "invalid_pointer"
  | "not_array_item"
  | "mixed_parent"
  | "non_contiguous";

interface SiblingLocation {
  pointer: Pointer;
  parent: Pointer;
  index: number;
}

type SiblingRangeResult =
  | {
      ok: true;
      parent: Pointer;
      locations: ReadonlyArray<SiblingLocation>;
      contiguous: boolean;
    }
  | {
      ok: false;
      code: SiblingRangeErrorCode;
      reason: string;
      pointer?: Pointer;
    };

interface ResolveSiblingRangeOptions {
  dedupe?: boolean;
  pruneDescendants?: boolean;
  requireContiguous?: boolean;
}

function resolveSiblingRange(
  source: Pointer | ReadonlyArray<Pointer>,
  options?: ResolveSiblingRangeOptions,
): SiblingRangeResult;

resolveSiblingRange는 document state를 읽지 않는 순수 path helper다.

설계 의도: document 변경 후에도 selection, annotation, comment anchor, external id mapping 같은 보조 상태는 가능한 한 같은 논리적 대상을 따라가야 한다. trackPointer는 applied patch를 기준으로 pointer를 갱신하고, 삭제된 target은 null로 표현한다.

resolveSiblingRange는 move, cut, duplicate처럼 같은 parent 아래의 연속 item을 다루는 command를 위한 준비 단계다. document 값을 읽지 않는 helper라서 selection adapter, command planner, test code에서 가볍게 재사용할 수 있다.

17. Low-level patch helper

Document instance 없이 schema와 state만으로 patch를 적용할 때 쓴다.

const result = applyPatch(Schema, state, [
  { op: "replace", path: "/title", value: "Ready" },
]);

Helper:

interface ApplyResult<S extends z.ZodTypeAny> {
  state: z.output<S>;
  result: JSONResult;
  applied: ReadonlyArray<JSONPatchOperation>;
}

function applyOperation<S extends z.ZodTypeAny>(
  schema: S,
  state: z.output<S>,
  op: JSONPatchOperation,
): ApplyResult<S>;

function applyPatch<S extends z.ZodTypeAny>(
  schema: S,
  state: z.output<S>,
  ops: ReadonlyArray<JSONPatchOperation>,
): ApplyResult<S>;

function applyPatchToTrustedState<S extends z.ZodTypeAny>(
  schema: S,
  state: z.output<S>,
  ops: ReadonlyArray<JSONPatchOperation>,
): ApplyResult<S>;

설계 의도: low-level helper는 document instance가 필요 없는 환경을 위한 API다. server-side transform, import pipeline, unit test, custom adapter는 selection/history/clipboard 없이 schema와 state만으로 patch 결과를 확인할 수 있다.

제품 editor에서는 보통 JSONDocument<T> command를 먼저 쓴다. low-level helper는 더 작은 책임 범위가 필요할 때 선택한다.

18. Runtime exports

아래 runtime 이름은 public entrypoint에서 제공되는 공개 export다. 1.x 범위에서는 제거, rename, 의미 변경 없이 하위 호환성을 유지한다.

JSONDocumentError
PointerSyntaxError
appendSegment
applyOperation
applyPatch
applyPatchToTrustedState
buildPointer
createJSONDocument
escapeSegment
lastSegment
lastSegmentIndex
parentPointer
parsePointer
resolveSiblingRange
trackPointer
tryParsePointer
unescapeSegment
withLastSegment
useJSONDocument

설계 의도: 이 목록은 제품 코드가 runtime에서 import할 수 있는 이름의 기준이다. 문서 본문에 예시로 나온 내부 alias나 설명용 type이 있더라도 import 기준은 이 export 목록과 package entrypoint다.

19. Type exports

아래 type 이름은 public entrypoint에서 제공되는 공개 export다. 1.x 범위에서는 제거, rename, 의미 변경 없이 하위 호환성을 유지한다.

HistoryTransactionOptions
JSONCapabilityResult
JSONChangeMetadata
JSONDocument
JSONDocumentCommitOptions
JSONDocumentDuplicateError
JSONDocumentDuplicateOptions
JSONDocumentDuplicateResult
JSONDocumentHistory
JSONDocumentOptions
JSONDocumentPasteOptions
JSONDocumentPasteTarget
JSONPatchInput
JSONPatchOperation
SelectionPoint
JSONResult
Pointer
ClipboardCopyOptions
ClipboardCopyError
ClipboardCopyOk
ClipboardCopyResult
ClipboardCutError
ClipboardCutOk
ClipboardCutOptions
ClipboardCutResult
ClipboardEmpty
ClipboardMutationOk
ClipboardPasteDiscriminatorMismatch
ClipboardPasteError
ClipboardPasteResult
ClipboardReadOk
ClipboardReadOptions
ClipboardReadResult
ClipboardState
ClipboardWriteOptions
EntriesResult
EntryKind
QueryResult
ReadEntry
ReadResult
ResolveSiblingRangeOptions
SiblingLocation
SiblingRangeErrorCode
SiblingRangeResult
SchemaDescription
SchemaDescriptionResult
SchemaErrorCode
SchemaErrorResult
SchemaKind
SchemaKindResult
SchemaPathMode
SchemaQueryResult
SchemaState
SelectionOptions
SelectionPointObject
SelectionOrderedRange
SelectionOrderedRangeEntry
SelectionAffinity
SelectionContext
SelectionCursorDirection
SelectionCursorErrorCode
SelectionCursorOptions
SelectionCursorResult
SelectionCursorTarget
SelectionDirection
SelectionEdge
SelectionMode
SelectionOrderErrorCode
SelectionOrderOptions
SelectionPointOrderResult
SelectionPointerSpan
SelectionPointerSpansResult
SelectionRange
SelectionRangeInput
SelectionRangeOrderResult
SelectionRangesOrderResult
SelectionScopeErrorCode
SelectionScopeOptions
SelectionScopeResult
SelectionScopeTarget
SelectionSnap
SelectionSource
SelectionSpanOptions
SelectionState
SelectionType
DeleteSelectionTextResult
ReplaceSelectionTextResult
SelectionTextDeleteDirection
SelectionTextDeleteOptions
SelectionTextEdit
SelectionTextEditErrorCode
SelectionTextEditOptions
SelectionTextEditsResult
ClipboardSource

예시를 읽기 쉽게 만들기 위해 본문에 나온 구조적 alias는 추가 import 대상이 아니다. 위 목록과 public entrypoint가 import 기준이다.

설계 의도: type export는 제품 코드가 wrapper, adapter, extension package를 만들 때 공개 경계로 삼는 이름이다. type 이름을 명시적으로 적어두는 이유는 TypeScript 사용자가 구현 내부 파일을 열지 않고도 의존 가능한 공개 표면을 판단할 수 있게 하기 위해서다.

20. 1.x 호환성 보장

1.x에서는 이 문서의 공개 API를 제거, rename, 의미 변경하지 않는다.

호환성은 export 이름뿐 아니라 semantic fixture도 포함한다. Result code, 기본 strict: false, 실패 atomicity, clipboard spread, selection/history 복구는 1.0 Semantic Contract로 고정한다.

호환성을 깨는 변경:

  • export 제거
  • export, type, field, method, 에러 코드 rename
  • result 구조 변경
  • 원자성 변경
  • 기본 strict 동작 변경
  • clipboard spread 기본값 변경
  • selection tracking 의미 변경
  • private module import 요구

새 API는 기존 제품이 수정 없이 계속 동작할 때만 추가한다.

labs는 이 1.x 호환성 보장 범위에 포함되지 않는다. 공식 extension package는 별도 package이며 이 core 공개 표면 위에 의존한다.

설계 의도: 1.x 호환성은 이미 제품 코드가 이 core 위에 올라가 있다는 전제를 반영한다. 새 기능은 추가될 수 있지만, 기존 제품이 import path, result 분기, error code, selection 동작, clipboard 동작을 바꾸지 않아도 계속 동작해야 한다.

labs를 분리하는 이유는 실험 속도와 core 안정성을 동시에 유지하기 위해서다. 실험 package에서 검증된 기능은 별도 official extension이나 이후 core API로 승격될 수 있지만, 그 전까지는 이 문서의 보장 범위와 구분한다.