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
2 changes: 1 addition & 1 deletion docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ doc.schema.accepts("/lists/0/cards/-", candidateCard, "insert");

큰 문서의 hot path는 document facade인 `doc.patch`, `doc.commit`, `doc.canPatch`를 기준으로 둡니다. 공개 `applyPatch`는 외부 JSON 경계입니다.

Document 내부 state는 신뢰된 document state입니다. schema가 구조만 가진 Zod schema이고 edit가 independent non-root `replace` 또는 array edit에 해당하면 document path는 더 좁은 검증 경로를 쓸 수 있습니다. Refinement, transform, check가 있는 schema는 전체 루트 schema 검증으로 돌아갑니다.
Document 내부 state는 신뢰된 document state입니다. schema가 구조만 가진 Zod schema이고 edit가 non-root `replace` batch(독립 경로와 순차 ancestor/descendant overlap 포함) 또는 지원되는 array edit에 해당하면 document path는 더 좁은 검증 경로를 쓸 수 있습니다. Leading `test` assertion이 붙은 guarded batch도 assertion 확인 뒤 같은 mutation 경로를 사용합니다. Overlapping `replace`의 history inverse도 operation별 이전 값과 undo 순서를 그대로 유지하는 copy-on-write 순회 구현을 사용합니다. Refinement, transform, check가 있는 schema는 전체 루트 schema 검증으로 돌아갑니다.

## 트리 편집 cookbook

Expand Down
7 changes: 7 additions & 0 deletions docs/public/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,13 @@ if (proposedChanges.canAccept("rename-title").ok) {
}
```

`canAccept`는 현재 상태를 설명하는 capability probe다. 실제 `accept`는 저장된 guard를
RFC 6902 `test`로 바꾸어 proposed operations 앞에 놓고 하나의 atomic patch로 실행한다.
따라서 schema validation 중 document revision이 바뀌어도 최신 상태에서 guard부터 다시
확인한다. Document state가 실제 publish되면 생성된 `test` operation도 `lastPatch`,
subscriber, history forward에 나타난다. 같은 proposal id는 `accept` 실행 동안 예약되어
document subscriber의 재진입이 proposal을 교체하거나 제거하지 못한다.

## comments

```ts
Expand Down
8 changes: 8 additions & 0 deletions docs/standard/foundation-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,11 @@ replace가 있어도 touched container를 commit당 한 번만 복제한다. 최
경로와 동일하게 유지해야 한다. Plain structural schema의 overlapping replace는 각
value가 target schema에 허용됨을 빠르게 증명할 수 있을 때만 이 bulk 경로로 승격하고,
그 외 배치는 기존 validation/error-order 경로로 되돌린다.

History inverse 계산도 같은 private sequential-replace COW seam을 재사용한다. 각 write
직전 값을 캡처해 기존 operation별 inverse와 undo 순서를 유지하며, inverse를 상위 path
하나로 압축하거나 public prepared/history 계약을 추가하지 않는다.

Guarded change가 leading RFC 6902 `test` assertion을 붙여도 plain structural schema에서는
assertion을 먼저 평가한 뒤 mutation suffix와 inverse를 같은 fast path에 위임한다. Assertion
실패나 지원하지 않는 suffix는 기존 full validation/error-order 경로가 정본이다.
2 changes: 1 addition & 1 deletion docs/standard/json-document-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ 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 path는 신뢰된 document state와 구조만 가진 Zod schema에서만 적용된다. 대상 schema는 refinement, transform, check가 없는 object, array, record, scalar validator다. 지원 edit는 independent non-root `replace`, array `add`/`remove`/`copy`/`move`, same-array `add`/`remove` batch다. `refine`, `superRefine`, transform, check가 있으면 의도적으로 전체 루트 schema 검증으로 돌아간다.
빠른 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
npm run perf:core
Expand Down
34 changes: 34 additions & 0 deletions packages/json-document/src/domain/schema/validation/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export function applyPatchWithLocalSchemaValidation<S extends z.ZodType>(
if (!isPlainStructuralSchema(schema)) return null;
const valuesTrusted = options.valuesTrusted === true;

const leadingTests = applyLeadingTestsWithLocalSchemaValidation(schema, state, ops, valuesTrusted);
if (leadingTests) return leadingTests;

const singleReplace = applySingleReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted);
if (singleReplace) return singleReplace;
const sameArrayFieldReplace = applySameArrayFieldReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted);
Expand Down Expand Up @@ -98,6 +101,37 @@ export function applyPatchWithLocalSchemaValidation<S extends z.ZodType>(
return applySequentialPatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted);
}

