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/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ All notable changes to this project are documented here.

### Performance

- Added serial CPU-time p95 readiness gates, with wall-time p95 diagnostics, for
a single-node replacement and 100-node replacement batch in a Canvas-shaped
5,000-node document plus an Editable-shaped 5,000-block refined text commit
with extended selection publication.
- Added recursive `lazy`/`union` structural inspection and local non-root
replace validation for Zod's declarative string/number constraints and
built-in string trim. Custom refinements and container checks retain full-root
validation.
- Kept the isolated whole-candidate clone for full-root schema validation,
including custom checks and error callbacks. Checked structural schemas avoid
that root clone only when a supported single or independent replacement can be
validated at its changed value; object payloads are cloned before local Zod
validation so schema/global error callbacks cannot mutate caller-owned input.
- 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
Expand Down
6 changes: 5 additions & 1 deletion docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,11 @@ 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가 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 검증으로 돌아갑니다.
Document 내부 state는 신뢰된 document state입니다. 구조만 가진 Zod schema(check가 없는 구조 schema)의 지원 edit는 더 좁은 검증 경로를 씁니다. Zod의 선언적 string/number constraint와 built-in `trim()`만 leaf에 있는 schema도 non-root `replace`와 독립 replace batch를 변경 위치에서 검증합니다. Array 구조 edit는 check가 없는 schema에서만 이 경로를 사용합니다. Leading `test` assertion이 붙은 guarded batch도 assertion 확인 뒤 같은 mutation 경로를 사용합니다. Overlapping `replace`의 history inverse도 operation별 이전 값과 undo 순서를 그대로 유지하는 copy-on-write 순회 구현을 사용합니다.

`refine`, `superRefine`, custom check/overwrite, custom error callback, container check, coercion, pipe transform은 전체 루트 schema 검증으로 돌아갑니다. 전체 검증과 validation 격리용 candidate clone을 생략하지 않습니다. 위치별 검증에서도 object payload는 local Zod 검증 전에 clone하므로 schema-level 또는 global error callback이 caller input을 변경할 수 없습니다.

`npm run perf:adapters`는 다른 process의 descheduling 영향을 제외하면서 json-document 자체 계산과 GC를 포함하는 process CPU-time p95를 hard gate로 사용합니다. 같은 실행의 wall-time p50/p95/max도 진단값으로 출력합니다. 사용자 체감 wall latency의 최종 판정은 Canvas/Editable 전용 또는 idle runner에서 수행해야 합니다.

## 트리 편집 cookbook

Expand Down
7 changes: 6 additions & 1 deletion docs/standard/json-document-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,14 @@ 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 검증으로 돌아간다.
빠른 document path는 신뢰된 document state와 구조를 정적으로 해석할 수 있는 Zod schema에 적용된다. Check가 없는 object, array, record, scalar validator는 기존 non-root `replace` batch(독립 경로와 순차 ancestor/descendant overlap 포함), array `add`/`remove`/`copy`/`move`, same-array `add`/`remove` batch를 지원한다. Zod의 선언적 string/number constraint와 built-in `trim()`이 primitive leaf에만 있는 schema는 non-root 단일/독립 `replace` batch만 위치별로 검증한다. Leading `test` assertion 뒤에 이 edit들이 오는 guarded batch도 assertion을 먼저 확인한 뒤 같은 mutation fast path를 사용한다. Overlapping `replace`의 history inverse도 operation별 이전 값과 역순을 유지하면서 같은 private copy-on-write 순회 구현을 사용한다.

`refine`, `superRefine`, custom check/overwrite, custom error callback, container check, coercion, pipe transform은 의도적으로 전체 루트 schema 검증으로 돌아간다. 전체 루트 검증은 Zod check와 schema/global error callback이 실패한 input까지 관찰할 수 있으므로 candidate를 전체 JSON clone한 뒤 수행한다. 지원되는 위치별 `replace` 검증도 object payload만 먼저 clone해 caller-owned input과 live state를 격리한다.

Adapter readiness benchmark의 hard gate는 process CPU-time p95다. 이는 json-document process의 계산과 GC CPU는 포함하고 다른 process 때문에 deschedule된 시간은 제외한다. 같은 run의 wall-time p50/p95/max는 진단값이며, 사용자 체감 wall latency는 Canvas/Editable 전용 또는 idle runner에서 별도로 확인한다.

```sh
npm run perf:adapters
npm run perf:core
```

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ import { applyOperation, applyPatch, applyPatchToTrustedState } from "@interacti

큰 document에서는 hot UI path를 document facade에 둔다: `doc.patch`, `doc.commit`, `doc.canPatch`.

빠른 document path는 신뢰된 document state와 구조만 가진 Zod schema에서만 적용된다. `refine`, `superRefine`, transform, check가 있는 schema는 전체 루트 schema 검증을 사용한다.
빠른 document path는 신뢰된 document state와 구조만 가진 Zod schema에 적용된다. 선언적 string/number constraint와 built-in `trim()`만 primitive leaf에 있는 schema도 단일 또는 독립 non-root `replace`를 위치별로 검증한다. `refine`, `superRefine`, custom check/error callback, transform이 있는 schema와 지원하지 않는 batch는 격리용 candidate clone 뒤 전체 루트 schema 검증을 사용한다.

