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
13 changes: 13 additions & 0 deletions docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,19 @@ doc.paste({ after: "/lists/0/cards/0" });

Pointer 배열을 copy/cut하면 clipboard payload도 배열입니다. 여러 source를 담은 clipboard buffer는 array 삽입 target에 기본으로 펼쳐집니다.

성공 result shape는 method family별로 다릅니다.

| Method | 성공 result |
| --- | --- |
| `insert`, `replace`, `delete`, `move`, `patch`, `commit` | `{ ok: true }`; 실제 patch는 `doc.lastPatch`, subscriber, history에 기록 |
| `copy` | `{ ok: true, payload, source, sources }` |
| `cut` | `{ ok: true, value, applied, payload, source, sources }` |
| `paste` | `{ ok: true, value, applied }` |
| `duplicate` | `{ ok: true, value, applied, duplicatedTo }` |

`applied`는 이미 commit된 JSON Patch입니다. 다시 `commit`하지 않습니다. 실패
result는 `reason` 문구가 아니라 stable `code`로 분기합니다.

## history

History는 document patch와 inverse patch를 기록합니다.
Expand Down
12 changes: 10 additions & 2 deletions docs/standard/result-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,22 @@ Clipboard와 structural command result는 `ok` discriminant를 공유하지만 A
| Family | 성공 payload |
| --- | --- |
| read/query/entries | 읽은 값 또는 pointer 목록 |
| copy | clipboard `payload`, source pointer |
| cut | next `value`, `applied`, clipboard `payload`, source pointer |
| `insert`, `replace`, `delete`, `move` | `JSONResult` 성공 `{ ok: true }`; 실제 commit patch는 `doc.lastPatch`, subscriber, history entry에 기록 |
| copy | clipboard `payload`, primary `source`, all `sources` |
| cut | next `value`, `applied`, clipboard `payload`, primary `source`, all `sources` |
| paste | next `value`, `applied` |
| duplicate | next `value`, `applied`, `duplicatedTo` |
| undo/redo | top-level document command는 `JSONCapabilityResult` |

Clipboard family와 structural result의 실패 diagnostic field는 `reason`이다.
Stable branch key는 `code`다.

`applied`를 가진 성공 result의 `value`는 commit 후 document value다. 같은
document instance에서 읽는 `doc.value`와 같은 최종 state를 가리킨다. 반대로
`insert`, `replace`, `delete`, `move`, `patch`, `commit`은 low-level
`JSONResult` family라 성공 result 자체에 `applied`를 싣지 않는다. 이 family의
호출자는 `doc.lastPatch`나 subscriber metadata를 통해 실제 applied patch를 읽는다.

`discriminator_mismatch`는 추가 정보를 제공한다.

```ts
Expand Down Expand Up @@ -188,6 +195,7 @@ throw 여부가 아니라 result contract의 `code`로 실패를 분류해야
- `ok` discriminant 제거 또는 의미 변경.
- 기존 error code 제거, rename, 의미 변경.
- 실패 result의 `code` 제거.
- `copy`/`cut`의 primary `source`와 all `sources` 의미 변경.
- `violations[].path`를 JSON Pointer가 아닌 형식으로 변경.
- `schema-slot`과 `document-result` violation path 기준 변경.
- 성공 mutation result에서 `applied` 의미를 실제 commit patch가 아닌 것으로 변경.
Expand Down
5 changes: 5 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ const duplicated = doc.duplicate("/lists/0/cards/0", {
```

`duplicate`, `doc.cut`, `doc.paste`는 즉시 mutate한다. 성공 결과의 `applied`는 이미 적용된 patch record이므로 다시 `commit`하지 않는다.
`insert`, `replace`, `delete`, `move`, `patch`, `commit`의 성공 result는 `JSONResult` family인 `{ ok: true }`다. 실제 applied patch는 `doc.lastPatch`, subscriber, history entry에서 읽는다.
`copy` 성공 result는 `{ ok: true, payload, source, sources }`다.
`cut` 성공 result는 `{ ok: true, value, applied, payload, source, sources }`다.
`paste` 성공 result는 `{ ok: true, value, applied }`다.
`duplicate` 성공 result는 `{ ok: true, value, applied, duplicatedTo }`다.

## 선택

Expand Down
158 changes: 158 additions & 0 deletions packages/json-document/tests/public/result-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, test } from "vitest";
import * as z from "zod";

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