function applyLeadingTestsWithLocalSchemaValidation<S extends z.ZodType>(
schema: S,
state: z.output<S>,
operations: ReadonlyArray<JSONPatchOperation>,
valuesTrusted: boolean,
): ApplyResult<S> | null {
let testCount = 0;
while (testCount < operations.length && operations[testCount]?.op === "test") {
testCount += 1;
}
if (testCount === 0 || testCount === operations.length) return null;

const tests = operations.slice(0, testCount);
const asserted = valuesTrusted
? applyAcceptedPatch(state, tests)
: applyTrustedPatch(state, tests);
if (!asserted.result.ok) return failedLocalSchemaValidation(state, asserted.result);

const mutation = applyPatchWithLocalSchemaValidation(
schema,
state,
operations.slice(testCount),
{ valuesTrusted },
);
if (mutation === null || !mutation.result.ok) return null;
return okLocalSchemaValidation(mutation.state, [
...asserted.applied,
...mutation.applied,
]);
}

// schema-인지 trusted-state patch 적용: 먼저 local schema validation 경로를 시도하고,
// 다루지 못하는 형태면 foundation 의 trusted-state core 로 위임한다.
export function applyPatchToTrustedState<S extends z.ZodTypeAny>(
Expand Down
2 changes: 1 addition & 1 deletion packages/json-document/src/foundation/patch/fast/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import {
removedRootKeysMatchSuffix,
} from "../object.js";
import { validateOperationShape } from "../apply.js";
import { applySequentialReplacePatch } from "../sequentialReplace.js";
import {
applyAppendOnlyAddPatch,
applySameArrayStructuralPatch,
applyTailRemovePatch,
} from "./array.js";
import {
applyIndependentReplacePatch,
applySequentialReplacePatch,
applySameArrayElementReplacePatch,
applySameArrayFieldReplacePatch,
applySameArrayNestedReplacePatch,
Expand Down
128 changes: 0 additions & 128 deletions packages/json-document/src/foundation/patch/fast/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
arrayRemoveLocation,
arrayFieldText,
indexDirection,
numericSegment,
parseArrayFieldPath,
parseFirstArrayNestedPath,
parseKnownArrayNestedIndex,
Expand Down Expand Up @@ -277,133 +276,6 @@ export function applyIndependentReplacePatch(
return { handled: true, state: applyReplaceTree(state, buildReplaceTree(items)), applied: items.map((item) => item.op) };
}

interface SequentialReplaceOperation {
op: Extract<JSONPatchOperation, { op: "replace" }>;
segments: string[];
}

/**
* Applies an overlapping replace batch through a private copy-on-write draft.
*
* Earlier replace strategies cover uniform and independent paths. This fallback
* preserves RFC 6902 operation order for ancestor/descendant and repeated paths
* while cloning each container at most once for the lifetime of the batch.
* The draft is never published on failure, so the caller's state and values stay
* immutable.
*/
export function applySequentialReplacePatch(
state: unknown,
ops: ReadonlyArray<JSONPatchOperation>,
valuesTrusted = false,
): FastPatchResult {
if (ops.length < 2) return { handled: false };

const prepared = new Array<SequentialReplaceOperation>(ops.length);
for (let index = 0; index < ops.length; index += 1) {
if (!(index in ops)) return { handled: false };
const op = ops[index]!;
if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") {
return { handled: false };
}
if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false };
const parsed = parseSafe(op.path);
if (!("ok" in parsed)) return { handled: false };
prepared[index] = { op, segments: parsed.segs };
}

const draftContainers = new WeakSet<object>();
let draft = state;
for (const operation of prepared) {
const replaced = replaceDraftValue(draft, operation.segments, operation.op.value, draftContainers);
if (replaced === null) return { handled: false };
draft = replaced;
}

return {
handled: true,
state: draft,
applied: prepared.map((operation) => operation.op),
};
}

