Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<workerId>`

## 0.2.0-alpha.0

Breaking-ish alpha cut (still `alpha` dist-tag). Public lifecycle verb is now **`acquire`** (was `ensure`).
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/jest-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<rootDir>/jest.cedar-global.cjs",
* globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"),
* // Prefer setupFilesAfterEnv + beforeAll; setupFiles also works (memo is process-scoped).
* setupFilesAfterEnv: ["<rootDir>/jest.cedar-worker.cjs"],
*
* // jest.cedar-worker.cjs
Expand Down
44 changes: 33 additions & 11 deletions src/adapters/template-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,27 @@ export type CloneWorkerDatabaseOptions = {
name?: string;
};

let workerOnce: Promise<void> | 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<void>;
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 (
Expand All @@ -86,31 +105,34 @@ 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<void> {
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",
name,
setEnv: true,
});
})().catch((err) => {
workerOnce = undefined;
workerOnceKey = undefined;
writeCloneWorkerMemo(undefined);
throw err;
});
return workerOnce;
writeCloneWorkerMemo({ promise, key });
return promise;
}
30 changes: 30 additions & 0 deletions tests/template-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ async function withMockedCore<T>(
},
run: () => Promise<T>,
): Promise<T> {
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<typeof import("../src/core/lifecycle.ts")>(
Expand Down Expand Up @@ -74,6 +76,7 @@ async function withMockedCore<T>(
vi.doUnmock("../src/core/lifecycle.ts");
vi.doUnmock("../src/core/template.ts");
vi.resetModules();
delete (globalThis as typeof globalThis & { [cloneWorkerMemo]?: unknown })[cloneWorkerMemo];
}
}

Expand Down Expand Up @@ -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;
Expand Down
Loading