const Item = z.object({
id: z.string(),
title: z.string(),
});

const Schema = z.object({
items: z.array(Item),
meta: z.record(z.string(), z.string()),
title: z.string(),
});

type Value = z.output<typeof Schema>;

const initial: Value = {
items: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
],
meta: { owner: "core" },
title: "Document",
};

function createDoc() {
return createJSONDocument(Schema, initial, {
history: 20,
selection: { mode: "multiple", initial: ["/items/0"] },
});
}

function expectOkKeys(result: unknown, keys: ReadonlyArray<string>) {
expect(result).toMatchObject({ ok: true });
expect(Object.keys(result as Record<string, unknown>).sort()).toEqual([...keys].sort());
}

describe("json-document 1.0 result contract", () => {
test("locks JSONResult mutation shape and lastPatch handoff", () => {
const doc = createDoc();

const insertPatch: JSONPatchOperation[] = [
{ op: "add", path: "/items/2", value: { id: "c", title: "C" } },
];
const inserted = doc.insert("/items/-", { id: "c", title: "C" });
expect(inserted).toEqual({ ok: true });
expect(doc.lastPatch).toEqual(insertPatch);

const replaced = doc.replace("/items/2/title", "C1");
expect(replaced).toEqual({ ok: true });
expect(doc.lastPatch).toEqual([{ op: "replace", path: "/items/2/title", value: "C1" }]);

const moved = doc.move("/items/2", { before: "/items/0" });
expect(moved).toEqual({ ok: true });
expect(doc.lastPatch).toEqual([{ op: "move", from: "/items/2", path: "/items/0" }]);

const deleted = doc.delete("/items/0");
expect(deleted).toEqual({ ok: true });
expect(doc.lastPatch).toEqual([{ op: "remove", path: "/items/0" }]);
});

test("locks clipboard and structural command success payloads", () => {
const doc = createDoc();

const copied = doc.copy(["/items/0", "/items/1"]);
expectOkKeys(copied, ["ok", "payload", "source", "sources"]);
if (!copied.ok) return;
expect(copied).toMatchObject({
payload: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
],
source: "/items/0",
sources: ["/items/0", "/items/1"],
});
expect(doc.value).toEqual(initial);
expect(doc.lastPatch).toEqual([]);

const pasted = doc.paste("/items/-");
expectOkKeys(pasted, ["ok", "value", "applied"]);
if (!pasted.ok) return;
expect(pasted.applied).toEqual([
{ op: "add", path: "/items/2", value: { id: "a", title: "A" } },
{ op: "add", path: "/items/3", value: { id: "b", title: "B" } },
]);
expect(pasted.value).toBe(doc.value);
expect(doc.lastPatch).toEqual(pasted.applied);

const duplicated = doc.duplicate("/items/0", {
rekey: { fields: ["id"], strategy: "suffix" },
});
expectOkKeys(duplicated, ["ok", "value", "applied", "duplicatedTo"]);
if (!duplicated.ok) return;
expect(duplicated.duplicatedTo).toBe("/items/1");
expect(duplicated.applied).toEqual([
{
op: "add",
path: "/items/1",
value: { id: "a-copy", title: "A" },
},
]);
expect(duplicated.value).toBe(doc.value);
expect(doc.lastPatch).toEqual(duplicated.applied);

const cut = doc.cut("/items/1");
expectOkKeys(cut, ["ok", "value", "applied", "payload", "source", "sources"]);
if (!cut.ok) return;
expect(cut).toMatchObject({
applied: [{ op: "remove", path: "/items/1" }],
payload: { id: "a-copy", title: "A" },
source: "/items/1",
sources: ["/items/1"],
});
expect(cut.value).toBe(doc.value);
expect(doc.lastPatch).toEqual(cut.applied);
});

test("locks failure code payloads without depending on reason text", () => {
const doc = createDoc();

expect(doc.at("items/0")).toMatchObject({
ok: false,
code: "invalid_pointer",
pointer: "items/0",
});
expect(doc.query("$.items[")).toMatchObject({
ok: false,
code: "invalid_query",
});
expect(doc.canUndo()).toMatchObject({
ok: false,
code: "empty_stack",
});
expect(doc.canReplace("/items/0/id", 1)).toMatchObject({
ok: false,
code: "schema_violation",
violations: [{ path: "/items/0/id", message: expect.any(String) }],
});
expect(doc.copy([])).toMatchObject({
ok: false,
code: "empty_selection",
});
expect(doc.clipboard.write({ fn: () => undefined })).toMatchObject({
ok: false,
code: "not_serializable",
});
expect(doc.paste("/items/-")).toMatchObject({
ok: false,
code: "empty_clipboard",
});
expect(doc.duplicate("/meta/owner")).toMatchObject({
ok: false,
code: "missing_new_key",
});
});
});
77 changes: 77 additions & 0 deletions packages/json-document/tests/public/signature-contract.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import {
applyPatch,
applyPatchToTrustedState,
createJSONDocument,
type ClipboardCopyError,
type ClipboardCopyOptions,
type ClipboardCopyResult,
type ClipboardCutError,
type ClipboardCutOptions,
type ClipboardCutResult,
type ClipboardEmpty,
type ClipboardPasteError,
type ClipboardPasteResult,
type ClipboardReadResult,
type ClipboardState,
type EntriesResult,
type JSONCapabilityResult,
Expand Down Expand Up @@ -101,6 +106,59 @@ type FixtureApplyResult = {
applied: ReadonlyArray<JSONPatchOperation>;
};

type ExpectedJSONResultCode =
| "invalid_pointer"
| "path_not_found"
| "move_into_self"
| "schema_violation"
| "test_failed"
| "not_serializable";

type ExpectedCapabilityErrorCode =
| ExpectedJSONResultCode
| "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";

type ExpectedCopyErrorCode =
| "empty_selection"
| "invalid_pointer"
| "path_not_found"
| "not_serializable";

type ExpectedCutErrorCode = ExpectedCopyErrorCode | ExpectedJSONResultCode | "preflight_failed";
type ExpectedPasteErrorCode =
| "empty_selection"
| "not_serializable"
| "rekey_failed"
| ExpectedJSONResultCode
| "preflight_failed";
type ExpectedDuplicateErrorCode =
| "empty_selection"
| "invalid_pointer"
| "path_not_found"
| "missing_new_key"
| "key_conflict"
| "not_serializable"
| "rekey_failed"
| ExpectedJSONResultCode
| "preflight_failed";

interface ExpectedPatchHelpers {
applyOperation(schema: FixtureSchema, state: FixtureRow, op: JSONPatchOperation): FixtureApplyResult;
applyPatch(schema: FixtureSchema, state: FixtureRow, ops: ReadonlyArray<JSONPatchOperation>): FixtureApplyResult;
Expand Down Expand Up @@ -189,6 +247,25 @@ type _applyPatchToTrustedStateSignature = Expect<
Equal<typeof applyPatchToTrustedState<FixtureSchema>, ExpectedPatchHelpers["applyPatchToTrustedState"]>
>;

type _jsonResultCodeCatalog = Expect<
Equal<Extract<JSONResult, { ok: false }>["code"], ExpectedJSONResultCode>
>;
type _capabilityResultCodeCatalog = Expect<
Equal<Extract<JSONCapabilityResult, { ok: false }>["code"], ExpectedCapabilityErrorCode>
>;
type _copyErrorCodeCatalog = Expect<Equal<ClipboardCopyError["code"], ExpectedCopyErrorCode>>;
type _cutErrorCodeCatalog = Expect<Equal<ClipboardCutError["code"], ExpectedCutErrorCode>>;
type _pasteErrorCodeCatalog = Expect<
Equal<ClipboardPasteError["code"], ExpectedPasteErrorCode>
>;
type _pasteEmptyCodeCatalog = Expect<Equal<ClipboardEmpty["code"], "empty_clipboard">>;
type _readEmptyCodeCatalog = Expect<
Equal<Extract<ClipboardReadResult, { ok: false }>["code"], "empty_clipboard">
>;
type _duplicateErrorCodeCatalog = Expect<
Equal<Extract<JSONDocumentDuplicateResult<FixtureRow>, { ok: false }>["code"], ExpectedDuplicateErrorCode>
>;

type _documentKeys = Expect<Equal<keyof JSONDocument<FixtureRow>, keyof ExpectedJSONDocument<FixtureRow>>>;
type _documentAcceptsExpectedSurface = Expect<
IsAssignable<JSONDocument<FixtureRow>, ExpectedJSONDocument<FixtureRow>>
Expand Down