function replaceDraftValue(
state: unknown,
segments: ReadonlyArray<string>,
value: unknown,
draftContainers: WeakSet<object>,
): unknown | null {
if (segments.length === 0) return null;
const root = ensureDraftContainer(state, draftContainers);
if (root === null) return null;

let current = root;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index]!;
const child = readDraftChild(current, segment);
if (!child.ok) return null;
const childDraft = ensureDraftContainer(child.value, draftContainers);
if (childDraft === null) return null;
if (childDraft !== child.value) writeDraftChild(current, child.key, childDraft);
current = childDraft;
}

const target = readDraftChild(current, segments[segments.length - 1]!);
if (!target.ok) return null;
writeDraftChild(current, target.key, value);
return root;
}

type DraftContainer = unknown[] | Record<string, unknown>;

function ensureDraftContainer(
value: unknown,
draftContainers: WeakSet<object>,
): DraftContainer | null {
if (value === null || typeof value !== "object") return null;
if (draftContainers.has(value)) return value as DraftContainer;
const draft: DraftContainer = Array.isArray(value)
? value.slice()
: { ...(value as Record<string, unknown>) };
draftContainers.add(draft);
return draft;
}

function readDraftChild(
container: DraftContainer,
segment: string,
): { ok: true; key: number | string; value: unknown } | { ok: false } {
if (Array.isArray(container)) {
const index = numericSegment(segment);
return index === null || index >= container.length
? { ok: false }
: { ok: true, key: index, value: container[index] };
}
if (!objectHasOwn.call(container, segment)) return { ok: false };
return { ok: true, key: segment, value: container[segment] };
}

function writeDraftChild(
container: DraftContainer,
key: number | string,
value: unknown,
): void {
if (Array.isArray(container)) {
container[key as number] = value;
return;
}
const property = key as string;
if (property === "__proto__") {
Object.defineProperty(container, property, {
value,
enumerable: true,
configurable: true,
writable: true,
});
return;
}
container[property] = value;
}

function buildReplaceTree(items: ReadonlyArray<{ segments: string[]; value: unknown }>): ReplaceTree {
const root: ReplaceTree = { children: new Map() };
for (const item of items) {
Expand Down
3 changes: 3 additions & 0 deletions packages/json-document/src/foundation/patch/inverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cloneTrustedPlainJson } from "../json/trustedClone.js";
import { applyOpRaw } from "./apply.js";
import { getValueAt, resolveAppendPath } from "./container.js";
import { objectHasOwn } from "./object.js";
import { computeSequentialReplaceInverses } from "./sequentialReplace.js";
import {
arrayFieldText,
arrayLocation,
Expand Down Expand Up @@ -84,6 +85,8 @@ export function computeInverses(
if (arrayNestedReplace) return arrayNestedReplace;
const replaceOnly = computeIndependentReplaceInverses(state, ops);
if (replaceOnly) return replaceOnly;
const sequentialReplace = computeSequentialReplaceInverses(state, ops);
if (sequentialReplace !== null) return { ok: true, inverses: sequentialReplace };
const arrayOnly = computeSameArrayStructuralInverses(state, ops);
if (arrayOnly) return arrayOnly;

Expand Down
Loading