diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3306a..80f69f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- TEMPLATE `cloneWorkerDatabase`: process-once memo lives on `globalThis` so Jest `setupFiles` (module reload per file) no longer re-clones and hits `database already exists` on `_c_` + ## 0.2.0-alpha.0 Breaking-ish alpha cut (still `alpha` dist-tag). Public lifecycle verb is now **`acquire`** (was `ensure`). diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts index 9b58685..9ffd6b1 100644 --- a/src/adapters/jest-template.ts +++ b/src/adapters/jest-template.ts @@ -20,8 +20,11 @@ export type { * module.exports = createGlobalSetup({ migrate: async ({ databaseUrl }) => {} }); * * // jest.config.cjs + * // When .env has a real TEST_DATABASE_URL, set once here (inherits to workers): + * // process.env.CEDAR_PG_FORCE = "1"; * globalSetup: "/jest.cedar-global.cjs", * globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"), + * // Prefer setupFilesAfterEnv + beforeAll; setupFiles also works (memo is process-scoped). * setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], * * // jest.cedar-worker.cjs diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index 76c0245..ef89c62 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -69,8 +69,27 @@ export type CloneWorkerDatabaseOptions = { name?: string; }; -let workerOnce: Promise | undefined; -let workerOnceKey: string | undefined; +/** Survives Jest `setupFiles` module reloads (module-scoped `let` does not). */ +const CLONE_WORKER_MEMO = Symbol.for("@cedarjs/pg/cloneWorkerDatabase"); + +type CloneWorkerMemo = { + promise: Promise; + key: string; +}; + +type GlobalWithCloneWorkerMemo = typeof globalThis & { + [CLONE_WORKER_MEMO]?: CloneWorkerMemo; +}; + +function readCloneWorkerMemo(): CloneWorkerMemo | undefined { + return (globalThis as GlobalWithCloneWorkerMemo)[CLONE_WORKER_MEMO]; +} + +function writeCloneWorkerMemo(memo: CloneWorkerMemo | undefined): void { + const g = globalThis as GlobalWithCloneWorkerMemo; + if (memo === undefined) delete g[CLONE_WORKER_MEMO]; + else g[CLONE_WORKER_MEMO] = memo; +} function resolveWorkerName(options: CloneWorkerDatabaseOptions): string { return ( @@ -86,21 +105,24 @@ function workerOptionsKey(root: string | undefined, name: string): string { * Process-once per-worker clone (JEST_WORKER_ID / VITEST_POOL_ID / pid by default). * Uses `cloneFromTemplateIfNeeded` (same skip policy as `acquireIfNeeded`) with `setEnv: true`. * First call wins for `root`/`name`; conflicting later calls throw. + * + * Memo lives on `globalThis` so Jest `setupFiles` (module reload per file) still + * shares one clone per worker. `setupFilesAfterEnv` + `beforeAll` also works. */ export function cloneWorkerDatabase(options: CloneWorkerDatabaseOptions = {}): Promise { const name = resolveWorkerName(options); const key = workerOptionsKey(options.root, name); - if (workerOnce) { - if (workerOnceKey !== key) { + const existing = readCloneWorkerMemo(); + if (existing) { + if (existing.key !== key) { throw new Error( `cloneWorkerDatabase already started with different root/name ` + - `(first: ${JSON.stringify(workerOnceKey)}, now: ${JSON.stringify(key)})`, + `(first: ${JSON.stringify(existing.key)}, now: ${JSON.stringify(key)})`, ); } - return workerOnce; + return existing.promise; } - workerOnceKey = key; - workerOnce = (async () => { + const promise = (async () => { await cloneFromTemplateIfNeeded({ root: options.root, mode: "test", @@ -108,9 +130,9 @@ export function cloneWorkerDatabase(options: CloneWorkerDatabaseOptions = {}): P setEnv: true, }); })().catch((err) => { - workerOnce = undefined; - workerOnceKey = undefined; + writeCloneWorkerMemo(undefined); throw err; }); - return workerOnce; + writeCloneWorkerMemo({ promise, key }); + return promise; } diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index 2e0f1e1..124c7f2 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -47,6 +47,8 @@ async function withMockedCore( }, run: () => Promise, ): Promise { + const cloneWorkerMemo = Symbol.for("@cedarjs/pg/cloneWorkerDatabase"); + delete (globalThis as typeof globalThis & { [cloneWorkerMemo]?: unknown })[cloneWorkerMemo]; vi.resetModules(); vi.doMock("../src/core/lifecycle.ts", async () => { const actual = await vi.importActual( @@ -74,6 +76,7 @@ async function withMockedCore( vi.doUnmock("../src/core/lifecycle.ts"); vi.doUnmock("../src/core/template.ts"); vi.resetModules(); + delete (globalThis as typeof globalThis & { [cloneWorkerMemo]?: unknown })[cloneWorkerMemo]; } } @@ -198,6 +201,33 @@ test("cloneWorkerDatabase clones via cloneFromTemplateIfNeeded with setEnv true" } }); +test("cloneWorkerDatabase memo survives module reload (Jest setupFiles)", async () => { + const prevJest = process.env.JEST_WORKER_ID; + const prevCedar = process.env.CEDAR_PG; + process.env.JEST_WORKER_ID = "1"; + delete process.env.CEDAR_PG; + + const cloneFromTemplateIfNeeded = vi.fn(async () => + clonedWorker({ databaseName: "cpg_tmpl_c_1" }), + ); + + try { + await withMockedCore({ cloneFromTemplateIfNeeded }, async () => { + const first = await import("../src/adapters/template-mode.ts"); + await first.cloneWorkerDatabase({ root: "/tmp/wt" }); + vi.resetModules(); + const second = await import("../src/adapters/template-mode.ts"); + await second.cloneWorkerDatabase({ root: "/tmp/wt" }); + expect(cloneFromTemplateIfNeeded).toHaveBeenCalledTimes(1); + }); + } finally { + if (prevJest === undefined) delete process.env.JEST_WORKER_ID; + else process.env.JEST_WORKER_ID = prevJest; + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + } +}); + test("cloneWorkerDatabase is idempotent per process", async () => { const prevJest = process.env.JEST_WORKER_ID; const prevCedar = process.env.CEDAR_PG;