Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/cli-engine/src/config-base-dir.ts
Original file line number Diff line number Diff line change
@@ -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<string> };

/** 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<T>(
dir: string,
evaluate: () => Promise<T>,
): Promise<T> {
const slot = globalThis as BaseDirSlot;
slot[BASE_DIR_KEY] ??= new AsyncLocalStorage<string>();
return slot[BASE_DIR_KEY].run(dir, evaluate);
}
9 changes: 8 additions & 1 deletion packages/cli-engine/src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -456,7 +461,9 @@ async function evaluateChainFile(
): Promise<EvaluatedChainFile> {
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,
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/src/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
type SpawnDeclarations,
type WorkflowStep,
} from "../commands";
export { BASE_DIR_KEY, baseDir, withBaseDir } from "../config-base-dir";
export {
definePrismaConfig,
loadConfig,
Expand Down
110 changes: 110 additions & 0 deletions packages/cli-engine/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<void>((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();
});
});
3 changes: 3 additions & 0 deletions packages/cli-engine/tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -45,6 +47,7 @@ describe("main export", () => {
"resolveSectionOverChain",
"resolveSectionPath",
"telemetryCommandGroup",
"withBaseDir",
]);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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() },
});
Original file line number Diff line number Diff line change
@@ -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() },
});
Loading