diff --git a/docs/architecture/adrs/0005-config-sections-declare-their-shape.md b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md index 18693d59..e7895288 100644 --- a/docs/architecture/adrs/0005-config-sections-declare-their-shape.md +++ b/docs/architecture/adrs/0005-config-sections-declare-their-shape.md @@ -52,6 +52,7 @@ Several designs were tried before this one, and each put the knowledge in the wr - `defineConfigSection({ name, schema })` derives the section's validator. The engine runs it on the merged section value with its provenance, after discovery and merging, so defaults declared in the schema apply once to the merged value and never let one file's default shadow another file's authored value. A relative `path` default is declared as a thunk, `["path", "=", () => "./migrations"]`, which arktype evaluates and morphs when the default is applied, so it resolves against the nearest file like an authored value; a relative literal default would be stored unresolved, and is refused when the schema is defined. - Each arktype error becomes a `CLI.CONFIG_FIELD_INVALID` diagnostic carrying `meta.section`, `meta.field`, and `where.path`, the file that declared the field's top-level key, so a chain of files still tells the user which one to fix. - The validated value of a plain-object section carries `baseDir`, the directory of the nearest file declaring the section, for commands that need the project's location rather than one of its files. The key is reserved: a config file that writes it is refused. +- Resolving a path or applying a default transforms the value, and arktype clones what it is given before it transforms it, so a config file's own objects are never written to. Its built-in clone rebuilds every object it reaches, which would hand a command a lookalike of the codec table or the contract serializer the file built. The engine supplies a clone that rebuilds plain objects and arrays and nothing else, so a class instance, a `Map` or a function reaches the command exactly as the file constructed it. - An absent section is validated as an empty object: a schema whose fields are all optional accepts it, and a required field is reported by name. - `defineConfigSection({ name, validate })` remains for a section a schema cannot express; such a validator resolves its own path fields through `resolveSectionPath`. 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..f1f79d06 100644 --- a/packages/cli-engine/src/config-schema.ts +++ b/packages/cli-engine/src/config-schema.ts @@ -47,14 +47,22 @@ function resolvePathValue(value: string, path: readonly PropertyKey[]): string { return file === undefined ? value : resolve(dirname(file), value); } -const configScope = scope({ - /** - * A string relative to the config file that wrote it. Validation turns it - * into an absolute path against that file's directory; an absolute value - * passes through unchanged. - */ - path: type("string").pipe((value, ctx) => resolvePathValue(value, ctx.path)), -}); +const configScope = scope( + { + /** + * A string relative to the config file that wrote it. Validation turns it + * into an absolute path against that file's directory; an absolute value + * passes through unchanged. + */ + path: type("string").pipe((value, ctx) => + resolvePathValue(value, ctx.path), + ), + }, + { + clone: (original: original): original => + copyPlainParts(original, new Map()) as original, + }, +); /** * Declares the shape of a config section once. Definitions are arktype @@ -99,17 +107,45 @@ 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 { +/** + * Before arktype applies a morph it clones the value, so resolving a path or + * applying a default never writes into what the caller passed in. Its own + * clone rebuilds every object it reaches. A config file's objects cannot + * survive that: a codec table, a contract serializer, anything whose + * behaviour lives in the instance rather than in its keys comes back as a + * lookalike that no longer works. + * + * So the scope above clones through arktype's `clone` option instead, and + * rebuilds only the plain objects and arrays a schema can write into. + * Everything else a config file constructed reaches the command as the file + * built it. `seen` carries the copies made so far, so a value that refers + * back to itself is copied once rather than followed forever. + */ +function copyPlainParts(value: unknown, seen: Map): unknown { + if (!Array.isArray(value) && !isPlainObject(value)) { + return value; + } + const copied = seen.get(value); + if (copied !== undefined) { + return copied; + } if (Array.isArray(value)) { - return value.map(copyPlainData); + const elements: unknown[] = []; + seen.set(value, elements); + for (const element of value) { + elements.push(copyPlainParts(element, seen)); + } + return elements; } - if (isPlainObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]), + const entries: Record = {}; + seen.set(value, entries); + for (const key of Reflect.ownKeys(value)) { + entries[key] = copyPlainParts( + (value as Record)[key], + seen, ); } - return value; + return entries; } function fieldDiagnostic( @@ -161,9 +197,7 @@ 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)); + const out: unknown = schema(raw === undefined ? {} : raw); 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..3a13fd90 100644 --- a/packages/cli-engine/tests/config-schema.test.ts +++ b/packages/cli-engine/tests/config-schema.test.ts @@ -44,6 +44,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", @@ -166,6 +168,307 @@ describe("validateSectionWithSchema", () => { expect(dir).toBe("/app"); }); + test("what a config file constructed reaches the command working, frozen section or not", () => { + class Serializer { + deserialize(json: unknown): unknown { + return json; + } + } + const checkedOnly = configSchema("object").narrow(() => true); + const schema = configSchema({ + target: checkedOnly, + "contract?": { source: checkedOnly, "output?": "path" }, + "extensions?": [checkedOnly, "[]"], + migrations: [ + { dir: ["path", "=", () => "./migrations"] }, + "=", + () => ({}), + ], + }); + const serializer = new Serializer(); + const load = () => 1; + const target = Object.freeze({ + kind: "target", + serializer, + create() { + return this.kind; + }, + }); + const raw = Object.freeze({ + target, + contract: Object.freeze({ + source: Object.freeze({ load }), + 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: { load: () => number }; output: string }; + extensions: (typeof target)[]; + migrations: { dir: string }; + }; + expect(value.target.serializer).toBe(serializer); + expect(value.target.create()).toBe("target"); + expect(value.contract.source.load).toBe(load); + expect(value.extensions[0].serializer).toBe(serializer); + expect(value.contract.output).toBe("/app/out.json"); + expect(value.migrations.dir).toBe("/app/migrations"); + }); + + test("a plain object the schema describes is copied, not written to in place", () => { + const schema = configSchema({ contract: { output: "path" } }); + const contract = { output: "./out.json" }; + + const result = validateSectionWithSchema( + "toy", + schema, + { contract }, + single, + ); + + expect( + result.ok && + (result.value as { contract: { output: string } }).contract.output, + ).toBe("/app/out.json"); + expect(contract.output).toBe("./out.json"); + }); + + test("a plain object that refers back to itself is copied once", () => { + const schema = configSchema({ node: "object", "out?": "path" }); + const node: { name: string; self?: unknown } = { name: "root" }; + node.self = node; + + const result = validateSectionWithSchema( + "toy", + schema, + { node, out: "./o" }, + single, + ); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const value = result.value as { node: typeof node; out: string }; + expect(value.node.self).toBe(value.node); + expect(value.out).toBe("/app/o"); + }); + + 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: checkedOnly, + migrations: [ + { dir: ["path", "=", () => "./migrations"] }, + "=", + () => ({}), + ], + }).narrow(() => true); + const create = () => 1; + const family = { kind: "family", create }; + + const result = validateSectionWithSchema("toy", schema, { family }, single); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + expect((result.value as { family: typeof family }).family).toEqual(family); + expect((result.value as { family: typeof family }).family.create).toBe( + create, + ); + expect( + (result.value as { migrations: { dir: string } }).migrations.dir, + ).toBe("/app/migrations"); + }); + + 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) + .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 union resolves paths in the branch the value matches, keeping the rest", () => { + const checkedOnly = configSchema("object").narrow(() => true); + const schema = configSchema({ + either: [ + { kind: "'a'", "dir?": "path" }, + "|", + { kind: "'b'", inner: checkedOnly }, + ], + }); + 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 paths by position, prefix and postfix alike", () => { + const checkedOnly = configSchema("object").narrow(() => true); + const schema = configSchema({ + pair: ["path", checkedOnly], + tail: ["path", "...", "object[]", "path"], + }); + const second = new Date(1); + const middle = new Date(2); + + const result = validateSectionWithSchema( + "toy", + schema, + { 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.diagnostics.every( + (diagnostic) => diagnostic.code === "CLI.CONFIG_FIELD_INVALID", + ), + ).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 is kept", () => { + 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 resolves every value it describes", () => { + const schema = configSchema({ "[string]": "path" }); + + const result = validateSectionWithSchema( + "toy", + schema, + { a: "./x", b: "./y" }, + single, + ); + + expect(result.ok && result.value).toMatchObject({ + a: "/app/x", + b: "/app/y", + }); + }); + + 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