## 트리 편집

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
"labs:extensions:check": "node scripts/evaluate-extension-lab.mjs",
"labs:extensions:verify": "node scripts/evaluate-extension-lab.mjs --verify",
"labs:extensions:verify:changed": "node scripts/evaluate-extension-lab.mjs --verify --changed",
"perf:adapters": "npm run build -w @interactive-os/json-document && node scripts/benchmark-adapter-readiness.mjs",
"perf:readiness": "npm run perf:adapters && npm run perf:causal",
"perf:core": "npm run build -w @interactive-os/json-document && node scripts/benchmark-core.mjs",
"perf:causal": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-patch-rebase && npm run build -w @interactive-os/json-document-stable-id-rebase && npm run build -w @interactive-os/json-document-causal-patch-inbox && node scripts/benchmark-causal.mjs",
"release:check": "npm run verify && npm run standard:check && npm run perf:core && npm run pack:library",
"release:check": "npm run verify && npm run standard:check && npm run perf:adapters && npm run perf:core && npm run pack:library",
"standard:check": "node scripts/evaluate-standardization.mjs && npm test -w @interactive-os/json-document -- standard-conformance",
"verify": "npm run typecheck && npm test && npm run build && npm run smoke:package && npm run build && npm run extensions:verify && npm run labs:extensions:verify && npm run docs:evaluate && npm run playground:typecheck && npm run playground:test && npm run playground:build && npm run site:evaluate && npm run site:verify:pages && npm run browser:test",
"pack:library": "npm pack -w @interactive-os/json-document --cache ./.npm-cache",
Expand Down
17 changes: 9 additions & 8 deletions packages/json-document/src/domain/schema/array/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,17 +371,18 @@ export function evaluateArrayIndexReplaceValues<
if (op.index < 0 || op.index >= array.length) return { ok: false, result: null };
const replacement = replacementValue(op, array[op.index]);
if (!replacement.ok) return { ok: false, result: null };
const valueFailure = evaluateAppliedReplaceValueValidationPlan(
state,
[op],
valueSchema,
(value) => acceptsKnownJsonValueWithValidator(valueValidator, value),
valuesTrusted,
);
if (valueFailure) return { ok: false, result: valueFailure };
replacements[opIndex] = { index: op.index, value: replacement.value };
}

const valueFailure = evaluateAppliedReplaceValueValidationPlan(
state,
operations,
valueSchema,
(value) => acceptsKnownJsonValueWithValidator(valueValidator, value),
valuesTrusted,
);
if (valueFailure) return { ok: false, result: valueFailure };

return { ok: true, replacements };
}

Expand Down
3 changes: 3 additions & 0 deletions packages/json-document/src/domain/schema/inspection/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type * as z from "zod";

