From 595dbc88882641c35deebfb4ab49ce0332215b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 15 Jul 2026 00:09:36 +0900 Subject: [PATCH] perf(json-document): harden adapter-scale validation --- docs/changelog.md | 13 + docs/public/api.md | 6 +- docs/standard/json-document-spec.md | 7 +- llms.txt | 2 +- package.json | 4 +- .../src/domain/schema/array/replace.ts | 17 +- .../src/domain/schema/inspection/zod.ts | 3 + .../src/domain/schema/model/schema.ts | 147 +++++- .../src/domain/schema/model/value.ts | 46 +- .../src/domain/schema/validation/patch.ts | 20 +- .../src/domain/schema/validation/replace.ts | 9 +- .../structural-schema-fast-path.test.ts | 448 ++++++++++++++++ scripts/benchmark-adapter-readiness.mjs | 498 ++++++++++++++++++ 13 files changed, 1162 insertions(+), 58 deletions(-) create mode 100644 packages/json-document/tests/regression/structural-schema-fast-path.test.ts create mode 100644 scripts/benchmark-adapter-readiness.mjs diff --git a/docs/changelog.md b/docs/changelog.md index fe8fcec8..de279c8f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/docs/public/api.md b/docs/public/api.md index af7e7eac..44f01311 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -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 diff --git a/docs/standard/json-document-spec.md b/docs/standard/json-document-spec.md index 77bda9b5..4cce5440 100644 --- a/docs/standard/json-document-spec.md +++ b/docs/standard/json-document-spec.md @@ -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 ``` diff --git a/llms.txt b/llms.txt index c0be55c8..1505a5de 100644 --- a/llms.txt +++ b/llms.txt @@ -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 검증을 사용한다. ## 트리 편집 diff --git a/package.json b/package.json index 665f6b9d..2149d03e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/json-document/src/domain/schema/array/replace.ts b/packages/json-document/src/domain/schema/array/replace.ts index 4dbce568..ba416c62 100644 --- a/packages/json-document/src/domain/schema/array/replace.ts +++ b/packages/json-document/src/domain/schema/array/replace.ts @@ -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 }; } diff --git a/packages/json-document/src/domain/schema/inspection/zod.ts b/packages/json-document/src/domain/schema/inspection/zod.ts index 222983ed..49f95d3f 100644 --- a/packages/json-document/src/domain/schema/inspection/zod.ts +++ b/packages/json-document/src/domain/schema/inspection/zod.ts @@ -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; diff --git a/packages/json-document/src/domain/schema/model/schema.ts b/packages/json-document/src/domain/schema/model/schema.ts index d853b49c..ca8e7e6d 100644 --- a/packages/json-document/src/domain/schema/model/schema.ts +++ b/packages/json-document/src/domain/schema/model/schema.ts @@ -9,7 +9,9 @@ interface LocalSchemaCache { pointerSchemas: Map; } -const plainStructuralSchemaCache = new WeakMap(); +type LocalSchemaValidationCapability = "none" | "replace" | "all"; + +const localSchemaValidationCapabilityCache = new WeakMap(); const knownJsonOutputSchemaCache = new WeakMap(); const localSchemaCaches = new WeakMap(); @@ -37,40 +39,89 @@ export function cachedSchemaAtPointer( } export function isPlainStructuralSchema(schema: z.ZodType, seen?: WeakSet): 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, +): LocalSchemaValidationCapability { + const cached = localSchemaValidationCapabilityCache.get(schema as object); if (cached !== undefined) return cached; + const shouldCache = seen === undefined; const activeSeen = seen ?? new WeakSet(); - 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": @@ -78,15 +129,65 @@ export function isPlainStructuralSchema(schema: z.ZodType, seen?: WeakSet, + seen: WeakSet, +): 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): boolean { diff --git a/packages/json-document/src/domain/schema/model/value.ts b/packages/json-document/src/domain/schema/model/value.ts index 23a6d4fc..652c03a3 100644 --- a/packages/json-document/src/domain/schema/model/value.ts +++ b/packages/json-document/src/domain/schema/model/value.ts @@ -1,9 +1,13 @@ import type * as z from "zod"; import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; import type { Pointer } from "../../../foundation/pointer/index.js"; -import { jsonSerializableError } from "../../../foundation/json/index.js"; +import { + cloneJsonSerializable, + cloneTrustedPlainJson, +} from "../../../foundation/json/index.js"; import { appendArrayIndexPath } from "../../../foundation/patch/index.js"; import { operationFailure, schemaViolation } from "./result.js"; +import { prefixIssues } from "./schema.js"; export interface PlanLocalSchemaValidationValueValidationInput { path: Pointer; @@ -44,23 +48,38 @@ export function planLocalSchemaValidationValueValidation( input: PlanLocalSchemaValidationValueValidationInput, ): LocalSchemaValidationValueValidationPlan { if (input.knownJsonAccepted) return { kind: "accepted" }; - if (!input.valuesTrusted) { - const jsonError = jsonSerializableError(input.value); - if (jsonError !== null) return { kind: "notSerializable", reason: jsonError }; + let value: unknown; + if (input.valuesTrusted) { + value = cloneTrustedPlainJson(input.value); + } else { + const cloned = cloneJsonSerializable(input.value); + if (!cloned.ok) return { kind: "notSerializable", reason: cloned.reason }; + value = cloned.value; } - return { kind: "parse", path: input.path, schema: input.schema, value: input.value }; + return { kind: "parse", path: input.path, schema: input.schema, value }; } export function evaluateLocalSchemaValidationValueValidationPlan( state: z.output, plan: LocalSchemaValidationValueValidationPlan, ): ApplyResult | null { - if (plan.kind === "notSerializable") return operationFailure(state, "not_serializable", plan.reason); - if (plan.kind === "parse") { + return evaluateLocalSchemaValidationValueValidationPlans(state, [plan]); +} + +export function evaluateLocalSchemaValidationValueValidationPlans( + state: z.output, + plans: ReadonlyArray, +): ApplyResult | null { + const issues: z.ZodError["issues"] = []; + for (const plan of plans) { + if (plan.kind === "notSerializable") { + return operationFailure(state, "not_serializable", plan.reason); + } + if (plan.kind !== "parse") continue; const result = plan.schema.safeParse(plan.value); - if (!result.success) return schemaViolation(state, plan.path, result.error.issues); + if (!result.success) issues.push(...prefixIssues(plan.path, result.error.issues)); } - return null; + return issues.length === 0 ? null : schemaViolation(state, "", issues); } export function planArrayAddAppliedOperations(input: { @@ -103,18 +122,17 @@ export function evaluateAppliedValueValidationPlan< knownJsonAccepted: (value: unknown) => boolean; valuesTrusted: boolean; }): ApplyResult | null { + const plans: LocalSchemaValidationValueValidationPlan[] = []; for (const op of input.operations) { - const valueValidation = planLocalSchemaValidationValueValidation({ + plans.push(planLocalSchemaValidationValueValidation({ path: op.path, schema: input.schema, value: op.value, knownJsonAccepted: input.knownJsonAccepted(op.value), valuesTrusted: input.valuesTrusted, - }); - const valueFailure = evaluateLocalSchemaValidationValueValidationPlan(input.state, valueValidation); - if (valueFailure) return valueFailure; + })); } - return null; + return evaluateLocalSchemaValidationValueValidationPlans(input.state, plans); } export function evaluateAppliedAddValueValidationPlan( diff --git a/packages/json-document/src/domain/schema/validation/patch.ts b/packages/json-document/src/domain/schema/validation/patch.ts index f966eabe..c3493b0a 100644 --- a/packages/json-document/src/domain/schema/validation/patch.ts +++ b/packages/json-document/src/domain/schema/validation/patch.ts @@ -1,4 +1,5 @@ import type * as z from "zod"; +import { cloneTrustedPlainJson } from "../../../foundation/json/index.js"; import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; import { applyAcceptedPatch, applyTrustedPatch } from "../../../foundation/patch/index.js"; import { validateOperationShape, validatePatchOperations } from "../../../foundation/patch/index.js"; @@ -17,6 +18,7 @@ import { import { cachedSchemaAtPointer, isPlainStructuralSchema, + supportsLocalReplaceSchemaValidation, } from "../model/schema.js"; import { arrayElementSchemaAtPath } from "./schema.js"; import { @@ -68,7 +70,8 @@ export function applyPatchWithLocalSchemaValidation( ops: ReadonlyArray, options: LocalSchemaValidationOptions = {}, ): LocalSchemaValidationResult { - if (!isPlainStructuralSchema(schema)) return null; + const supportsAllLocalValidation = isPlainStructuralSchema(schema); + if (!supportsAllLocalValidation && !supportsLocalReplaceSchemaValidation(schema)) return null; const valuesTrusted = options.valuesTrusted === true; const leadingTests = applyLeadingTestsWithLocalSchemaValidation(schema, state, ops, valuesTrusted); @@ -76,6 +79,12 @@ export function applyPatchWithLocalSchemaValidation( const singleReplace = applySingleReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (singleReplace) return singleReplace; + if (!supportsAllLocalValidation) { + return planIndependentReplacePatch(ops) + ? applyReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted) + : null; + } + const sameArrayFieldReplace = applySameArrayFieldReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (sameArrayFieldReplace) return sameArrayFieldReplace; const sameArrayElementReplace = applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation(schema, state, ops); @@ -84,13 +93,14 @@ export function applyPatchWithLocalSchemaValidation( if (sameArrayNestedReplace) return sameArrayNestedReplace; const rootObjectReplace = applyRootObjectReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (rootObjectReplace) return rootObjectReplace; + const knownJsonReplace = applyKnownJsonReplacePatchWithLocalSchemaValidation(schema, state, ops); + if (knownJsonReplace?.result.ok) return knownJsonReplace; + if (planIndependentReplacePatch(ops)) return applyReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); + const rootRecordAdd = applyRootRecordAddPatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (rootRecordAdd) return rootRecordAdd; const rootRecordRemove = applyRootRecordRemovePatchWithLocalSchemaValidation(schema, state, ops); if (rootRecordRemove) return rootRecordRemove; - const knownJsonReplace = applyKnownJsonReplacePatchWithLocalSchemaValidation(schema, state, ops); - if (knownJsonReplace?.result.ok) return knownJsonReplace; - if (planIndependentReplacePatch(ops)) return applyReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); const appendOnlyAdd = applyAppendOnlyAddPatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (appendOnlyAdd) return appendOnlyAdd; @@ -290,7 +300,7 @@ export function evaluateAppliedLocalOpValidationPlan( plan: AppliedLocalOpValidationPlan, ): ApplyResult { if (plan.kind === "presence") return okLocalSchemaValidation(state, [appliedOp]); - const parsed = plan.schema.safeParse(plan.value); + const parsed = plan.schema.safeParse(cloneTrustedPlainJson(plan.value)); return parsed.success ? okLocalSchemaValidation(state, [appliedOp]) : schemaViolation(state, plan.path, parsed.error.issues); } diff --git a/packages/json-document/src/domain/schema/validation/replace.ts b/packages/json-document/src/domain/schema/validation/replace.ts index 9f9fe756..cf499dc6 100644 --- a/packages/json-document/src/domain/schema/validation/replace.ts +++ b/packages/json-document/src/domain/schema/validation/replace.ts @@ -19,7 +19,7 @@ import { readRootRecordForLocalSchemaValidation, } from "../object/replace.js"; import { - evaluateLocalSchemaValidationValueValidationPlan, + evaluateLocalSchemaValidationValueValidationPlans, planLocalSchemaValidationValueValidation, } from "../model/value.js"; @@ -55,13 +55,14 @@ export function evaluateAppliedReplaceOperations( operations: ReadonlyArray, valuesTrusted: boolean, ): { ok: true } | { ok: false; result: ApplyResult | null } { + const validationPlans: ReturnType[] = []; for (const op of operations) { const valueValidation = planAppliedReplaceValueValidation(schema, op, valuesTrusted); if (valueValidation === null) return { ok: false, result: null }; - const valueFailure = evaluateLocalSchemaValidationValueValidationPlan(state, valueValidation); - if (valueFailure) return { ok: false, result: valueFailure }; + validationPlans.push(valueValidation); } - return { ok: true }; + const valueFailure = evaluateLocalSchemaValidationValueValidationPlans(state, validationPlans); + return valueFailure ? { ok: false, result: valueFailure } : { ok: true }; } export function planAppliedReplaceValueValidation(schema: z.ZodType, operation: JSONPatchOperation, valuesTrusted: boolean) { diff --git a/packages/json-document/tests/regression/structural-schema-fast-path.test.ts b/packages/json-document/tests/regression/structural-schema-fast-path.test.ts new file mode 100644 index 00000000..048038db --- /dev/null +++ b/packages/json-document/tests/regression/structural-schema-fast-path.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, test, vi } from "vitest"; +import * as z from "zod"; + +import { applyPatch, createJSONDocument } from "@interactive-os/json-document"; + +describe("structural schema validation isolation", () => { + test("keeps full validation isolated while preserving committed COW branches", () => { + let labelValidations = 0; + const LabelSchema = z.string().trim().min(1).superRefine(() => { + labelValidations += 1; + }); + const Schema = createCanvasSchema(LabelSchema); + const doc = createJSONDocument(Schema, { + nodes: Array.from({ length: 64 }, (_, index) => ({ + label: `node-${index}`, + props: { index, nested: { visible: true } }, + })), + }); + const before = doc.value; + const safeParse = vi.spyOn(Schema, "safeParse"); + labelValidations = 0; + + expect(doc.patch({ + op: "replace", + path: "/nodes/63/label", + value: "updated", + })).toEqual({ ok: true }); + + expect(safeParse).toHaveBeenCalledTimes(1); + const validationInput = safeParse.mock.calls[0]?.[0] as typeof before; + expect(validationInput.nodes[0]).not.toBe(before.nodes[0]); + expect(validationInput.nodes[63]).not.toBe(before.nodes[63]); + expect(doc.value.nodes[0]).toBe(before.nodes[0]); + expect(doc.value.nodes[63]?.label).toBe("updated"); + expect(labelValidations).toBe(64); + }); + + test("validates a Canvas built-in scalar replacement without reparsing the root", () => { + const LabelSchema = z.string().trim().min(1); + const Schema = createCanvasSchema(LabelSchema); + const doc = createJSONDocument(Schema, { + nodes: Array.from({ length: 64 }, (_, index) => ({ + label: `node-${index}`, + props: { index, nested: { visible: true } }, + })), + }); + const rootSafeParse = vi.spyOn(Schema, "safeParse"); + const labelSafeParse = vi.spyOn(LabelSchema, "safeParse"); + + expect(doc.patch({ + op: "replace", + path: "/nodes/63/label", + value: "updated", + })).toEqual({ ok: true }); + + expect(rootSafeParse).not.toHaveBeenCalled(); + expect(labelSafeParse).toHaveBeenCalledTimes(1); + expect(doc.value.nodes[63]?.label).toBe("updated"); + }); + + test("rejects an invalid Canvas scalar replacement atomically at its document path", () => { + const Schema = createCanvasSchema(z.string().trim().min(1)); + const doc = createJSONDocument(Schema, { + nodes: [{ label: "node-0", props: {} }], + }); + const listener = vi.fn(); + doc.subscribe(listener); + + const operation = { + op: "replace", + path: "/nodes/0/label", + value: " ", + } as const; + expect(doc.canPatch(operation)).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/nodes/0/label", message: expect.any(String) }], + }); + expect(doc.patch(operation)).toMatchObject({ + ok: false, + code: "schema_violation", + }); + + expect(doc.value.nodes[0]?.label).toBe("node-0"); + expect(doc.lastPatch).toEqual([]); + expect(listener).not.toHaveBeenCalled(); + }); + + test("reports every invalid scalar in an independent Canvas replacement batch", () => { + const Schema = createCanvasSchema(z.string().trim().min(1)); + const doc = createJSONDocument(Schema, { + nodes: [ + { label: "node-0", props: {} }, + { label: "node-1", props: {} }, + ], + }); + + const result = doc.canPatch([ + { op: "replace", path: "/nodes/0/label", value: " " }, + { op: "replace", path: "/nodes/1/label", value: "\t" }, + ]); + + expect(result).toMatchObject({ ok: false, code: "schema_violation" }); + if (result.ok) throw new Error("invalid Canvas batch was accepted"); + expect(result.violations).toEqual([ + { path: "/nodes/0/label", message: expect.any(String) }, + { path: "/nodes/1/label", message: expect.any(String) }, + ]); + expect(doc.value.nodes.map((node) => node.label)).toEqual(["node-0", "node-1"]); + }); + + test("keeps custom scalar refinement on full-root validation", () => { + let acceptsGuardedValue = true; + let guardedValidations = 0; + const Schema = z.object({ + guarded: z.string().superRefine((_, context) => { + guardedValidations += 1; + if (!acceptsGuardedValue) { + context.addIssue({ code: "custom", message: "guard is closed" }); + } + }), + other: z.string(), + }); + const doc = createJSONDocument(Schema, { guarded: "stable", other: "before" }); + guardedValidations = 0; + acceptsGuardedValue = false; + + expect(doc.patch({ op: "replace", path: "/other", value: "after" })).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(guardedValidations).toBe(1); + expect(doc.value).toEqual({ guarded: "stable", other: "before" }); + }); + + test("isolates refined object validation while preserving committed COW branches", () => { + let blockValidations = 0; + const BlockSchema = z.object({ + id: z.string().min(1), + text: z.string(), + }).superRefine((block) => { + blockValidations += 1; + block.text = block.text.toUpperCase(); + }); + const Schema = z.object({ + blocks: z.array(BlockSchema), + }).refine((value) => new Set(value.blocks.map((block) => block.id)).size === value.blocks.length); + const doc = createJSONDocument(Schema, { + blocks: [ + { id: "a", text: "first" }, + { id: "b", text: "second" }, + ], + }); + const before = doc.value; + const safeParse = vi.spyOn(Schema, "safeParse"); + blockValidations = 0; + + expect(doc.patch({ + op: "replace", + path: "/blocks/1/text", + value: "after", + })).toEqual({ ok: true }); + + const validationInput = safeParse.mock.calls[0]?.[0] as typeof before; + expect(validationInput.blocks[0]).not.toBe(before.blocks[0]); + expect(validationInput.blocks[1]).not.toBe(before.blocks[1]); + expect(doc.value.blocks[0]).toBe(before.blocks[0]); + expect(blockValidations).toBe(2); + expect(doc.value.blocks[1]?.text).toBe("after"); + }); + + test("isolates container checks that run against a failed partial parse", () => { + const Schema = z.object({ + source: z.object({ value: z.number() }), + target: z.string(), + }).refine((value) => { + if (typeof value.target === "object" && value.target !== null) { + (value.target as { value: number }).value = 999; + } + return true; + }, { when: () => true }); + const initial = { source: { value: 1 }, target: "valid" }; + + const result = applyPatch(Schema, initial, [ + { op: "move", from: "/source", path: "/target" }, + ]); + + expect(result.result).toMatchObject({ ok: false, code: "schema_violation" }); + expect(result.state).toBe(initial); + expect(initial).toEqual({ source: { value: 1 }, target: "valid" }); + + for (const materialize of [false, true]) { + const doc = createJSONDocument(Schema, { + source: { value: 1 }, + target: "valid", + }); + if (materialize) void doc.value; + + expect(doc.patch({ + op: "move", + from: "/source", + path: "/target", + })).toMatchObject({ ok: false, code: "schema_violation" }); + expect(doc.value).toEqual({ source: { value: 1 }, target: "valid" }); + expect(doc.lastPatch).toEqual([]); + } + }); + + test("validates the final state of repeated checked root-field replacements", () => { + const doc = createJSONDocument( + z.object({ label: z.string().min(1) }), + { label: "before" }, + ); + + expect(doc.patch([ + { op: "replace", path: "/label", value: "" }, + { op: "replace", path: "/label", value: "after" }, + ])).toEqual({ ok: true }); + expect(doc.value).toEqual({ label: "after" }); + }); + + test("validates the final state of repeated checked array-field replacements", () => { + const doc = createJSONDocument( + z.object({ items: z.array(z.object({ label: z.string().min(1) })) }), + { items: [{ label: "before" }] }, + ); + + expect(doc.patch([ + { op: "replace", path: "/items/0/label", value: "" }, + { op: "replace", path: "/items/0/label", value: "after" }, + ])).toEqual({ ok: true }); + expect(doc.value).toEqual({ items: [{ label: "after" }] }); + }); + + test("keeps standalone custom string formats on full-root validation", () => { + let acceptsGuard = true; + const GuardSchema = z.stringFormat("dynamic-guard", () => acceptsGuard); + const Schema = z.object({ + guard: GuardSchema, + other: z.string(), + }); + const doc = createJSONDocument(Schema, { + guard: "stable", + other: "before", + }); + acceptsGuard = false; + + expect(doc.patch({ + op: "replace", + path: "/other", + value: "after", + })).toMatchObject({ ok: false, code: "schema_violation" }); + expect(doc.value).toEqual({ guard: "stable", other: "before" }); + }); + + test("isolates local validation error callbacks from replacement payloads", () => { + const MutatingStringSchema = z.string({ + error: (issue) => { + if (issue.input !== null && typeof issue.input === "object") { + (issue.input as { value?: number }).value = 999; + } + return "must be a string"; + }, + }); + const doc = createJSONDocument( + z.object({ label: MutatingStringSchema }), + { label: "before" }, + ); + const payload = { value: 1 }; + + expect(doc.patch({ + op: "replace", + path: "/label", + value: payload, + } as never)).toMatchObject({ ok: false, code: "schema_violation" }); + expect(payload).toEqual({ value: 1 }); + expect(doc.value).toEqual({ label: "before" }); + }); + + test("isolates global error callbacks during local value validation", () => { + const globalConfig = z.config(); + const previousCustomError = globalConfig.customError; + z.config({ + customError: (issue) => { + if (issue.input !== null && typeof issue.input === "object") { + (issue.input as { value?: number }).value = 999; + } + return "global validation failure"; + }, + }); + try { + const doc = createJSONDocument( + z.object({ label: z.string() }), + { label: "before" }, + ); + const payload = { value: 1 }; + + expect(doc.patch({ + op: "replace", + path: "/label", + value: payload, + } as never)).toMatchObject({ ok: false, code: "schema_violation" }); + expect(payload).toEqual({ value: 1 }); + expect(doc.value).toEqual({ label: "before" }); + } finally { + if (previousCustomError === undefined) { + delete globalConfig.customError; + } else { + z.config({ customError: previousCustomError }); + } + } + }); + + test("isolates global error callbacks from sequential copy and move sources", () => { + const globalConfig = z.config(); + const previousCustomError = globalConfig.customError; + z.config({ + customError: (issue) => { + if (issue.input !== null && typeof issue.input === "object") { + (issue.input as { value?: number }).value = 555; + } + return "global validation failure"; + }, + }); + try { + const Schema = z.object({ + sources: z.array(z.object({ value: z.number() })), + targets: z.array(z.string()), + }); + for (const operation of ["copy", "move"] as const) { + for (const materialize of [false, true]) { + const doc = createJSONDocument(Schema, { + sources: [{ value: 1 }], + targets: ["valid"], + }); + if (materialize) void doc.value; + + expect(doc.patch({ + op: operation, + from: "/sources/0", + path: "/targets/0", + })).toMatchObject({ ok: false, code: "schema_violation" }); + expect(doc.value).toEqual({ + sources: [{ value: 1 }], + targets: ["valid"], + }); + expect(doc.lastPatch).toEqual([]); + } + } + } finally { + if (previousCustomError === undefined) { + delete globalConfig.customError; + } else { + z.config({ customError: previousCustomError }); + } + } + }); + + test("isolates a nested mutating custom check from the candidate graph", () => { + const Schema = z.object({ + nested: z.any().superRefine((value) => { + if (value?.trigger === true) value.poison = () => "not JSON"; + }), + }); + const doc = createJSONDocument(Schema, { nested: { trigger: false } }); + + expect(doc.patch({ + op: "replace", + path: "/nested/trigger", + value: true, + })).toEqual({ ok: true }); + expect(doc.value).toEqual({ nested: { trigger: true } }); + expect("poison" in doc.value.nested).toBe(false); + }); + + test("does not cache a recursive back-edge as independently structural", () => { + const GuardedSchema = z.object({ + enabled: z.boolean(), + value: z.number(), + }).superRefine((value, context) => { + if (!value.enabled && value.value > 0) { + context.addIssue({ code: "custom", path: ["value"], message: "disabled" }); + } + }); + let RecursiveSchema: z.ZodTypeAny; + const Backedge: z.ZodTypeAny = z.lazy(() => RecursiveSchema); + RecursiveSchema = z.object({ + child: Backedge.optional(), + guarded: GuardedSchema, + }); + const first = createJSONDocument(RecursiveSchema, { + guarded: { enabled: false, value: 0 }, + }); + expect(first.patch({ op: "replace", path: "/guarded/value", value: -1 })).toEqual({ ok: true }); + + const WrappedSchema = z.object({ node: Backedge }); + const wrapped = createJSONDocument(WrappedSchema, { + node: { guarded: { enabled: false, value: 0 } }, + }); + expect(wrapped.patch({ + op: "replace", + path: "/node/guarded/value", + value: 1, + })).toMatchObject({ ok: false, code: "schema_violation" }); + expect(wrapped.value).toEqual({ + node: { guarded: { enabled: false, value: 0 } }, + }); + }); + + test("keeps checked record keys and container invariants on full validation", () => { + const record = createJSONDocument( + z.record(z.string().min(2), z.number()), + { ok: 1 }, + ); + expect(record.patch({ op: "add", path: "/x", value: 2 })).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(record.value).toEqual({ ok: 1 }); + + const array = createJSONDocument( + z.object({ items: z.array(z.string()).min(1) }), + { items: ["kept"] }, + ); + expect(array.patch({ op: "remove", path: "/items/0" })).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(array.value).toEqual({ items: ["kept"] }); + }); +}); + +function createCanvasSchema(LabelSchema: z.ZodType) { + const JSONValueSchema: z.ZodType = z.lazy(() => z.union([ + z.boolean(), + z.number().finite(), + z.string(), + z.null(), + z.array(JSONValueSchema), + z.record(z.string(), JSONValueSchema), + ])); + return z.object({ + nodes: z.array(z.object({ + label: LabelSchema, + props: z.record(z.string(), JSONValueSchema), + }).strict()), + }).strict(); +} diff --git a/scripts/benchmark-adapter-readiness.mjs b/scripts/benchmark-adapter-readiness.mjs new file mode 100644 index 00000000..c820dc46 --- /dev/null +++ b/scripts/benchmark-adapter-readiness.mjs @@ -0,0 +1,498 @@ +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; +import * as z from "zod"; + +const distEntry = new URL( + "../packages/json-document/dist/application/document/index.js", + import.meta.url, +); + +if (!existsSync(distEntry)) { + console.error( + "Missing package dist. Run `npm run build -w @interactive-os/json-document` first.", + ); + process.exit(1); +} + +const { createJSONDocument } = await import(distEntry.href); + +const warmups = envPositiveInteger("PERF_ADAPTER_WARMUPS", 20); +const samples = envPositiveInteger("PERF_ADAPTER_SAMPLES", 300); +const canvasNodes = envPositiveInteger("PERF_ADAPTER_CANVAS_NODES", 5_000); +const canvasBatchSize = envPositiveInteger("PERF_ADAPTER_CANVAS_BATCH_SIZE", 100); +const editableBlocks = envPositiveInteger("PERF_ADAPTER_EDITABLE_BLOCKS", 5_000); +const canvasNodeCpuP95BudgetMs = envPositiveNumber( + "PERF_ADAPTER_CANVAS_NODE_CPU_P95_BUDGET_MS", + 4, +); +const canvasBatchCpuP95BudgetMs = envPositiveNumber( + "PERF_ADAPTER_CANVAS_BATCH_CPU_P95_BUDGET_MS", + 8, +); +const editableCommitCpuP95BudgetMs = envPositiveNumber( + "PERF_ADAPTER_EDITABLE_COMMIT_CPU_P95_BUDGET_MS", + 14, +); + +if (canvasBatchSize > canvasNodes) { + throw new RangeError("PERF_ADAPTER_CANVAS_BATCH_SIZE must not exceed the node count"); +} + +const DesignJSONValueSchema = z.lazy(() => + z.union([ + z.boolean(), + z.number().finite(), + z.string(), + z.null(), + z.array(DesignJSONValueSchema), + z.record(z.string(), DesignJSONValueSchema), + ]), +); +const DesignJSONObjectSchema = z.record(z.string(), DesignJSONValueSchema); +const StableIdSchema = z.string().trim().min(1); +const DesignNodeSchema = z.object({ + id: StableIdSchema, + label: StableIdSchema, + definition: z.object({ + kind: z.enum(["component", "intrinsic", "widget"]), + id: StableIdSchema, + }).strict(), + children: z.array(StableIdSchema), + props: DesignJSONObjectSchema, + text: z.string().nullable(), + layout: DesignJSONObjectSchema, + style: DesignJSONObjectSchema, + frame: z.object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite().nonnegative(), + height: z.number().finite().nonnegative(), + rotation: z.number().finite(), + widthMode: z.enum(["content", "fixed"]), + heightMode: z.enum(["content", "fixed"]), + overflow: z.enum(["clip", "scroll", "visible"]), + }).strict().nullable(), + component: z.object({ + definitionId: StableIdSchema, + instanceId: StableIdSchema, + slotId: StableIdSchema, + }).strict().nullable(), +}).strict(); +const DesignDocumentSchema = z.object({ + schemaVersion: z.literal(1), + roots: z.array(StableIdSchema), + nodes: z.array(DesignNodeSchema), +}).strict(); + +const EditableInlineMarkSchema = z.object({ + type: z.enum(["bold", "italic", "underline", "strike", "code"]), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), +}); +const EditableBlockSchema = z.object({ + id: z.string().min(1), + type: z.enum(["paragraph", "heading", "quote", "code"]), + text: z.string(), + marks: z.record(z.string().min(1), EditableInlineMarkSchema), +}).superRefine((block, context) => { + for (const [id, mark] of Object.entries(block.marks)) { + if (mark.start >= mark.end || mark.end > block.text.length) { + context.addIssue({ + code: "custom", + message: "Inline marks must be non-empty and stay inside block text.", + path: ["marks", id], + }); + } + } +}); +const EditableDocumentSchema = z.object({ + schema: z.literal("interactive-os.editable-document@3"), + id: z.string().min(1), + blocks: z.array(EditableBlockSchema), +}).refine( + (value) => new Set(value.blocks.map((block) => block.id)).size === value.blocks.length, + { message: "Block ids must be unique.", path: ["blocks"] }, +); + +const isolatedScenario = process.env.JSON_DOCUMENT_ADAPTER_READINESS_SCENARIO; +if (isolatedScenario !== undefined) { + const result = runNamedScenario(isolatedScenario); + process.stdout.write(`${JSON.stringify(result)}\n`); +} else { + const results = ["editable", "canvas-node", "canvas-batch"] + .map(runIsolatedScenario); + + console.log("json-document adapter readiness benchmark"); + console.log(`node=${process.version} platform=${process.platform} arch=${process.arch}`); + console.log(`warmups=${warmups} samples=${samples}`); + for (const result of results) { + console.log( + `${result.name}: cpu p50=${result.stats.cpu.p50.toFixed(3)}ms ` + + `p95=${result.stats.cpu.p95.toFixed(3)}ms max=${result.stats.cpu.max.toFixed(3)}ms ` + + `budget=${result.cpuBudgetMs.toFixed(3)}ms pass=${result.pass}; ` + + `wall p50=${result.stats.wall.p50.toFixed(3)}ms ` + + `p95=${result.stats.wall.p95.toFixed(3)}ms max=${result.stats.wall.max.toFixed(3)}ms`, + ); + } + + if (results.some((result) => !result.pass)) { + process.exitCode = 1; + } +} + +function runNamedScenario(name) { + if (name === "canvas-node") return measureCanvasNodeReplace(); + if (name === "canvas-batch") return measureCanvasBatchReplace(); + if (name === "editable") return measureEditableCommit(); + throw new RangeError(`Unknown adapter readiness scenario: ${name}`); +} + +function runIsolatedScenario(name) { + const child = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + encoding: "utf8", + env: { + ...process.env, + JSON_DOCUMENT_ADAPTER_READINESS_SCENARIO: name, + }, + maxBuffer: 1024 * 1024, + }, + ); + if (child.status !== 0) { + throw new Error( + `Adapter readiness scenario ${name} failed:\n${child.stderr || child.stdout}`, + ); + } + const output = child.stdout.trim(); + if (output.length === 0) { + throw new Error(`Adapter readiness scenario ${name} produced no result`); + } + return JSON.parse(output); +} + +function measureCanvasNodeReplace() { + const targetIndex = canvasNodes - 1; + const state = createCanvasState(canvasNodes); + const doc = createJSONDocument(DesignDocumentSchema, state, { + history: 100, + trustedInitial: true, + }); + const iterations = Array.from({ length: warmups + samples }, (_, iteration) => { + const label = `node-${targetIndex}-update-${iteration}`; + return { + expectedLabel: label, + operations: [{ + op: "replace", + path: `/nodes/${targetIndex}`, + value: createCanvasNode(targetIndex, [], label), + }], + }; + }); + let expectedLabel = ""; + let publications = 0; + doc.subscribe((operations) => { + publications += 1; + if (operations.length !== 1 || operations[0]?.op !== "replace") { + throw new Error("Canvas node readiness publication was not one replacement"); + } + if (doc.value.nodes[targetIndex]?.label !== expectedLabel) { + throw new Error("Canvas node readiness subscriber observed stale state"); + } + }); + + const stats = measureIterations({ + iterations, + resetPublications: () => { publications = 0; }, + readPublications: () => publications, + run: (iteration) => { + expectedLabel = iteration.expectedLabel; + assertCommit(doc.commit(iteration.operations, { + label: "Canvas adapter node update", + }), "Canvas node"); + }, + }); + return readinessResult( + `canvas node replace ${canvasNodes}`, + stats, + canvasNodeCpuP95BudgetMs, + ); +} + +function measureCanvasBatchReplace() { + const firstTarget = canvasNodes - canvasBatchSize; + const state = createCanvasState(canvasNodes); + const doc = createJSONDocument(DesignDocumentSchema, state, { + history: 100, + trustedInitial: true, + }); + const iterations = Array.from({ length: warmups + samples }, (_, iteration) => { + const operations = Array.from({ length: canvasBatchSize }, (_, offset) => { + const nodeIndex = firstTarget + offset; + return { + op: "replace", + path: `/nodes/${nodeIndex}`, + value: createCanvasNode( + nodeIndex, + [], + `node-${nodeIndex}-batch-${iteration}`, + ), + }; + }); + return { + expectedLabel: operations.at(-1)?.value.label ?? "", + operations, + }; + }); + let expectedLabel = ""; + let publications = 0; + doc.subscribe((operations) => { + publications += 1; + if (operations.length !== canvasBatchSize) { + throw new Error( + `Canvas batch readiness expected ${canvasBatchSize} operations, received ${operations.length}`, + ); + } + if (doc.value.nodes[canvasNodes - 1]?.label !== expectedLabel) { + throw new Error("Canvas batch readiness subscriber observed stale state"); + } + }); + + const stats = measureIterations({ + iterations, + resetPublications: () => { publications = 0; }, + readPublications: () => publications, + run: (iteration) => { + expectedLabel = iteration.expectedLabel; + assertCommit(doc.commit(iteration.operations, { + label: "Canvas adapter frame batch", + }), "Canvas batch"); + }, + }); + return readinessResult( + `canvas batch ${canvasNodes}/${canvasBatchSize}`, + stats, + canvasBatchCpuP95BudgetMs, + ); +} + +function measureEditableCommit() { + const targetIndex = editableBlocks - 1; + const textPath = `/blocks/${targetIndex}/text`; + const doc = createJSONDocument( + EditableDocumentSchema, + { + schema: "interactive-os.editable-document@3", + id: "readiness-document", + blocks: Array.from({ length: editableBlocks }, (_, index) => ({ + id: `block-${index}`, + type: "paragraph", + text: `block ${index}`, + marks: {}, + })), + }, + { + history: 100, + selection: { + mode: "extended", + initial: [{ + anchor: { path: textPath, offset: 0 }, + focus: { path: textPath, offset: 0 }, + }], + }, + }, + ); + if (!doc.selection) throw new Error("Editable readiness selection was not enabled"); + + const iterations = Array.from({ length: warmups + samples }, (_, iteration) => { + const text = `edited block ${iteration}`; + const offset = (iteration + 1) % 2; + const point = { path: textPath, offset }; + return { + expectedOffset: offset, + expectedText: text, + operations: [{ op: "replace", path: textPath, value: text }], + selectionAfter: { anchor: point, focus: point }, + }; + }); + let expectedOffset = 0; + let expectedText = ""; + let documentPublications = 0; + let selectionPublications = 0; + doc.subscribe((operations, metadata) => { + documentPublications += 1; + if (operations.length !== 1 || operations[0]?.op !== "replace") { + throw new Error("Editable readiness publication was not one replacement"); + } + if (doc.value.blocks[targetIndex]?.text !== expectedText) { + throw new Error("Editable readiness subscriber observed stale text"); + } + if (metadata?.selectionAfter?.focus?.offset !== expectedOffset) { + throw new Error("Editable readiness metadata observed stale selection"); + } + }); + doc.selection.subscribe((selection) => { + selectionPublications += 1; + if (selection.focus?.path !== textPath || selection.focus.offset !== expectedOffset) { + throw new Error("Editable readiness selection subscriber observed stale state"); + } + }); + + const stats = measureIterations({ + iterations, + resetPublications: () => { + documentPublications = 0; + selectionPublications = 0; + }, + readPublications: () => { + if (documentPublications !== selectionPublications) { + throw new Error( + `Editable readiness publication mismatch: document=${documentPublications} selection=${selectionPublications}`, + ); + } + return documentPublications; + }, + run: (iteration) => { + expectedOffset = iteration.expectedOffset; + expectedText = iteration.expectedText; + assertCommit(doc.commit(iteration.operations, { + label: "Editable adapter text input", + origin: "keyboard", + mergeKey: "typing", + selectionAfter: iteration.selectionAfter, + }), "Editable commit"); + }, + }); + return readinessResult( + `editable commit ${editableBlocks}`, + stats, + editableCommitCpuP95BudgetMs, + ); +} + +function measureIterations({ + iterations, + readPublications, + resetPublications, + run, +}) { + for (let index = 0; index < warmups; index += 1) { + run(iterations[index]); + } + resetPublications(); + + const cpuDurations = new Array(samples); + const wallDurations = new Array(samples); + for (let index = 0; index < samples; index += 1) { + const iteration = iterations[warmups + index]; + const startedCpu = process.cpuUsage(); + const startedWall = performance.now(); + run(iteration); + wallDurations[index] = performance.now() - startedWall; + const cpuUsage = process.cpuUsage(startedCpu); + cpuDurations[index] = (cpuUsage.user + cpuUsage.system) / 1_000; + const publications = readPublications(); + if (publications !== index + 1) { + throw new Error( + `Readiness iteration ${index} expected ${index + 1} cumulative publications, received ${publications}`, + ); + } + } + return { + cpu: sampleStats(cpuDurations), + wall: sampleStats(wallDurations), + }; +} + +function createCanvasState(nodeCount) { + return deepFreeze({ + schemaVersion: 1, + roots: ["node-0"], + nodes: Array.from({ length: nodeCount }, (_, index) => + createCanvasNode( + index, + index === 0 + ? Array.from({ length: nodeCount - 1 }, (__, child) => `node-${child + 1}`) + : [], + )), + }); +} + +function createCanvasNode(index, children, label = `node-${index}`) { + return { + id: `node-${index}`, + label, + definition: { kind: "intrinsic", id: "div" }, + children, + props: { index, nested: { visible: true } }, + text: null, + layout: {}, + style: {}, + frame: null, + component: null, + }; +} + +function assertCommit(result, scenario) { + if (!result.ok) { + throw new Error(`${scenario} readiness commit failed: ${JSON.stringify(result)}`); + } +} + +function readinessResult(name, stats, cpuBudgetMs) { + return { name, stats, cpuBudgetMs, pass: stats.cpu.p95 <= cpuBudgetMs }; +} + +function envPositiveInteger(name, fallback) { + const value = envPositiveNumber(name, fallback); + if (!Number.isSafeInteger(value)) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value; +} + +function envPositiveNumber(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`${name} must be a positive finite number`); + } + return value; +} + +function sampleStats(values) { + const sorted = [...values].sort((left, right) => left - right); + return { + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + max: sorted.at(-1) ?? 0, + }; +} + +function percentile(sorted, quantile) { + if (sorted.length === 0) return 0; + const index = Math.max(0, Math.ceil(sorted.length * quantile) - 1); + return sorted[index] ?? 0; +} + +function deepFreeze(value) { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + const pending = [value]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined || Object.isFrozen(current)) continue; + Object.freeze(current); + for (const child of Object.values(current)) { + if (child !== null && typeof child === "object" && !Object.isFrozen(child)) { + pending.push(child); + } + } + } + return value; +}