From cd4e893a795bead4db64597e5a73b5bc97a5f97d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 11:52:18 +0200 Subject: [PATCH 1/4] fix(engine): a schema-declared section keeps opaque values by reference arktype rebuilds an object whenever a morph or a default applies anywhere inside it, and the rebuild deep-clones every property, including values the schema only checks by predicate. A control descriptor the config file built is such a value: its create closes over module state, its codec tables and contract serializer are class instances relying on this. The clone arktype handed back was structurally equal and behaviourally broken: prisma db init failed reading the contract marker with "unexpected typeParams for non-parameterized codec" because a cloned codec descriptor no longer dispatched through its prototype. Validation now copies the input only along the structure the schema declares, so arktype can assign defaults onto parents the config file froze, and after validation puts the input's own value back at every path the schema leaves opaque. A transformed value (a pipe, a resolved path) keeps its output. A morph node's declared structure lives on its in side, which the walk now follows. Engine 0.6.0 is on the registry, so this ships as 0.6.1; the recorded engine-pin exceptions move with it. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/package.json | 2 +- packages/cli-engine/src/config-schema.ts | 128 ++++++++++++++++-- .../cli-engine/tests/config-schema.test.ts | 109 +++++++++++++++ packages/cli/package.json | 2 +- packages/cli/scripts/conformance.ts | 14 +- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 +- 7 files changed, 239 insertions(+), 22 deletions(-) diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index df3165a8..44d92d0a 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.6.0", + "version": "0.6.1", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index fa20bbe8..e5207799 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -99,17 +99,112 @@ function isPlainObject(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -/** Plain objects and arrays copied; anything else, functions included, by reference. */ -function copyPlainData(value: unknown): unknown { +/** + * The part of a compiled arktype node this copy reads: a structural node + * declares object keys (`props`) and, for an array, an element node. + */ +interface StructureLike { + readonly props?: ReadonlyArray<{ + readonly key: PropertyKey; + readonly value: NodeLike; + }>; + readonly sequence?: { readonly element?: NodeLike }; +} + +interface NodeLike { + readonly structure?: StructureLike; + readonly branches?: readonly NodeLike[]; + /** A morph node validates its `in` side, where the declared structure lives. */ + readonly in?: NodeLike; + /** Whether a morph (a pipe, or a default) applies at or under this node. */ + readonly includesTransform?: boolean; +} + +function structureOf(node: NodeLike | undefined): StructureLike | undefined { + if (node === undefined) return undefined; + if (node.structure !== undefined) return node.structure; + // A morph (a default or a pipe anywhere inside an object literal makes + // the whole literal one) keeps its declared structure on its `in` side. + if (node.in !== undefined && node.in !== node) { + const inner = structureOf(node.in); + if (inner !== undefined) return inner; + } + // A union: the structural branch, if any, is the one arktype may write + // defaults into. + return node.branches + ?.map((branch) => branch.structure) + .find((s) => s !== undefined); +} + +/** + * Copies `value` along the paths the schema declares as structure, and no + * further. arktype applies a default by assigning to the parent object, so + * every plain object on a declared path must be writable even when the + * config file froze it. A value the schema does not open — an `object` + * predicate, a `Function`, a `Date` — is user-constructed runtime data: + * closures over module state, class instances relying on `this`, codec + * tables. It passes through by reference, which is why a section schema + * validates such values by predicate rather than by shape. + */ +function copyAlongSchema(value: unknown, node: NodeLike | undefined): unknown { + const structure = structureOf(node); + if (structure === undefined) return value; if (Array.isArray(value)) { - return value.map(copyPlainData); + const element = structure.sequence?.element; + return value.map((entry) => copyAlongSchema(entry, element)); } - if (isPlainObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]), + if (!isPlainObject(value)) return value; + const declared = new Map( + structure.props?.map((prop) => [prop.key, prop.value]) ?? [], + ); + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + copyAlongSchema(entry, declared.get(key)), + ]), + ); +} + +/** + * arktype rebuilds an object whenever a morph or a default applies anywhere + * inside it, and the rebuild deep-clones every property, opaque ones + * included. An opaque value — an `object` predicate, a `Function`, a `Date` + * — is user-constructed runtime data: closures over module state, class + * instances relying on `this`, codec tables. A clone of it is not it. So + * after validation the input's own value is put back at every path the + * schema does not open, which is why a section schema validates such values + * by predicate rather than by shape. + */ +function restoreOpaque( + input: unknown, + output: unknown, + node: NodeLike | undefined, +): unknown { + const structure = structureOf(node); + if (structure === undefined) { + // A node that transforms (a pipe, a resolved path) produced its output + // on purpose. An untransformed opaque object was merely cloned, and the + // input is the value the config file built. + if (node?.includesTransform === true) return output; + return typeof input === "object" && input !== null ? input : output; + } + if (Array.isArray(output)) { + if (!Array.isArray(input)) return output; + const element = structure.sequence?.element; + return output.map((entry, index) => + restoreOpaque(input[index], entry, element), ); } - return value; + if (!isPlainObject(output) || !isPlainObject(input)) return output; + const declared = new Map( + structure.props?.map((prop) => [prop.key, prop.value]) ?? [], + ); + return Object.fromEntries( + Object.entries(output).map(([key, entry]) => [ + key, + restoreOpaque(input[key], entry, declared.get(key)), + ]), + ); } function fieldDiagnostic( @@ -161,9 +256,22 @@ export function validateSectionWithSchema( const previous = current; current = { name, provenance }; try { - // arktype applies defaults and morphs onto the objects it is handed, and - // the merged section value arrives frozen, so it validates a copy. - const out: unknown = schema(raw === undefined ? {} : copyPlainData(raw)); + // arktype writes a default by assigning to the parent object, and the + // merged section value arrives frozen, so the declared structure is + // copied first; everything the schema leaves opaque keeps its identity. + const node = schema.internal as unknown as NodeLike; + const validated: unknown = schema( + raw === undefined ? {} : copyAlongSchema(raw, node), + ); + if (validated instanceof type.errors) { + return { + ok: false, + diagnostics: [...validated].map((error) => + fieldDiagnostic(name, error, provenance), + ), + }; + } + const out = restoreOpaque(raw, validated, node); if (out instanceof type.errors) { return { ok: false, diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index 01aaf9e8..9f568313 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -166,6 +166,115 @@ describe("validateSectionWithSchema", () => { expect(dir).toBe("/app"); }); + test("an opaque value keeps its identity even inside a frozen section", () => { + class Serializer { + deserialize(json: unknown): unknown { + return json; + } + } + const opaque = configSchema("object").narrow(() => true); + const schema = configSchema({ + target: opaque, + "contract?": { source: opaque, "output?": "path" }, + "extensions?": [opaque, "[]"], + migrations: [ + { dir: ["path", "=", () => "./migrations"] }, + "=", + () => ({}), + ], + }); + const target = Object.freeze({ + kind: "target", + serializer: new Serializer(), + create() { + return this.kind; + }, + }); + const source = Object.freeze({ load: () => 1 }); + const raw = Object.freeze({ + target, + contract: Object.freeze({ source, output: "./out.json" }), + extensions: Object.freeze([target]), + }); + + const result = validateSectionWithSchema("toy", schema, raw, single); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const value = result.value as { + target: typeof target; + contract: { source: typeof source; output: string }; + extensions: (typeof target)[]; + migrations: { dir: string }; + }; + expect(value.target).toBe(target); + expect(value.target.create()).toBe("target"); + expect(value.contract.source).toBe(source); + expect(value.extensions[0]).toBe(target); + expect(value.contract.output).toBe("/app/out.json"); + expect(value.migrations.dir).toBe("/app/migrations"); + }); + + test("an opaque value keeps its identity when the section has a root narrow and defaults", () => { + const opaque = configSchema("object").narrow(() => true); + const schema = configSchema({ + family: opaque, + migrations: [ + { dir: ["path", "=", () => "./migrations"] }, + "=", + () => ({}), + ], + }).narrow(() => true); + const family = { kind: "family", create: () => 1 }; + + const result = validateSectionWithSchema("toy", schema, { family }, single); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + expect((result.value as { family: unknown }).family).toBe(family); + expect( + (result.value as { migrations: { dir: string } }).migrations.dir, + ).toBe("/app/migrations"); + }); + + test("an opaque value with its own pipe keeps the pipe's output", () => { + const source = { load: () => 1, inputs: ["./a"] }; + const withResolvedInputs = configSchema("object") + .narrow(() => true) + .pipe((value) => ({ ...(value as object), inputs: ["/resolved/a"] })); + const schema = configSchema({ source: withResolvedInputs, "out?": "path" }); + + const result = validateSectionWithSchema( + "toy", + schema, + { source, out: "./o" }, + single, + ); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const value = result.value as { + source: { load: () => number; inputs: string[] }; + out: string; + }; + expect(value.source.inputs).toEqual(["/resolved/a"]); + expect(value.source.load).toBe(source.load); + expect(value.out).toBe("/app/o"); + }); + + test("a default nested under a frozen declared object is applied without writing to the input", () => { + const schema = configSchema({ + "given?": { "deeper?": { c: "string = 'w'" } }, + }); + const deeper = Object.freeze({}); + const given = Object.freeze({ deeper }); + const raw = Object.freeze({ given }); + + const result = validateSectionWithSchema("toy", schema, raw, single); + + expect(result.ok && result.value).toMatchObject({ + given: { deeper: { c: "w" } }, + }); + expect("c" in deeper).toBe(false); + }); + test("a value that is not a plain object keeps its identity and data", () => { const schema = configSchema({ when: "Date" }); const when = new Date(0); diff --git a/packages/cli/package.json b/packages/cli/package.json index c03c9608..b7a6558b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.0", + "@prisma/cli-engine": "workspace:0.6.1", "@prisma/composer-cli": "0.21.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 8dd8621b..133cfb41 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -113,25 +113,25 @@ async function tarball(): Promise { // in flight: the engine must publish before a family can peer it, // so the mismatch is real until both families release against it. // The entries expire with the versions they name, and the PR that - // pins the families' 0.6.0-peering releases removes them; while + // pins the families' 0.6.1-peering releases removes them; while // they stand, a release could ship the two-engine install they // describe, which is why they must not outlive the transition. exceptions: [ { familyPackage: "@prisma/composer-cli", familyPin: "0.4.0", - shellPin: "0.6.0", - reason: "engine 0.6.0 must publish before composer-cli can peer it", + shellPin: "0.6.1", + reason: "engine 0.6.1 must publish before composer-cli can peer it", removeWhen: - "composer-cli releases peering 0.6.0 and the follow-up bump PR pins that release", + "composer-cli releases peering 0.6.1 and the follow-up bump PR pins that release", }, { familyPackage: "@prisma/orm-toolchain", familyPin: "0.4.0", - shellPin: "0.6.0", - reason: "engine 0.6.0 must publish before orm-toolchain can peer it", + shellPin: "0.6.1", + reason: "engine 0.6.1 must publish before orm-toolchain can peer it", removeWhen: - "orm-toolchain releases peering 0.6.0 and the follow-up bump PR pins that release", + "orm-toolchain releases peering 0.6.1 and the follow-up bump PR pins that release", }, ], channel: CHANNEL, diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 69bd73fc..075a4851 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.0", + "@prisma/cli-engine": "workspace:0.6.1", "@prisma/composer-cli": "0.21.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691fb15d..447cbb5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.0 + specifier: workspace:0.6.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.21.0 @@ -210,7 +210,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.0 + specifier: workspace:0.6.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.21.0 From fad8359244a9e011f6a71325a3826aef1764e511 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 12:29:46 +0200 Subject: [PATCH 2/4] fix(engine): the copy and restore walks follow the node that governs the value Review follow-ups: a union is walked through the branch that accepts the value (arktype's allows), so a value matching a later structural branch is copied for defaults and restored correctly; a tuple is walked by position through sequence.prefix, a list through sequence.element; own symbol keys a morph adds survive the restore (Reflect.ownKeys); and an index signature on declared structure is refused with a diagnostic naming the alternative, rather than silently walked. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-schema.ts | 117 ++++++++++++------ .../cli-engine/tests/config-schema.test.ts | 91 ++++++++++++++ 2 files changed, 170 insertions(+), 38 deletions(-) diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index e5207799..0238d61f 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -100,67 +100,106 @@ function isPlainObject(value: unknown): value is Record { } /** - * The part of a compiled arktype node this copy reads: a structural node - * declares object keys (`props`) and, for an array, an element node. + * The part of a compiled arktype node the copy and restore walks read. A + * structural node declares object keys (`props`), a tuple's positions + * (`sequence.prefix`), a list's element (`sequence.element`), or index + * signatures (`index`). A union offers `branches`; a morph keeps its + * declared structure on its `in` side. */ interface StructureLike { readonly props?: ReadonlyArray<{ readonly key: PropertyKey; readonly value: NodeLike; }>; - readonly sequence?: { readonly element?: NodeLike }; + readonly sequence?: { + readonly prefix?: readonly NodeLike[]; + readonly element?: NodeLike; + }; + readonly index?: readonly unknown[]; } interface NodeLike { readonly structure?: StructureLike; readonly branches?: readonly NodeLike[]; - /** A morph node validates its `in` side, where the declared structure lives. */ readonly in?: NodeLike; /** Whether a morph (a pipe, or a default) applies at or under this node. */ readonly includesTransform?: boolean; + readonly allows?: (value: unknown) => boolean; } -function structureOf(node: NodeLike | undefined): StructureLike | undefined { +/** + * The node that governs `value` at this position: the node itself, a + * morph's `in` side, or the union branch that accepts the value. Undefined + * for a union no branch of which accepts the value, which validation is + * about to report anyway. + */ +function governingNode( + node: NodeLike | undefined, + value: unknown, +): NodeLike | undefined { if (node === undefined) return undefined; - if (node.structure !== undefined) return node.structure; - // A morph (a default or a pipe anywhere inside an object literal makes - // the whole literal one) keeps its declared structure on its `in` side. - if (node.in !== undefined && node.in !== node) { - const inner = structureOf(node.in); - if (inner !== undefined) return inner; + if (node.branches !== undefined && node.branches.length > 1) { + const branch = node.branches.find( + (candidate) => candidate.allows?.(value) === true, + ); + return branch === undefined ? undefined : governingNode(branch, value); + } + if ( + node.structure === undefined && + node.in !== undefined && + node.in !== node + ) { + const inner = governingNode(node.in, value); + return inner?.structure === undefined ? node : inner; } - // A union: the structural branch, if any, is the one arktype may write - // defaults into. - return node.branches - ?.map((branch) => branch.structure) - .find((s) => s !== undefined); + return node; +} + +function structureOf( + node: NodeLike | undefined, + value: unknown, +): StructureLike | undefined { + const structure = governingNode(node, value)?.structure; + if (structure?.index !== undefined && structure.index.length > 0) { + throw new Error( + "@prisma/cli-engine: a config section schema cannot declare an index signature; declare the keys, or validate the value by predicate", + ); + } + return structure; +} + +/** The node for array position `index`: a tuple's own position, else the list element. */ +function elementNode( + structure: StructureLike, + index: number, +): NodeLike | undefined { + return structure.sequence?.prefix?.[index] ?? structure.sequence?.element; } /** * Copies `value` along the paths the schema declares as structure, and no - * further. arktype applies a default by assigning to the parent object, so - * every plain object on a declared path must be writable even when the - * config file froze it. A value the schema does not open — an `object` - * predicate, a `Function`, a `Date` — is user-constructed runtime data: - * closures over module state, class instances relying on `this`, codec - * tables. It passes through by reference, which is why a section schema - * validates such values by predicate rather than by shape. + * further, so arktype can assign a default to a parent object the config + * file froze. Everything the schema leaves opaque passes through untouched. */ function copyAlongSchema(value: unknown, node: NodeLike | undefined): unknown { - const structure = structureOf(node); + const structure = structureOf(node, value); if (structure === undefined) return value; if (Array.isArray(value)) { - const element = structure.sequence?.element; - return value.map((entry) => copyAlongSchema(entry, element)); + return value.map((entry, index) => + copyAlongSchema(entry, elementNode(structure, index)), + ); } if (!isPlainObject(value)) return value; const declared = new Map( structure.props?.map((prop) => [prop.key, prop.value]) ?? [], ); return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ + Reflect.ownKeys(value).map((key) => [ key, - copyAlongSchema(entry, declared.get(key)), + copyAlongSchema( + (value as Record)[key], + declared.get(key), + ), ]), ); } @@ -173,26 +212,24 @@ function copyAlongSchema(value: unknown, node: NodeLike | undefined): unknown { * instances relying on `this`, codec tables. A clone of it is not it. So * after validation the input's own value is put back at every path the * schema does not open, which is why a section schema validates such values - * by predicate rather than by shape. + * by predicate rather than by shape. A transformed node (a pipe, a resolved + * path) produced its output on purpose and keeps it. */ function restoreOpaque( input: unknown, output: unknown, node: NodeLike | undefined, ): unknown { - const structure = structureOf(node); + const governing = governingNode(node, output); + const structure = structureOf(governing, output); if (structure === undefined) { - // A node that transforms (a pipe, a resolved path) produced its output - // on purpose. An untransformed opaque object was merely cloned, and the - // input is the value the config file built. - if (node?.includesTransform === true) return output; + if (governing?.includesTransform === true) return output; return typeof input === "object" && input !== null ? input : output; } if (Array.isArray(output)) { if (!Array.isArray(input)) return output; - const element = structure.sequence?.element; return output.map((entry, index) => - restoreOpaque(input[index], entry, element), + restoreOpaque(input[index], entry, elementNode(structure, index)), ); } if (!isPlainObject(output) || !isPlainObject(input)) return output; @@ -200,9 +237,13 @@ function restoreOpaque( structure.props?.map((prop) => [prop.key, prop.value]) ?? [], ); return Object.fromEntries( - Object.entries(output).map(([key, entry]) => [ + Reflect.ownKeys(output).map((key) => [ key, - restoreOpaque(input[key], entry, declared.get(key)), + restoreOpaque( + (input as Record)[key], + (output as Record)[key], + declared.get(key), + ), ]), ); } diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index 9f568313..cb836c5b 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -259,6 +259,97 @@ describe("validateSectionWithSchema", () => { expect(value.out).toBe("/app/o"); }); + test("a union picks the branch the value matches, for copying and for restoring", () => { + const opaque = configSchema("object").narrow(() => true); + const schema = configSchema({ + either: [ + { kind: "'a'", "dir?": "path" }, + "|", + { kind: "'b'", inner: opaque }, + ], + }); + const inner = { keep: () => 1 }; + + const a = validateSectionWithSchema( + "toy", + schema, + Object.freeze({ either: Object.freeze({ kind: "a", dir: "./d" }) }), + single, + ); + const b = validateSectionWithSchema( + "toy", + schema, + { either: { kind: "b", inner } }, + single, + ); + + expect(a.ok && (a.value as { either: { dir: string } }).either.dir).toBe( + "/app/d", + ); + expect( + b.ok && (b.value as { either: { inner: unknown } }).either.inner, + ).toBe(inner); + }); + + test("a tuple resolves and restores by position", () => { + const opaque = configSchema("object").narrow(() => true); + const schema = configSchema({ pair: ["path", opaque] }); + const second = { keep: () => 1 }; + + const result = validateSectionWithSchema( + "toy", + schema, + { pair: ["./first", second] }, + single, + ); + + expect( + result.ok && (result.value as { pair: [string, unknown] }).pair, + ).toEqual(["/app/first", second]); + expect( + result.ok && (result.value as { pair: [string, unknown] }).pair[1], + ).toBe(second); + }); + + test("a symbol-keyed property a morph adds survives the restore", () => { + const TAG = Symbol("tag"); + const schema = configSchema({ + tagged: configSchema({ n: "number" }).pipe((value) => ({ + ...value, + [TAG]: true, + })), + "out?": "path", + }); + + const result = validateSectionWithSchema( + "toy", + schema, + { tagged: { n: 1 }, out: "./o" }, + single, + ); + + expect( + result.ok && + (result.value as { tagged: Record }).tagged[TAG], + ).toBe(true); + }); + + test("an index signature on declared structure is refused", () => { + const schema = configSchema({ "[string]": "path" }); + + expect(() => + validateSectionWithSchema("toy", schema, { a: "./x" }, single), + ).not.toThrow(); + const result = validateSectionWithSchema( + "toy", + schema, + { a: "./x" }, + single, + ); + expect(result.ok).toBe(false); + expect(result.diagnostics[0]?.summary).toContain("index signature"); + }); + test("a default nested under a frozen declared object is applied without writing to the input", () => { const schema = configSchema({ "given?": { "deeper?": { c: "string = 'w'" } }, From 21d98f74ecb0011d8e897705e978af1b8b8f3080 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 14:43:28 +0200 Subject: [PATCH 3/4] refactor(engine): say what the copy and restore walks do, in ordinary words The names were invented vocabulary: StructureLike, NodeLike, and "opaque" for a value the schema checks without describing what is inside it. They are now SchemaShape, SchemaNode, copyWhereDescribed and putBackOriginalValues, and the comments say the thing rather than a coined label for it. Four defects found while renaming: - An index signature in a schema is its author's bug, but the throw was caught with everything else and reported as a broken config file, telling the user to edit a file that is fine. It is now a ConfigSchemaError the catch re-throws. - The error branch after the restore walk was unreachable: the walk returns values, never ArkErrors. Removed. - Optional and postfix tuple positions resolved to the variadic element node, so a trailing declared position was walked against the wrong node. Positions now count from both ends. - A union no alternative matched left a frozen value uncopied, so applying a default threw a read-only write that surfaced as an unreadable section instead of a field error. Such a value is now copied one level. The restore walk also resolved the applicable node twice per value. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-schema.ts | 203 ++++++++++-------- packages/cli-engine/src/exports/index.ts | 1 + .../cli-engine/tests/config-schema.test.ts | 84 +++++--- packages/cli-engine/tests/engine.test.ts | 1 + 4 files changed, 175 insertions(+), 114 deletions(-) diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index 0238d61f..039580ab 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -100,103 +100,141 @@ function isPlainObject(value: unknown): value is Record { } /** - * The part of a compiled arktype node the copy and restore walks read. A - * structural node declares object keys (`props`), a tuple's positions - * (`sequence.prefix`), a list's element (`sequence.element`), or index - * signatures (`index`). A union offers `branches`; a morph keeps its - * declared structure on its `in` side. + * Thrown when a section's schema itself is wrong, as opposed to the config + * file being wrong. It escapes validateSectionWithSchema rather than becoming + * a diagnostic, so the schema's author sees it instead of the user being told + * to fix a config file that is fine. */ -interface StructureLike { +export class ConfigSchemaError extends Error {} + +/** + * What a compiled arktype schema says about one value: the object keys it + * declares (`props`), the positions of a tuple or list (`sequence`), or an + * index signature. A schema that says nothing about a value has no shape + * here, which is what tells the two walks below to leave that value alone. + */ +interface SchemaShape { readonly props?: ReadonlyArray<{ readonly key: PropertyKey; - readonly value: NodeLike; + readonly value: SchemaNode; }>; readonly sequence?: { - readonly prefix?: readonly NodeLike[]; - readonly element?: NodeLike; + readonly prefix?: readonly SchemaNode[]; + readonly optionals?: readonly SchemaNode[]; + readonly postfix?: readonly SchemaNode[]; + readonly element?: SchemaNode; }; readonly index?: readonly unknown[]; } -interface NodeLike { - readonly structure?: StructureLike; - readonly branches?: readonly NodeLike[]; - readonly in?: NodeLike; - /** Whether a morph (a pipe, or a default) applies at or under this node. */ +/** One node of a compiled arktype schema. */ +interface SchemaNode { + readonly structure?: SchemaShape; + /** The alternatives of a union. */ + readonly branches?: readonly SchemaNode[]; + /** A node that transforms its input keeps what it accepts on its `in` side. */ + readonly in?: SchemaNode; + /** Whether a pipe or a default applies at or under this node. */ readonly includesTransform?: boolean; readonly allows?: (value: unknown) => boolean; } /** - * The node that governs `value` at this position: the node itself, a - * morph's `in` side, or the union branch that accepts the value. Undefined - * for a union no branch of which accepts the value, which validation is - * about to report anyway. + * The node that applies to `value` here: the node itself, what a + * transforming node accepts, or the union alternative that matches. Nothing + * when no alternative matches, which validation is about to report. */ -function governingNode( - node: NodeLike | undefined, +function nodeForValue( + node: SchemaNode | undefined, value: unknown, -): NodeLike | undefined { +): SchemaNode | undefined { if (node === undefined) return undefined; if (node.branches !== undefined && node.branches.length > 1) { - const branch = node.branches.find( - (candidate) => candidate.allows?.(value) === true, + const match = node.branches.find( + (branch) => branch.allows?.(value) === true, ); - return branch === undefined ? undefined : governingNode(branch, value); + return match === undefined ? undefined : nodeForValue(match, value); } if ( node.structure === undefined && node.in !== undefined && node.in !== node ) { - const inner = governingNode(node.in, value); + const inner = nodeForValue(node.in, value); return inner?.structure === undefined ? node : inner; } return node; } -function structureOf( - node: NodeLike | undefined, - value: unknown, -): StructureLike | undefined { - const structure = governingNode(node, value)?.structure; - if (structure?.index !== undefined && structure.index.length > 0) { - throw new Error( - "@prisma/cli-engine: a config section schema cannot declare an index signature; declare the keys, or validate the value by predicate", +/** The shape `node` gives this value, having already resolved the node. */ +function shapeOf(node: SchemaNode | undefined): SchemaShape | undefined { + const shape = node?.structure; + if (shape?.index !== undefined && shape.index.length > 0) { + throw new ConfigSchemaError( + "@prisma/cli-engine: a config section schema cannot declare an index signature, because the keys it would match are not known ahead of the value; declare the keys, or check the value with a predicate", ); } - return structure; + return shape; } -/** The node for array position `index`: a tuple's own position, else the list element. */ -function elementNode( - structure: StructureLike, +/** + * The node for one position of an array: a tuple's own position counting + * from either end, else the element every remaining entry shares. + */ +function nodeForPosition( + shape: SchemaShape, index: number, -): NodeLike | undefined { - return structure.sequence?.prefix?.[index] ?? structure.sequence?.element; + length: number, +): SchemaNode | undefined { + const sequence = shape.sequence; + if (sequence === undefined) return undefined; + const prefix = sequence.prefix ?? []; + if (index < prefix.length) return prefix[index]; + const optionals = sequence.optionals ?? []; + if (index < prefix.length + optionals.length) { + return optionals[index - prefix.length]; + } + const postfix = sequence.postfix ?? []; + const fromEnd = length - index; + if (fromEnd <= postfix.length) return postfix[postfix.length - fromEnd]; + return sequence.element; } /** - * Copies `value` along the paths the schema declares as structure, and no - * further, so arktype can assign a default to a parent object the config - * file froze. Everything the schema leaves opaque passes through untouched. + * Copies `value` wherever the schema describes its shape, and no further. + * arktype applies a default by assigning to the object that holds it, so an + * object the config file froze has to be copied first. Anything the schema + * only checks, never describes, is passed through untouched. */ -function copyAlongSchema(value: unknown, node: NodeLike | undefined): unknown { - const structure = structureOf(node, value); - if (structure === undefined) return value; +function copyWhereDescribed( + value: unknown, + node: SchemaNode | undefined, +): unknown { + const shape = shapeOf(nodeForValue(node, value)); + // No alternative of a union matched: the value is about to fail + // validation, and copying it one level keeps a frozen object from turning + // that failure into a write to a read-only property. + const unmatchedUnion = + shape === undefined && + node?.branches !== undefined && + node.branches.length > 1; + if (shape === undefined && !unmatchedUnion) return value; if (Array.isArray(value)) { return value.map((entry, index) => - copyAlongSchema(entry, elementNode(structure, index)), + shape === undefined + ? entry + : copyWhereDescribed( + entry, + nodeForPosition(shape, index, value.length), + ), ); } if (!isPlainObject(value)) return value; - const declared = new Map( - structure.props?.map((prop) => [prop.key, prop.value]) ?? [], - ); + const declared = new Map(shape?.props?.map((prop) => [prop.key, prop.value])); return Object.fromEntries( Reflect.ownKeys(value).map((key) => [ key, - copyAlongSchema( + copyWhereDescribed( (value as Record)[key], declared.get(key), ), @@ -205,41 +243,43 @@ function copyAlongSchema(value: unknown, node: NodeLike | undefined): unknown { } /** - * arktype rebuilds an object whenever a morph or a default applies anywhere - * inside it, and the rebuild deep-clones every property, opaque ones - * included. An opaque value — an `object` predicate, a `Function`, a `Date` - * — is user-constructed runtime data: closures over module state, class - * instances relying on `this`, codec tables. A clone of it is not it. So - * after validation the input's own value is put back at every path the - * schema does not open, which is why a section schema validates such values - * by predicate rather than by shape. A transformed node (a pipe, a resolved - * path) produced its output on purpose and keeps it. + * arktype rebuilds an object whenever a default or a pipe applies anywhere + * inside it, and the rebuild clones every property, including values the + * schema only checked. Such a value is something the config file built at + * runtime: a function closing over module state, a class instance whose + * methods need their own `this`, a table of codecs. A clone of it is not it. + * So this walk puts the config file's own value back wherever the schema + * described no shape, which is why a section schema checks such values with + * a predicate instead of describing them. A value the schema transformed on + * purpose (a pipe, a resolved path) keeps what the transform produced. */ -function restoreOpaque( +function putBackOriginalValues( input: unknown, output: unknown, - node: NodeLike | undefined, + node: SchemaNode | undefined, ): unknown { - const governing = governingNode(node, output); - const structure = structureOf(governing, output); - if (structure === undefined) { - if (governing?.includesTransform === true) return output; + const applicable = nodeForValue(node, output); + const shape = shapeOf(applicable); + if (shape === undefined) { + if (applicable?.includesTransform === true) return output; return typeof input === "object" && input !== null ? input : output; } if (Array.isArray(output)) { if (!Array.isArray(input)) return output; return output.map((entry, index) => - restoreOpaque(input[index], entry, elementNode(structure, index)), + putBackOriginalValues( + input[index], + entry, + nodeForPosition(shape, index, output.length), + ), ); } if (!isPlainObject(output) || !isPlainObject(input)) return output; - const declared = new Map( - structure.props?.map((prop) => [prop.key, prop.value]) ?? [], - ); + const declared = new Map(shape.props?.map((prop) => [prop.key, prop.value])); return Object.fromEntries( Reflect.ownKeys(output).map((key) => [ key, - restoreOpaque( + putBackOriginalValues( (input as Record)[key], (output as Record)[key], declared.get(key), @@ -297,12 +337,12 @@ export function validateSectionWithSchema( const previous = current; current = { name, provenance }; try { - // arktype writes a default by assigning to the parent object, and the - // merged section value arrives frozen, so the declared structure is - // copied first; everything the schema leaves opaque keeps its identity. - const node = schema.internal as unknown as NodeLike; + // arktype applies a default by assigning to the object that holds it, + // and the merged section arrives frozen, so the described shape is + // copied first; values the schema only checks keep their identity. + const node = schema.internal as unknown as SchemaNode; const validated: unknown = schema( - raw === undefined ? {} : copyAlongSchema(raw, node), + raw === undefined ? {} : copyWhereDescribed(raw, node), ); if (validated instanceof type.errors) { return { @@ -312,15 +352,7 @@ export function validateSectionWithSchema( ), }; } - const out = restoreOpaque(raw, validated, node); - if (out instanceof type.errors) { - return { - ok: false, - diagnostics: [...out].map((error) => - fieldDiagnostic(name, error, provenance), - ), - }; - } + const out = putBackOriginalValues(raw, validated, node); const nearest = provenance.files[0]; const value = isPlainObject(out) && nearest !== undefined @@ -328,6 +360,9 @@ export function validateSectionWithSchema( : out; return { ok: true, value: value as ConfigSchemaValue, diagnostics: [] }; } catch (cause) { + // A schema that cannot be walked is its author's bug, not the user's + // config, so it is never turned into a diagnostic about their file. + if (cause instanceof ConfigSchemaError) throw cause; // A getter that throws when arktype reads it, or a morph that throws: // config-file content, reported as such rather than as a bug. return { diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 8ff861da..c89768cc 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -58,6 +58,7 @@ export { } from "../config-merge"; export { type ConfigSchema, + ConfigSchemaError, type ConfigSchemaValue, configSchema, validateSectionWithSchema, diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index cb836c5b..ee2dedb9 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -7,6 +7,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { + ConfigSchemaError, configSchema, defineCommand, defineConfigSection, @@ -166,17 +167,17 @@ describe("validateSectionWithSchema", () => { expect(dir).toBe("/app"); }); - test("an opaque value keeps its identity even inside a frozen section", () => { + test("a value the schema only checks is the config file's own object, frozen section or not", () => { class Serializer { deserialize(json: unknown): unknown { return json; } } - const opaque = configSchema("object").narrow(() => true); + const checkedOnly = configSchema("object").narrow(() => true); const schema = configSchema({ - target: opaque, - "contract?": { source: opaque, "output?": "path" }, - "extensions?": [opaque, "[]"], + target: checkedOnly, + "contract?": { source: checkedOnly, "output?": "path" }, + "extensions?": [checkedOnly, "[]"], migrations: [ { dir: ["path", "=", () => "./migrations"] }, "=", @@ -214,10 +215,10 @@ describe("validateSectionWithSchema", () => { expect(value.migrations.dir).toBe("/app/migrations"); }); - test("an opaque value keeps its identity when the section has a root narrow and defaults", () => { - const opaque = configSchema("object").narrow(() => true); + test("a checked-only value survives a section whose root has a narrow and defaults", () => { + const checkedOnly = configSchema("object").narrow(() => true); const schema = configSchema({ - family: opaque, + family: checkedOnly, migrations: [ { dir: ["path", "=", () => "./migrations"] }, "=", @@ -235,7 +236,7 @@ describe("validateSectionWithSchema", () => { ).toBe("/app/migrations"); }); - test("an opaque value with its own pipe keeps the pipe's output", () => { + test("a checked-only value with its own pipe keeps what the pipe produced", () => { const source = { load: () => 1, inputs: ["./a"] }; const withResolvedInputs = configSchema("object") .narrow(() => true) @@ -260,12 +261,12 @@ describe("validateSectionWithSchema", () => { }); test("a union picks the branch the value matches, for copying and for restoring", () => { - const opaque = configSchema("object").narrow(() => true); + const checkedOnly = configSchema("object").narrow(() => true); const schema = configSchema({ either: [ { kind: "'a'", "dir?": "path" }, "|", - { kind: "'b'", inner: opaque }, + { kind: "'b'", inner: checkedOnly }, ], }); const inner = { keep: () => 1 }; @@ -291,24 +292,55 @@ describe("validateSectionWithSchema", () => { ).toBe(inner); }); - test("a tuple resolves and restores by position", () => { - const opaque = configSchema("object").narrow(() => true); - const schema = configSchema({ pair: ["path", opaque] }); + test("a tuple resolves and restores by position, prefix and postfix alike", () => { + const checkedOnly = configSchema("object").narrow(() => true); + const schema = configSchema({ + pair: ["path", checkedOnly], + tail: ["path", "...", "object[]", "path"], + }); const second = { keep: () => 1 }; + const middle = { keep: () => 2 }; const result = validateSectionWithSchema( "toy", schema, - { pair: ["./first", second] }, + { pair: ["./first", second], tail: ["./head", middle, "./last"] }, single, ); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const value = result.value as { + pair: [string, unknown]; + tail: [string, unknown, string]; + }; + expect(value.pair).toEqual(["/app/first", second]); + expect(value.pair[1]).toBe(second); + expect(value.tail).toEqual(["/app/head", middle, "/app/last"]); + expect(value.tail[1]).toBe(middle); + }); + + test("a union no alternative matches fails as a field error, not a write to a frozen object", () => { + const schema = configSchema({ + either: [ + { kind: "'a'", dir: ["path", "=", () => "./d"] }, + "|", + { kind: "'b'" }, + ], + }); + + const result = validateSectionWithSchema( + "toy", + schema, + Object.freeze({ either: Object.freeze({ kind: "c" }) }), + single, + ); + + expect(result.ok).toBe(false); expect( - result.ok && (result.value as { pair: [string, unknown] }).pair, - ).toEqual(["/app/first", second]); - expect( - result.ok && (result.value as { pair: [string, unknown] }).pair[1], - ).toBe(second); + result.diagnostics.every( + (diagnostic) => diagnostic.code === "CLI.CONFIG_FIELD_INVALID", + ), + ).toBe(true); }); test("a symbol-keyed property a morph adds survives the restore", () => { @@ -334,20 +366,12 @@ describe("validateSectionWithSchema", () => { ).toBe(true); }); - test("an index signature on declared structure is refused", () => { + test("an index signature in a schema is the schema author's error, not the user's", () => { const schema = configSchema({ "[string]": "path" }); expect(() => validateSectionWithSchema("toy", schema, { a: "./x" }, single), - ).not.toThrow(); - const result = validateSectionWithSchema( - "toy", - schema, - { a: "./x" }, - single, - ); - expect(result.ok).toBe(false); - expect(result.diagnostics[0]?.summary).toContain("index signature"); + ).toThrow(ConfigSchemaError); }); test("a default nested under a frozen declared object is applied without writing to the input", () => { diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 82f1b01f..360b9a68 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -15,6 +15,7 @@ import { describe, expect, test } from "vitest"; describe("main export", () => { test("exposes exactly the definition-surface runtime values", () => { expect(Object.keys(engine).sort()).toEqual([ + "ConfigSchemaError", "EnvironmentCredentialManager", "PRESENTED", "PRISMA_CONFIG_VERSION", From c37f9c353faea229a79a08e5ccae77328a119020 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 22 Sep 2026 14:55:07 +0200 Subject: [PATCH 4/4] fix(engine): a pipe that returns an object of its own keeps it The restore walk looked through a transforming node to the shape it accepts, so it descended into what a pipe produced and put the input back inside it. A schema like configSchema({ source: checkedOnly }).pipe(() => ({ source: other })) lost the pipe's source. Whether a value was rebuilt by arktype or replaced by a pipe is not visible on the compiled node: an object literal with defaults plus a narrow compiles exactly like an object literal with a pipe. So the walk now asks the values instead. A rebuild carries the same own keys as the input, and only then is the input put back; a replacement is a different object and is kept. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-schema.ts | 30 ++++++++++++++++--- .../cli-engine/tests/config-schema.test.ts | 28 +++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/cli-engine/src/config-schema.ts b/packages/cli-engine/src/config-schema.ts index 039580ab..170a2acd 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -242,6 +242,26 @@ function copyWhereDescribed( ); } +/** + * Whether `output` is arktype's rebuild of `input`, rather than a different + * value a pipe produced in its place. A rebuild carries the same own keys; + * a replacement is a different object. Asking this per value is what keeps + * the walk below from undoing a pipe that returns an object of its own, + * without having to tell arktype's own rebuild apart from a pipe at the + * node above (in a compiled schema they look the same). + */ +function isRebuildOf(input: unknown, output: unknown): boolean { + if (typeof input !== "object" || input === null) return false; + if (typeof output !== "object" || output === null) return false; + if (Array.isArray(input) !== Array.isArray(output)) return false; + const inputKeys = Reflect.ownKeys(input); + const outputKeys = new Set(Reflect.ownKeys(output)); + return ( + inputKeys.length === outputKeys.size && + inputKeys.every((key) => outputKeys.has(key)) + ); +} + /** * arktype rebuilds an object whenever a default or a pipe applies anywhere * inside it, and the rebuild clones every property, including values the @@ -249,9 +269,11 @@ function copyWhereDescribed( * runtime: a function closing over module state, a class instance whose * methods need their own `this`, a table of codecs. A clone of it is not it. * So this walk puts the config file's own value back wherever the schema - * described no shape, which is why a section schema checks such values with - * a predicate instead of describing them. A value the schema transformed on - * purpose (a pipe, a resolved path) keeps what the transform produced. + * described no shape and the result is a rebuild of it, which is why a + * section schema checks such values with a predicate instead of describing + * them. A value the schema transformed on purpose keeps what the transform + * produced: a pipe at the value itself, and a pipe further up that returned + * a different object rather than a rebuild of this one. */ function putBackOriginalValues( input: unknown, @@ -262,7 +284,7 @@ function putBackOriginalValues( const shape = shapeOf(applicable); if (shape === undefined) { if (applicable?.includesTransform === true) return output; - return typeof input === "object" && input !== null ? input : output; + return isRebuildOf(input, output) ? input : output; } if (Array.isArray(output)) { if (!Array.isArray(input)) return output; diff --git a/packages/cli-engine/tests/config-schema.test.ts b/packages/cli-engine/tests/config-schema.test.ts index ee2dedb9..c05a93c9 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -45,6 +45,8 @@ const single: SectionProvenance = { }; describe("validateSectionWithSchema", () => { + const checkedOnlyValue = configSchema("object").narrow(() => true); + test("resolves a path field against the file that declared it and records baseDir", () => { const result = validateSectionWithSchema( "toy", @@ -343,6 +345,32 @@ describe("validateSectionWithSchema", () => { ).toBe(true); }); + test("a pipe that returns an object of its own keeps it, rather than the input", () => { + const replacement = { replaced: true }; + const schema = configSchema({ + nested: configSchema({ source: checkedOnlyValue }).pipe(() => ({ + source: replacement, + })), + "out?": "path", + }); + const original = { keep: () => 1 }; + + const result = validateSectionWithSchema( + "toy", + schema, + { nested: { source: original }, out: "./o" }, + single, + ); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const value = result.value as { + nested: { source: unknown }; + out: string; + }; + expect(value.nested.source).toBe(replacement); + expect(value.out).toBe("/app/o"); + }); + test("a symbol-keyed property a morph adds survives the restore", () => { const TAG = Symbol("tag"); const schema = configSchema({