export interface ZodInternalDef {
type?: string;
check?: string;
fn?: unknown;
error?: unknown;
coerce?: boolean;
checks?: unknown[];
shape?: unknown;
Expand Down
147 changes: 124 additions & 23 deletions packages/json-document/src/domain/schema/model/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ interface LocalSchemaCache {
pointerSchemas: Map<string, z.ZodType | null>;
}

const plainStructuralSchemaCache = new WeakMap<object, boolean>();
type LocalSchemaValidationCapability = "none" | "replace" | "all";

const localSchemaValidationCapabilityCache = new WeakMap<object, LocalSchemaValidationCapability>();
const knownJsonOutputSchemaCache = new WeakMap<object, boolean>();
const localSchemaCaches = new WeakMap<object, LocalSchemaCache>();

Expand Down Expand Up @@ -37,56 +39,155 @@ export function cachedSchemaAtPointer(
}

export function isPlainStructuralSchema(schema: z.ZodType, seen?: WeakSet<object>): boolean {
const cached = plainStructuralSchemaCache.get(schema as object);
return localSchemaValidationCapability(schema, seen) === "all";
}

export function supportsLocalReplaceSchemaValidation(schema: z.ZodType): boolean {
return localSchemaValidationCapability(schema) !== "none";
}

function localSchemaValidationCapability(
schema: z.ZodType,
seen?: WeakSet<object>,
): LocalSchemaValidationCapability {
const cached = localSchemaValidationCapabilityCache.get(schema as object);
if (cached !== undefined) return cached;
const shouldCache = seen === undefined;
const activeSeen = seen ?? new WeakSet<object>();
if (activeSeen.has(schema as object)) return true;
if (activeSeen.has(schema as object)) return "all";
activeSeen.add(schema as object);
const finish = (capability: LocalSchemaValidationCapability): LocalSchemaValidationCapability => {
activeSeen.delete(schema as object);
if (shouldCache) localSchemaValidationCapabilityCache.set(schema as object, capability);
return capability;
};

const def = getDef(schema);
if (Array.isArray(def.checks) && def.checks.length > 0) return cachePlainStructuralSchema(schema, false);
if (def.coerce || typeof def.error === "function") return finish("none");
// Standalone checks such as `z.stringFormat(name, fn)` keep their check on
// the schema definition instead of `def.checks`. Keep these on full-root
// validation; otherwise a dynamic custom check can be skipped by an edit to
// an unrelated path. Chained declarative checks are classified below.
if (typeof def.check === "string" || typeof def.fn === "function") {
return finish("none");
}
const hasChecks = Array.isArray(def.checks) && def.checks.length > 0;

switch (def.type) {
case "object": {
if (hasChecks) return finish("none");
const shape = getObjectShape(schema);
if (!shape) return cachePlainStructuralSchema(schema, false);
if (!Object.values(shape).every((child) => isPlainStructuralSchema(child, activeSeen))) {
return cachePlainStructuralSchema(schema, false);
}
return cachePlainStructuralSchema(schema, def.catchall ? isPlainStructuralSchema(def.catchall, activeSeen) : true);
if (!shape) return finish("none");
const children = Object.values(shape);
if (def.catchall) children.push(def.catchall);
return finish(combineLocalSchemaValidationCapabilities(children, activeSeen));
}
case "array": {
if (hasChecks) return finish("none");
const element = getArrayElement(schema);
return cachePlainStructuralSchema(schema, element ? isPlainStructuralSchema(element, activeSeen) : false);
return finish(element ? localSchemaValidationCapability(element, activeSeen) : "none");
}
case "record":
return cachePlainStructuralSchema(
schema,
(!def.keyType || isPlainStructuralSchema(def.keyType, activeSeen))
&& !!def.valueType
&& isPlainStructuralSchema(def.valueType, activeSeen),
return finish(
!hasChecks && (!def.keyType || isPlainStringKeySchema(def.keyType)) && def.valueType
? localSchemaValidationCapability(def.valueType, activeSeen)
: "none",
);
case "union":
return finish(
!hasChecks && Array.isArray(def.options) && def.options.length > 0
? combineLocalSchemaValidationCapabilities(def.options, activeSeen)
: "none",
);
case "lazy": {
if (hasChecks || !def.getter) return finish("none");
try {
return finish(localSchemaValidationCapability(def.getter(), activeSeen));
} catch {
return finish("none");
}
}
case "optional":
case "nullable":
return cachePlainStructuralSchema(schema, !!def.innerType && isPlainStructuralSchema(def.innerType, activeSeen));
case "string":
case "number":
return finish(
!hasChecks && def.innerType
? localSchemaValidationCapability(def.innerType, activeSeen)
: "none",
);
case "string": {
if (!hasChecks) return finish("all");
return finish(scalarChecksSupportLocalValidation("string", def.checks) ? "replace" : "none");
}
case "number": {
if (!hasChecks) return finish("all");
return finish(scalarChecksSupportLocalValidation("number", def.checks) ? "replace" : "none");
}
case "boolean":
case "null":
case "literal":
case "enum":
case "unknown":
case "any":
case "never":
return cachePlainStructuralSchema(schema, true);
return finish(hasChecks ? "none" : "all");
default:
return cachePlainStructuralSchema(schema, false);
return finish("none");
}
}

function combineLocalSchemaValidationCapabilities(
schemas: ReadonlyArray<z.ZodType>,
seen: WeakSet<object>,
): LocalSchemaValidationCapability {
let combined: LocalSchemaValidationCapability = "all";
for (const schema of schemas) {
const capability = localSchemaValidationCapability(schema, seen);
if (capability === "none") return "none";
if (capability === "replace") combined = "replace";
}
return combined;
}

function scalarChecksSupportLocalValidation(
scalar: "string" | "number",
checks: unknown[] | undefined,
): boolean {
if (!Array.isArray(checks) || checks.length === 0) return true;
return checks.every((check) => {
if (check === null || typeof check !== "object") return false;
const checkDef = (check as {
_zod?: { def?: { check?: unknown; fn?: unknown; tx?: unknown; error?: unknown } };
})._zod?.def;
if (
!checkDef
|| typeof checkDef.check !== "string"
|| typeof checkDef.fn === "function"
|| typeof checkDef.error === "function"
) {
return false;
}
if (scalar === "number") {
return checkDef.check === "less_than"
|| checkDef.check === "greater_than"
|| checkDef.check === "multiple_of"
|| checkDef.check === "number_format";
}
if (checkDef.check === "overwrite") return isBuiltinTrimOverwrite(checkDef.tx);
return checkDef.check === "min_length"
|| checkDef.check === "max_length"
|| checkDef.check === "length_equals"
|| checkDef.check === "string_format";
});
}

function cachePlainStructuralSchema(schema: z.ZodType, value: boolean): boolean {
plainStructuralSchemaCache.set(schema as object, value);
return value;
function isBuiltinTrimOverwrite(transform: unknown): boolean {
if (typeof transform !== "function") return false;
try {
return Function.prototype.toString.call(transform).replace(/\s+/g, " ")
=== "(input) => input.trim()";
} catch {
return false;
}
}

export function schemaOutputIsKnownJson(schema: z.ZodType, seen?: WeakSet<object>): boolean {
Expand Down
Loading