From 8476ba8bf50281e92434484f30631d6357c5e30a 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 02:21:27 +0900 Subject: [PATCH] refactor(json-document): unify replace validation --- docs/changelog.md | 9 + docs/standard/foundation-gate.md | 8 +- .../src/domain/schema/array/path.ts | 72 --- .../src/domain/schema/array/replace.ts | 543 ------------------ .../domain/schema/inspection/introspection.ts | 2 +- .../src/domain/schema/model/knownJson.ts | 3 + .../src/domain/schema/model/value.ts | 24 - .../src/domain/schema/object/record.ts | 2 +- .../src/domain/schema/object/replace.ts | 202 ------- .../src/domain/schema/object/value.ts | 17 +- .../src/domain/schema/validation/patch.ts | 32 +- .../src/domain/schema/validation/replace.ts | 469 +++++++++++---- .../tests/document/schema/integration.test.ts | 17 + .../overlapping-replace-batch.test.ts | 391 +++++++++++++ .../structural-schema-fast-path.test.ts | 10 + 15 files changed, 810 insertions(+), 991 deletions(-) delete mode 100644 packages/json-document/src/domain/schema/array/replace.ts delete mode 100644 packages/json-document/src/domain/schema/object/replace.ts diff --git a/docs/changelog.md b/docs/changelog.md index de279c8f..63416e0c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -34,6 +34,10 @@ All notable changes to this project are documented here. immutable canonical patch records. Mutable ingress values are owned before commit; pre-frozen `trustedInitial` snapshots retain the identity-preserving zero-copy path, while only statically known-JSON schemas skip the JSON scan. +- Changed overlapping replacement validation to inspect the effective final + targets after one atomic batch apply. A later descendant or ancestor replace + may repair an intermediate invalid value; legacy failure ordering remains the + fallback when the batch itself cannot be applied. ### Fixed @@ -51,6 +55,8 @@ All notable changes to this project are documented here. live state, failed previews, or capability probes. - Kept publication-local patch records and revision order across reentrant subscribers, and stopped test-only/no-op patches from creating stale history. +- Rejected inherited prototype properties during schema pointer introspection + and custom standalone string checks used as record-key schemas. ### Performance @@ -80,6 +86,9 @@ All notable changes to this project are documented here. instead of rescanning a validated large base for every replay step. - Added `perf:causal` to compare revision-suffix materialization with the legacy full-journal path at configurable journal sizes. +- Consolidated root-object, same-array, independent, and overlapping replace + schema validation behind one final-target planner and the foundation patch + executor, removing the duplicate schema-side replace executors. ## 1.1.0-rc.0 - 2026-07-11 diff --git a/docs/standard/foundation-gate.md b/docs/standard/foundation-gate.md index d56efb3e..f5a017da 100644 --- a/docs/standard/foundation-gate.md +++ b/docs/standard/foundation-gate.md @@ -105,9 +105,11 @@ Remote transport와 conflict policy는 계속 core 밖에 둔다. 성능상 prepare 단계의 private draft는 같은 batch에서 겹치는 ancestor/descendant replace가 있어도 touched container를 commit당 한 번만 복제한다. 최적화 경로는 순차 RFC 6902 결과, 실패 atomicity, applied order, structural sharing을 reference -경로와 동일하게 유지해야 한다. Plain structural schema의 overlapping replace는 각 -value가 target schema에 허용됨을 빠르게 증명할 수 있을 때만 이 bulk 경로로 승격하고, -그 외 배치는 기존 validation/error-order 경로로 되돌린다. +경로와 동일하게 유지해야 한다. Plain structural schema의 overlapping replace는 batch를 +한 번 적용한 뒤 실제로 남은 final target만 검증한다. 따라서 중간의 invalid value가 뒤의 +ancestor/descendant replace로 복구되면 성공할 수 있다. Batch 적용 실패, 지원하지 않는 +schema 경로, 기존 진단 순서를 보존해야 하는 형태는 canonical full validation 또는 기존 +sequential error-order 경로로 되돌린다. History inverse 계산도 같은 private sequential-replace COW seam을 재사용한다. 각 write 직전 값을 캡처해 기존 operation별 inverse와 undo 순서를 유지하며, inverse를 상위 path diff --git a/packages/json-document/src/domain/schema/array/path.ts b/packages/json-document/src/domain/schema/array/path.ts index ca5797d4..8547d14f 100644 --- a/packages/json-document/src/domain/schema/array/path.ts +++ b/packages/json-document/src/domain/schema/array/path.ts @@ -5,7 +5,6 @@ import { type Pointer, } from "../../../foundation/pointer/index.js"; import type { JSONPatchOperation } from "../../../foundation/patch/index.js"; -import { validateOperationShape } from "../../../foundation/patch/index.js"; import { numericSegment } from "../../../foundation/patch/index.js"; export type AppliedLocalOpSourceValue = { ok: true; value: unknown } | { ok: false }; @@ -53,67 +52,6 @@ export function arrayIndexPathLocation( return index === null ? null : { parent, parentSegments: segments.slice(0, -1), index }; } -export function arrayElementReplaceLocation( - path: Pointer, -): { parent: Pointer; parentSegments: string[]; index: number } | null { - const simple = parseSimpleArrayElementReplacePath(path); - if (simple !== null) return simple; - - const parent = parentPointer(path); - if (parent === null) return null; - let segments: string[]; - try { - segments = parsePointer(path); - } catch { - return null; - } - const segment = segments[segments.length - 1]; - if (segment === undefined) return null; - const index = numericSegment(segment); - return index === null ? null : { parent, parentSegments: segments.slice(0, -1), index }; -} - -export function arrayElementIndexPrefix(parent: Pointer): string { - return parent === "" ? "/" : `${parent}/`; -} - -export function parseKnownArrayElementReplaceIndex(path: Pointer, prefix: string): number | null { - if (!path.startsWith(prefix)) return null; - const indexText = path.slice(prefix.length); - return indexText.includes("/") ? null : numericSegment(indexText); -} - -export function planIndependentReplacePaths(operations: ReadonlyArray): Pointer[] | null { - if (!Array.isArray(operations) || operations.length === 0) return null; - const paths = new Array(operations.length); - - for (let index = 0; index < operations.length; index++) { - if (!(index in operations)) return null; - const op = operations[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || typeof op.path !== "string" || op.path === "") return null; - try { - const segments = parsePointer(op.path); - if (segments.includes("-")) return null; - } catch { - return null; - } - paths[index] = op.path; - } - - return paths; -} - -export function haveIndependentReplacePaths(paths: ReadonlyArray): boolean { - if (!Array.isArray(paths) || paths.length === 0) return false; - const sorted = [...paths].sort(); - for (let index = 1; index < sorted.length; index++) { - const previous = sorted[index - 1]!; - const current = sorted[index]!; - if (current === previous || current.startsWith(`${previous}/`)) return false; - } - return true; -} - export function readArrayAtSegments( state: unknown, segments: ReadonlyArray, @@ -139,13 +77,3 @@ function parseSimpleArrayIndexPath(path: Pointer): { parent: Pointer; index: num const index = segment === "-" ? "-" : numericSegment(segment); return index === null ? null : { parent: path.slice(0, indexSlash), index }; } - -function parseSimpleArrayElementReplacePath(path: Pointer): { parent: Pointer; parentSegments: string[]; index: number } | null { - if (path === "" || path[0] !== "/" || path.includes("~")) return null; - const indexSlash = path.lastIndexOf("/"); - if (indexSlash < 0) return null; - const index = numericSegment(path.slice(indexSlash + 1)); - if (index === null) return null; - const parent = path.slice(0, indexSlash); - return { parent, parentSegments: parent === "" ? [] : parent.slice(1).split("/"), index }; -} diff --git a/packages/json-document/src/domain/schema/array/replace.ts b/packages/json-document/src/domain/schema/array/replace.ts deleted file mode 100644 index ba416c62..00000000 --- a/packages/json-document/src/domain/schema/array/replace.ts +++ /dev/null @@ -1,543 +0,0 @@ -import type * as z from "zod"; -import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; -import { validateOperationShape } from "../../../foundation/patch/index.js"; -import { - arrayFieldText, - parseArrayFieldPath, - parseFirstArrayNestedPath, - parseKnownArrayFieldIndex, - parseKnownArrayNestedIndex, -} from "../../../foundation/patch/index.js"; -import type { ArrayFieldText } from "../../../foundation/patch/index.js"; -import { replaceValueAtSegments } from "../../../foundation/patch/index.js"; -import { parsePointer, type Pointer } from "../../../foundation/pointer/index.js"; -import { - acceptsKnownJsonValueWithValidator, - knownJsonValueValidatorForSchema, -} from "../model/knownJson.js"; -import { - arrayElementSchemaAtParent, - cachedSchemaAtPointer, -} from "../model/schema.js"; -import { replaceObjectDataValue } from "../object/value.js"; -import { - arrayElementIndexPrefix, - arrayElementReplaceLocation, - parseKnownArrayElementReplaceIndex, - readArrayAtSegments, -} from "./path.js"; -import { okLocalSchemaValidation } from "../model/result.js"; -import { - evaluateAppliedReplaceValueValidationPlan, - toAppliedReplaceOperations, - type IndexedReplaceValueValidationOperation, -} from "../model/value.js"; - -export interface ArrayIndexReplacement { - index: number; - value: unknown; -} - -export type ArrayReplacementValueResult = - | { ok: true; value: unknown } - | { ok: false }; - -export interface SingleArrayFieldReplacePlan { - arrayPath: Pointer; - index: number; - key: string; - value: unknown; -} - -export interface SameArrayFieldReplacePatchPlan { - arrayPath: Pointer; - arraySegments: string[]; - field: string; - operations: SameArrayFieldReplaceOperationPlan[]; -} - -export interface SameArrayFieldReplaceOperationPlan extends IndexedReplaceValueValidationOperation { - op: "replace"; -} - -export interface SameArrayElementReplacePatchPlan { - parent: Pointer; - parentSegments: string[]; - operations: SameArrayElementReplaceOperationPlan[]; -} - -export interface SameArrayElementReplaceOperationPlan extends IndexedReplaceValueValidationOperation { - op: "replace"; -} - -export interface SameArrayNestedReplacePatchPlan { - arrayPath: Pointer; - arraySegments: string[]; - suffixSegments: string[]; - operations: SameArrayNestedReplaceOperationPlan[]; -} - -export interface SameArrayNestedReplaceOperationPlan extends IndexedReplaceValueValidationOperation { - op: "replace"; -} - -export function applySameArrayFieldReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, - valuesTrusted: boolean, -): ApplyResult | null { - const plan = planSameArrayFieldReplacePatch({ operations: ops }); - if (plan === null) return null; - const first = plan.operations[0]; - if (first === undefined) return null; - const valueSchema = cachedSchemaAtPointer(schema, first.path, "value"); - if (!valueSchema) return null; - - return applyValidatedArrayFieldReplacementsAtSegments({ - state, - arraySegments: plan.arraySegments, - operations: plan.operations, - field: plan.field, - valueSchema, - valuesTrusted, - }); -} - -export function applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, -): ApplyResult | null { - const plan = planSameArrayElementReplacePatch({ operations: ops }); - if (plan === null) return null; - const elementSchema = arrayElementSchemaAtParent(schema, plan.parent); - if (!elementSchema) return null; - return applyKnownJsonArrayIndexReplacementsAtSegments({ - state, - schema: elementSchema, - arraySegments: plan.parentSegments, - operations: plan.operations, - }); -} - -export function applySameArrayNestedReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, - valuesTrusted: boolean, -): ApplyResult | null { - const plan = planSameArrayNestedReplacePatch({ state, operations: ops }); - if (plan === null) return null; - const first = plan.operations[0]; - if (first === undefined) return null; - const valueSchema = cachedSchemaAtPointer(schema, first.path, "value"); - if (!valueSchema) return null; - - return applyValidatedArrayNestedValueReplacementsAtSegments({ - state, - arraySegments: plan.arraySegments, - suffixSegments: plan.suffixSegments, - operations: plan.operations, - valueSchema, - valuesTrusted, - }); -} - -export function planSingleArrayFieldReplace(input: { - path: Pointer; - value: unknown; -}): SingleArrayFieldReplacePlan | null { - const location = parseArrayFieldPath(input.path); - return location === null ? null : { ...location, value: input.value }; -} - -export function planSameArrayElementReplacePatch(input: { - operations: ReadonlyArray; -}): SameArrayElementReplacePatchPlan | null { - const ops = input.operations; - if (!Array.isArray(ops) || ops.length === 0) return null; - if (!(0 in ops)) return null; - const first = ops[0]!; - if (!isReplacePatchOperationCandidate(first)) return null; - - const firstLocation = arrayElementReplaceLocation(first.path); - if (firstLocation === null) return null; - const operations = planSameArrayElementReplaceOperations(ops, firstLocation.parent); - return operations === null - ? null - : { parent: firstLocation.parent, parentSegments: firstLocation.parentSegments, operations }; -} - -export function planSameArrayElementReplaceOperations( - ops: ReadonlyArray, - parent: Pointer, -): SameArrayElementReplaceOperationPlan[] | null { - if (!Array.isArray(ops) || ops.length === 0) return null; - const parentIndexPrefix = arrayElementIndexPrefix(parent); - const operations = new Array(ops.length); - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return null; - const op = ops[opIndex]!; - if (!isReplacePatchOperationCandidate(op)) return null; - const index = parseKnownArrayElementReplaceIndex(op.path, parentIndexPrefix); - if (index === null) return null; - operations[opIndex] = { op: "replace", path: op.path, index, value: op.value }; - } - return operations; -} - -export function planSameArrayFieldReplacePatch(input: { - operations: ReadonlyArray; -}): SameArrayFieldReplacePatchPlan | null { - const ops = input.operations; - if (!Array.isArray(ops) || ops.length < 2) return null; - if (!(0 in ops)) return null; - const first = ops[0]!; - if (!isReplacePatchOperationCandidate(first) || first.path === "") return null; - - const firstLocation = parseArrayFieldPath(first.path); - if (firstLocation === null) return null; - let arraySegments: string[]; - try { - arraySegments = parsePointer(firstLocation.arrayPath); - } catch { - return null; - } - const fieldText = arrayFieldText(first.path); - if (fieldText === null) return null; - const operations = planSameArrayFieldReplaceOperations(ops, firstLocation.arrayPath, firstLocation.key, fieldText); - return operations === null ? null : { arrayPath: firstLocation.arrayPath, arraySegments, field: firstLocation.key, operations }; -} - -export function planSameArrayFieldReplaceOperations( - ops: ReadonlyArray, - arrayPath: Pointer, - field: string, - fieldText: ArrayFieldText, -): SameArrayFieldReplaceOperationPlan[] | null { - if (!Array.isArray(ops) || ops.length < 2) return null; - const operations = new Array(ops.length); - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return null; - const op = ops[opIndex]!; - if (!isReplacePatchOperationCandidate(op) || op.path === "") return null; - const knownIndex = parseKnownArrayFieldIndex(op.path, fieldText); - const location = knownIndex === null - ? parseArrayFieldPath(op.path) - : { arrayPath, index: knownIndex, key: field }; - if (location === null || location.arrayPath !== arrayPath || location.key !== field) return null; - operations[opIndex] = { op: "replace", path: op.path, index: location.index, value: op.value }; - } - return operations; -} - -export function planSameArrayNestedReplacePatch(input: { - state: unknown; - operations: ReadonlyArray; -}): SameArrayNestedReplacePatchPlan | null { - const ops = input.operations; - if (!Array.isArray(ops) || ops.length < 2) return null; - if (!(0 in ops)) return null; - const first = ops[0]!; - if (!isReplacePatchOperationCandidate(first) || first.path === "") return null; - - const firstLocation = parseFirstArrayNestedPath(input.state, first.path); - if (firstLocation === null) return null; - const operations = planSameArrayNestedReplaceOperations( - ops, - firstLocation.arrayPath, - firstLocation.suffixSegments, - firstLocation.prefixText, - firstLocation.suffixText, - ); - return operations === null - ? null - : { arrayPath: firstLocation.arrayPath, arraySegments: firstLocation.arraySegments, suffixSegments: firstLocation.suffixSegments, operations }; -} - -export function planSameArrayNestedReplaceOperations( - ops: ReadonlyArray, - arrayPath: Pointer, - suffixSegments: string[], - prefixText: string, - suffixText: string, -): SameArrayNestedReplaceOperationPlan[] | null { - if (!Array.isArray(ops) || ops.length < 2) return null; - const operations = new Array(ops.length); - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return null; - const op = ops[opIndex]!; - if (!isReplacePatchOperationCandidate(op) || op.path === "") return null; - const index = parseKnownArrayNestedIndex(op.path, arrayPath, suffixSegments, prefixText, suffixText); - if (index === null) return null; - operations[opIndex] = { op: "replace", path: op.path, index, value: op.value }; - } - return operations; -} - -export function isReplacePatchOperationCandidate( - op: JSONPatchOperation, -): op is Extract { - return !!op - && typeof op === "object" - && validateOperationShape(op) === null - && op.op === "replace" - && typeof op.path === "string"; -} - -export function applySingleArrayFieldReplace(input: { - state: unknown; - path: Pointer; - value: unknown; -}): unknown | null { - const { state, path, value } = input; - const plan = planSingleArrayFieldReplace({ path, value }); - if (plan === null) return null; - - const rootArrayReplace = applySingleRootArrayFieldReplace({ state, ...plan }); - if (rootArrayReplace !== null) return rootArrayReplace; - return applyArrayFieldReplaceAtPointer({ state, ...plan }); -} - -export function applyArrayFieldReplaceAtPointer(input: { - state: unknown; - arrayPath: Pointer; - index: number; - key: string; - value: unknown; -}): unknown | null { - const { state, arrayPath, index, key, value } = input; - let arraySegments: string[]; - try { - arraySegments = parsePointer(arrayPath); - } catch { - return null; - } - - const current = readArrayAtSegments(state, arraySegments); - if (!current.ok) return null; - const nextArray = replaceArrayField(current.array, index, key, value); - return nextArray === null ? null : replaceValueAtSegments(state, arraySegments, 0, nextArray); -} - -export function applySingleRootArrayFieldReplace(input: { - state: unknown; - arrayPath: Pointer; - index: number; - key: string; - value: unknown; -}): unknown | null { - const { state, arrayPath, index, key, value } = input; - const target = readSingleRootArrayFieldTarget(state, arrayPath); - if (target === null) return null; - - const nextArray = replaceArrayField(target.array, index, key, value); - if (nextArray === null) return null; - return target.kind === "root" ? nextArray : { ...target.source, [target.key]: nextArray }; -} - -export function replaceArrayField( - array: ReadonlyArray, - index: number, - key: string, - value: unknown, -): unknown[] | null { - if (index < 0 || index >= array.length) return null; - const replaced = replaceObjectDataValue(array[index], key, value); - if (replaced === null) return null; - const next = array.slice(); - next[index] = replaced; - return next; -} - -export function evaluateArrayIndexReplaceValues< - S extends z.ZodType, - Operation extends IndexedReplaceValueValidationOperation = IndexedReplaceValueValidationOperation, ->(input: { - state: z.output; - array: ReadonlyArray; - operations: ReadonlyArray; - valueSchema: z.ZodType; - valuesTrusted: boolean; - replacementValue: (operation: Operation, currentValue: unknown) => ArrayReplacementValueResult; -}): { ok: true; replacements: ArrayIndexReplacement[] } | { ok: false; result: ApplyResult | null } { - const { state, array, operations, valueSchema, valuesTrusted, replacementValue } = input; - const valueValidator = knownJsonValueValidatorForSchema(valueSchema); - const replacements = new Array(operations.length); - - for (let opIndex = 0; opIndex < operations.length; opIndex += 1) { - const op = operations[opIndex]!; - 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 }; - 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 }; -} - -export function applyValidatedArrayIndexReplacements< - S extends z.ZodType, - Operation extends IndexedReplaceValueValidationOperation = IndexedReplaceValueValidationOperation, ->(input: { - state: z.output; - arraySegments: ReadonlyArray; - array: ReadonlyArray; - operations: ReadonlyArray; - valueSchema: z.ZodType; - valuesTrusted: boolean; - replacementValue: (operation: Operation, currentValue: unknown) => ArrayReplacementValueResult; -}): ApplyResult | null { - const replacements = evaluateArrayIndexReplaceValues(input); - if (!replacements.ok) return replacements.result; - const nextState = applyArrayIndexReplacements({ - state: input.state, - arraySegments: input.arraySegments, - array: input.array, - replacements: replacements.replacements, - }); - return nextState === null ? null : okLocalSchemaValidation(nextState as z.output, toAppliedReplaceOperations(input.operations)); -} - -export function applyValidatedArrayFieldReplacementsAtSegments< - S extends z.ZodType, - Operation extends IndexedReplaceValueValidationOperation = IndexedReplaceValueValidationOperation, ->(input: { - state: z.output; - arraySegments: ReadonlyArray; - field: string; - operations: ReadonlyArray; - valueSchema: z.ZodType; - valuesTrusted: boolean; -}): ApplyResult | null { - const current = readArrayAtSegments(input.state, input.arraySegments); - if (!current.ok) return null; - return applyValidatedArrayIndexReplacements({ - state: input.state, - arraySegments: input.arraySegments, - array: current.array, - operations: input.operations, - valueSchema: input.valueSchema, - valuesTrusted: input.valuesTrusted, - replacementValue: (op, currentValue) => { - const replaced = replaceObjectDataValue(currentValue, input.field, op.value); - return replaced === null ? { ok: false } : { ok: true, value: replaced }; - }, - }); -} - -export function applyValidatedArrayNestedValueReplacementsAtSegments< - S extends z.ZodType, - Operation extends IndexedReplaceValueValidationOperation = IndexedReplaceValueValidationOperation, ->(input: { - state: z.output; - arraySegments: ReadonlyArray; - suffixSegments: ReadonlyArray; - operations: ReadonlyArray; - valueSchema: z.ZodType; - valuesTrusted: boolean; -}): ApplyResult | null { - const current = readArrayAtSegments(input.state, input.arraySegments); - if (!current.ok) return null; - const replacements = evaluateArrayIndexReplaceValues({ - state: input.state, - array: current.array, - operations: input.operations, - valueSchema: input.valueSchema, - valuesTrusted: input.valuesTrusted, - replacementValue: (op) => ({ ok: true, value: op.value }), - }); - if (!replacements.ok) return replacements.result; - const nextState = applyArrayNestedReplacements({ - state: input.state, - arraySegments: input.arraySegments, - array: current.array, - suffixSegments: input.suffixSegments, - replacements: replacements.replacements, - }); - return nextState === null ? null : okLocalSchemaValidation(nextState as z.output, toAppliedReplaceOperations(input.operations)); -} - -export function applyKnownJsonArrayIndexReplacementsAtSegments< - S extends z.ZodType, - Operation extends IndexedReplaceValueValidationOperation = IndexedReplaceValueValidationOperation, ->(input: { - state: z.output; - schema: z.ZodType; - arraySegments: ReadonlyArray; - operations: ReadonlyArray; -}): ApplyResult | null { - const current = readArrayAtSegments(input.state, input.arraySegments); - if (!current.ok) return null; - const valueValidator = knownJsonValueValidatorForSchema(input.schema); - const replacements = new Array(input.operations.length); - for (let opIndex = 0; opIndex < input.operations.length; opIndex += 1) { - const op = input.operations[opIndex]!; - if (op.index < 0 || op.index >= current.array.length) return null; - if (!acceptsKnownJsonValueWithValidator(valueValidator, op.value)) return null; - replacements[opIndex] = { index: op.index, value: op.value }; - } - const nextState = applyArrayIndexReplacements({ state: input.state, arraySegments: input.arraySegments, array: current.array, replacements }); - return nextState === null ? null : okLocalSchemaValidation(nextState as z.output, toAppliedReplaceOperations(input.operations)); -} - -export function applyArrayIndexReplacements(input: { - state: unknown; - arraySegments: ReadonlyArray; - array: ReadonlyArray; - replacements: ReadonlyArray; -}): unknown | null { - const next = input.array.slice(); - for (const replacement of input.replacements) { - if (replacement.index < 0 || replacement.index >= next.length) return null; - next[replacement.index] = replacement.value; - } - return replaceValueAtSegments(input.state, input.arraySegments, 0, next); -} - -export function applyArrayNestedReplacements(input: { - state: unknown; - arraySegments: ReadonlyArray; - array: ReadonlyArray; - suffixSegments: ReadonlyArray; - replacements: ReadonlyArray; -}): unknown | null { - const rowReplacements = new Array(input.replacements.length); - for (let index = 0; index < input.replacements.length; index += 1) { - const replacement = input.replacements[index]!; - if (replacement.index < 0 || replacement.index >= input.array.length) return null; - const replaced = replaceValueAtSegments(input.array[replacement.index], input.suffixSegments, 0, replacement.value); - if (replaced === null) return null; - rowReplacements[index] = { index: replacement.index, value: replaced }; - } - return applyArrayIndexReplacements({ - state: input.state, - arraySegments: input.arraySegments, - array: input.array, - replacements: rowReplacements, - }); -} - -function readSingleRootArrayFieldTarget(state: unknown, arrayPath: Pointer): - | { kind: "root"; array: unknown[] } - | { kind: "property"; source: Record; key: string; array: unknown[] } - | null { - if (arrayPath === "") return Array.isArray(state) ? { kind: "root", array: state } : null; - if (arrayPath[0] !== "/" || arrayPath.includes("~") || arrayPath.indexOf("/", 1) !== -1) return null; - const arrayKey = arrayPath.slice(1); - if (arrayKey === "__proto__") return null; - if (state === null || typeof state !== "object" || Array.isArray(state)) return null; - const source = state as Record; - const current = source[arrayKey]; - return Array.isArray(current) ? { kind: "property", source, key: arrayKey, array: current } : null; -} diff --git a/packages/json-document/src/domain/schema/inspection/introspection.ts b/packages/json-document/src/domain/schema/inspection/introspection.ts index e699e43b..dec39f2b 100644 --- a/packages/json-document/src/domain/schema/inspection/introspection.ts +++ b/packages/json-document/src/domain/schema/inspection/introspection.ts @@ -56,7 +56,7 @@ function schemaAtPointerUncached(schema: z.ZodType, pointer: Pointer, mode: "val } const shape = getObjectShape(container); - if (shape && segment in shape) { + if (shape && Object.prototype.hasOwnProperty.call(shape, segment)) { current = shape[segment] ?? null; continue; } diff --git a/packages/json-document/src/domain/schema/model/knownJson.ts b/packages/json-document/src/domain/schema/model/knownJson.ts index 6b2614e9..12c6d731 100644 --- a/packages/json-document/src/domain/schema/model/knownJson.ts +++ b/packages/json-document/src/domain/schema/model/knownJson.ts @@ -191,6 +191,9 @@ export function isPlainStringKeySchema(schema: z.ZodType): boolean { const def = getDef(schema); return def.type === "string" && !def.coerce + && def.check === undefined + && def.fn === undefined + && typeof def.error !== "function" && (!Array.isArray(def.checks) || def.checks.length === 0); } diff --git a/packages/json-document/src/domain/schema/model/value.ts b/packages/json-document/src/domain/schema/model/value.ts index 652c03a3..5502c53f 100644 --- a/packages/json-document/src/domain/schema/model/value.ts +++ b/packages/json-document/src/domain/schema/model/value.ts @@ -31,14 +31,6 @@ export interface AppliedAddValueValidationOperation extends AppliedValueValidati op: "add"; } -export interface AppliedReplaceValueValidationOperation extends AppliedValueValidationOperation { - op: "replace"; -} - -export interface IndexedReplaceValueValidationOperation extends AppliedReplaceValueValidationOperation { - index: number; -} - export interface AppliedRemoveOperation { op: "remove"; path: Pointer; @@ -100,12 +92,6 @@ export function toAppliedAddOperations( return operations.map((op) => ({ op: "add", path: op.path, value: op.value })); } -export function toAppliedReplaceOperations( - operations: ReadonlyArray, -): Extract[] { - return operations.map((op) => ({ op: "replace", path: op.path, value: op.value })); -} - export function toAppliedRemoveOperations( operations: ReadonlyArray, ): Extract[] { @@ -144,13 +130,3 @@ export function evaluateAppliedAddValueValidationPlan( ): ApplyResult | null { return evaluateAppliedValueValidationPlan({ state, operations, schema, knownJsonAccepted, valuesTrusted }); } - -export function evaluateAppliedReplaceValueValidationPlan( - state: z.output, - operations: ReadonlyArray, - schema: z.ZodType, - knownJsonAccepted: (value: unknown) => boolean, - valuesTrusted: boolean, -): ApplyResult | null { - return evaluateAppliedValueValidationPlan({ state, operations, schema, knownJsonAccepted, valuesTrusted }); -} diff --git a/packages/json-document/src/domain/schema/object/record.ts b/packages/json-document/src/domain/schema/object/record.ts index bb0c9de5..12f1a187 100644 --- a/packages/json-document/src/domain/schema/object/record.ts +++ b/packages/json-document/src/domain/schema/object/record.ts @@ -13,10 +13,10 @@ import { createDataKeySet, objectHasOwn, removedRootKeysMatchSuffix, + readRootRecordForLocalSchemaValidation, writeObjectDataValue, } from "./value.js"; import { okLocalSchemaValidation } from "../model/result.js"; -import { readRootRecordForLocalSchemaValidation } from "./replace.js"; import { evaluateLocalSchemaValidationValueValidationPlan, planLocalSchemaValidationValueValidation, diff --git a/packages/json-document/src/domain/schema/object/replace.ts b/packages/json-document/src/domain/schema/object/replace.ts deleted file mode 100644 index eca97401..00000000 --- a/packages/json-document/src/domain/schema/object/replace.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type * as z from "zod"; -import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; -import { validateOperationShape } from "../../../foundation/patch/index.js"; -import { - acceptsKnownJsonValue, - acceptsKnownJsonValueWithValidator, - knownJsonValueValidatorForSchema, -} from "../model/knownJson.js"; -import { - copyRootRecord, - copyRootRecordKeys, - createDataKeySet, - objectHasOwn, - writeObjectDataValue, -} from "./value.js"; -import { okLocalSchemaValidation } from "../model/result.js"; -import { - evaluateLocalSchemaValidationValueValidationPlan, - planLocalSchemaValidationValueValidation, - toAppliedReplaceOperations, - type AppliedReplaceValueValidationOperation, - type LocalSchemaValidationValueValidationPlan, -} from "../model/value.js"; -import { getDef, getObjectShape } from "../inspection/zod.js"; - -export type RootObjectReplacePatchStrategy = "orderedReplace" | "copyWrite"; - -export interface RootObjectReplaceOperationPlan extends AppliedReplaceValueValidationOperation { - op: "replace"; - key: string; -} - -export interface RootObjectReplacePatchPlan { - operations: RootObjectReplaceOperationPlan[]; - strategy: RootObjectReplacePatchStrategy; -} - -export interface SingleRootObjectReplacePatchPlan { - operation: Extract; - key: string; -} - -export type RootObjectReplaceValueSource = - | { kind: "object"; shape: Record } - | { kind: "record"; schema: z.ZodType; acceptsKnownJson: (value: unknown) => boolean }; - -export function applyRootObjectReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, - valuesTrusted: boolean, -): ApplyResult | null { - if (!Array.isArray(ops) || ops.length < 2) return null; - const root = readRootRecordForLocalSchemaValidation(state); - if (!root.ok) return null; - const plan = planRootObjectReplacePatch(ops, root.sourceKeys); - if (plan === null) return null; - const valueSource = rootObjectReplaceValueSourceForLocalSchemaValidation(schema); - if (valueSource === null) return null; - const valueValidation = evaluateRootObjectReplaceValues(state, plan.operations, valueSource, valuesTrusted); - if (!valueValidation.ok) return valueValidation.result; - const resultState = applyRootObjectReplacePlan(root.source, root.sourceKeys, plan); - return okLocalSchemaValidation(resultState as z.output, toAppliedReplaceOperations(plan.operations)); -} - -export function rootObjectReplaceValueSourceForLocalSchemaValidation(schema: z.ZodType): RootObjectReplaceValueSource | null { - const shape = getObjectShape(schema); - if (shape !== null) return { kind: "object", shape }; - const rootDef = getDef(schema); - const recordValueSchema = rootDef.type === "record" ? (rootDef.valueType ?? null) : null; - if (recordValueSchema === null) return null; - const recordValueValidator = knownJsonValueValidatorForSchema(recordValueSchema); - return { - kind: "record", - schema: recordValueSchema, - acceptsKnownJson: (value) => acceptsKnownJsonValueWithValidator(recordValueValidator, value), - }; -} - -export function evaluateRootObjectReplaceValues( - state: z.output, - operations: ReadonlyArray, - source: RootObjectReplaceValueSource, - valuesTrusted: boolean, -): { ok: true } | { ok: false; result: ApplyResult | null } { - for (const op of operations) { - const valueValidation = planRootObjectReplaceValueValidation(source, op, valuesTrusted); - if (valueValidation === null) return { ok: false, result: null }; - const valueFailure = evaluateLocalSchemaValidationValueValidationPlan(state, valueValidation); - if (valueFailure) return { ok: false, result: valueFailure }; - } - return { ok: true }; -} - -export function planRootObjectReplaceValueValidation( - source: RootObjectReplaceValueSource, - operation: RootObjectReplaceOperationPlan, - valuesTrusted: boolean, -): LocalSchemaValidationValueValidationPlan | null { - const valueSchema = rootObjectReplaceValueSchema(source, operation.key); - if (valueSchema === null) return null; - return planLocalSchemaValidationValueValidation({ - path: operation.path, - schema: valueSchema, - value: operation.value, - knownJsonAccepted: source.kind === "record" ? source.acceptsKnownJson(operation.value) : acceptsKnownJsonValue(valueSchema, operation.value), - valuesTrusted, - }); -} - -export function applyRootObjectReplacePlan( - source: Record, - sourceKeys: ReadonlyArray, - plan: RootObjectReplacePatchPlan, -): Record { - const next = plan.strategy === "orderedReplace" ? {} : copyRootRecordKeys(source, sourceKeys); - for (const op of plan.operations) writeObjectDataValue(next, op.key, op.value); - return next; -} - -export function applySingleRootObjectReplacePlan( - source: Record, - plan: SingleRootObjectReplacePatchPlan, -): Record { - const next = copyRootRecord(source); - writeObjectDataValue(next, plan.key, plan.operation.value); - return next; -} - -export function planRootObjectReplacePatch( - operationsInput: ReadonlyArray, - sourceKeys: ReadonlyArray, -): RootObjectReplacePatchPlan | null { - const operations = planRootObjectReplaceOperations(operationsInput, sourceKeys); - return operations === null ? null : { operations, strategy: planRootObjectReplaceStrategy(operations, sourceKeys) }; -} - -export function planRootObjectReplaceOperations( - ops: ReadonlyArray, - sourceKeys: ReadonlyArray, -): RootObjectReplaceOperationPlan[] | null { - if (!Array.isArray(ops) || ops.length < 2 || !Array.isArray(sourceKeys)) return null; - const sourceKeySet = createDataKeySet(sourceKeys); - const operations: RootObjectReplaceOperationPlan[] = []; - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return null; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || typeof op.path !== "string" || op.path[0] !== "/" || op.path.includes("~") || op.path.indexOf("/", 1) !== -1) return null; - const key = op.path.slice(1); - if (key === "" || !objectHasOwn.call(sourceKeySet, key)) return null; - operations.push({ op: "replace", path: op.path, key, value: op.value }); - } - return operations; -} - -export function planSingleRootObjectReplacePatch( - operation: Extract, - sourceKeys: ReadonlyArray, -): SingleRootObjectReplacePatchPlan | null { - if ( - !Array.isArray(sourceKeys) - || validateOperationShape(operation) !== null - || operation.op !== "replace" - || typeof operation.path !== "string" - || operation.path[0] !== "/" - || operation.path.includes("~") - || operation.path.indexOf("/", 1) !== -1 - ) { - return null; - } - - const key = operation.path.slice(1); - if (key === "") return null; - for (const sourceKey of sourceKeys) { - if (sourceKey === key) return { operation, key }; - } - return null; -} - -export function planRootObjectReplaceStrategy( - operations: ReadonlyArray, - sourceKeys: ReadonlyArray, -): RootObjectReplacePatchStrategy { - if (operations.length !== sourceKeys.length) return "copyWrite"; - for (let index = 0; index < operations.length; index += 1) { - if (operations[index]!.key !== sourceKeys[index]) return "copyWrite"; - } - return "orderedReplace"; -} - -export function readRootRecordForLocalSchemaValidation(state: unknown): - | { ok: true; source: Record; sourceKeys: string[] } - | { ok: false } { - if (state === null || typeof state !== "object" || Array.isArray(state)) return { ok: false }; - const source = state as Record; - return { ok: true, source, sourceKeys: Object.keys(source) }; -} - -function rootObjectReplaceValueSchema(source: RootObjectReplaceValueSource, key: string): z.ZodType | null { - if (source.kind === "record") return source.schema; - return objectHasOwn.call(source.shape, key) ? (source.shape[key] ?? null) : null; -} diff --git a/packages/json-document/src/domain/schema/object/value.ts b/packages/json-document/src/domain/schema/object/value.ts index 41843a10..267fdc8a 100644 --- a/packages/json-document/src/domain/schema/object/value.ts +++ b/packages/json-document/src/domain/schema/object/value.ts @@ -21,17 +21,16 @@ export function writeObjectDataValue(target: Record, key: strin } } -export function replaceObjectDataValue(current: unknown, key: string, value: unknown): Record | null { - if (current === null || typeof current !== "object" || Array.isArray(current)) return null; - if (!objectHasOwn.call(current, key)) return null; - - const next = { ...(current as Record) }; - writeObjectDataValue(next, key, value); - return next; -} - export function createDataKeySet(keys: ReadonlyArray): Record { const keySet = Object.create(null) as Record; for (const key of keys) keySet[key] = true; return keySet; } + +export function readRootRecordForLocalSchemaValidation(state: unknown): + | { ok: true; source: Record; sourceKeys: string[] } + | { ok: false } { + if (state === null || typeof state !== "object" || Array.isArray(state)) return { ok: false }; + const source = state as Record; + return { ok: true, source, sourceKeys: Object.keys(source) }; +} diff --git a/packages/json-document/src/domain/schema/validation/patch.ts b/packages/json-document/src/domain/schema/validation/patch.ts index c3493b0a..2fd0fd61 100644 --- a/packages/json-document/src/domain/schema/validation/patch.ts +++ b/packages/json-document/src/domain/schema/validation/patch.ts @@ -10,11 +10,6 @@ import { evaluateArrayAddElementValues, applyIncreasingArrayAddPatchWithLocalSchemaValidation, } from "../array/add.js"; -import { - applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation, - applySameArrayFieldReplacePatchWithLocalSchemaValidation, - applySameArrayNestedReplacePatchWithLocalSchemaValidation, -} from "../array/replace.js"; import { cachedSchemaAtPointer, isPlainStructuralSchema, @@ -22,16 +17,13 @@ import { } from "../model/schema.js"; import { arrayElementSchemaAtPath } from "./schema.js"; import { - applyKnownJsonReplacePatchWithLocalSchemaValidation, - applyReplacePatchWithLocalSchemaValidation, + applyFinalStateReplacePatchWithLocalSchemaValidation, applySingleReplacePatchWithLocalSchemaValidation, - planIndependentReplacePatch, } from "./replace.js"; import { applyRootRecordAddPatchWithLocalSchemaValidation, applyRootRecordRemovePatchWithLocalSchemaValidation, } from "../object/record.js"; -import { applyRootObjectReplacePatchWithLocalSchemaValidation } from "../object/replace.js"; import { arrayIndexInParent, arrayIndexPathLocation, @@ -79,23 +71,15 @@ export function applyPatchWithLocalSchemaValidation( const singleReplace = applySingleReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (singleReplace) return singleReplace; - if (!supportsAllLocalValidation) { - return planIndependentReplacePatch(ops) - ? applyReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted) + + const replaceBatch = applyFinalStateReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); + if (replaceBatch.kind === "handled") return replaceBatch.result; + if (replaceBatch.kind === "operationFailure") { + return supportsAllLocalValidation + ? applySequentialPatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted) : null; } - - const sameArrayFieldReplace = applySameArrayFieldReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); - if (sameArrayFieldReplace) return sameArrayFieldReplace; - const sameArrayElementReplace = applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation(schema, state, ops); - if (sameArrayElementReplace) return sameArrayElementReplace; - const sameArrayNestedReplace = applySameArrayNestedReplacePatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); - 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); + if (replaceBatch.kind === "requiresFullValidation" || !supportsAllLocalValidation) return null; const rootRecordAdd = applyRootRecordAddPatchWithLocalSchemaValidation(schema, state, ops, valuesTrusted); if (rootRecordAdd) return rootRecordAdd; diff --git a/packages/json-document/src/domain/schema/validation/replace.ts b/packages/json-document/src/domain/schema/validation/replace.ts index cf499dc6..06303811 100644 --- a/packages/json-document/src/domain/schema/validation/replace.ts +++ b/packages/json-document/src/domain/schema/validation/replace.ts @@ -1,160 +1,405 @@ import type * as z from "zod"; import type { ApplyResult, JSONPatchOperation } from "../../../foundation/patch/index.js"; -import { applyAcceptedPatch, applyTrustedPatch } from "../../../foundation/patch/index.js"; -import { validateOperationShape } from "../../../foundation/patch/index.js"; -import { cachedSchemaAtPointer } from "../model/schema.js"; -import { acceptsKnownJsonValue } from "../model/knownJson.js"; -import { - haveIndependentReplacePaths, - planIndependentReplacePaths as planIndependentReplacePathsRaw, -} from "../array/path.js"; -import { failedLocalSchemaValidation, okLocalSchemaValidation } from "../model/result.js"; import { - applySingleArrayFieldReplace, - applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation, -} from "../array/replace.js"; + applyAcceptedPatch, + applyTrustedPatch, + numericSegment, + validateOperationShape, +} from "../../../foundation/patch/index.js"; import { - applySingleRootObjectReplacePlan, - planSingleRootObjectReplacePatch, - readRootRecordForLocalSchemaValidation, -} from "../object/replace.js"; + readAt, + tryParsePointer, + type Pointer, +} from "../../../foundation/pointer/index.js"; +import { acceptsKnownJsonValue } from "../model/knownJson.js"; +import { failedLocalSchemaValidation, okLocalSchemaValidation } from "../model/result.js"; +import { cachedSchemaAtPointer, isPlainStructuralSchema } from "../model/schema.js"; import { evaluateLocalSchemaValidationValueValidationPlans, planLocalSchemaValidationValueValidation, + type LocalSchemaValidationValueValidationPlan, } from "../model/value.js"; +import { getDef, getObjectShape } from "../inspection/zod.js"; -export function applyReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, - valuesTrusted: boolean, -): ApplyResult | null { - const acceptedValues = valuesTrusted ? null : applyKnownJsonReplacePatchWithLocalSchemaValidation(schema, state, ops); - if (acceptedValues) return acceptedValues; - const applied = applyReplaceOperations(state, ops, valuesTrusted); - if (!applied.result.ok) return failedLocalSchemaValidation(state, applied.result); - const validation = evaluateAppliedReplaceOperations(schema, state, applied.applied, valuesTrusted); - if (!validation.ok) return validation.result; - return okLocalSchemaValidation(applied.state as z.output, applied.applied); +type ReplaceOperation = Extract; + +export type ReplacePatchAttempt = + | { kind: "notApplicable" } + | { kind: "requiresFullValidation" } + | { kind: "operationFailure" } + | { kind: "handled"; result: ApplyResult }; + +interface ReplaceValidationTarget { + path: Pointer; + segments: string[]; + order: number; + value: unknown; + readFromState: boolean; } -export function applyReplaceOperations( - state: unknown, - operations: ReadonlyArray, - valuesTrusted: boolean, -): { state: unknown; result: ApplyResult["result"]; applied: ReadonlyArray } { - const applied = valuesTrusted ? applyAcceptedPatch(state, operations) : applyTrustedPatch(state, operations); - return applied.result.ok - ? { state: applied.state, result: applied.result, applied: applied.applied } - : { state, result: applied.result, applied: [] }; +interface ReplaceTargetTrieNode { + children: Map; + target?: ReplaceValidationTarget; +} + +interface ReplaceBatchPlan { + inputs: ReplaceValidationTarget[]; + targets: ReplaceValidationTarget[]; + overlapping: boolean; + legacyValidationMode: "first" | "aggregate" | null; } -export function evaluateAppliedReplaceOperations( +type ReplaceBatchPlanResult = + | { kind: "notApplicable" } + | { kind: "requiresFullValidation" } + | { kind: "planned"; plan: ReplaceBatchPlan }; + +export function applyFinalStateReplacePatchWithLocalSchemaValidation( schema: S, state: z.output, 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 }; - validationPlans.push(valueValidation); +): ReplacePatchAttempt { + const planned = planReplaceBatch(schema, state, operations); + if (planned.kind !== "planned") return planned; + + if (planned.plan.legacyValidationMode === "first") { + for (const target of planned.plan.inputs) { + const legacyPlans = planFinalReplaceValidations(schema, state, [target], valuesTrusted); + if (legacyPlans === null) { + return planned.plan.inputs.some((input) => input.segments.includes("-")) + ? { kind: "notApplicable" } + : { kind: "requiresFullValidation" }; + } + const failure = evaluateLocalSchemaValidationValueValidationPlans(state, legacyPlans); + if (failure) return { kind: "handled", result: failure }; + } + } else if (planned.plan.legacyValidationMode === "aggregate") { + const legacyPlans = planFinalReplaceValidations( + schema, + state, + planned.plan.inputs, + valuesTrusted, + ); + if (legacyPlans === null) return { kind: "requiresFullValidation" }; + const failure = evaluateLocalSchemaValidationValueValidationPlans(state, legacyPlans); + if (failure) return { kind: "handled", result: failure }; + } + + const applied = valuesTrusted + ? applyAcceptedPatch(state, operations) + : applyTrustedPatch(state, operations); + if (!applied.result.ok) { + return planned.plan.overlapping + ? { kind: "operationFailure" } + : { kind: "handled", result: failedLocalSchemaValidation(state, applied.result) }; + } + + if (planned.plan.legacyValidationMode !== null) { + return { + kind: "handled", + result: okLocalSchemaValidation(applied.state as z.output, applied.applied), + }; + } + + const plans = planFinalReplaceValidations(schema, applied.state, planned.plan.targets, valuesTrusted); + if (plans === null) return { kind: "requiresFullValidation" }; + + const failure = evaluateLocalSchemaValidationValueValidationPlans(state, plans); + if (failure && planned.plan.overlapping && !isPlainStructuralSchema(schema)) { + return { kind: "requiresFullValidation" }; } - const valueFailure = evaluateLocalSchemaValidationValueValidationPlans(state, validationPlans); - return valueFailure ? { ok: false, result: valueFailure } : { ok: true }; + return failure + ? { kind: "handled", result: failure } + : { + kind: "handled", + result: okLocalSchemaValidation(applied.state as z.output, applied.applied), + }; } -export function planAppliedReplaceValueValidation(schema: z.ZodType, operation: JSONPatchOperation, valuesTrusted: boolean) { - if (operation.op !== "replace") return null; +export function applySingleReplacePatchWithLocalSchemaValidation( + schema: S, + state: z.output, + operations: ReadonlyArray, + valuesTrusted: boolean, +): ApplyResult | null { + const operation = singleReplaceOperation(operations); + if (operation === null) return null; const valueSchema = cachedSchemaAtPointer(schema, operation.path, "value"); - if (!valueSchema) return null; - return planLocalSchemaValidationValueValidation({ + if (valueSchema === null) return null; + + const plan = planLocalSchemaValidationValueValidation({ path: operation.path, schema: valueSchema, value: operation.value, knownJsonAccepted: acceptsKnownJsonValue(valueSchema, operation.value), valuesTrusted, }); -} + const failure = evaluateLocalSchemaValidationValueValidationPlans(state, [plan]); + if (failure) return failure; -export function applySingleReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, - valuesTrusted: boolean, -): ApplyResult | null { - const op = planSingleReplacePatch(ops); - if (op === null) return null; - const valueValidation = evaluateAppliedReplaceOperations(schema, state, [op], valuesTrusted); - if (!valueValidation.ok) return valueValidation.result; - const applied = applySingleReplaceOperation(state, op); + const applied = applyAcceptedPatch(state, [operation]); return applied.result.ok ? okLocalSchemaValidation(applied.state as z.output, applied.applied) : failedLocalSchemaValidation(state, applied.result); } -export function planSingleReplacePatch(ops: ReadonlyArray): Extract | null { - if (!Array.isArray(ops) || ops.length !== 1 || !(0 in ops)) return null; - const op = ops[0]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || typeof op.path !== "string" || op.path === "") return null; - return op; +function isReplaceBatchCandidate(operations: ReadonlyArray): boolean { + if (!Array.isArray(operations) || operations.length < 2) return false; + for (let index = 0; index < operations.length; index += 1) { + if (!(index in operations)) return false; + const operation = operations[index]; + if (operation === null || typeof operation !== "object" || operation.op !== "replace") return false; + } + return true; } -export function applySingleReplaceOperation( +function planReplaceBatch( + schema: z.ZodType, state: unknown, - operation: Extract, -): { state: unknown; result: ApplyResult["result"]; applied: ReadonlyArray } { - const singleArrayFieldReplace = applySingleArrayFieldReplace({ state, path: operation.path, value: operation.value }); - if (singleArrayFieldReplace !== null) return { state: singleArrayFieldReplace, result: { ok: true }, applied: [operation] }; - const root = readRootRecordForLocalSchemaValidation(state); - if (root.ok) { - const plan = planSingleRootObjectReplacePatch(operation, root.sourceKeys); - if (plan !== null) return { state: applySingleRootObjectReplacePlan(root.source, plan), result: { ok: true }, applied: [operation] }; + operations: ReadonlyArray, +): ReplaceBatchPlanResult { + if (!isReplaceBatchCandidate(operations)) return { kind: "notApplicable" }; + + const candidates = new Array(operations.length); + for (let index = 0; index < operations.length; index += 1) { + const operation = operations[index]!; + if ( + validateOperationShape(operation) !== null + || operation.op !== "replace" + || typeof operation.path !== "string" + ) { + return { kind: "requiresFullValidation" }; + } + const segments = tryParsePointer(operation.path); + if (segments === null) { + return { kind: "requiresFullValidation" }; + } + candidates[index] = { + path: operation.path, + segments, + order: index, + value: operation.value, + readFromState: false, + }; } - const applied = applyAcceptedPatch(state, [operation]); - return applied.result.ok - ? { state: applied.state, result: applied.result, applied: applied.applied } - : { state, result: applied.result, applied: [] }; + + const legacyValidationMode = legacyReplaceValidationMode(schema, state, operations, candidates); + if ( + legacyValidationMode === null + && candidates.some((candidate) => candidate.segments.includes("-")) + ) { + // `-` is an ordinary object key but an invalid replace index for arrays. + // Keep ambiguous batches on the established sequential/full-validation + // fallback because its failure ordering is part of the public contract. + return { kind: "notApplicable" }; + } + let targets = candidates; + let overlapping = false; + if (legacyValidationMode === null && !haveUniqueCanonicalPathsAtSameDepth(candidates)) { + const root: ReplaceTargetTrieNode = { children: new Map() }; + for (let index = candidates.length - 1; index >= 0; index -= 1) { + overlapping = addFinalTargetFromRight(root, candidates[index]!) || overlapping; + } + targets = collectReplaceTargets(root); + targets.sort((left, right) => left.order - right.order); + } + return { + kind: "planned", + plan: { + inputs: candidates, + targets, + overlapping, + legacyValidationMode, + }, + }; } -export function applyKnownJsonReplacePatchWithLocalSchemaValidation( - schema: S, - state: z.output, - ops: ReadonlyArray, -): ApplyResult | null { - const operations = planKnownJsonReplaceOperations(ops); - if (operations === null) return null; - const sameArrayElementReplace = applyKnownJsonSameArrayElementReplacePatchWithLocalSchemaValidation(schema, state, ops); - if (sameArrayElementReplace) return sameArrayElementReplace; - if (!evaluateKnownJsonReplaceValues(schema, operations)) return null; - const applied = applyAcceptedPatch(state, operations); - return applied.result.ok - ? okLocalSchemaValidation(applied.state as z.output, applied.applied) - : failedLocalSchemaValidation(state, applied.result); +function haveUniqueCanonicalPathsAtSameDepth( + candidates: ReadonlyArray, +): boolean { + const depth = candidates[0]!.segments.length; + const paths = new Set(); + for (const candidate of candidates) { + if ( + candidate.path[0] !== "/" + || candidate.segments.length !== depth + || paths.has(candidate.path) + ) { + return false; + } + paths.add(candidate.path); + } + return true; +} + +function singleReplaceOperation( + operations: ReadonlyArray, +): ReplaceOperation | null { + if (!Array.isArray(operations) || operations.length !== 1 || !(0 in operations)) return null; + const operation = operations[0]!; + return validateOperationShape(operation) === null + && operation.op === "replace" + && typeof operation.path === "string" + && operation.path !== "" + ? operation + : null; } -export function planKnownJsonReplaceOperations(ops: ReadonlyArray): Extract[] | null { - if (!Array.isArray(ops) || ops.length === 0) return null; - const operations = new Array>(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return null; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || typeof op.path !== "string") return null; - operations[index] = op; +function addFinalTargetFromRight( + root: ReplaceTargetTrieNode, + candidate: ReplaceValidationTarget, +): boolean { + let node = root; + for (const segment of candidate.segments) { + if (node.target) return true; + let child = node.children.get(segment); + if (!child) { + child = { children: new Map() }; + node.children.set(segment, child); + } + node = child; } - return operations; + + if (node.target) { + node.target.order = candidate.order; + return true; + } + const overlapping = node.children.size > 0; + candidate.readFromState = overlapping; + node.children.clear(); + node.target = candidate; + return overlapping; } -export function evaluateKnownJsonReplaceValues(schema: z.ZodType, operations: ReadonlyArray>): boolean { - for (const op of operations) { - const valueSchema = cachedSchemaAtPointer(schema, op.path, "value"); - if (!valueSchema || !acceptsKnownJsonValue(valueSchema, op.value)) return false; +function collectReplaceTargets(root: ReplaceTargetTrieNode): ReplaceValidationTarget[] { + const targets: ReplaceValidationTarget[] = []; + const pending = [root]; + while (pending.length > 0) { + const node = pending.pop()!; + if (node.target) targets.push(node.target); + for (const child of node.children.values()) pending.push(child); } - return true; + return targets; +} + +function planFinalReplaceValidations( + schema: z.ZodType, + state: unknown, + targets: ReadonlyArray, + valuesTrusted: boolean, +): LocalSchemaValidationValueValidationPlan[] | null { + const plans: LocalSchemaValidationValueValidationPlan[] = []; + for (const target of targets) { + const valueSchema = cachedSchemaAtPointer(schema, target.path, "value"); + if (valueSchema === null) return null; + let value = target.value; + if (target.readFromState) { + const read = readAt(state, target.segments); + if (!read.ok) return null; + value = read.value; + } + plans.push(planLocalSchemaValidationValueValidation({ + path: target.path, + schema: valueSchema, + value, + knownJsonAccepted: acceptsKnownJsonValue(valueSchema, value), + valuesTrusted, + })); + } + return plans; +} + +function isFlatRootReplaceBatch( + schema: z.ZodType, + state: unknown, + operations: ReadonlyArray, +): boolean { + const def = getDef(schema); + if (getObjectShape(schema) === null && (def.type !== "record" || !def.valueType)) return false; + return state === null + || typeof state !== "object" + || Array.isArray(state) + ? false + : operations.every((operation) => operation.op === "replace" + && operation.path.length > 1 + && operation.path[0] === "/" + && !operation.path.includes("~") + && operation.path.indexOf("/", 1) === -1 + && Object.prototype.hasOwnProperty.call(state, operation.path.slice(1))); +} + +function legacyReplaceValidationMode( + schema: z.ZodType, + state: unknown, + operations: ReadonlyArray, + candidates: ReadonlyArray, +): "first" | "aggregate" | null { + if (!isPlainStructuralSchema(schema)) return null; + if (isFlatRootReplaceBatch(schema, state, operations)) return "first"; + return isSameArrayValueBatch(state, candidates) ? "aggregate" : null; +} + +function isSameArrayValueBatch( + state: unknown, + candidates: ReadonlyArray, +): boolean { + const reference = candidates[0]!.segments; + if (isSameArrayFieldBatch(state, candidates, reference)) return true; + if (reference.length < 3) return false; + + for (let indexPosition = 0; indexPosition < reference.length - 1; indexPosition += 1) { + if (numericSegment(reference[indexPosition]!) === null) continue; + const parent = readAt(state, reference.slice(0, indexPosition)); + if (!parent.ok || !Array.isArray(parent.value)) continue; + const array = parent.value; + return candidates.every((candidate) => { + if (candidate.segments.length !== reference.length) return false; + const index = numericSegment(candidate.segments[indexPosition]!); + return index !== null + && index < array.length + && segmentsMatchExcept(candidate.segments, reference, indexPosition); + }); + } + return false; } -export function planIndependentReplacePatch(operations: ReadonlyArray): boolean { - const paths = planIndependentReplacePathsRaw(operations); - return paths === null ? false : haveIndependentReplacePaths(paths); +function isSameArrayFieldBatch( + state: unknown, + candidates: ReadonlyArray, + reference: ReadonlyArray, +): boolean { + const indexPosition = reference.length - 2; + if (indexPosition < 0 || numericSegment(reference[indexPosition]!) === null) return false; + const parent = readAt(state, reference.slice(0, indexPosition)); + if (!parent.ok || !Array.isArray(parent.value)) return false; + const array = parent.value; + const field = reference[indexPosition + 1]!; + + return candidates.every((candidate) => { + if (candidate.segments.length !== reference.length) return false; + const index = numericSegment(candidate.segments[indexPosition]!); + if ( + index === null + || !segmentsMatchExcept(candidate.segments, reference, indexPosition) + ) { + return false; + } + const row = array[index]; + return row !== null + && typeof row === "object" + && !Array.isArray(row) + && Object.prototype.hasOwnProperty.call(row, field); + }); +} + +function segmentsMatchExcept( + left: ReadonlyArray, + right: ReadonlyArray, + except: number, +): boolean { + for (let index = 0; index < left.length; index += 1) { + if (index !== except && left[index] !== right[index]) return false; + } + return true; } diff --git a/packages/json-document/tests/document/schema/integration.test.ts b/packages/json-document/tests/document/schema/integration.test.ts index ca07ee65..550b7d16 100644 --- a/packages/json-document/tests/document/schema/integration.test.ts +++ b/packages/json-document/tests/document/schema/integration.test.ts @@ -166,4 +166,21 @@ describe("doc.schema — schema introspection facade", () => { pointer: "/missing", }); }); + + test("rejects inherited Object.prototype properties as schema paths", () => { + const doc = createJSONDocument(Schema, initial); + + for (const key of ["constructor", "toString", "__proto__"]) { + const path = `/${key}`; + const expected = { + ok: false, + code: "path_not_found", + reason: `schema path not found: ${path}`, + pointer: path, + }; + + expect(doc.schema.at(path)).toEqual(expected); + expect(doc.schema.accepts(path, "value")).toEqual(expected); + } + }); }); diff --git a/packages/json-document/tests/regression/overlapping-replace-batch.test.ts b/packages/json-document/tests/regression/overlapping-replace-batch.test.ts index b8461a81..24f25b5b 100644 --- a/packages/json-document/tests/regression/overlapping-replace-batch.test.ts +++ b/packages/json-document/tests/regression/overlapping-replace-batch.test.ts @@ -119,6 +119,22 @@ describe("overlapping replace batch", () => { expect(ancestorThenDescendant.value.items[0]?.meta).toEqual({ tag: "ancestor", rank: 22 }); }); + test("validates the final state after overlapping replacements repair intermediate values", () => { + const descendantThenAncestor = createDocument(); + expect(descendantThenAncestor.patch([ + { op: "replace", path: "/items/0/meta/rank", value: "temporarily-invalid" }, + { op: "replace", path: "/items/0/meta", value: { tag: "ancestor", rank: 21 } }, + ])).toEqual({ ok: true }); + expect(descendantThenAncestor.value.items[0]?.meta).toEqual({ tag: "ancestor", rank: 21 }); + + const ancestorThenDescendant = createDocument(); + expect(ancestorThenDescendant.patch([ + { op: "replace", path: "/items/0/meta", value: { tag: "ancestor", rank: "temporarily-invalid" } }, + { op: "replace", path: "/items/0/meta/rank", value: 22 }, + ])).toEqual({ ok: true }); + expect(ancestorThenDescendant.value.items[0]?.meta).toEqual({ tag: "ancestor", rank: 22 }); + }); + test("keeps leading test assertions in forward history without adding inverses", () => { const doc = createDocument(); const operations: JSONPatchOperation[] = [ @@ -247,6 +263,272 @@ describe("overlapping replace batch", () => { expect(pathFirst.value).toBe(pathFirstBefore); }); + test("preserves whole-batch failure priority and indexes for independent replacements", () => { + const ErrorSchema = z.object({ + first: z.string().min(1), + second: z.string(), + }); + + const nonSerializable = createJSONDocument(ErrorSchema, { first: "ok", second: "ok" }); + const serializationFailure = nonSerializable.patch([ + { op: "replace", path: "/first", value: "" }, + { op: "replace", path: "/second", value: () => "invalid" } as never, + ]); + expect(serializationFailure).toMatchObject({ ok: false, code: "not_serializable" }); + if (!serializationFailure.ok) expect(serializationFailure.reason).toContain("op[1]"); + + const missingPath = createJSONDocument(ErrorSchema, { first: "ok", second: "ok" }); + expect(missingPath.patch([ + { op: "replace", path: "/first", value: "" }, + { op: "replace", path: "/missing", value: "value" }, + ])).toMatchObject({ + ok: false, + code: "path_not_found", + pointer: "/missing", + reason: expect.stringContaining("op[1]"), + }); + }); + + test("preserves the first violation for flat root replacement batches", () => { + const FlatSchema = z.object({ first: z.number(), second: z.number() }); + const doc = createJSONDocument(FlatSchema, { first: 1, second: 2 }); + const result = doc.canPatch([ + { op: "replace", path: "/second", value: "invalid" }, + { op: "replace", path: "/first", value: "invalid" }, + ]); + + expect(result).toMatchObject({ ok: false, code: "schema_violation" }); + if (!result.ok) { + expect(result.violations).toEqual([ + { path: "/second", message: expect.any(String) }, + ]); + } + }); + + test("preserves pre-apply validation precedence for legacy root and array batches", () => { + const RootSchema = z.object({ first: z.number(), second: z.string() }); + const invalidFirst = createJSONDocument(RootSchema, { first: 1, second: "ok" }); + expect(invalidFirst.canPatch([ + { op: "replace", path: "/first", value: "invalid" }, + { op: "replace", path: "/second", value: () => "invalid" } as never, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/first", message: expect.any(String) }], + }); + + const invalidSecond = createJSONDocument(RootSchema, { first: 1, second: "ok" }); + const rootSerialization = invalidSecond.patch([ + { op: "replace", path: "/first", value: 2 }, + { op: "replace", path: "/second", value: () => "invalid" } as never, + ]); + expect(rootSerialization).toMatchObject({ ok: false, code: "not_serializable" }); + if (!rootSerialization.ok) expect(rootSerialization.reason).not.toContain("op[1]"); + + const ArraySchema = z.object({ + items: z.array(z.object({ value: z.number() })), + }); + const array = createJSONDocument(ArraySchema, { + items: [{ value: 1 }, { value: 2 }], + }); + const arraySerialization = array.patch([ + { op: "replace", path: "/items/0/value", value: 3 }, + { op: "replace", path: "/items/1/value", value: () => 4 } as never, + ]); + expect(arraySerialization).toMatchObject({ ok: false, code: "not_serializable" }); + if (!arraySerialization.ok) expect(arraySerialization.reason).not.toContain("op[1]"); + }); + + test("preserves root pre-validation before an unsupported existing key", () => { + const CatchallSchema = z.object({ a: z.number() }).catchall(z.number()); + const doc = createJSONDocument(CatchallSchema, { a: 1, extra: 2 }); + const result = doc.canPatch([ + { op: "replace", path: "/a", value: "invalid" }, + { op: "replace", path: "/extra", value: () => 2 } as never, + ]); + + expect(result).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/a", message: expect.any(String) }], + }); + }); + + test("keeps full violation aggregation for wrapped root objects", () => { + const BaseSchema = z.object({ a: z.number(), b: z.number() }); + const schemas = [ + BaseSchema.optional(), + BaseSchema.nullable(), + z.lazy(() => BaseSchema), + ]; + + for (const schema of schemas) { + const doc = createJSONDocument(schema, { a: 1, b: 2 }); + const result = doc.canPatch([ + { op: "replace", path: "/a", value: "invalid" }, + { op: "replace", path: "/b", value: "invalid" }, + ]); + + expect(result).toMatchObject({ ok: false, code: "schema_violation" }); + if (!result.ok) { + expect(result.violations?.map((violation) => violation.path)).toEqual([ + "/a", + "/b", + ]); + } + } + }); + + test("preserves array pre-validation before a later missing leaf", () => { + const FieldSchema = z.object({ + items: z.array(z.object({ value: z.number().optional() })), + }); + const field = createJSONDocument(FieldSchema, { items: [{ value: 1 }, {}] }); + expect(field.canPatch([ + { op: "replace", path: "/items/0/value", value: "invalid" }, + { op: "replace", path: "/items/1/value", value: 2 }, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/items/0/value", message: expect.any(String) }], + }); + + const NestedSchema = z.object({ + items: z.array(z.object({ + nested: z.object({ x: z.number().optional() }), + })), + }); + const nested = createJSONDocument(NestedSchema, { + items: [{ nested: { x: 1 } }, { nested: {} }], + }); + expect(nested.canPatch([ + { op: "replace", path: "/items/0/nested/x", value: "invalid" }, + { op: "replace", path: "/items/1/nested/x", value: 2 }, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/items/0/nested/x", message: expect.any(String) }], + }); + }); + + test("does not widen legacy aggregation past the first nested array", () => { + const InnerArraySchema = z.object({ + rows: z.array(z.object({ + cells: z.array(z.object({ nested: z.object({ x: z.number() }) })), + })), + }); + const doc = createJSONDocument(InnerArraySchema, { + rows: [{ + cells: [ + { nested: { x: 1 } }, + { nested: { x: 2 } }, + ], + }], + }); + const result = doc.patch([ + { op: "replace", path: "/rows/0/cells/0/nested/x", value: 3 }, + { op: "replace", path: "/rows/0/cells/1/nested/x", value: () => 4 } as never, + ]); + + expect(result).toMatchObject({ ok: false, code: "not_serializable" }); + if (!result.ok) expect(result.reason).toContain("op[1]"); + }); + + test("handles independent mixed-depth replacements without entering array batching", () => { + const MixedSchema = z.object({ + items: z.array(z.object({ field: z.number().optional() })), + other: z.number(), + }); + const doc = createJSONDocument(MixedSchema, { + items: [{ field: 1 }], + other: 2, + }); + + expect(doc.patch([ + { op: "replace", path: "/items/0/field", value: 3 }, + { op: "replace", path: "/other", value: 4 }, + ])).toEqual({ ok: true }); + expect(doc.value).toEqual({ items: [{ field: 3 }], other: 4 }); + }); + + test("keeps checked root aggregation and whole-batch failure priority", () => { + const CheckedRootSchema = z.object({ + first: z.number().min(0), + second: z.number().min(0), + }); + const checked = createJSONDocument(CheckedRootSchema, { first: 1, second: 2 }); + const checkedResult = checked.canPatch([ + { op: "replace", path: "/first", value: -1 }, + { op: "replace", path: "/second", value: -2 }, + ]); + expect(checkedResult).toMatchObject({ ok: false, code: "schema_violation" }); + if (!checkedResult.ok) { + expect(checkedResult.violations?.map((violation) => violation.path)).toEqual([ + "/first", + "/second", + ]); + } + + const NestedCheckedSchema = z.object({ + item: z.object({ count: z.number().min(0) }), + }); + const missing = createJSONDocument(NestedCheckedSchema, { item: { count: 1 } }); + expect(missing.patch([ + { op: "replace", path: "/item", value: { count: -1 } }, + { op: "replace", path: "/item/missing", value: 2 }, + ])).toMatchObject({ + ok: false, + code: "path_not_found", + pointer: "/item/missing", + reason: expect.stringContaining("op[1]"), + }); + + const nonSerializable = createJSONDocument(NestedCheckedSchema, { item: { count: 1 } }); + expect(nonSerializable.patch([ + { op: "replace", path: "/item", value: { count: -1 } }, + { op: "replace", path: "/item/count", value: () => 2 } as never, + ])).toMatchObject({ + ok: false, + code: "not_serializable", + reason: expect.stringContaining("op[1]"), + }); + }); + + test("keeps canonical violation order for checked overlapping replacements", () => { + const CheckedObject = z.object({ + a: z.number().min(0), + b: z.number().min(0), + }); + const object = createJSONDocument(CheckedObject, { a: 1, b: 2 }); + const objectResult = object.canPatch([ + { op: "replace", path: "/b", value: 3 }, + { op: "replace", path: "/a", value: -1 }, + { op: "replace", path: "/b", value: -2 }, + ]); + expect(objectResult).toMatchObject({ ok: false, code: "schema_violation" }); + if (!objectResult.ok) { + expect(objectResult.violations?.map((violation) => violation.path)).toEqual([ + "/a", + "/b", + ]); + } + + const CheckedArray = z.array(z.number().min(0)); + const array = createJSONDocument(CheckedArray, [1, 2]); + const arrayResult = array.canPatch([ + { op: "replace", path: "/1", value: 3 }, + { op: "replace", path: "/0", value: -1 }, + { op: "replace", path: "/1", value: -2 }, + ]); + expect(arrayResult).toMatchObject({ ok: false, code: "schema_violation" }); + if (!arrayResult.ok) { + expect(arrayResult.violations?.map((violation) => violation.path)).toEqual([ + "/0", + "/1", + ]); + } + }); + test("falls back to the legacy singleton error wording when a known value removes a later path", () => { const OptionalSchema = z.object({ a: z.object({ x: z.number().optional() }), @@ -353,4 +635,113 @@ describe("overlapping replace batch", () => { expect(replacement).toEqual({ rank: 1 }); expect(observed).toEqual([operations, inverse, operations]); }); + + test("preserves root dash-key validation precedence", () => { + const doc = createJSONDocument( + z.record(z.string(), z.number()), + { "-": 1, stable: 2 }, + ); + const before = doc.value; + + expect(doc.canPatch([ + { op: "replace", path: "/-", value: "invalid" }, + { op: "replace", path: "/stable", value: () => 3 } as never, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/-", message: expect.any(String) }], + }); + expect(doc.value).toBe(before); + }); + + test("preserves singleton error indexing for an unsupported root dash key", () => { + const doc = createJSONDocument( + z.object({ a: z.number() }).catchall(z.number()), + { a: 1, extra: 2, "-": 3 }, + ); + const result = doc.patch([ + { op: "replace", path: "/a", value: 0 }, + { op: "replace", path: "/extra", value: undefined } as never, + { op: "replace", path: "/-", value: 4 }, + ]); + + expect(result).toMatchObject({ ok: false, code: "not_serializable" }); + if (!result.ok) expect(result.reason).toContain("op[0]"); + }); + + test("preserves fallback precedence for nested and array dash segments", () => { + const NestedSchema = z.object({ + value: z.number(), + nested: z.record(z.string(), z.number()), + items: z.array(z.number()), + }); + + const nested = createJSONDocument(NestedSchema, { + value: 1, + nested: { "-": 2 }, + items: [3], + }); + expect(nested.canPatch([ + { op: "replace", path: "/value", value: "invalid" }, + { op: "replace", path: "/nested/-", value: () => 2 } as never, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/value", message: expect.any(String) }], + }); + + const array = createJSONDocument(NestedSchema, { + value: 1, + nested: { "-": 2 }, + items: [3], + }); + expect(array.canPatch([ + { op: "replace", path: "/value", value: "invalid" }, + { op: "replace", path: "/items/-", value: 4 }, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/value", message: expect.any(String) }], + }); + }); + + test("preserves sequential failure precedence when a root replacement cannot be applied", () => { + const doc = createJSONDocument(z.object({ value: z.number() }), { value: 1 }); + + expect(doc.canPatch([ + { op: "replace", path: "/value", value: "invalid" }, + { op: "replace", path: "", value: () => ({ value: 2 }) } as never, + ])).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/value", message: expect.any(String) }], + }); + }); + + test("keeps escaped segments distinct from nested paths in replacement targets", () => { + const EscapedSchema = z.object({ + records: z.object({ + "a/b": z.object({ value: z.number() }), + a: z.object({ b: z.object({ value: z.number() }) }), + }), + }); + const doc = createJSONDocument(EscapedSchema, { + records: { + "a/b": { value: 1 }, + a: { b: { value: 2 } }, + }, + }); + const result = doc.canPatch([ + { op: "replace", path: "/records/a~1b/value", value: "invalid" }, + { op: "replace", path: "/records/a/b/value", value: "invalid" }, + ]); + + expect(result).toMatchObject({ ok: false, code: "schema_violation" }); + if (!result.ok) { + expect(result.violations?.map((violation) => violation.path)).toEqual([ + "/records/a~1b/value", + "/records/a/b/value", + ]); + } + }); }); 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 index 048038db..3bb4b2db 100644 --- a/packages/json-document/tests/regression/structural-schema-fast-path.test.ts +++ b/packages/json-document/tests/regression/structural-schema-fast-path.test.ts @@ -418,6 +418,16 @@ describe("structural schema validation isolation", () => { }); expect(record.value).toEqual({ ok: 1 }); + const formattedRecord = createJSONDocument( + z.record(z.stringFormat("x-key", (key) => key.startsWith("x")), z.number()), + { xgood: 1 }, + ); + expect(formattedRecord.patch({ op: "add", path: "/bad", value: 2 })).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(formattedRecord.value).toEqual({ xgood: 1 }); + const array = createJSONDocument( z.object({ items: z.array(z.string()).min(1) }), { items: ["kept"] },