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..c9c1aade --- /dev/null +++ b/packages/cli-engine/src/config-base-dir.ts @@ -0,0 +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 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]?: AsyncLocalStorage }; + +/** The published base directory, or undefined when no loader has published one. */ +export function baseDir(): string | undefined { + return (globalThis as BaseDirSlot)[BASE_DIR_KEY]?.getStore(); +} + +/** 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; + slot[BASE_DIR_KEY] ??= new AsyncLocalStorage(); + return slot[BASE_DIR_KEY].run(dir, evaluate); +} 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..d6476d35 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,111 @@ 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("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 () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(baseDir()).toBeUndefined(); + }); +}); + +describe("loadConfig publishes the base directory", { timeout: 60_000 }, () => { + 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(baseDirOf(loaded, join(dir, "prisma.config.ts"))).toBe(dir); + }); + + test("a --config file elsewhere sees its own directory, not cwd", async () => { + const loaded = await loadConfig( + FIXTURES, + join("base-dir", "prisma.config.ts"), + ); + + 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 store is empty once the files have been read", async () => { + await loadConfig(child); + + 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/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 new file mode 100644 index 00000000..0dbed770 --- /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 store = (globalThis as { [key: symbol]: { getStore(): unknown } })[ + Symbol.for("prisma.config.baseDir") +]; + +export default definePrismaConfig({ + toy: { greeting: "hello", baseDir: store.getStore() }, +});