From 20e88cd74956665cccfea349448c9c53eb5a9fd0 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 15:17:05 +0200 Subject: [PATCH 1/2] feat(engine): the loader tells a config file where it is A relative path inside prisma.config.ts means "relative to this file", but the engine handed sections over exactly as written and nothing told a command family which file they came from. The ORM resolved its paths against the working directory instead, so contract emit --config ./sub/prisma.config.ts run from the parent looked for ./contract.prisma in the parent and failed. The loader now publishes the directory of the file it is evaluating in a slot on globalThis under Symbol.for("prisma.config.baseDir") for the duration of the evaluation (withBaseDir), so a family's config helper can resolve its own paths while the file runs and record the base directory on its section. Nothing changes for config authors, and the engine never learns which fields are paths. The engine moves to 0.5.0: a changed engine ships under a new version. Design: ADR 253 in prisma/orm. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-base-dir.ts | 40 ++++++++++ packages/cli-engine/src/config-loader.ts | 9 ++- packages/cli-engine/src/exports/index.ts | 1 + packages/cli-engine/tests/config.test.ts | 78 +++++++++++++++++++ packages/cli-engine/tests/engine.test.ts | 3 + .../fixtures/config/base-dir/prisma.config.ts | 11 +++ 6 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 packages/cli-engine/src/config-base-dir.ts create mode 100644 packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts diff --git a/packages/cli-engine/src/config-base-dir.ts b/packages/cli-engine/src/config-base-dir.ts new file mode 100644 index 00000000..6d1f1ba0 --- /dev/null +++ b/packages/cli-engine/src/config-base-dir.ts @@ -0,0 +1,40 @@ +/** + * The directory relative paths in a config file resolve against: the + * directory of the file being evaluated. The loader publishes it in a slot on + * globalThis around the evaluation, and a family's config helper reads it + * while the file runs to resolve its own paths. `Symbol.for` so every loader + * and helper in a dependency tree shares one slot, and a family declares it + * without importing the engine. + */ +export const BASE_DIR_KEY: unique symbol = Symbol.for("prisma.config.baseDir"); + +type BaseDirSlot = { [BASE_DIR_KEY]?: string }; + +/** The published base directory, or undefined outside a loader's evaluation. */ +export function baseDir(): string | undefined { + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]; +} + +/** + * Runs `evaluate` with `dir` published as the base directory and restores the + * previous value after, whether or not the evaluation throws. Evaluations are + * awaited one at a time; concurrent loads in one process would need the slot + * moved to an AsyncLocalStorage behind these same two functions. + */ +export async function withBaseDir( + dir: string, + evaluate: () => Promise, +): Promise { + const slot = globalThis as BaseDirSlot; + const previous = slot[BASE_DIR_KEY]; + slot[BASE_DIR_KEY] = dir; + try { + return await evaluate(); + } finally { + if (previous === undefined) { + delete slot[BASE_DIR_KEY]; + } else { + slot[BASE_DIR_KEY] = previous; + } + } +} diff --git a/packages/cli-engine/src/config-loader.ts b/packages/cli-engine/src/config-loader.ts index 80a92300..bf395260 100644 --- a/packages/cli-engine/src/config-loader.ts +++ b/packages/cli-engine/src/config-loader.ts @@ -23,6 +23,10 @@ * a Runtime member a host can replace — checks them against the * sections the mounted commands declare. * + * A section's relative paths are relative to the file that wrote them. The + * loader publishes each file's directory (withBaseDir) while that file + * runs, and the family's config helper resolves its own paths against it. + * * Finding no file is not an error: section validators own absence, so * a chain with no files yields no sections and no diagnostics. * Absence of a file the user NAMED with --config is an error — they @@ -41,6 +45,7 @@ import { existsSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { withBaseDir } from "./config-base-dir"; import type { Diagnostic } from "./protocol"; import type { LoadedConfig, LoadedConfigFile } from "./runtime"; import { PRISMA_CONFIG_VERSION } from "./runtime"; @@ -456,7 +461,9 @@ async function evaluateChainFile( ): Promise { let exported: unknown; try { - exported = await evaluateConfigFile(path); + // The file's config helpers read the base directory while the file + // runs, so relative paths inside it resolve against this file. + exported = await withBaseDir(dirname(path), () => evaluateConfigFile(path)); } catch (cause) { return { ok: false, diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index bb094a87..f1202de8 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -45,6 +45,7 @@ export { type SpawnDeclarations, type WorkflowStep, } from "../commands"; +export { BASE_DIR_KEY, baseDir, withBaseDir } from "../config-base-dir"; export { definePrismaConfig, loadConfig, diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 1b7fd61c..ab8c84f4 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -18,6 +18,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { + baseDir, type ConfigSection, createCli, defineCommand, @@ -34,6 +35,7 @@ import { resolveSectionPath, type SectionProvenance, type SectionValidation, + withBaseDir, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; @@ -2175,3 +2177,79 @@ describe("warnings on a successful section validation", { expect(run.stderr).toBe("✔ hi\n"); }); }); + +/** + * A relative path inside a config file means "relative to this file". The + * loader is the one party that knows which file it is evaluating, so it + * publishes the file's directory while the file runs and the family's config + * helper resolves its own paths against it (ADR 253 in prisma/orm). + */ +describe("withBaseDir", () => { + test("publishes the directory during the evaluation and clears it after", async () => { + let seen: string | undefined; + + await withBaseDir("/app", async () => { + seen = baseDir(); + }); + + expect(seen).toBe("/app"); + expect(baseDir()).toBeUndefined(); + }); + + test("restores the outer directory after a nested evaluation", async () => { + let inner: string | undefined; + let afterInner: string | undefined; + + await withBaseDir("/outer", async () => { + await withBaseDir("/inner", async () => { + inner = baseDir(); + }); + afterInner = baseDir(); + }); + + expect({ inner, afterInner }).toEqual({ + inner: "/inner", + afterInner: "/outer", + }); + }); + + test("clears the directory when the evaluation throws", async () => { + await expect( + withBaseDir("/app", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(baseDir()).toBeUndefined(); + }); +}); + +describe("loadConfig publishes the base directory", { timeout: 60_000 }, () => { + test("a discovered file sees its own directory", async () => { + const dir = join(FIXTURES, "base-dir"); + + const loaded = await loadConfig(dir); + + expect(loaded.diagnostics).toEqual([]); + expect(loaded.sections).toEqual({ + toy: { greeting: "hello", baseDir: dir }, + }); + }); + + test("a --config file elsewhere sees its own directory, not cwd", async () => { + const dir = join(FIXTURES, "base-dir"); + + const loaded = await loadConfig( + FIXTURES, + join("base-dir", "prisma.config.ts"), + ); + + expect((loaded.sections.toy as { baseDir: string }).baseDir).toBe(dir); + }); + + test("the slot is clear once the file has been read", async () => { + await loadConfig(join(FIXTURES, "base-dir")); + + expect(baseDir()).toBeUndefined(); + }); +}); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 08755f70..9298bce5 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -15,11 +15,13 @@ import { describe, expect, test } from "vitest"; describe("main export", () => { test("exposes exactly the definition-surface runtime values", () => { expect(Object.keys(engine).sort()).toEqual([ + "BASE_DIR_KEY", "EnvironmentCredentialManager", "PRESENTED", "PRISMA_CONFIG_VERSION", "SERVICE_TOKEN_ENV_VAR", "authServiceError", + "baseDir", "claimedExpiresAt", "claimedIdentity", "createCli", @@ -45,6 +47,7 @@ describe("main export", () => { "resolveSectionOverChain", "resolveSectionPath", "telemetryCommandGroup", + "withBaseDir", ]); }); diff --git a/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts new file mode 100644 index 00000000..5b46207c --- /dev/null +++ b/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts @@ -0,0 +1,11 @@ +import { definePrismaConfig } from "@prisma/cli-engine"; + +// What a family's config helper does while the file runs: read the base +// directory the loader published and resolve against it. +const baseDir = (globalThis as { [key: symbol]: unknown })[ + Symbol.for("prisma.config.baseDir") +]; + +export default definePrismaConfig({ + toy: { greeting: "hello", baseDir }, +}); From 9697c6c6d46adc4f019083c6022163f8efb0dfc3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 16:05:28 +0200 Subject: [PATCH 2/2] fix(engine): scope the published base directory to its evaluation A plain global slot let two config evaluations that overlap in time read each other's directory: a language server loading two projects at once would have had one file record the other project's base directory with no error. The slot now holds an AsyncLocalStorage, still shared through the same Symbol.for key, so each evaluation sees only its own directory. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/config-base-dir.ts | 43 ++++++--------- packages/cli-engine/tests/config.test.ts | 52 +++++++++++++++---- .../config/base-dir/child/prisma.config.ts | 11 ++++ .../fixtures/config/base-dir/prisma.config.ts | 4 +- 4 files changed, 72 insertions(+), 38 deletions(-) create mode 100644 packages/cli-engine/tests/fixtures/config/base-dir/child/prisma.config.ts diff --git a/packages/cli-engine/src/config-base-dir.ts b/packages/cli-engine/src/config-base-dir.ts index 6d1f1ba0..c9c1aade 100644 --- a/packages/cli-engine/src/config-base-dir.ts +++ b/packages/cli-engine/src/config-base-dir.ts @@ -1,40 +1,31 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + /** * The directory relative paths in a config file resolve against: the - * directory of the file being evaluated. The loader publishes it in a slot on - * globalThis around the evaluation, and a family's config helper reads it - * while the file runs to resolve its own paths. `Symbol.for` so every loader - * and helper in a dependency tree shares one slot, and a family declares it - * without importing the engine. + * directory of the file being evaluated. The loader publishes it for the + * duration of the evaluation, and a family's config helper reads it while the + * file runs to resolve its own paths. The store lives on globalThis under a + * `Symbol.for` key so every loader and helper in a dependency tree shares + * one, and a family reads it without importing the engine or + * node:async_hooks. It is an AsyncLocalStorage rather than a plain value so + * evaluations that overlap in time, such as a language server loading several + * projects at once, each see their own directory. */ export const BASE_DIR_KEY: unique symbol = Symbol.for("prisma.config.baseDir"); -type BaseDirSlot = { [BASE_DIR_KEY]?: string }; +type BaseDirSlot = { [BASE_DIR_KEY]?: AsyncLocalStorage }; -/** The published base directory, or undefined outside a loader's evaluation. */ +/** The published base directory, or undefined when no loader has published one. */ export function baseDir(): string | undefined { - return (globalThis as BaseDirSlot)[BASE_DIR_KEY]; + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]?.getStore(); } -/** - * Runs `evaluate` with `dir` published as the base directory and restores the - * previous value after, whether or not the evaluation throws. Evaluations are - * awaited one at a time; concurrent loads in one process would need the slot - * moved to an AsyncLocalStorage behind these same two functions. - */ -export async function withBaseDir( +/** Runs `evaluate` with `dir` published as the base directory for everything it awaits. */ +export function withBaseDir( dir: string, evaluate: () => Promise, ): Promise { const slot = globalThis as BaseDirSlot; - const previous = slot[BASE_DIR_KEY]; - slot[BASE_DIR_KEY] = dir; - try { - return await evaluate(); - } finally { - if (previous === undefined) { - delete slot[BASE_DIR_KEY]; - } else { - slot[BASE_DIR_KEY] = previous; - } - } + slot[BASE_DIR_KEY] ??= new AsyncLocalStorage(); + return slot[BASE_DIR_KEY].run(dir, evaluate); } diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index ab8c84f4..d6476d35 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -2213,6 +2213,28 @@ describe("withBaseDir", () => { }); }); + test("keeps two overlapping evaluations apart", async () => { + let seenA: string | undefined; + let seenB: string | undefined; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + await Promise.all([ + withBaseDir("/a", async () => { + await gate; + seenA = baseDir(); + }), + withBaseDir("/b", async () => { + release(); + seenB = baseDir(); + }), + ]); + + expect({ seenA, seenB }).toEqual({ seenA: "/a", seenB: "/b" }); + }); + test("clears the directory when the evaluation throws", async () => { await expect( withBaseDir("/app", async () => { @@ -2225,30 +2247,40 @@ describe("withBaseDir", () => { }); describe("loadConfig publishes the base directory", { timeout: 60_000 }, () => { - test("a discovered file sees its own directory", async () => { - const dir = join(FIXTURES, "base-dir"); + const dir = join(FIXTURES, "base-dir"); + const child = join(dir, "child"); + + function baseDirOf(loaded: LoadedConfig, path: string): unknown { + const file = loaded.files.find((entry) => entry.path === path); + return (file?.sections.toy as { baseDir?: unknown } | undefined)?.baseDir; + } + test("a discovered file sees its own directory", async () => { const loaded = await loadConfig(dir); expect(loaded.diagnostics).toEqual([]); - expect(loaded.sections).toEqual({ - toy: { greeting: "hello", baseDir: dir }, - }); + expect(baseDirOf(loaded, join(dir, "prisma.config.ts"))).toBe(dir); }); test("a --config file elsewhere sees its own directory, not cwd", async () => { - const dir = join(FIXTURES, "base-dir"); - const loaded = await loadConfig( FIXTURES, join("base-dir", "prisma.config.ts"), ); - expect((loaded.sections.toy as { baseDir: string }).baseDir).toBe(dir); + expect(baseDirOf(loaded, join(dir, "prisma.config.ts"))).toBe(dir); + }); + + test("each file on a discovery chain sees its own directory", async () => { + const loaded = await loadConfig(child); + + expect(loaded.diagnostics).toEqual([]); + expect(baseDirOf(loaded, join(child, "prisma.config.ts"))).toBe(child); + expect(baseDirOf(loaded, join(dir, "prisma.config.ts"))).toBe(dir); }); - test("the slot is clear once the file has been read", async () => { - await loadConfig(join(FIXTURES, "base-dir")); + test("the store is empty once the files have been read", async () => { + await loadConfig(child); expect(baseDir()).toBeUndefined(); }); diff --git a/packages/cli-engine/tests/fixtures/config/base-dir/child/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/base-dir/child/prisma.config.ts new file mode 100644 index 00000000..0dbed770 --- /dev/null +++ b/packages/cli-engine/tests/fixtures/config/base-dir/child/prisma.config.ts @@ -0,0 +1,11 @@ +import { definePrismaConfig } from "@prisma/cli-engine"; + +// What a family's config helper does while the file runs: read the base +// directory the loader published and resolve against it. +const store = (globalThis as { [key: symbol]: { getStore(): unknown } })[ + Symbol.for("prisma.config.baseDir") +]; + +export default definePrismaConfig({ + toy: { greeting: "hello", baseDir: store.getStore() }, +}); diff --git a/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts b/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts index 5b46207c..0dbed770 100644 --- a/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts +++ b/packages/cli-engine/tests/fixtures/config/base-dir/prisma.config.ts @@ -2,10 +2,10 @@ import { definePrismaConfig } from "@prisma/cli-engine"; // What a family's config helper does while the file runs: read the base // directory the loader published and resolve against it. -const baseDir = (globalThis as { [key: symbol]: unknown })[ +const store = (globalThis as { [key: symbol]: { getStore(): unknown } })[ Symbol.for("prisma.config.baseDir") ]; export default definePrismaConfig({ - toy: { greeting: "hello", baseDir }, + toy: { greeting: "hello", baseDir: store.getStore() }, });