From 3fdf92a41a150a24c511b25de98ba8077214ea1d Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 2 Aug 2026 20:52:31 -0400 Subject: [PATCH 1/7] feat: add optional Jest/Vitest TEMPLATE mode adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrate ensure → app migrate → markTemplate → per-worker clone so stock ensure/dispose adapters are not mistaken for migrate-once isolation. --- README.md | 81 ++++++- package.json | 26 +++ scripts/smoke.mjs | 8 + src/adapters/jest-template-teardown.ts | 6 + src/adapters/jest-template-worker.ts | 14 ++ src/adapters/jest-template.ts | 33 +++ src/adapters/jest.ts | 4 +- src/adapters/template-mode.ts | 146 +++++++++++++ src/adapters/vitest-template-worker.ts | 11 + src/adapters/vitest-template.ts | 51 +++++ src/adapters/vitest.ts | 3 +- src/index.ts | 15 ++ tests/template-mode.test.ts | 291 +++++++++++++++++++++++++ vite.config.ts | 6 + 14 files changed, 683 insertions(+), 12 deletions(-) create mode 100644 src/adapters/jest-template-teardown.ts create mode 100644 src/adapters/jest-template-worker.ts create mode 100644 src/adapters/jest-template.ts create mode 100644 src/adapters/template-mode.ts create mode 100644 src/adapters/vitest-template-worker.ts create mode 100644 src/adapters/vitest-template.ts create mode 100644 tests/template-mode.test.ts diff --git a/README.md b/README.md index ae2442f..09b0fa1 100644 --- a/README.md +++ b/README.md @@ -237,24 +237,83 @@ See [`.github/actions/setup-autopg`](.github/actions/setup-autopg/README.md) for The action runs `scripts/ci-install-autopg.sh` under the hood. For published-package consumers under `CI=true` without the Action, set `CEDAR_PG_INSTALL_AUTOPG=1` so `postinstall` runs that same script (not upstream `install.sh`) — that flag alone is not enough when the package manager disables lifecycle scripts (`--ignore-scripts`, `YARN_ENABLE_SCRIPTS=false`, etc.). Prefer this Action, or bake the binary into the image. -### Migrate-once + TEMPLATE clones (Jest workers) +### Migrate-once + TEMPLATE clones (Jest / Vitest) + +Stock `@cedarjs/pg/jest` and `@cedarjs/pg/vitest` only run `ensureIfNeeded` + `dispose` (one shared test DB). They are **not** a full replacement for Redwood-style globalSetup that migrates once and clones per worker. For that, use template mode (or the programmatic API below). + +**Jest (template mode):** + +```js +// jest.config.cjs +module.exports = { + globalSetup: require.resolve("@cedarjs/pg/jest/template"), + globalTeardown: require.resolve("@cedarjs/pg/jest/template/teardown"), + setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], +}; + +// jest.cedar-worker.cjs — once per worker process +const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template/worker"); +beforeAll(() => ensureWorkerDatabase()); +``` + +**Vitest (template mode):** ```ts -import { ensure, markTemplate, cloneFromTemplate, dispose } from "@cedarjs/pg"; +export default defineConfig({ + test: { + globalSetup: ["@cedarjs/pg/vitest/template"], + setupFiles: ["@cedarjs/pg/vitest/template/worker"], + }, +}); +``` -const ensured = await ensure({ mode: "test" }); -// run migrations once against ensured.databaseUrl (Prisma migrate reset, etc.) -await markTemplate(ensured); +Migrate stays app-owned. Either set `CEDAR_PG_MIGRATE` to a module that exports `migrate` / `default`: -const worker = await cloneFromTemplate({ name: process.env.JEST_WORKER_ID ?? "1" }); -// worker.databaseUrl — same role credentials; adminUrl for privileged DDL if needed +```ts +// scripts/cedar-pg-migrate.ts +export async function migrate({ databaseUrl }: { databaseUrl: string }) { + // prisma migrate reset / drizzle push / etc. against databaseUrl +} +``` + +```bash +CEDAR_PG_MIGRATE=./scripts/cedar-pg-migrate.ts pnpm test +``` -await dispose({ mode: "test" }); // drops TEMPLATE + all clones owned by the test role +…or wrap with a migrate hook: + +```ts +import { createGlobalSetup } from "@cedarjs/pg/jest/template"; + +export default createGlobalSetup({ + migrate: async ({ databaseUrl }) => { + /* migrate once */ + }, +}); +``` + +**Programmatic (same flow without runner adapters):** + +```ts +import { + setupTemplateMode, + setupTemplateWorker, + teardownTemplateMode, + // or: ensure, markTemplate, cloneFromTemplate, dispose +} from "@cedarjs/pg"; + +await setupTemplateMode({ + migrate: async ({ databaseUrl, adminUrl }) => { + /* migrate once against databaseUrl; adminUrl for privileged DDL if needed */ + }, +}); +await setupTemplateWorker({ name: process.env.JEST_WORKER_ID ?? "1" }); +await teardownTemplateMode(); ``` `ensure` returns `adminUrl` so apps do not re-derive `postgresql://postgres:postgres@127.0.0.1:/postgres`. `cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. -`dispose` unsets `IS_TEMPLATE` and drops every DB owned by the test role (template + clones). +`dispose` / `teardownTemplateMode` unsets `IS_TEMPLATE` and drops every DB owned by the test role (template + clones). ## Env @@ -269,6 +328,8 @@ await dispose({ mode: "test" }); // drops TEMPLATE + all clones owned by the tes | `CEDAR_PG_REGISTRY_DIR` | Override global lease registry (for `gc`) | | `CEDAR_PG_SKIP_POSTINSTALL=1` | Skip autopg install hook | | `CEDAR_PG_INSTALL_AUTOPG=1` | Under `CI=true`, run binary-only `ci-install-autopg.sh` from postinstall | +| `CEDAR_PG_MIGRATE` | Module path for template-mode migrate hook (`migrate` or default) | +| `CEDAR_PG_ADMIN_URL` | Set by template globalSetup (superuser URL for privileged DDL) | ## Alpha caveats @@ -278,4 +339,4 @@ await dispose({ mode: "test" }); // drops TEMPLATE + all clones owned by the tes (ephemeral cold-start when the runner has no live host; attach-wins otherwise). - State lives in product-owned `.cedarpg` (worktree + `~/.cedarpg/registry`), not under autopg's `~/.autopg/` or a generic `.pg`. - Role passwords are derived from `roleName` (`cedar-pg\\0` + roleName, scheme v2) so TEMPLATE clones that reuse a role keep working; bump the scheme id to change the derivation. -- Test TEMPLATE flow: `ensure` returns `adminUrl`; `markTemplate` / `cloneFromTemplate` / `dispose` own migrate-once worker isolation (dispose drops role-owned DBs). +- Test TEMPLATE flow: `ensure` returns `adminUrl`; `markTemplate` / `cloneFromTemplate` / `dispose` own clone + teardown. Optional `@cedarjs/pg/jest/template` + `@cedarjs/pg/vitest/template` adapters orchestrate ensure → app migrate → mark → per-worker clone; migrate itself stays app-owned. diff --git a/package.json b/package.json index af36b04..094cfad 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,16 @@ "import": "./dist/vitest.mjs", "require": "./dist/vitest.cjs" }, + "./vitest/template": { + "types": "./dist/vitest-template.d.mts", + "import": "./dist/vitest-template.mjs", + "require": "./dist/vitest-template.cjs" + }, + "./vitest/template/worker": { + "types": "./dist/vitest-template-worker.d.mts", + "import": "./dist/vitest-template-worker.mjs", + "require": "./dist/vitest-template-worker.cjs" + }, "./jest": { "types": "./dist/jest.d.mts", "import": "./dist/jest.mjs", @@ -73,6 +83,22 @@ "import": "./dist/test-env.mjs", "require": "./dist/test-env.cjs" }, + "./jest/template": { + "types": "./dist/jest-template.d.mts", + "import": "./dist/jest-template.mjs", + "require": "./dist/jest-template.cjs" + }, + "./jest/template/teardown": { + "types": "./dist/jest-template-teardown.d.mts", + "import": "./dist/jest-template-teardown.mjs", + "require": "./dist/jest-template-teardown.cjs" + }, + "./jest/template/worker": { + "types": "./dist/jest-template-worker.d.mts", + "import": "./dist/jest-template-worker.mjs", + "require": "./dist/jest-template-worker.cjs" + }, + "./package.json": "./package.json" }, "publishConfig": { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 602b4d4..88979e3 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -36,6 +36,7 @@ run( import { buildDatabaseName, loadTestEnv, + setupTemplateMode, STATE_DIRNAME, } from '${PACKAGE_NAME}'; import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus'; @@ -43,6 +44,9 @@ import vitestSetup from '${PACKAGE_NAME}/vitest'; import jestSetup from '${PACKAGE_NAME}/jest'; import jestTeardown from '${PACKAGE_NAME}/jest-teardown'; import '${PACKAGE_NAME}/test-env'; +import jestTemplate from '${PACKAGE_NAME}/jest/template'; +import { ensureWorkerDatabase } from '${PACKAGE_NAME}/jest/template/worker'; +import vitestTemplate from '${PACKAGE_NAME}/vitest/template'; const name = buildDatabaseName( { root: '/tmp/x', repoSlug: 'cedar', worktreeSlug: 'feat', pathHash: 'abcd1234' }, 'dev', @@ -55,6 +59,10 @@ if (typeof jestSetup !== 'function') throw new Error('jest setup export missing' if (typeof jestTeardown !== 'function') throw new Error('jest-teardown export missing'); if (typeof loadTestEnv !== 'function') throw new Error('loadTestEnv export missing'); if (STATE_DIRNAME !== '.cedarpg') throw new Error('bad STATE_DIRNAME ' + STATE_DIRNAME); +if (typeof setupTemplateMode !== 'function') throw new Error('missing setupTemplateMode'); +if (typeof jestTemplate !== 'function') throw new Error('missing jest/template'); +if (typeof ensureWorkerDatabase !== 'function') throw new Error('missing jest/template/worker'); +if (typeof vitestTemplate !== 'function') throw new Error('missing vitest/template'); console.log('ok', name, STATE_DIRNAME, Object.keys(tasks).join(',')); `, ], diff --git a/src/adapters/jest-template-teardown.ts b/src/adapters/jest-template-teardown.ts new file mode 100644 index 0000000..15d41fb --- /dev/null +++ b/src/adapters/jest-template-teardown.ts @@ -0,0 +1,6 @@ +import { teardownTemplateMode } from "./template-mode.ts"; + +/** Jest globalTeardown: `require.resolve("@cedarjs/pg/jest/template/teardown")`. */ +export default async function globalTeardown(): Promise { + await teardownTemplateMode(); +} diff --git a/src/adapters/jest-template-worker.ts b/src/adapters/jest-template-worker.ts new file mode 100644 index 0000000..bb6704b --- /dev/null +++ b/src/adapters/jest-template-worker.ts @@ -0,0 +1,14 @@ +import { ensureWorkerDatabase, setupTemplateWorker } from "./template-mode.ts"; + +export { ensureWorkerDatabase, setupTemplateWorker }; + +/** + * Jest per-worker helper. Prefer setupFilesAfterEnv: + * + * ```js + * // jest.cedar-worker.cjs + * const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template/worker"); + * beforeAll(() => ensureWorkerDatabase()); + * ``` + */ +export default ensureWorkerDatabase; diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts new file mode 100644 index 0000000..ff22235 --- /dev/null +++ b/src/adapters/jest-template.ts @@ -0,0 +1,33 @@ +import { + createTemplateGlobalSetup, + ensureWorkerDatabase, + resolveMigrateFromEnv, + setupTemplateMode, + setupTemplateWorker, + teardownTemplateMode, +} from "./template-mode.ts"; + +export { + createTemplateGlobalSetup as createGlobalSetup, + ensureWorkerDatabase, + setupTemplateMode, + setupTemplateWorker, + teardownTemplateMode as teardown, +}; + +/** + * Jest globalSetup (template mode). + * + * ```js + * globalSetup: require.resolve("@cedarjs/pg/jest/template"), + * globalTeardown: require.resolve("@cedarjs/pg/jest/template/teardown"), + * setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], + * ``` + * + * Migrate via `CEDAR_PG_MIGRATE` (module exporting `migrate` or default) or + * `createGlobalSetup({ migrate })`. Stock `@cedarjs/pg/jest` is one shared test DB only. + */ +export default async function globalSetup(): Promise { + const migrate = await resolveMigrateFromEnv(); + await setupTemplateMode({ migrate }); +} diff --git a/src/adapters/jest.ts b/src/adapters/jest.ts index d2ae170..e3b857c 100644 --- a/src/adapters/jest.ts +++ b/src/adapters/jest.ts @@ -1,7 +1,9 @@ import { ensureIfNeeded } from "../core/lifecycle.ts"; /** - * Jest globalSetup. Call from jest config: + * Jest globalSetup for a single shared test DB (`ensureIfNeeded` + dispose). + * Not a migrate-once / per-worker TEMPLATE runner — use `@cedarjs/pg/jest/template`. + * * ```js * globalSetup: require.resolve('@cedarjs/pg/jest'), * globalTeardown: require.resolve('@cedarjs/pg/jest-teardown'), diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts new file mode 100644 index 0000000..a6ee06a --- /dev/null +++ b/src/adapters/template-mode.ts @@ -0,0 +1,146 @@ +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; +import { + cloneFromTemplate, + dispose, + ensureIfNeeded, + markTemplate, + type EnsureIfNeededResult, +} from "../core/lifecycle.ts"; +import { resolveEnsureSkip } from "../core/policy.ts"; + +export type TemplateMigrateContext = { + databaseUrl: string; + adminUrl: string; + databaseName: string; + roleName: string; +}; + +export type TemplateMigrateFn = (ctx: TemplateMigrateContext) => void | Promise; + +export type SetupTemplateModeOptions = { + root?: string; + /** + * App-owned migrate step. When provided, runs once against the ensured DB + * then `markTemplate` so workers can `cloneFromTemplate`. + */ + migrate?: TemplateMigrateFn; + setEnv?: boolean; +}; + +/** + * Global setup for migrate-once + TEMPLATE clones: + * ensure host/lease → optional migrate hook → markTemplate. + * + * Without `migrate`, only ensures and sets `CEDAR_PG_ADMIN_URL`; call + * `markTemplate` yourself after your app migrate before workers clone. + */ +export async function setupTemplateMode( + options: SetupTemplateModeOptions = {}, +): Promise { + const result = await ensureIfNeeded({ + root: options.root, + mode: "test", + setEnv: options.setEnv !== false, + }); + if (result.status !== "ensured") return result; + + process.env.CEDAR_PG_ADMIN_URL = result.adminUrl; + + if (options.migrate) { + await options.migrate({ + databaseUrl: result.databaseUrl, + adminUrl: result.adminUrl, + databaseName: result.databaseName, + roleName: result.roleName, + }); + await markTemplate({ + root: result.root, + mode: "test", + databaseName: result.databaseName, + adminUrl: result.adminUrl, + }); + } + + return result; +} + +export type SetupTemplateWorkerOptions = { + root?: string; + /** Clone suffix; defaults to JEST_WORKER_ID / VITEST_POOL_ID / pid. */ + name?: string; +}; + +/** + * Per-worker clone → sets DATABASE_URL / TEST_DATABASE_URL. + * Idempotent per process (safe with setupFilesAfterEnv + beforeAll). + */ +export async function setupTemplateWorker(options: SetupTemplateWorkerOptions = {}): Promise { + const skip = resolveEnsureSkip(); + if (skip.skip) { + if (skip.reason === "external-url") { + process.env.DATABASE_URL = skip.databaseUrl; + } + return; + } + + const name = + options.name ?? process.env.JEST_WORKER_ID ?? process.env.VITEST_POOL_ID ?? String(process.pid); + + await cloneFromTemplate({ + root: options.root, + mode: "test", + name, + setEnv: true, + }); +} + +let workerOnce: Promise | undefined; + +/** Process-once wrapper around {@link setupTemplateWorker}. */ +export function ensureWorkerDatabase(options: SetupTemplateWorkerOptions = {}): Promise { + workerOnce ??= setupTemplateWorker(options).catch((err) => { + workerOnce = undefined; + throw err; + }); + return workerOnce; +} + +/** Drop TEMPLATE + all role-owned clones; forget lease. */ +export async function teardownTemplateMode(options: { root?: string } = {}): Promise { + await dispose({ root: options.root, mode: "test" }); +} + +/** + * `createGlobalSetup({ migrate })` for thin app globalSetup files. + * Bare `require.resolve("@cedarjs/pg/jest/template")` uses {@link resolveMigrateFromEnv}. + */ +export function createTemplateGlobalSetup(options: SetupTemplateModeOptions = {}) { + return async () => { + await setupTemplateMode(options); + }; +} + +/** Resolve migrate hook from `CEDAR_PG_MIGRATE` (module path or package). */ +export async function resolveMigrateFromEnv(): Promise { + const spec = process.env.CEDAR_PG_MIGRATE?.trim(); + if (!spec) return undefined; + + const mod = (await import(toImportUrl(spec))) as { + default?: unknown; + migrate?: unknown; + }; + const fn = mod.migrate ?? mod.default; + if (typeof fn !== "function") { + throw new Error("CEDAR_PG_MIGRATE must export migrate() or a default function"); + } + return fn as TemplateMigrateFn; +} + +function toImportUrl(spec: string): string { + if (spec.startsWith("file:") || spec.includes("://")) return spec; + if (spec.startsWith(".") || isAbsolute(spec)) { + return pathToFileURL(resolve(process.cwd(), spec)).href; + } + return spec; +} diff --git a/src/adapters/vitest-template-worker.ts b/src/adapters/vitest-template-worker.ts new file mode 100644 index 0000000..4d12fdc --- /dev/null +++ b/src/adapters/vitest-template-worker.ts @@ -0,0 +1,11 @@ +import { ensureWorkerDatabase, setupTemplateWorker } from "./template-mode.ts"; + +export { ensureWorkerDatabase, setupTemplateWorker }; + +/** + * Vitest setupFiles entry — clones once per worker process and sets DATABASE_URL. + * Uses top-level await (ESM). + */ +await ensureWorkerDatabase(); + +export default ensureWorkerDatabase; diff --git a/src/adapters/vitest-template.ts b/src/adapters/vitest-template.ts new file mode 100644 index 0000000..287dc57 --- /dev/null +++ b/src/adapters/vitest-template.ts @@ -0,0 +1,51 @@ +import { + ensureWorkerDatabase, + resolveMigrateFromEnv, + setupTemplateMode, + setupTemplateWorker, + teardownTemplateMode, + type SetupTemplateModeOptions, +} from "./template-mode.ts"; + +export { + ensureWorkerDatabase, + setupTemplateMode, + setupTemplateWorker, + teardownTemplateMode as teardown, +}; + +/** + * Vitest globalSetup (template mode). Returns teardown that disposes TEMPLATE + clones. + * + * ```ts + * export default defineConfig({ + * test: { + * globalSetup: ["@cedarjs/pg/vitest/template"], + * setupFiles: ["@cedarjs/pg/vitest/template/worker"], + * }, + * }) + * ``` + * + * Set `CEDAR_PG_MIGRATE` or use `createSetup({ migrate })`. + */ +export async function setup(): Promise<() => Promise> { + const migrate = await resolveMigrateFromEnv(); + return runSetup({ migrate }); +} + +export function createSetup(options: SetupTemplateModeOptions = {}) { + return async () => runSetup(options); +} + +async function runSetup(options: SetupTemplateModeOptions): Promise<() => Promise> { + const result = await setupTemplateMode(options); + if (result.status !== "ensured") { + return async () => {}; + } + const root = result.root; + return async () => { + await teardownTemplateMode({ root }); + }; +} + +export default setup; diff --git a/src/adapters/vitest.ts b/src/adapters/vitest.ts index e670388..a096985 100644 --- a/src/adapters/vitest.ts +++ b/src/adapters/vitest.ts @@ -1,7 +1,8 @@ import { ensureIfNeeded } from "../core/lifecycle.ts"; /** - * Vitest globalSetup: ensure test DB, return teardown that disposes it. + * Vitest globalSetup for a single shared test DB. + * Not a migrate-once / per-worker TEMPLATE runner — use `@cedarjs/pg/vitest/template`. * * Vitest runs globalSetup in the main process then spawns workers, so * `process.env` mutations (via `setEnv`) are inherited. Pair with diff --git a/src/index.ts b/src/index.ts index 88dcbf7..4efb22e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,21 @@ export type { DisposeResult, } from "./core/lifecycle.ts"; +export { + setupTemplateMode, + setupTemplateWorker, + ensureWorkerDatabase, + teardownTemplateMode, + createTemplateGlobalSetup, + resolveMigrateFromEnv, +} from "./adapters/template-mode.ts"; +export type { + SetupTemplateModeOptions, + SetupTemplateWorkerOptions, + TemplateMigrateContext, + TemplateMigrateFn, +} from "./adapters/template-mode.ts"; + export { resolveWorktreeIdentity, resolveRoot } from "./core/worktree.ts"; export type { WorktreeIdentity } from "./core/worktree.ts"; diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts new file mode 100644 index 0000000..0109a0d --- /dev/null +++ b/tests/template-mode.test.ts @@ -0,0 +1,291 @@ +import { expect, test, vi } from "vite-plus/test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +async function withMockedLifecycle( + mocks: { + ensureIfNeeded?: ReturnType; + markTemplate?: ReturnType; + cloneFromTemplate?: ReturnType; + dispose?: ReturnType; + }, + run: () => Promise, +): Promise { + vi.resetModules(); + vi.doMock("../src/core/lifecycle.ts", async () => { + const actual = await vi.importActual( + "../src/core/lifecycle.ts", + ); + return { + ...actual, + ensureIfNeeded: mocks.ensureIfNeeded ?? actual.ensureIfNeeded, + markTemplate: mocks.markTemplate ?? actual.markTemplate, + cloneFromTemplate: mocks.cloneFromTemplate ?? actual.cloneFromTemplate, + dispose: mocks.dispose ?? actual.dispose, + }; + }); + try { + return await run(); + } finally { + vi.doUnmock("../src/core/lifecycle.ts"); + vi.resetModules(); + } +} + +test("setupTemplateMode ensures, migrates, then markTemplate", async () => { + const ensureIfNeeded = vi.fn(async () => ({ + status: "ensured" as const, + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + root: "/tmp/wt", + mode: "test" as const, + port: 5433, + dispose: async () => {}, + })); + const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + const migrate = vi.fn(async () => {}); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); + const result = await setupTemplateMode({ migrate, setEnv: false }); + expect(result.status).toBe("ensured"); + expect(process.env.CEDAR_PG_ADMIN_URL).toBe( + "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + ); + expect(migrate).toHaveBeenCalledWith({ + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + }); + expect(markTemplate).toHaveBeenCalledWith({ + root: "/tmp/wt", + mode: "test", + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + }); + }); +}); + +test("setupTemplateMode without migrate does not markTemplate", async () => { + const ensureIfNeeded = vi.fn(async () => ({ + status: "ensured" as const, + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + root: "/tmp/wt", + mode: "test" as const, + port: 5433, + dispose: async () => {}, + })); + const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); + await setupTemplateMode({ setEnv: false }); + expect(markTemplate).not.toHaveBeenCalled(); + }); +}); + +test("setupTemplateMode skips mark when ensure is skipped", async () => { + const ensureIfNeeded = vi.fn(async () => ({ + status: "skipped" as const, + reason: "disabled" as const, + })); + const markTemplate = vi.fn(async () => ({ databaseName: "x" })); + const migrate = vi.fn(async () => {}); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); + const result = await setupTemplateMode({ migrate }); + expect(result).toEqual({ status: "skipped", reason: "disabled" }); + expect(migrate).not.toHaveBeenCalled(); + expect(markTemplate).not.toHaveBeenCalled(); + }); +}); + +test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { + const prevJest = process.env.JEST_WORKER_ID; + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + process.env.JEST_WORKER_ID = "3"; + delete process.env.CEDAR_PG; + delete process.env.TEST_DATABASE_URL; + + const cloneFromTemplate = vi.fn(async () => ({ + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_3", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl_c_3", + roleName: "cpg_tmpl_role", + templateName: "cpg_tmpl", + port: 5433, + dispose: async () => {}, + })); + + try { + await withMockedLifecycle({ cloneFromTemplate }, async () => { + const { setupTemplateWorker } = await import("../src/adapters/template-mode.ts"); + await setupTemplateWorker({ root: "/tmp/wt" }); + expect(cloneFromTemplate).toHaveBeenCalledWith({ + root: "/tmp/wt", + mode: "test", + name: "3", + setEnv: true, + }); + }); + } 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; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + } +}); + +test("ensureWorkerDatabase is idempotent per process", async () => { + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + delete process.env.CEDAR_PG; + delete process.env.TEST_DATABASE_URL; + + const cloneFromTemplate = vi.fn(async () => ({ + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_1", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl_c_1", + roleName: "cpg_tmpl_role", + templateName: "cpg_tmpl", + port: 5433, + dispose: async () => {}, + })); + + try { + await withMockedLifecycle({ cloneFromTemplate }, async () => { + const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); + await ensureWorkerDatabase({ name: "1" }); + await ensureWorkerDatabase({ name: "1" }); + expect(cloneFromTemplate).toHaveBeenCalledTimes(1); + }); + } finally { + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + } +}); + +test("teardownTemplateMode disposes test lease", async () => { + const dispose = vi.fn(async () => ({ dropped: false as const, reason: "no-lease" as const })); + await withMockedLifecycle({ dispose }, async () => { + const { teardownTemplateMode } = await import("../src/adapters/template-mode.ts"); + await teardownTemplateMode({ root: "/tmp/wt" }); + expect(dispose).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test" }); + }); +}); + +test("resolveMigrateFromEnv loads migrate export from file URL", async () => { + const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); + const file = join(dir, "migrate.mjs"); + writeFileSync( + file, + `export async function migrate(ctx) { globalThis.__cedarMigrateCtx = ctx; }\n`, + ); + const prev = process.env.CEDAR_PG_MIGRATE; + process.env.CEDAR_PG_MIGRATE = pathToFileURL(file).href; + + try { + vi.resetModules(); + const { resolveMigrateFromEnv } = await import("../src/adapters/template-mode.ts"); + const migrate = await resolveMigrateFromEnv(); + expect(typeof migrate).toBe("function"); + await migrate?.({ + databaseUrl: "u", + adminUrl: "a", + databaseName: "d", + roleName: "r", + }); + expect((globalThis as { __cedarMigrateCtx?: unknown }).__cedarMigrateCtx).toEqual({ + databaseUrl: "u", + adminUrl: "a", + databaseName: "d", + roleName: "r", + }); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; + else process.env.CEDAR_PG_MIGRATE = prev; + rmSync(dir, { recursive: true, force: true }); + vi.resetModules(); + } +}); + +test("createTemplateGlobalSetup wires migrate hook", async () => { + const ensureIfNeeded = vi.fn(async () => ({ + status: "ensured" as const, + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + root: "/tmp/wt", + mode: "test" as const, + port: 5433, + dispose: async () => {}, + })); + const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + const migrate = vi.fn(async () => {}); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + const { createTemplateGlobalSetup } = await import("../src/adapters/template-mode.ts"); + await createTemplateGlobalSetup({ migrate })(); + expect(migrate).toHaveBeenCalledTimes(1); + expect(markTemplate).toHaveBeenCalledTimes(1); + }); +}); + +test("jest template default export uses CEDAR_PG_MIGRATE", async () => { + const ensureIfNeeded = vi.fn(async () => ({ + status: "ensured" as const, + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + root: "/tmp/wt", + mode: "test" as const, + port: 5433, + dispose: async () => {}, + })); + const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + + const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); + const file = join(dir, "migrate.mjs"); + writeFileSync(file, `export default async function migrate() {}\n`); + const prev = process.env.CEDAR_PG_MIGRATE; + process.env.CEDAR_PG_MIGRATE = pathToFileURL(file).href; + + try { + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + const mod = await import("../src/adapters/jest-template.ts"); + await mod.default(); + expect(markTemplate).toHaveBeenCalledTimes(1); + }); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; + else process.env.CEDAR_PG_MIGRATE = prev; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/vite.config.ts b/vite.config.ts index 50ab082..cbb6c95 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,10 +11,16 @@ export default defineConfig({ "vite-plus": "src/adapters/vite-plus.ts", nx: "src/adapters/nx.ts", vitest: "src/adapters/vitest.ts", + "vitest-template": "src/adapters/vitest-template.ts", + "vitest-template-worker": "src/adapters/vitest-template-worker.ts", jest: "src/adapters/jest.ts", "jest-teardown": "src/adapters/jest-teardown.ts", "test-env": "src/adapters/test-env.ts", + "jest-template": "src/adapters/jest-template.ts", + "jest-template-teardown": "src/adapters/jest-template-teardown.ts", + "jest-template-worker": "src/adapters/jest-template-worker.ts", }, + dts: true, format: ["esm", "cjs"], sourcemap: true, From fca6f84dfe0715f272b8b9d6751c1a41ebda5f98 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 2 Aug 2026 20:54:41 -0400 Subject: [PATCH 2/7] refactor: require migrate hook and slim template adapter surface Make setupTemplateMode always migrate+mark, fail default hooks without CEDAR_PG_MIGRATE, and drop barrel re-exports that duplicated the API. --- README.md | 61 ++++----- src/adapters/jest-template-worker.ts | 5 +- src/adapters/jest-template.ts | 26 ++-- src/adapters/template-mode.ts | 71 +++++----- src/adapters/vitest-template-worker.ts | 5 +- src/adapters/vitest-template.ts | 19 +-- src/index.ts | 2 - tests/template-mode.test.ts | 179 +++++++++++-------------- 8 files changed, 164 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 09b0fa1..90e6697 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,18 @@ The action runs `scripts/ci-install-autopg.sh` under the hood. For published-pac ### Migrate-once + TEMPLATE clones (Jest / Vitest) -Stock `@cedarjs/pg/jest` and `@cedarjs/pg/vitest` only run `ensureIfNeeded` + `dispose` (one shared test DB). They are **not** a full replacement for Redwood-style globalSetup that migrates once and clones per worker. For that, use template mode (or the programmatic API below). +Stock `@cedarjs/pg/jest` and `@cedarjs/pg/vitest` only run `ensureIfNeeded` + `dispose` (one shared test DB). They are **not** a full replacement for Redwood-style globalSetup that migrates once and clones per worker. For that, use template mode. + +Migrate stays app-owned; template mode **requires** a migrate hook (via `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`), then marks TEMPLATE and clones per worker. + +**1. App migrate module** (`CEDAR_PG_MIGRATE`): + +```ts +// scripts/cedar-pg-migrate.ts +export async function migrate({ databaseUrl }: { databaseUrl: string }) { + // prisma migrate reset / drizzle push / etc. against databaseUrl +} +``` **Jest (template mode):** @@ -256,31 +267,11 @@ const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template/worker"); beforeAll(() => ensureWorkerDatabase()); ``` -**Vitest (template mode):** - -```ts -export default defineConfig({ - test: { - globalSetup: ["@cedarjs/pg/vitest/template"], - setupFiles: ["@cedarjs/pg/vitest/template/worker"], - }, -}); -``` - -Migrate stays app-owned. Either set `CEDAR_PG_MIGRATE` to a module that exports `migrate` / `default`: - -```ts -// scripts/cedar-pg-migrate.ts -export async function migrate({ databaseUrl }: { databaseUrl: string }) { - // prisma migrate reset / drizzle push / etc. against databaseUrl -} -``` - ```bash CEDAR_PG_MIGRATE=./scripts/cedar-pg-migrate.ts pnpm test ``` -…or wrap with a migrate hook: +Or in-process migrate without the env var: ```ts import { createGlobalSetup } from "@cedarjs/pg/jest/template"; @@ -292,25 +283,35 @@ export default createGlobalSetup({ }); ``` -**Programmatic (same flow without runner adapters):** +**Vitest (template mode):** + +```ts +export default defineConfig({ + test: { + globalSetup: ["@cedarjs/pg/vitest/template"], + setupFiles: ["@cedarjs/pg/vitest/template/worker"], + }, +}); +``` + +Same `CEDAR_PG_MIGRATE` / `createGlobalSetup({ migrate })` from `@cedarjs/pg/vitest/template`. + +**Programmatic:** ```ts -import { - setupTemplateMode, - setupTemplateWorker, - teardownTemplateMode, - // or: ensure, markTemplate, cloneFromTemplate, dispose -} from "@cedarjs/pg"; +import { setupTemplateMode, setupTemplateWorker, teardownTemplateMode } from "@cedarjs/pg"; await setupTemplateMode({ migrate: async ({ databaseUrl, adminUrl }) => { - /* migrate once against databaseUrl; adminUrl for privileged DDL if needed */ + /* migrate once; adminUrl for privileged DDL if needed */ }, }); await setupTemplateWorker({ name: process.env.JEST_WORKER_ID ?? "1" }); await teardownTemplateMode(); ``` +Or wire `ensure` → your migrate → `markTemplate` → `cloneFromTemplate` → `dispose` yourself. + `ensure` returns `adminUrl` so apps do not re-derive `postgresql://postgres:postgres@127.0.0.1:/postgres`. `cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. `dispose` / `teardownTemplateMode` unsets `IS_TEMPLATE` and drops every DB owned by the test role (template + clones). diff --git a/src/adapters/jest-template-worker.ts b/src/adapters/jest-template-worker.ts index bb6704b..444f739 100644 --- a/src/adapters/jest-template-worker.ts +++ b/src/adapters/jest-template-worker.ts @@ -1,6 +1,4 @@ -import { ensureWorkerDatabase, setupTemplateWorker } from "./template-mode.ts"; - -export { ensureWorkerDatabase, setupTemplateWorker }; +import { ensureWorkerDatabase } from "./template-mode.ts"; /** * Jest per-worker helper. Prefer setupFilesAfterEnv: @@ -11,4 +9,5 @@ export { ensureWorkerDatabase, setupTemplateWorker }; * beforeAll(() => ensureWorkerDatabase()); * ``` */ +export { ensureWorkerDatabase }; export default ensureWorkerDatabase; diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts index ff22235..3512178 100644 --- a/src/adapters/jest-template.ts +++ b/src/adapters/jest-template.ts @@ -1,20 +1,10 @@ import { createTemplateGlobalSetup, - ensureWorkerDatabase, - resolveMigrateFromEnv, + requireMigrateFromEnv, setupTemplateMode, - setupTemplateWorker, - teardownTemplateMode, + type SetupTemplateModeOptions, } from "./template-mode.ts"; -export { - createTemplateGlobalSetup as createGlobalSetup, - ensureWorkerDatabase, - setupTemplateMode, - setupTemplateWorker, - teardownTemplateMode as teardown, -}; - /** * Jest globalSetup (template mode). * @@ -24,10 +14,14 @@ export { * setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], * ``` * - * Migrate via `CEDAR_PG_MIGRATE` (module exporting `migrate` or default) or - * `createGlobalSetup({ migrate })`. Stock `@cedarjs/pg/jest` is one shared test DB only. + * Requires `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`. + * Stock `@cedarjs/pg/jest` is one shared test DB only. */ export default async function globalSetup(): Promise { - const migrate = await resolveMigrateFromEnv(); - await setupTemplateMode({ migrate }); + await setupTemplateMode({ migrate: await requireMigrateFromEnv() }); +} + +/** Build a Jest globalSetup with an in-process migrate hook. */ +export function createGlobalSetup(options: SetupTemplateModeOptions) { + return createTemplateGlobalSetup(options); } diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index a6ee06a..bace678 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -20,23 +20,20 @@ export type TemplateMigrateFn = (ctx: TemplateMigrateContext) => void | Promise< export type SetupTemplateModeOptions = { root?: string; - /** - * App-owned migrate step. When provided, runs once against the ensured DB - * then `markTemplate` so workers can `cloneFromTemplate`. - */ - migrate?: TemplateMigrateFn; + /** App-owned migrate; runs once, then `markTemplate`. Required. */ + migrate: TemplateMigrateFn; setEnv?: boolean; }; /** * Global setup for migrate-once + TEMPLATE clones: - * ensure host/lease → optional migrate hook → markTemplate. + * ensure host/lease → migrate → markTemplate. * - * Without `migrate`, only ensures and sets `CEDAR_PG_ADMIN_URL`; call - * `markTemplate` yourself after your app migrate before workers clone. + * For migrate-yourself without a hook, use `ensure` + `markTemplate` from `@cedarjs/pg` + * instead of this helper. */ export async function setupTemplateMode( - options: SetupTemplateModeOptions = {}, + options: SetupTemplateModeOptions, ): Promise { const result = await ensureIfNeeded({ root: options.root, @@ -47,20 +44,18 @@ export async function setupTemplateMode( process.env.CEDAR_PG_ADMIN_URL = result.adminUrl; - if (options.migrate) { - await options.migrate({ - databaseUrl: result.databaseUrl, - adminUrl: result.adminUrl, - databaseName: result.databaseName, - roleName: result.roleName, - }); - await markTemplate({ - root: result.root, - mode: "test", - databaseName: result.databaseName, - adminUrl: result.adminUrl, - }); - } + await options.migrate({ + databaseUrl: result.databaseUrl, + adminUrl: result.adminUrl, + databaseName: result.databaseName, + roleName: result.roleName, + }); + await markTemplate({ + root: result.root, + mode: "test", + databaseName: result.databaseName, + adminUrl: result.adminUrl, + }); return result; } @@ -73,13 +68,14 @@ export type SetupTemplateWorkerOptions = { /** * Per-worker clone → sets DATABASE_URL / TEST_DATABASE_URL. - * Idempotent per process (safe with setupFilesAfterEnv + beforeAll). + * Prefer {@link ensureWorkerDatabase} from setupFilesAfterEnv (process-once). */ export async function setupTemplateWorker(options: SetupTemplateWorkerOptions = {}): Promise { const skip = resolveEnsureSkip(); if (skip.skip) { if (skip.reason === "external-url") { process.env.DATABASE_URL = skip.databaseUrl; + process.env.TEST_DATABASE_URL = skip.databaseUrl; } return; } @@ -97,9 +93,12 @@ export async function setupTemplateWorker(options: SetupTemplateWorkerOptions = let workerOnce: Promise | undefined; -/** Process-once wrapper around {@link setupTemplateWorker}. */ -export function ensureWorkerDatabase(options: SetupTemplateWorkerOptions = {}): Promise { - workerOnce ??= setupTemplateWorker(options).catch((err) => { +/** + * Process-once {@link setupTemplateWorker} using JEST_WORKER_ID / VITEST_POOL_ID / pid. + * For custom `name`/`root`, call `setupTemplateWorker` once yourself. + */ +export function ensureWorkerDatabase(): Promise { + workerOnce ??= setupTemplateWorker().catch((err) => { workerOnce = undefined; throw err; }); @@ -111,11 +110,8 @@ export async function teardownTemplateMode(options: { root?: string } = {}): Pro await dispose({ root: options.root, mode: "test" }); } -/** - * `createGlobalSetup({ migrate })` for thin app globalSetup files. - * Bare `require.resolve("@cedarjs/pg/jest/template")` uses {@link resolveMigrateFromEnv}. - */ -export function createTemplateGlobalSetup(options: SetupTemplateModeOptions = {}) { +/** Factory for Jest-style `globalSetup` modules (void). */ +export function createTemplateGlobalSetup(options: SetupTemplateModeOptions) { return async () => { await setupTemplateMode(options); }; @@ -137,6 +133,17 @@ export async function resolveMigrateFromEnv(): Promise { + const migrate = await resolveMigrateFromEnv(); + if (!migrate) { + throw new Error( + "template mode requires a migrate hook: set CEDAR_PG_MIGRATE or use createGlobalSetup({ migrate })", + ); + } + return migrate; +} + function toImportUrl(spec: string): string { if (spec.startsWith("file:") || spec.includes("://")) return spec; if (spec.startsWith(".") || isAbsolute(spec)) { diff --git a/src/adapters/vitest-template-worker.ts b/src/adapters/vitest-template-worker.ts index 4d12fdc..2b0e98d 100644 --- a/src/adapters/vitest-template-worker.ts +++ b/src/adapters/vitest-template-worker.ts @@ -1,6 +1,4 @@ -import { ensureWorkerDatabase, setupTemplateWorker } from "./template-mode.ts"; - -export { ensureWorkerDatabase, setupTemplateWorker }; +import { ensureWorkerDatabase } from "./template-mode.ts"; /** * Vitest setupFiles entry — clones once per worker process and sets DATABASE_URL. @@ -8,4 +6,5 @@ export { ensureWorkerDatabase, setupTemplateWorker }; */ await ensureWorkerDatabase(); +export { ensureWorkerDatabase }; export default ensureWorkerDatabase; diff --git a/src/adapters/vitest-template.ts b/src/adapters/vitest-template.ts index 287dc57..bee91b4 100644 --- a/src/adapters/vitest-template.ts +++ b/src/adapters/vitest-template.ts @@ -1,19 +1,10 @@ import { - ensureWorkerDatabase, - resolveMigrateFromEnv, + requireMigrateFromEnv, setupTemplateMode, - setupTemplateWorker, teardownTemplateMode, type SetupTemplateModeOptions, } from "./template-mode.ts"; -export { - ensureWorkerDatabase, - setupTemplateMode, - setupTemplateWorker, - teardownTemplateMode as teardown, -}; - /** * Vitest globalSetup (template mode). Returns teardown that disposes TEMPLATE + clones. * @@ -26,14 +17,14 @@ export { * }) * ``` * - * Set `CEDAR_PG_MIGRATE` or use `createSetup({ migrate })`. + * Requires `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`. */ export async function setup(): Promise<() => Promise> { - const migrate = await resolveMigrateFromEnv(); - return runSetup({ migrate }); + return runSetup({ migrate: await requireMigrateFromEnv() }); } -export function createSetup(options: SetupTemplateModeOptions = {}) { +/** Build a Vitest globalSetup with an in-process migrate hook. */ +export function createGlobalSetup(options: SetupTemplateModeOptions) { return async () => runSetup(options); } diff --git a/src/index.ts b/src/index.ts index 4efb22e..01cde44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,8 +24,6 @@ export { setupTemplateWorker, ensureWorkerDatabase, teardownTemplateMode, - createTemplateGlobalSetup, - resolveMigrateFromEnv, } from "./adapters/template-mode.ts"; export type { SetupTemplateModeOptions, diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index 0109a0d..93b52a8 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -3,6 +3,28 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import type { EnsureIfNeededResult } from "../src/core/lifecycle.ts"; + +function ensuredLease( + overrides: Partial> = {}, +): Extract { + return { + status: "ensured", + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + root: "/tmp/wt", + mode: "test", + port: 5433, + dispose: async () => {}, + ...overrides, + }; +} + async function withMockedLifecycle( mocks: { ensureIfNeeded?: ReturnType; @@ -34,20 +56,7 @@ async function withMockedLifecycle( } test("setupTemplateMode ensures, migrates, then markTemplate", async () => { - const ensureIfNeeded = vi.fn(async () => ({ - status: "ensured" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl", - roleName: "cpg_tmpl_role", - repoSlug: "cedar", - worktreeSlug: "main", - pathHash: "abcd1234", - root: "/tmp/wt", - mode: "test" as const, - port: 5433, - dispose: async () => {}, - })); + const ensureIfNeeded = vi.fn(async () => ensuredLease()); const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); const migrate = vi.fn(async () => {}); @@ -73,31 +82,7 @@ test("setupTemplateMode ensures, migrates, then markTemplate", async () => { }); }); -test("setupTemplateMode without migrate does not markTemplate", async () => { - const ensureIfNeeded = vi.fn(async () => ({ - status: "ensured" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl", - roleName: "cpg_tmpl_role", - repoSlug: "cedar", - worktreeSlug: "main", - pathHash: "abcd1234", - root: "/tmp/wt", - mode: "test" as const, - port: 5433, - dispose: async () => {}, - })); - const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); - - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { - const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); - await setupTemplateMode({ setEnv: false }); - expect(markTemplate).not.toHaveBeenCalled(); - }); -}); - -test("setupTemplateMode skips mark when ensure is skipped", async () => { +test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => { const ensureIfNeeded = vi.fn(async () => ({ status: "skipped" as const, reason: "disabled" as const, @@ -153,11 +138,42 @@ test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { } }); +test("setupTemplateWorker external-url skip sets DATABASE_URL and TEST_DATABASE_URL", async () => { + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + const prevDb = process.env.DATABASE_URL; + delete process.env.CEDAR_PG; + process.env.TEST_DATABASE_URL = "postgresql://ci:ci@db.example/app"; + + const cloneFromTemplate = vi.fn(async () => { + throw new Error("should not clone"); + }); + + try { + await withMockedLifecycle({ cloneFromTemplate }, async () => { + const { setupTemplateWorker } = await import("../src/adapters/template-mode.ts"); + await setupTemplateWorker(); + expect(cloneFromTemplate).not.toHaveBeenCalled(); + expect(process.env.DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + expect(process.env.TEST_DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + }); + } finally { + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + if (prevDb === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = prevDb; + } +}); + test("ensureWorkerDatabase is idempotent per process", async () => { const prevCedar = process.env.CEDAR_PG; const prevUrl = process.env.TEST_DATABASE_URL; + const prevJest = process.env.JEST_WORKER_ID; delete process.env.CEDAR_PG; delete process.env.TEST_DATABASE_URL; + process.env.JEST_WORKER_ID = "1"; const cloneFromTemplate = vi.fn(async () => ({ databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_1", @@ -172,8 +188,8 @@ test("ensureWorkerDatabase is idempotent per process", async () => { try { await withMockedLifecycle({ cloneFromTemplate }, async () => { const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); - await ensureWorkerDatabase({ name: "1" }); - await ensureWorkerDatabase({ name: "1" }); + await ensureWorkerDatabase(); + await ensureWorkerDatabase(); expect(cloneFromTemplate).toHaveBeenCalledTimes(1); }); } finally { @@ -181,6 +197,8 @@ test("ensureWorkerDatabase is idempotent per process", async () => { else process.env.CEDAR_PG = prevCedar; if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; else process.env.TEST_DATABASE_URL = prevUrl; + if (prevJest === undefined) delete process.env.JEST_WORKER_ID; + else process.env.JEST_WORKER_ID = prevJest; } }); @@ -193,56 +211,8 @@ test("teardownTemplateMode disposes test lease", async () => { }); }); -test("resolveMigrateFromEnv loads migrate export from file URL", async () => { - const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); - const file = join(dir, "migrate.mjs"); - writeFileSync( - file, - `export async function migrate(ctx) { globalThis.__cedarMigrateCtx = ctx; }\n`, - ); - const prev = process.env.CEDAR_PG_MIGRATE; - process.env.CEDAR_PG_MIGRATE = pathToFileURL(file).href; - - try { - vi.resetModules(); - const { resolveMigrateFromEnv } = await import("../src/adapters/template-mode.ts"); - const migrate = await resolveMigrateFromEnv(); - expect(typeof migrate).toBe("function"); - await migrate?.({ - databaseUrl: "u", - adminUrl: "a", - databaseName: "d", - roleName: "r", - }); - expect((globalThis as { __cedarMigrateCtx?: unknown }).__cedarMigrateCtx).toEqual({ - databaseUrl: "u", - adminUrl: "a", - databaseName: "d", - roleName: "r", - }); - } finally { - if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; - else process.env.CEDAR_PG_MIGRATE = prev; - rmSync(dir, { recursive: true, force: true }); - vi.resetModules(); - } -}); - test("createTemplateGlobalSetup wires migrate hook", async () => { - const ensureIfNeeded = vi.fn(async () => ({ - status: "ensured" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl", - roleName: "cpg_tmpl_role", - repoSlug: "cedar", - worktreeSlug: "main", - pathHash: "abcd1234", - root: "/tmp/wt", - mode: "test" as const, - port: 5433, - dispose: async () => {}, - })); + const ensureIfNeeded = vi.fn(async () => ensuredLease()); const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); const migrate = vi.fn(async () => {}); @@ -254,21 +224,22 @@ test("createTemplateGlobalSetup wires migrate hook", async () => { }); }); +test("jest template default requires CEDAR_PG_MIGRATE", async () => { + const prev = process.env.CEDAR_PG_MIGRATE; + delete process.env.CEDAR_PG_MIGRATE; + try { + vi.resetModules(); + const mod = await import("../src/adapters/jest-template.ts"); + await expect(mod.default()).rejects.toThrow(/CEDAR_PG_MIGRATE|createGlobalSetup/); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; + else process.env.CEDAR_PG_MIGRATE = prev; + vi.resetModules(); + } +}); + test("jest template default export uses CEDAR_PG_MIGRATE", async () => { - const ensureIfNeeded = vi.fn(async () => ({ - status: "ensured" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl", - roleName: "cpg_tmpl_role", - repoSlug: "cedar", - worktreeSlug: "main", - pathHash: "abcd1234", - root: "/tmp/wt", - mode: "test" as const, - port: 5433, - dispose: async () => {}, - })); + const ensureIfNeeded = vi.fn(async () => ensuredLease()); const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); From 63249966b8c780b85b75861ddb1464ca0e761a4e Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 2 Aug 2026 20:56:41 -0400 Subject: [PATCH 3/7] refactor: keep template-mode free of runner factories Inline Jest createGlobalSetup and make CEDAR_PG_MIGRATE loading require-only so the shared module is orchestration only. --- src/adapters/jest-template.ts | 5 +++-- src/adapters/template-mode.ts | 28 +++++++--------------------- tests/template-mode.test.ts | 6 +++--- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts index 3512178..9219087 100644 --- a/src/adapters/jest-template.ts +++ b/src/adapters/jest-template.ts @@ -1,5 +1,4 @@ import { - createTemplateGlobalSetup, requireMigrateFromEnv, setupTemplateMode, type SetupTemplateModeOptions, @@ -23,5 +22,7 @@ export default async function globalSetup(): Promise { /** Build a Jest globalSetup with an in-process migrate hook. */ export function createGlobalSetup(options: SetupTemplateModeOptions) { - return createTemplateGlobalSetup(options); + return async () => { + await setupTemplateMode(options); + }; } diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index bace678..f741c5c 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -110,17 +110,14 @@ export async function teardownTemplateMode(options: { root?: string } = {}): Pro await dispose({ root: options.root, mode: "test" }); } -/** Factory for Jest-style `globalSetup` modules (void). */ -export function createTemplateGlobalSetup(options: SetupTemplateModeOptions) { - return async () => { - await setupTemplateMode(options); - }; -} - -/** Resolve migrate hook from `CEDAR_PG_MIGRATE` (module path or package). */ -export async function resolveMigrateFromEnv(): Promise { +/** Require migrate from `CEDAR_PG_MIGRATE` or throw (default runner hooks). */ +export async function requireMigrateFromEnv(): Promise { const spec = process.env.CEDAR_PG_MIGRATE?.trim(); - if (!spec) return undefined; + if (!spec) { + throw new Error( + "template mode requires a migrate hook: set CEDAR_PG_MIGRATE or use createGlobalSetup({ migrate })", + ); + } const mod = (await import(toImportUrl(spec))) as { default?: unknown; @@ -133,17 +130,6 @@ export async function resolveMigrateFromEnv(): Promise { - const migrate = await resolveMigrateFromEnv(); - if (!migrate) { - throw new Error( - "template mode requires a migrate hook: set CEDAR_PG_MIGRATE or use createGlobalSetup({ migrate })", - ); - } - return migrate; -} - function toImportUrl(spec: string): string { if (spec.startsWith("file:") || spec.includes("://")) return spec; if (spec.startsWith(".") || isAbsolute(spec)) { diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index 93b52a8..61babab 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -211,14 +211,14 @@ test("teardownTemplateMode disposes test lease", async () => { }); }); -test("createTemplateGlobalSetup wires migrate hook", async () => { +test("jest createGlobalSetup wires migrate hook", async () => { const ensureIfNeeded = vi.fn(async () => ensuredLease()); const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); const migrate = vi.fn(async () => {}); await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { - const { createTemplateGlobalSetup } = await import("../src/adapters/template-mode.ts"); - await createTemplateGlobalSetup({ migrate })(); + const { createGlobalSetup } = await import("../src/adapters/jest-template.ts"); + await createGlobalSetup({ migrate })(); expect(migrate).toHaveBeenCalledTimes(1); expect(markTemplate).toHaveBeenCalledTimes(1); }); From 6a1be63ea204acb9342e6dacebb191c2cd7dbb67 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 2 Aug 2026 20:58:21 -0400 Subject: [PATCH 4/7] fix: drop TLA from vitest template worker for CJS pack Pack emits CJS+ESM; top-level await broke smoke. Match the Jest pattern with a tiny app setupFiles file that awaits ensureWorkerDatabase. --- README.md | 7 ++++++- src/adapters/vitest-template-worker.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90e6697..4343c65 100644 --- a/README.md +++ b/README.md @@ -286,12 +286,17 @@ export default createGlobalSetup({ **Vitest (template mode):** ```ts +// vitest.config.ts export default defineConfig({ test: { globalSetup: ["@cedarjs/pg/vitest/template"], - setupFiles: ["@cedarjs/pg/vitest/template/worker"], + setupFiles: ["./vitest.cedar-worker.ts"], }, }); + +// vitest.cedar-worker.ts — once per worker process (ESM top-level await) +import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; +await ensureWorkerDatabase(); ``` Same `CEDAR_PG_MIGRATE` / `createGlobalSetup({ migrate })` from `@cedarjs/pg/vitest/template`. diff --git a/src/adapters/vitest-template-worker.ts b/src/adapters/vitest-template-worker.ts index 2b0e98d..bd2c05e 100644 --- a/src/adapters/vitest-template-worker.ts +++ b/src/adapters/vitest-template-worker.ts @@ -1,10 +1,15 @@ import { ensureWorkerDatabase } from "./template-mode.ts"; /** - * Vitest setupFiles entry — clones once per worker process and sets DATABASE_URL. - * Uses top-level await (ESM). + * Vitest per-worker helper. Prefer a local ESM setupFiles entry: + * + * ```ts + * // vitest.cedar-worker.ts + * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; + * await ensureWorkerDatabase(); + * ``` + * + * (No top-level await here — pack emits CJS + ESM.) */ -await ensureWorkerDatabase(); - export { ensureWorkerDatabase }; export default ensureWorkerDatabase; From 78cd70a8467dbfe264cf8ec2c8a5f2eec40f5d13 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Mon, 3 Aug 2026 18:50:46 -0400 Subject: [PATCH 5/7] refactor: collapse template adapter surface after main sync Unify skip/env injection, drop duplicate Jest teardown export, and tighten markTemplate/dispose contracts for role-scoped TEMPLATE teardown. --- README.md | 31 ++++--- package.json | 6 -- scripts/smoke.mjs | 6 +- src/adapters/jest-template-teardown.ts | 6 -- src/adapters/jest-template.ts | 2 +- src/adapters/template-mode.ts | 71 +++++---------- src/adapters/vitest-template-worker.ts | 4 +- src/adapters/vitest-template.ts | 12 ++- src/core/lifecycle.ts | 121 ++++++++++++++++++------- src/core/policy.ts | 14 +++ src/index.ts | 14 +-- src/providers/autopg.ts | 11 +++ tests/ensure-if-needed.test.ts | 3 +- tests/lifecycle-dispose.test.ts | 11 +-- tests/template-clone.test.ts | 95 ++++++++++++++++++- tests/template-mode.test.ts | 95 +++++++++---------- vite.config.ts | 1 - 17 files changed, 310 insertions(+), 193 deletions(-) delete mode 100644 src/adapters/jest-template-teardown.ts diff --git a/README.md b/README.md index 4343c65..d5e8cca 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ export async function migrate({ databaseUrl }: { databaseUrl: string }) { // jest.config.cjs module.exports = { globalSetup: require.resolve("@cedarjs/pg/jest/template"), - globalTeardown: require.resolve("@cedarjs/pg/jest/template/teardown"), + globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"), setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], }; @@ -301,25 +301,27 @@ await ensureWorkerDatabase(); Same `CEDAR_PG_MIGRATE` / `createGlobalSetup({ migrate })` from `@cedarjs/pg/vitest/template`. -**Programmatic:** +**Programmatic** (core API — no runner adapters): ```ts -import { setupTemplateMode, setupTemplateWorker, teardownTemplateMode } from "@cedarjs/pg"; - -await setupTemplateMode({ - migrate: async ({ databaseUrl, adminUrl }) => { - /* migrate once; adminUrl for privileged DDL if needed */ - }, +import { ensure, markTemplate, cloneFromTemplate, dispose } from "@cedarjs/pg"; + +const ensured = await ensure({ mode: "test" }); +await migrate({ databaseUrl: ensured.databaseUrl, adminUrl: ensured.adminUrl }); +await markTemplate({ root: ensured.root, mode: "test", adminUrl: ensured.adminUrl }); +const worker = await cloneFromTemplate({ + root: ensured.root, + mode: "test", + name: "1", + setEnv: true, }); -await setupTemplateWorker({ name: process.env.JEST_WORKER_ID ?? "1" }); -await teardownTemplateMode(); +// … tests … +await dispose({ root: ensured.root, mode: "test" }); // drops every DB owned by the lease role ``` -Or wire `ensure` → your migrate → `markTemplate` → `cloneFromTemplate` → `dispose` yourself. - `ensure` returns `adminUrl` so apps do not re-derive `postgresql://postgres:postgres@127.0.0.1:/postgres`. `cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. -`dispose` / `teardownTemplateMode` unsets `IS_TEMPLATE` and drops every DB owned by the test role (template + clones). +`dispose` unsets `IS_TEMPLATE` and drops every database owned by the lease role (template + clones). ## Env @@ -335,7 +337,6 @@ Or wire `ensure` → your migrate → `markTemplate` → `cloneFromTemplate` → | `CEDAR_PG_SKIP_POSTINSTALL=1` | Skip autopg install hook | | `CEDAR_PG_INSTALL_AUTOPG=1` | Under `CI=true`, run binary-only `ci-install-autopg.sh` from postinstall | | `CEDAR_PG_MIGRATE` | Module path for template-mode migrate hook (`migrate` or default) | -| `CEDAR_PG_ADMIN_URL` | Set by template globalSetup (superuser URL for privileged DDL) | ## Alpha caveats @@ -345,4 +346,4 @@ Or wire `ensure` → your migrate → `markTemplate` → `cloneFromTemplate` → (ephemeral cold-start when the runner has no live host; attach-wins otherwise). - State lives in product-owned `.cedarpg` (worktree + `~/.cedarpg/registry`), not under autopg's `~/.autopg/` or a generic `.pg`. - Role passwords are derived from `roleName` (`cedar-pg\\0` + roleName, scheme v2) so TEMPLATE clones that reuse a role keep working; bump the scheme id to change the derivation. -- Test TEMPLATE flow: `ensure` returns `adminUrl`; `markTemplate` / `cloneFromTemplate` / `dispose` own clone + teardown. Optional `@cedarjs/pg/jest/template` + `@cedarjs/pg/vitest/template` adapters orchestrate ensure → app migrate → mark → per-worker clone; migrate itself stays app-owned. +- Test TEMPLATE flow: `ensure` → app migrate → `markTemplate` → `cloneFromTemplate` → role-scoped `dispose`. Optional `@cedarjs/pg/jest/template` + `@cedarjs/pg/vitest/template` adapters orchestrate that pipeline; migrate stays app-owned. diff --git a/package.json b/package.json index 094cfad..63e8519 100644 --- a/package.json +++ b/package.json @@ -88,17 +88,11 @@ "import": "./dist/jest-template.mjs", "require": "./dist/jest-template.cjs" }, - "./jest/template/teardown": { - "types": "./dist/jest-template-teardown.d.mts", - "import": "./dist/jest-template-teardown.mjs", - "require": "./dist/jest-template-teardown.cjs" - }, "./jest/template/worker": { "types": "./dist/jest-template-worker.d.mts", "import": "./dist/jest-template-worker.mjs", "require": "./dist/jest-template-worker.cjs" }, - "./package.json": "./package.json" }, "publishConfig": { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 88979e3..1d54a0e 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -35,8 +35,9 @@ run( ` import { buildDatabaseName, + cloneFromTemplate, loadTestEnv, - setupTemplateMode, + markTemplate, STATE_DIRNAME, } from '${PACKAGE_NAME}'; import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus'; @@ -59,7 +60,8 @@ if (typeof jestSetup !== 'function') throw new Error('jest setup export missing' if (typeof jestTeardown !== 'function') throw new Error('jest-teardown export missing'); if (typeof loadTestEnv !== 'function') throw new Error('loadTestEnv export missing'); if (STATE_DIRNAME !== '.cedarpg') throw new Error('bad STATE_DIRNAME ' + STATE_DIRNAME); -if (typeof setupTemplateMode !== 'function') throw new Error('missing setupTemplateMode'); +if (typeof markTemplate !== 'function') throw new Error('missing markTemplate'); +if (typeof cloneFromTemplate !== 'function') throw new Error('missing cloneFromTemplate'); if (typeof jestTemplate !== 'function') throw new Error('missing jest/template'); if (typeof ensureWorkerDatabase !== 'function') throw new Error('missing jest/template/worker'); if (typeof vitestTemplate !== 'function') throw new Error('missing vitest/template'); diff --git a/src/adapters/jest-template-teardown.ts b/src/adapters/jest-template-teardown.ts deleted file mode 100644 index 15d41fb..0000000 --- a/src/adapters/jest-template-teardown.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { teardownTemplateMode } from "./template-mode.ts"; - -/** Jest globalTeardown: `require.resolve("@cedarjs/pg/jest/template/teardown")`. */ -export default async function globalTeardown(): Promise { - await teardownTemplateMode(); -} diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts index 9219087..3566bbd 100644 --- a/src/adapters/jest-template.ts +++ b/src/adapters/jest-template.ts @@ -9,7 +9,7 @@ import { * * ```js * globalSetup: require.resolve("@cedarjs/pg/jest/template"), - * globalTeardown: require.resolve("@cedarjs/pg/jest/template/teardown"), + * globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"), * setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], * ``` * diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index f741c5c..678ce42 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -1,13 +1,11 @@ import { pathToFileURL } from "node:url"; import { isAbsolute, resolve } from "node:path"; import { - cloneFromTemplate, - dispose, + cloneFromTemplateIfNeeded, ensureIfNeeded, markTemplate, type EnsureIfNeededResult, } from "../core/lifecycle.ts"; -import { resolveEnsureSkip } from "../core/policy.ts"; export type TemplateMigrateContext = { databaseUrl: string; @@ -26,11 +24,9 @@ export type SetupTemplateModeOptions = { }; /** - * Global setup for migrate-once + TEMPLATE clones: - * ensure host/lease → migrate → markTemplate. - * - * For migrate-yourself without a hook, use `ensure` + `markTemplate` from `@cedarjs/pg` - * instead of this helper. + * Runner orchestration: ensure → migrate → markTemplate. + * Programmatic apps that do not need a migrate hook should call core + * `ensure` + `markTemplate` + `cloneFromTemplate` + `dispose` instead. */ export async function setupTemplateMode( options: SetupTemplateModeOptions, @@ -42,8 +38,6 @@ export async function setupTemplateMode( }); if (result.status !== "ensured") return result; - process.env.CEDAR_PG_ADMIN_URL = result.adminUrl; - await options.migrate({ databaseUrl: result.databaseUrl, adminUrl: result.adminUrl, @@ -53,61 +47,46 @@ export async function setupTemplateMode( await markTemplate({ root: result.root, mode: "test", - databaseName: result.databaseName, adminUrl: result.adminUrl, }); return result; } -export type SetupTemplateWorkerOptions = { +export type EnsureWorkerDatabaseOptions = { root?: string; /** Clone suffix; defaults to JEST_WORKER_ID / VITEST_POOL_ID / pid. */ name?: string; }; -/** - * Per-worker clone → sets DATABASE_URL / TEST_DATABASE_URL. - * Prefer {@link ensureWorkerDatabase} from setupFilesAfterEnv (process-once). - */ -export async function setupTemplateWorker(options: SetupTemplateWorkerOptions = {}): Promise { - const skip = resolveEnsureSkip(); - if (skip.skip) { - if (skip.reason === "external-url") { - process.env.DATABASE_URL = skip.databaseUrl; - process.env.TEST_DATABASE_URL = skip.databaseUrl; - } - return; - } - - const name = - options.name ?? process.env.JEST_WORKER_ID ?? process.env.VITEST_POOL_ID ?? String(process.pid); - - await cloneFromTemplate({ - root: options.root, - mode: "test", - name, - setEnv: true, - }); -} - let workerOnce: Promise | undefined; /** - * Process-once {@link setupTemplateWorker} using JEST_WORKER_ID / VITEST_POOL_ID / pid. - * For custom `name`/`root`, call `setupTemplateWorker` once yourself. + * Process-once per-worker clone (JEST_WORKER_ID / VITEST_POOL_ID / pid by default). + * First call wins for `root`/`name` when using the process-once path. */ -export function ensureWorkerDatabase(): Promise { - workerOnce ??= setupTemplateWorker().catch((err) => { +export function ensureWorkerDatabase(options: EnsureWorkerDatabaseOptions = {}): Promise { + workerOnce ??= (async () => { + const name = + options.name ?? + process.env.JEST_WORKER_ID ?? + process.env.VITEST_POOL_ID ?? + String(process.pid); + await cloneFromTemplateIfNeeded({ + root: options.root, + mode: "test", + name, + setEnv: true, + }); + })().catch((err) => { workerOnce = undefined; throw err; }); return workerOnce; } -/** Drop TEMPLATE + all role-owned clones; forget lease. */ -export async function teardownTemplateMode(options: { root?: string } = {}): Promise { - await dispose({ root: options.root, mode: "test" }); +function isMigrateFn(value: unknown): value is TemplateMigrateFn { + return typeof value === "function"; } /** Require migrate from `CEDAR_PG_MIGRATE` or throw (default runner hooks). */ @@ -124,10 +103,10 @@ export async function requireMigrateFromEnv(): Promise { migrate?: unknown; }; const fn = mod.migrate ?? mod.default; - if (typeof fn !== "function") { + if (!isMigrateFn(fn)) { throw new Error("CEDAR_PG_MIGRATE must export migrate() or a default function"); } - return fn as TemplateMigrateFn; + return fn; } function toImportUrl(spec: string): string { diff --git a/src/adapters/vitest-template-worker.ts b/src/adapters/vitest-template-worker.ts index bd2c05e..12321ef 100644 --- a/src/adapters/vitest-template-worker.ts +++ b/src/adapters/vitest-template-worker.ts @@ -1,7 +1,7 @@ import { ensureWorkerDatabase } from "./template-mode.ts"; /** - * Vitest per-worker helper. Prefer a local ESM setupFiles entry: + * Import from a local ESM setupFiles file (not this module path as setupFiles): * * ```ts * // vitest.cedar-worker.ts @@ -9,7 +9,7 @@ import { ensureWorkerDatabase } from "./template-mode.ts"; * await ensureWorkerDatabase(); * ``` * - * (No top-level await here — pack emits CJS + ESM.) + * Pack emits CJS + ESM, so this file itself cannot use top-level await. */ export { ensureWorkerDatabase }; export default ensureWorkerDatabase; diff --git a/src/adapters/vitest-template.ts b/src/adapters/vitest-template.ts index bee91b4..894f6f2 100644 --- a/src/adapters/vitest-template.ts +++ b/src/adapters/vitest-template.ts @@ -1,7 +1,7 @@ +import { dispose } from "../core/lifecycle.ts"; import { requireMigrateFromEnv, setupTemplateMode, - teardownTemplateMode, type SetupTemplateModeOptions, } from "./template-mode.ts"; @@ -9,12 +9,18 @@ import { * Vitest globalSetup (template mode). Returns teardown that disposes TEMPLATE + clones. * * ```ts + * // vitest.config.ts * export default defineConfig({ * test: { * globalSetup: ["@cedarjs/pg/vitest/template"], - * setupFiles: ["@cedarjs/pg/vitest/template/worker"], + * // Local ESM file — pack emits CJS+ESM; do not point setupFiles at the packaged worker. + * setupFiles: ["./vitest.cedar-worker.ts"], * }, * }) + * + * // vitest.cedar-worker.ts + * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; + * await ensureWorkerDatabase(); * ``` * * Requires `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`. @@ -35,7 +41,7 @@ async function runSetup(options: SetupTemplateModeOptions): Promise<() => Promis } const root = result.root; return async () => { - await teardownTemplateMode({ root }); + await dispose({ root, mode: "test" }); }; } diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index fa5f59c..097f424 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -10,7 +10,7 @@ import { writeLease, type Lease, } from "./lease.ts"; -import { resolveEnsureSkip, type ResolveEnsureSkipInput } from "./policy.ts"; +import { applyDatabaseUrlEnv, resolveEnsureSkip, type ResolveEnsureSkipInput } from "./policy.ts"; import { resolveWorktreeIdentity } from "./worktree.ts"; import { buildDatabaseUrl, @@ -39,14 +39,18 @@ export function urlFromLease(lease: Lease): string { /** * DROP every DB owned by the lease role (TEMPLATE + clones), then forget lease. - * Unsets `IS_TEMPLATE` inside each drop. Never forget without successful DROP(s). + * Unsets `IS_TEMPLATE` inside each drop. Never invents a DROP target; never forgets + * without successful DROP(s). Clones first, then the leased datname when owned. */ async function dropThenForget(lease: Lease, adminUrl: string): Promise { const owned = await listDatabasesOwnedByRole({ adminUrl, roleName: lease.roleName, }); - const ordered = [...owned.filter((name) => name !== lease.databaseName), lease.databaseName]; + const ordered = [ + ...owned.filter((name) => name !== lease.databaseName), + ...owned.filter((name) => name === lease.databaseName), + ]; const dropped: string[] = []; for (const databaseName of ordered) { await dropDatabase({ @@ -124,10 +128,7 @@ export async function ensure(options: EnsureOptions): Promise { writeEnvFile(identity.root, mode, databaseUrl); if (options.setEnv !== false) { - process.env.DATABASE_URL = databaseUrl; - if (mode === "test") { - process.env.TEST_DATABASE_URL = databaseUrl; - } + applyDatabaseUrlEnv(databaseUrl, { mode }); } const disposeFn = async () => { @@ -156,27 +157,45 @@ export type EnsureIfNeededResult = | { status: "skipped"; reason: "external-url"; databaseUrl: string } | ({ status: "ensured" } & EnsureResult); +type EnsureSkipOutcome = + | { status: "skipped"; reason: "disabled" } + | { status: "skipped"; reason: "external-url"; databaseUrl: string } + | { status: "run" }; + +/** Shared skip → env injection for {@link ensureIfNeeded} / {@link cloneFromTemplateIfNeeded}. */ +function resolveEnsureSkipOutcome( + input: ResolveEnsureSkipInput & { mode: DbMode; setEnv?: boolean }, +): EnsureSkipOutcome { + const skip = resolveEnsureSkip({ + url: input.url, + force: input.force, + disabled: input.disabled, + }); + if (!skip.skip) return { status: "run" }; + if (skip.reason === "external-url") { + if (input.setEnv !== false) { + applyDatabaseUrlEnv(skip.databaseUrl, { mode: input.mode }); + } + return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; + } + return { status: "skipped", reason: "disabled" }; +} + /** * Resolve skip policy then ensure. Single entry for hosts (Cedar CLI, Jest, Vitest). - * On external-url skip, sets DATABASE_URL when `setEnv` is not false. + * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false. */ export async function ensureIfNeeded( options: EnsureIfNeededOptions, ): Promise { - const skip = resolveEnsureSkip({ + const outcome = resolveEnsureSkipOutcome({ url: options.url, force: options.force, disabled: options.disabled, + mode: options.mode, + setEnv: options.setEnv, }); - if (skip.skip) { - if (skip.reason === "external-url") { - if (options.setEnv !== false) { - process.env.DATABASE_URL = skip.databaseUrl; - } - return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; - } - return { status: "skipped", reason: "disabled" }; - } + if (outcome.status === "skipped") return outcome; const result = await ensure({ root: options.root, @@ -189,28 +208,30 @@ export async function ensureIfNeeded( export type MarkTemplateOptions = { root?: string; mode?: DbMode; - /** Override; defaults to leased database for mode. */ - databaseName?: string; + /** Optional; defaults to `ensureHostRunning().adminUrl`. */ adminUrl?: string; }; /** - * After migrations, mark the ensured DB as a PostgreSQL TEMPLATE so workers can clone it. - * Pass fields from `EnsureResult` (`databaseName` + `adminUrl`) or resolve from the lease. + * After migrations, mark the leased DB as a PostgreSQL TEMPLATE so workers can clone it. + * Requires a lease from `ensure` (no datname override). */ export async function markTemplate( options: MarkTemplateOptions = {}, -): Promise<{ databaseName: string }> { +): Promise<{ databaseName: string; adminUrl: string }> { const identity = resolveWorktreeIdentity(options.root); const mode = options.mode ?? "test"; const lease = readLease(identity.root, mode); - const databaseName = options.databaseName ?? lease?.databaseName; - if (!databaseName) { + if (!lease) { throw new Error(`no ${mode} lease; run ensure before markTemplate`); } const adminUrl = options.adminUrl ?? (await ensureHostRunning()).adminUrl; - await setDatabaseIsTemplate({ adminUrl, databaseName, isTemplate: true }); - return { databaseName }; + await setDatabaseIsTemplate({ + adminUrl, + databaseName: lease.databaseName, + isTemplate: true, + }); + return { databaseName: lease.databaseName, adminUrl }; } export type CloneFromTemplateOptions = { @@ -239,6 +260,7 @@ export type CloneResult = { /** * Clone the leased TEMPLATE database via admin (`CREATE DATABASE … TEMPLATE`). * Reuses the template role so `databaseUrl` passwords stay valid (scheme v2). + * Provider rejects when the leased DB is not marked TEMPLATE. */ export async function cloneFromTemplate( options: CloneFromTemplateOptions = {}, @@ -268,10 +290,7 @@ export async function cloneFromTemplate( }); if (options.setEnv) { - process.env.DATABASE_URL = databaseUrl; - if (mode === "test") { - process.env.TEST_DATABASE_URL = databaseUrl; - } + applyDatabaseUrlEnv(databaseUrl, { mode }); } const roleName = lease.roleName; @@ -290,6 +309,39 @@ export async function cloneFromTemplate( }; } +export type CloneFromTemplateIfNeededOptions = CloneFromTemplateOptions & ResolveEnsureSkipInput; + +export type CloneFromTemplateIfNeededResult = + | { status: "skipped"; reason: "disabled" } + | { status: "skipped"; reason: "external-url"; databaseUrl: string } + | ({ status: "cloned" } & CloneResult); + +/** + * Skip-aware clone for test runners. Shares ensure skip + env injection with {@link ensureIfNeeded}. + * Not exported from the package root — adapters own the runner wiring. + */ +export async function cloneFromTemplateIfNeeded( + options: CloneFromTemplateIfNeededOptions = {}, +): Promise { + const mode = options.mode ?? "test"; + const outcome = resolveEnsureSkipOutcome({ + url: options.url, + force: options.force, + disabled: options.disabled, + mode, + setEnv: options.setEnv, + }); + if (outcome.status === "skipped") return outcome; + + const result = await cloneFromTemplate({ + root: options.root, + mode, + name: options.name, + setEnv: options.setEnv !== false, + }); + return { status: "cloned", ...result }; +} + export type DisposeOptions = { root?: string; mode?: DbMode; @@ -300,9 +352,10 @@ export type DisposeResult = | { dropped: false; reason: "no-lease" | "host-unavailable" }; /** - * DROP every database owned by the lease role (TEMPLATE + clones), then forget the lease. - * Unsets `IS_TEMPLATE` as needed. No-ops without a valid lease (never invents a DROP target). - * If the host is unavailable, leaves the lease so dispose/gc can retry. + * Role-scoped teardown: DROP every database owned by the lease role (TEMPLATE + clones), + * then forget the lease. Unsets `IS_TEMPLATE` as needed. No-ops without a valid lease; + * never invents a DROP target beyond role ownership. If the host is unavailable, leaves + * the lease so dispose/gc can retry. */ export async function dispose(options: DisposeOptions = {}): Promise { const identity = resolveWorktreeIdentity(options.root); diff --git a/src/core/policy.ts b/src/core/policy.ts index b279478..353445e 100644 --- a/src/core/policy.ts +++ b/src/core/policy.ts @@ -19,6 +19,20 @@ export type ResolveEnsureSkipInput = { disabled?: boolean; }; +/** + * Inject DATABASE_URL (and TEST_DATABASE_URL for test mode) from a resolved URL. + * Single env path for ensure, clone, and external-url skip. + */ +export function applyDatabaseUrlEnv( + databaseUrl: string, + options: { mode?: "dev" | "test" } = {}, +): void { + process.env.DATABASE_URL = databaseUrl; + if ((options.mode ?? "test") === "test") { + process.env.TEST_DATABASE_URL = databaseUrl; + } +} + /** * True when the URL looks like a cedarpg provisioned database (`cpg_*` name/role). * These must never be treated as an external escape hatch; always re-ensure so diff --git a/src/index.ts b/src/index.ts index 01cde44..85774c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,19 +19,6 @@ export type { DisposeResult, } from "./core/lifecycle.ts"; -export { - setupTemplateMode, - setupTemplateWorker, - ensureWorkerDatabase, - teardownTemplateMode, -} from "./adapters/template-mode.ts"; -export type { - SetupTemplateModeOptions, - SetupTemplateWorkerOptions, - TemplateMigrateContext, - TemplateMigrateFn, -} from "./adapters/template-mode.ts"; - export { resolveWorktreeIdentity, resolveRoot } from "./core/worktree.ts"; export type { WorktreeIdentity } from "./core/worktree.ts"; @@ -50,6 +37,7 @@ export type { Lease } from "./core/lease.ts"; export { resolveEnsureSkip, + applyDatabaseUrlEnv, isCedarPgManagedUrl, isExternalDatabaseEscapeHatch, } from "./core/policy.ts"; diff --git a/src/providers/autopg.ts b/src/providers/autopg.ts index 2a630ac..973f70b 100644 --- a/src/providers/autopg.ts +++ b/src/providers/autopg.ts @@ -203,6 +203,17 @@ export async function cloneDatabaseFromTemplate(opts: { roleName: string; }): Promise { await withAdminClient(opts.adminUrl, async (client) => { + const tmpl = await client.query<{ datistemplate: boolean }>( + `SELECT datistemplate FROM pg_database WHERE datname = $1`, + [opts.templateName], + ); + if (!tmpl.rowCount) { + throw new Error(`template database not found: ${opts.templateName}`); + } + if (!tmpl.rows[0]?.datistemplate) { + throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`); + } + const exists = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [ opts.databaseName, ]); diff --git a/tests/ensure-if-needed.test.ts b/tests/ensure-if-needed.test.ts index c3c9281..9da3e46 100644 --- a/tests/ensure-if-needed.test.ts +++ b/tests/ensure-if-needed.test.ts @@ -28,7 +28,7 @@ test("ensureIfNeeded skips disabled without touching host", async () => { }); }); -test("ensureIfNeeded skips external url and sets DATABASE_URL", async () => { +test("ensureIfNeeded skips external url and sets DATABASE_URL + TEST_DATABASE_URL", async () => { const external = "postgresql://neon.example/db"; await withEnv( { @@ -50,6 +50,7 @@ test("ensureIfNeeded skips external url and sets DATABASE_URL", async () => { databaseUrl: external, }); expect(process.env.DATABASE_URL).toBe(external); + expect(process.env.TEST_DATABASE_URL).toBe(external); }, ); }); diff --git a/tests/lifecycle-dispose.test.ts b/tests/lifecycle-dispose.test.ts index 0d0608e..0d9485b 100644 --- a/tests/lifecycle-dispose.test.ts +++ b/tests/lifecycle-dispose.test.ts @@ -44,9 +44,6 @@ test("dispose removes mode env after successful DROP", async () => { const prev = process.env.CEDAR_PG_REGISTRY_DIR; process.env.CEDAR_PG_REGISTRY_DIR = registry; - const adminUrl = "postgresql://postgres@127.0.0.1:5432/postgres"; - const databaseName = "cpg_cedar_main_test_disposeenv"; - vi.resetModules(); vi.doMock("../src/providers/host.ts", async () => { const actual = await vi.importActual( @@ -55,7 +52,7 @@ test("dispose removes mode env after successful DROP", async () => { return { ...actual, ensureHostRunning: async () => ({ - adminUrl, + adminUrl: "postgresql://postgres@127.0.0.1:5432/postgres", port: 5432, }), }; @@ -66,7 +63,7 @@ test("dispose removes mode env after successful DROP", async () => { ); return { ...actual, - listDatabasesOwnedByRole: vi.fn(async () => [databaseName]), + listDatabasesOwnedByRole: vi.fn(async () => ["cpg_cedar_main_test_disposeenv"]), dropDatabase: vi.fn(async () => {}), }; }); @@ -74,7 +71,7 @@ test("dispose removes mode env after successful DROP", async () => { try { const lease = makeLease({ root, - databaseName, + databaseName: "cpg_cedar_main_test_disposeenv", }); writeLease(lease); writeFileSync(envPath(root, "test"), "DATABASE_URL=postgresql://u:p@127.0.0.1:5432/db\n", { @@ -126,7 +123,6 @@ test("dispose leaves lease+registry when host is unavailable", async () => { ); return { ...actual, - listDatabasesOwnedByRole: vi.fn(), dropDatabase: vi.fn(), }; }); @@ -148,7 +144,6 @@ test("dispose leaves lease+registry when host is unavailable", async () => { expect(readLease(root, "test")).toEqual(lease); expect(listRegistryLeases().map((l) => l.databaseName)).toContain(lease.databaseName); - expect(autopg.listDatabasesOwnedByRole).not.toHaveBeenCalled(); expect(autopg.dropDatabase).not.toHaveBeenCalled(); } finally { vi.doUnmock("../src/providers/host.ts"); diff --git a/tests/template-clone.test.ts b/tests/template-clone.test.ts index 7b89d13..f837cb3 100644 --- a/tests/template-clone.test.ts +++ b/tests/template-clone.test.ts @@ -104,10 +104,9 @@ test("markTemplate + cloneFromTemplate use admin and lease role password", async const { markTemplate, cloneFromTemplate, buildDatabaseUrl, rolePasswordFor } = await import("../src/index.ts"); - await expect( - markTemplate({ root, mode: "test", databaseName: templateName, adminUrl }), - ).resolves.toEqual({ + await expect(markTemplate({ root, mode: "test", adminUrl })).resolves.toEqual({ databaseName: templateName, + adminUrl, }); expect(setTemplate).toHaveBeenCalledWith({ adminUrl, @@ -244,3 +243,93 @@ test("dispose with only the leased DB still lists owned then drops", async () => rmSync(root, { recursive: true, force: true }); } }); + +test("dispose drops only role-owned DBs (never invents lease datname)", async () => { + const registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-")); + const root = mkdtempSync(join(tmpdir(), "cedarpg-wt-")); + const prev = process.env.CEDAR_PG_REGISTRY_DIR; + process.env.CEDAR_PG_REGISTRY_DIR = registry; + + const adminUrl = "postgresql://postgres:postgres@127.0.0.1:5433/postgres"; + const templateName = "cpg_cedar_main_test_gone0001"; + const lease = makeLease({ root, databaseName: templateName }); + writeLease(lease); + + const cloneOnly = `${templateName}_c_1`; + const droppedNames: string[] = []; + const dropDb = vi.fn(async (opts: { databaseName: string }) => { + droppedNames.push(opts.databaseName); + }); + const listOwned = vi.fn(async () => [cloneOnly]); + + try { + await withHostAndAutopgMocks( + adminUrl, + { + listDatabasesOwnedByRole: listOwned, + dropDatabase: dropDb, + }, + async () => { + const { dispose } = await import("../src/core/lifecycle.ts"); + await expect(dispose({ root, mode: "test" })).resolves.toEqual({ + dropped: true, + databaseName: templateName, + droppedDatabases: [cloneOnly], + }); + expect(droppedNames).toEqual([cloneOnly]); + expect(readLease(root, "test")).toBeNull(); + }, + ); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("markTemplate requires a lease", async () => { + const registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-")); + const root = mkdtempSync(join(tmpdir(), "cedarpg-wt-")); + const prev = process.env.CEDAR_PG_REGISTRY_DIR; + process.env.CEDAR_PG_REGISTRY_DIR = registry; + + vi.resetModules(); + try { + const { markTemplate } = await import("../src/core/lifecycle.ts"); + await expect(markTemplate({ root, mode: "test" })).rejects.toThrow(/no test lease/); + } finally { + vi.resetModules(); + if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("cloneFromTemplateIfNeeded external-url skip shares applyDatabaseUrlEnv", async () => { + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + const prevDb = process.env.DATABASE_URL; + delete process.env.CEDAR_PG; + process.env.TEST_DATABASE_URL = "postgresql://ci:ci@db.example/app"; + + try { + const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + const result = await cloneFromTemplateIfNeeded({ mode: "test", setEnv: true }); + expect(result).toEqual({ + status: "skipped", + reason: "external-url", + databaseUrl: "postgresql://ci:ci@db.example/app", + }); + expect(process.env.DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + expect(process.env.TEST_DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + } finally { + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + if (prevDb === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = prevDb; + } +}); diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index 61babab..998e08c 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -29,7 +29,7 @@ async function withMockedLifecycle( mocks: { ensureIfNeeded?: ReturnType; markTemplate?: ReturnType; - cloneFromTemplate?: ReturnType; + cloneFromTemplateIfNeeded?: ReturnType; dispose?: ReturnType; }, run: () => Promise, @@ -43,7 +43,8 @@ async function withMockedLifecycle( ...actual, ensureIfNeeded: mocks.ensureIfNeeded ?? actual.ensureIfNeeded, markTemplate: mocks.markTemplate ?? actual.markTemplate, - cloneFromTemplate: mocks.cloneFromTemplate ?? actual.cloneFromTemplate, + cloneFromTemplateIfNeeded: + mocks.cloneFromTemplateIfNeeded ?? actual.cloneFromTemplateIfNeeded, dispose: mocks.dispose ?? actual.dispose, }; }); @@ -57,16 +58,17 @@ async function withMockedLifecycle( test("setupTemplateMode ensures, migrates, then markTemplate", async () => { const ensureIfNeeded = vi.fn(async () => ensuredLease()); - const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + const markTemplate = vi.fn(async () => ({ + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); const migrate = vi.fn(async () => {}); await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); const result = await setupTemplateMode({ migrate, setEnv: false }); expect(result.status).toBe("ensured"); - expect(process.env.CEDAR_PG_ADMIN_URL).toBe( - "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - ); + expect(process.env.CEDAR_PG_ADMIN_URL).toBeUndefined(); expect(migrate).toHaveBeenCalledWith({ databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl", adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", @@ -76,7 +78,6 @@ test("setupTemplateMode ensures, migrates, then markTemplate", async () => { expect(markTemplate).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test", - databaseName: "cpg_tmpl", adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", }); }); @@ -87,7 +88,10 @@ test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => status: "skipped" as const, reason: "disabled" as const, })); - const markTemplate = vi.fn(async () => ({ databaseName: "x" })); + const markTemplate = vi.fn(async () => ({ + databaseName: "x", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); const migrate = vi.fn(async () => {}); await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { @@ -99,7 +103,7 @@ test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => }); }); -test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { +test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded", async () => { const prevJest = process.env.JEST_WORKER_ID; const prevCedar = process.env.CEDAR_PG; const prevUrl = process.env.TEST_DATABASE_URL; @@ -107,7 +111,8 @@ test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { delete process.env.CEDAR_PG; delete process.env.TEST_DATABASE_URL; - const cloneFromTemplate = vi.fn(async () => ({ + const cloneFromTemplateIfNeeded = vi.fn(async () => ({ + status: "cloned" as const, databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_3", adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", databaseName: "cpg_tmpl_c_3", @@ -118,10 +123,10 @@ test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { })); try { - await withMockedLifecycle({ cloneFromTemplate }, async () => { - const { setupTemplateWorker } = await import("../src/adapters/template-mode.ts"); - await setupTemplateWorker({ root: "/tmp/wt" }); - expect(cloneFromTemplate).toHaveBeenCalledWith({ + await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { + const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); + await ensureWorkerDatabase({ root: "/tmp/wt" }); + expect(cloneFromTemplateIfNeeded).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test", name: "3", @@ -138,35 +143,6 @@ test("setupTemplateWorker clones with JEST_WORKER_ID and setEnv", async () => { } }); -test("setupTemplateWorker external-url skip sets DATABASE_URL and TEST_DATABASE_URL", async () => { - const prevCedar = process.env.CEDAR_PG; - const prevUrl = process.env.TEST_DATABASE_URL; - const prevDb = process.env.DATABASE_URL; - delete process.env.CEDAR_PG; - process.env.TEST_DATABASE_URL = "postgresql://ci:ci@db.example/app"; - - const cloneFromTemplate = vi.fn(async () => { - throw new Error("should not clone"); - }); - - try { - await withMockedLifecycle({ cloneFromTemplate }, async () => { - const { setupTemplateWorker } = await import("../src/adapters/template-mode.ts"); - await setupTemplateWorker(); - expect(cloneFromTemplate).not.toHaveBeenCalled(); - expect(process.env.DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); - expect(process.env.TEST_DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); - }); - } finally { - if (prevCedar === undefined) delete process.env.CEDAR_PG; - else process.env.CEDAR_PG = prevCedar; - if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; - else process.env.TEST_DATABASE_URL = prevUrl; - if (prevDb === undefined) delete process.env.DATABASE_URL; - else process.env.DATABASE_URL = prevDb; - } -}); - test("ensureWorkerDatabase is idempotent per process", async () => { const prevCedar = process.env.CEDAR_PG; const prevUrl = process.env.TEST_DATABASE_URL; @@ -175,7 +151,8 @@ test("ensureWorkerDatabase is idempotent per process", async () => { delete process.env.TEST_DATABASE_URL; process.env.JEST_WORKER_ID = "1"; - const cloneFromTemplate = vi.fn(async () => ({ + const cloneFromTemplateIfNeeded = vi.fn(async () => ({ + status: "cloned" as const, databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_1", adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", databaseName: "cpg_tmpl_c_1", @@ -186,11 +163,11 @@ test("ensureWorkerDatabase is idempotent per process", async () => { })); try { - await withMockedLifecycle({ cloneFromTemplate }, async () => { + await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); await ensureWorkerDatabase(); await ensureWorkerDatabase(); - expect(cloneFromTemplate).toHaveBeenCalledTimes(1); + expect(cloneFromTemplateIfNeeded).toHaveBeenCalledTimes(1); }); } finally { if (prevCedar === undefined) delete process.env.CEDAR_PG; @@ -202,18 +179,29 @@ test("ensureWorkerDatabase is idempotent per process", async () => { } }); -test("teardownTemplateMode disposes test lease", async () => { +test("vitest template teardown disposes lease root", async () => { + const ensureIfNeeded = vi.fn(async () => ensuredLease()); + const markTemplate = vi.fn(async () => ({ + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); const dispose = vi.fn(async () => ({ dropped: false as const, reason: "no-lease" as const })); - await withMockedLifecycle({ dispose }, async () => { - const { teardownTemplateMode } = await import("../src/adapters/template-mode.ts"); - await teardownTemplateMode({ root: "/tmp/wt" }); + const migrate = vi.fn(async () => {}); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + const { createGlobalSetup } = await import("../src/adapters/vitest-template.ts"); + const teardown = await createGlobalSetup({ migrate })(); + await teardown(); expect(dispose).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test" }); }); }); test("jest createGlobalSetup wires migrate hook", async () => { const ensureIfNeeded = vi.fn(async () => ensuredLease()); - const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + const markTemplate = vi.fn(async () => ({ + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); const migrate = vi.fn(async () => {}); await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { @@ -240,7 +228,10 @@ test("jest template default requires CEDAR_PG_MIGRATE", async () => { test("jest template default export uses CEDAR_PG_MIGRATE", async () => { const ensureIfNeeded = vi.fn(async () => ensuredLease()); - const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl" })); + const markTemplate = vi.fn(async () => ({ + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); const file = join(dir, "migrate.mjs"); diff --git a/vite.config.ts b/vite.config.ts index cbb6c95..0950d4e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ "jest-teardown": "src/adapters/jest-teardown.ts", "test-env": "src/adapters/test-env.ts", "jest-template": "src/adapters/jest-template.ts", - "jest-template-teardown": "src/adapters/jest-template-teardown.ts", "jest-template-worker": "src/adapters/jest-template-worker.ts", }, From 4e3339bb3a17da34f94543965db1fd702db57411 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Tue, 4 Aug 2026 10:53:23 -0400 Subject: [PATCH 6/7] refactor: atomic template setup and shared cloneIfNeeded skip path Keep migrate+markTemplate all-or-nothing after ensure, move worker skip policy into cloneFromTemplateIfNeeded, and collapse template worker package entries into the jest/vitest template adapters. --- README.md | 58 +++---- package.json | 10 -- scripts/smoke.mjs | 20 ++- src/adapters/jest-template-worker.ts | 13 -- src/adapters/jest-template.ts | 34 +++- src/adapters/template-mode.ts | 107 ++++++------- src/adapters/vitest-template-worker.ts | 15 -- src/adapters/vitest-template.ts | 53 ++++--- src/core/lifecycle.ts | 145 ++++++++--------- src/index.ts | 3 + src/providers/autopg.ts | 80 ++++++---- tests/lifecycle-dispose.test.ts | 7 +- tests/template-clone.test.ts | 208 ++++++++++++++++++++----- tests/template-mode.test.ts | 197 ++++++++++++++--------- vite.config.ts | 2 - 15 files changed, 559 insertions(+), 393 deletions(-) delete mode 100644 src/adapters/jest-template-worker.ts delete mode 100644 src/adapters/vitest-template-worker.ts diff --git a/README.md b/README.md index d5e8cca..5243849 100644 --- a/README.md +++ b/README.md @@ -241,66 +241,55 @@ The action runs `scripts/ci-install-autopg.sh` under the hood. For published-pac Stock `@cedarjs/pg/jest` and `@cedarjs/pg/vitest` only run `ensureIfNeeded` + `dispose` (one shared test DB). They are **not** a full replacement for Redwood-style globalSetup that migrates once and clones per worker. For that, use template mode. -Migrate stays app-owned; template mode **requires** a migrate hook (via `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`), then marks TEMPLATE and clones per worker. - -**1. App migrate module** (`CEDAR_PG_MIGRATE`): - -```ts -// scripts/cedar-pg-migrate.ts -export async function migrate({ databaseUrl }: { databaseUrl: string }) { - // prisma migrate reset / drizzle push / etc. against databaseUrl -} -``` +Migrate stays app-owned via `createGlobalSetup({ migrate })`, then the adapter marks TEMPLATE and clones per worker. Point `globalSetup` at a **local** module that calls `createGlobalSetup` — string-resolving the package entry without a migrate hook throws. **Jest (template mode):** ```js +// jest.cedar-global.cjs +const { createGlobalSetup } = require("@cedarjs/pg/jest/template"); +module.exports = createGlobalSetup({ + migrate: async ({ databaseUrl }) => { + // prisma migrate reset / drizzle push / etc. + }, +}); + // jest.config.cjs module.exports = { - globalSetup: require.resolve("@cedarjs/pg/jest/template"), + globalSetup: "/jest.cedar-global.cjs", globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"), setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], }; // jest.cedar-worker.cjs — once per worker process -const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template/worker"); +const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template"); beforeAll(() => ensureWorkerDatabase()); ``` -```bash -CEDAR_PG_MIGRATE=./scripts/cedar-pg-migrate.ts pnpm test -``` - -Or in-process migrate without the env var: +**Vitest (template mode):** ```ts -import { createGlobalSetup } from "@cedarjs/pg/jest/template"; - +// vitest.cedar-global.ts +import { createGlobalSetup } from "@cedarjs/pg/vitest/template"; export default createGlobalSetup({ migrate: async ({ databaseUrl }) => { - /* migrate once */ + // migrate once }, }); -``` -**Vitest (template mode):** - -```ts // vitest.config.ts export default defineConfig({ test: { - globalSetup: ["@cedarjs/pg/vitest/template"], + globalSetup: ["./vitest.cedar-global.ts"], setupFiles: ["./vitest.cedar-worker.ts"], }, }); // vitest.cedar-worker.ts — once per worker process (ESM top-level await) -import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; +import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template"; await ensureWorkerDatabase(); ``` -Same `CEDAR_PG_MIGRATE` / `createGlobalSetup({ migrate })` from `@cedarjs/pg/vitest/template`. - **Programmatic** (core API — no runner adapters): ```ts @@ -316,12 +305,14 @@ const worker = await cloneFromTemplate({ setEnv: true, }); // … tests … -await dispose({ root: ensured.root, mode: "test" }); // drops every DB owned by the lease role +await worker.dropClone(); // optional: drop one clone only +await dispose({ root: ensured.root, mode: "test" }); // role-scoped: TEMPLATE + all clones + role ``` -`ensure` returns `adminUrl` so apps do not re-derive `postgresql://postgres:postgres@127.0.0.1:/postgres`. -`cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. -`dispose` unsets `IS_TEMPLATE` and drops every database owned by the lease role (template + clones). +`ensure` returns `adminUrl` for migrate hooks / privileged DDL; `markTemplate` / `cloneFromTemplate` accept it or rediscover the host when omitted. +`cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. `setEnv` defaults to false on the core API (worker adapters pass true). +Worker adapters call `cloneFromTemplateIfNeeded` (same skip policy as `ensureIfNeeded`) via `ensureWorkerDatabase`. +`dispose` is role-scoped suite teardown (not `dropClone`): unsets `IS_TEMPLATE` and drops every database owned by the lease role. ## Env @@ -336,7 +327,6 @@ await dispose({ root: ensured.root, mode: "test" }); // drops every DB owned by | `CEDAR_PG_REGISTRY_DIR` | Override global lease registry (for `gc`) | | `CEDAR_PG_SKIP_POSTINSTALL=1` | Skip autopg install hook | | `CEDAR_PG_INSTALL_AUTOPG=1` | Under `CI=true`, run binary-only `ci-install-autopg.sh` from postinstall | -| `CEDAR_PG_MIGRATE` | Module path for template-mode migrate hook (`migrate` or default) | ## Alpha caveats @@ -346,4 +336,4 @@ await dispose({ root: ensured.root, mode: "test" }); // drops every DB owned by (ephemeral cold-start when the runner has no live host; attach-wins otherwise). - State lives in product-owned `.cedarpg` (worktree + `~/.cedarpg/registry`), not under autopg's `~/.autopg/` or a generic `.pg`. - Role passwords are derived from `roleName` (`cedar-pg\\0` + roleName, scheme v2) so TEMPLATE clones that reuse a role keep working; bump the scheme id to change the derivation. -- Test TEMPLATE flow: `ensure` → app migrate → `markTemplate` → `cloneFromTemplate` → role-scoped `dispose`. Optional `@cedarjs/pg/jest/template` + `@cedarjs/pg/vitest/template` adapters orchestrate that pipeline; migrate stays app-owned. +- Test TEMPLATE flow: `ensure` → app migrate → `markTemplate` → `cloneFromTemplate` → role-scoped `dispose`. Optional `@cedarjs/pg/jest/template` + `@cedarjs/pg/vitest/template` adapters orchestrate that pipeline via `createGlobalSetup({ migrate })`; migrate stays app-owned. diff --git a/package.json b/package.json index 63e8519..2a69423 100644 --- a/package.json +++ b/package.json @@ -63,11 +63,6 @@ "import": "./dist/vitest-template.mjs", "require": "./dist/vitest-template.cjs" }, - "./vitest/template/worker": { - "types": "./dist/vitest-template-worker.d.mts", - "import": "./dist/vitest-template-worker.mjs", - "require": "./dist/vitest-template-worker.cjs" - }, "./jest": { "types": "./dist/jest.d.mts", "import": "./dist/jest.mjs", @@ -88,11 +83,6 @@ "import": "./dist/jest-template.mjs", "require": "./dist/jest-template.cjs" }, - "./jest/template/worker": { - "types": "./dist/jest-template-worker.d.mts", - "import": "./dist/jest-template-worker.mjs", - "require": "./dist/jest-template-worker.cjs" - }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 1d54a0e..57f8690 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -36,6 +36,7 @@ run( import { buildDatabaseName, cloneFromTemplate, + cloneFromTemplateIfNeeded, loadTestEnv, markTemplate, STATE_DIRNAME, @@ -45,9 +46,14 @@ import vitestSetup from '${PACKAGE_NAME}/vitest'; import jestSetup from '${PACKAGE_NAME}/jest'; import jestTeardown from '${PACKAGE_NAME}/jest-teardown'; import '${PACKAGE_NAME}/test-env'; -import jestTemplate from '${PACKAGE_NAME}/jest/template'; -import { ensureWorkerDatabase } from '${PACKAGE_NAME}/jest/template/worker'; -import vitestTemplate from '${PACKAGE_NAME}/vitest/template'; +import { + createGlobalSetup as createJestTemplateSetup, + ensureWorkerDatabase, +} from '${PACKAGE_NAME}/jest/template'; +import { + createGlobalSetup as createVitestTemplateSetup, + ensureWorkerDatabase as ensureVitestWorkerDatabase, +} from '${PACKAGE_NAME}/vitest/template'; const name = buildDatabaseName( { root: '/tmp/x', repoSlug: 'cedar', worktreeSlug: 'feat', pathHash: 'abcd1234' }, 'dev', @@ -62,9 +68,11 @@ if (typeof loadTestEnv !== 'function') throw new Error('loadTestEnv export missi if (STATE_DIRNAME !== '.cedarpg') throw new Error('bad STATE_DIRNAME ' + STATE_DIRNAME); if (typeof markTemplate !== 'function') throw new Error('missing markTemplate'); if (typeof cloneFromTemplate !== 'function') throw new Error('missing cloneFromTemplate'); -if (typeof jestTemplate !== 'function') throw new Error('missing jest/template'); -if (typeof ensureWorkerDatabase !== 'function') throw new Error('missing jest/template/worker'); -if (typeof vitestTemplate !== 'function') throw new Error('missing vitest/template'); +if (typeof cloneFromTemplateIfNeeded !== 'function') throw new Error('missing cloneFromTemplateIfNeeded'); +if (typeof createJestTemplateSetup !== 'function') throw new Error('missing jest/template createGlobalSetup'); +if (typeof ensureWorkerDatabase !== 'function') throw new Error('missing jest/template ensureWorkerDatabase'); +if (typeof createVitestTemplateSetup !== 'function') throw new Error('missing vitest/template createGlobalSetup'); +if (typeof ensureVitestWorkerDatabase !== 'function') throw new Error('missing vitest/template ensureWorkerDatabase'); console.log('ok', name, STATE_DIRNAME, Object.keys(tasks).join(',')); `, ], diff --git a/src/adapters/jest-template-worker.ts b/src/adapters/jest-template-worker.ts deleted file mode 100644 index 444f739..0000000 --- a/src/adapters/jest-template-worker.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ensureWorkerDatabase } from "./template-mode.ts"; - -/** - * Jest per-worker helper. Prefer setupFilesAfterEnv: - * - * ```js - * // jest.cedar-worker.cjs - * const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template/worker"); - * beforeAll(() => ensureWorkerDatabase()); - * ``` - */ -export { ensureWorkerDatabase }; -export default ensureWorkerDatabase; diff --git a/src/adapters/jest-template.ts b/src/adapters/jest-template.ts index 3566bbd..853eef6 100644 --- a/src/adapters/jest-template.ts +++ b/src/adapters/jest-template.ts @@ -1,28 +1,46 @@ import { - requireMigrateFromEnv, + ensureWorkerDatabase, setupTemplateMode, type SetupTemplateModeOptions, } from "./template-mode.ts"; +export { ensureWorkerDatabase }; +export type { + SetupTemplateModeOptions, + TemplateMigrateFn, + TemplateMigrateContext, +} from "./template-mode.ts"; + /** * Jest globalSetup (template mode). * * ```js - * globalSetup: require.resolve("@cedarjs/pg/jest/template"), + * // jest.cedar-global.cjs + * const { createGlobalSetup } = require("@cedarjs/pg/jest/template"); + * module.exports = createGlobalSetup({ migrate: async ({ databaseUrl }) => {} }); + * + * // jest.config.cjs + * globalSetup: "/jest.cedar-global.cjs", * globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"), * setupFilesAfterEnv: ["/jest.cedar-worker.cjs"], + * + * // jest.cedar-worker.cjs + * const { ensureWorkerDatabase } = require("@cedarjs/pg/jest/template"); + * beforeAll(() => ensureWorkerDatabase()); * ``` * - * Requires `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`. * Stock `@cedarjs/pg/jest` is one shared test DB only. */ -export default async function globalSetup(): Promise { - await setupTemplateMode({ migrate: await requireMigrateFromEnv() }); -} - -/** Build a Jest globalSetup with an in-process migrate hook. */ export function createGlobalSetup(options: SetupTemplateModeOptions) { return async () => { await setupTemplateMode(options); }; } + +/** String `require.resolve` without a migrate hook is unsupported — use `createGlobalSetup`. */ +export default async function globalSetup(): Promise { + throw new Error( + "@cedarjs/pg/jest/template requires createGlobalSetup({ migrate }). " + + "Point globalSetup at a local module that exports createGlobalSetup({ migrate }).", + ); +} diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index 678ce42..d20a160 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -1,7 +1,6 @@ -import { pathToFileURL } from "node:url"; -import { isAbsolute, resolve } from "node:path"; import { cloneFromTemplateIfNeeded, + dispose, ensureIfNeeded, markTemplate, type EnsureIfNeededResult, @@ -25,6 +24,8 @@ export type SetupTemplateModeOptions = { /** * Runner orchestration: ensure → migrate → markTemplate. + * After ensure succeeds, migrate + markTemplate are all-or-nothing: any failure + * best-effort disposes the lease so Vitest (no separate teardown) does not leak. * Programmatic apps that do not need a migrate hook should call core * `ensure` + `markTemplate` + `cloneFromTemplate` + `dispose` instead. */ @@ -38,17 +39,31 @@ export async function setupTemplateMode( }); if (result.status !== "ensured") return result; - await options.migrate({ - databaseUrl: result.databaseUrl, - adminUrl: result.adminUrl, - databaseName: result.databaseName, - roleName: result.roleName, - }); - await markTemplate({ - root: result.root, - mode: "test", - adminUrl: result.adminUrl, - }); + try { + await options.migrate({ + databaseUrl: result.databaseUrl, + adminUrl: result.adminUrl, + databaseName: result.databaseName, + roleName: result.roleName, + }); + await markTemplate({ + root: result.root, + mode: "test", + adminUrl: result.adminUrl, + }); + } catch (err) { + try { + await dispose({ root: result.root, mode: "test" }); + } catch { + // best-effort: leave lease for dispose/gc retry + } + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + `template setup failed after ensure; cleaned up lease DB (${result.databaseName}). ` + + `Fix the error and re-run: ${detail}`, + { cause: err }, + ); + } return result; } @@ -60,18 +75,37 @@ export type EnsureWorkerDatabaseOptions = { }; let workerOnce: Promise | undefined; +let workerOnceKey: string | undefined; + +function resolveWorkerName(options: EnsureWorkerDatabaseOptions): string { + return ( + options.name ?? process.env.JEST_WORKER_ID ?? process.env.VITEST_POOL_ID ?? String(process.pid) + ); +} + +function workerOptionsKey(root: string | undefined, name: string): string { + return `${root ?? ""}\0${name}`; +} /** * Process-once per-worker clone (JEST_WORKER_ID / VITEST_POOL_ID / pid by default). - * First call wins for `root`/`name` when using the process-once path. + * Uses `cloneFromTemplateIfNeeded` (same skip policy as `ensureIfNeeded`) with `setEnv: true`. + * First call wins for `root`/`name`; conflicting later calls throw. */ export function ensureWorkerDatabase(options: EnsureWorkerDatabaseOptions = {}): Promise { - workerOnce ??= (async () => { - const name = - options.name ?? - process.env.JEST_WORKER_ID ?? - process.env.VITEST_POOL_ID ?? - String(process.pid); + const name = resolveWorkerName(options); + const key = workerOptionsKey(options.root, name); + if (workerOnce) { + if (workerOnceKey !== key) { + throw new Error( + `ensureWorkerDatabase already started with different root/name ` + + `(first: ${JSON.stringify(workerOnceKey)}, now: ${JSON.stringify(key)})`, + ); + } + return workerOnce; + } + workerOnceKey = key; + workerOnce = (async () => { await cloneFromTemplateIfNeeded({ root: options.root, mode: "test", @@ -80,39 +114,8 @@ export function ensureWorkerDatabase(options: EnsureWorkerDatabaseOptions = {}): }); })().catch((err) => { workerOnce = undefined; + workerOnceKey = undefined; throw err; }); return workerOnce; } - -function isMigrateFn(value: unknown): value is TemplateMigrateFn { - return typeof value === "function"; -} - -/** Require migrate from `CEDAR_PG_MIGRATE` or throw (default runner hooks). */ -export async function requireMigrateFromEnv(): Promise { - const spec = process.env.CEDAR_PG_MIGRATE?.trim(); - if (!spec) { - throw new Error( - "template mode requires a migrate hook: set CEDAR_PG_MIGRATE or use createGlobalSetup({ migrate })", - ); - } - - const mod = (await import(toImportUrl(spec))) as { - default?: unknown; - migrate?: unknown; - }; - const fn = mod.migrate ?? mod.default; - if (!isMigrateFn(fn)) { - throw new Error("CEDAR_PG_MIGRATE must export migrate() or a default function"); - } - return fn; -} - -function toImportUrl(spec: string): string { - if (spec.startsWith("file:") || spec.includes("://")) return spec; - if (spec.startsWith(".") || isAbsolute(spec)) { - return pathToFileURL(resolve(process.cwd(), spec)).href; - } - return spec; -} diff --git a/src/adapters/vitest-template-worker.ts b/src/adapters/vitest-template-worker.ts deleted file mode 100644 index 12321ef..0000000 --- a/src/adapters/vitest-template-worker.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ensureWorkerDatabase } from "./template-mode.ts"; - -/** - * Import from a local ESM setupFiles file (not this module path as setupFiles): - * - * ```ts - * // vitest.cedar-worker.ts - * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; - * await ensureWorkerDatabase(); - * ``` - * - * Pack emits CJS + ESM, so this file itself cannot use top-level await. - */ -export { ensureWorkerDatabase }; -export default ensureWorkerDatabase; diff --git a/src/adapters/vitest-template.ts b/src/adapters/vitest-template.ts index 894f6f2..e756ab4 100644 --- a/src/adapters/vitest-template.ts +++ b/src/adapters/vitest-template.ts @@ -1,48 +1,55 @@ -import { dispose } from "../core/lifecycle.ts"; import { - requireMigrateFromEnv, + ensureWorkerDatabase, setupTemplateMode, type SetupTemplateModeOptions, } from "./template-mode.ts"; +export { ensureWorkerDatabase }; +export type { + SetupTemplateModeOptions, + TemplateMigrateFn, + TemplateMigrateContext, +} from "./template-mode.ts"; + /** * Vitest globalSetup (template mode). Returns teardown that disposes TEMPLATE + clones. * * ```ts + * // vitest.cedar-global.ts + * import { createGlobalSetup } from "@cedarjs/pg/vitest/template"; + * export default createGlobalSetup({ migrate: async ({ databaseUrl }) => {} }); + * * // vitest.config.ts * export default defineConfig({ * test: { - * globalSetup: ["@cedarjs/pg/vitest/template"], - * // Local ESM file — pack emits CJS+ESM; do not point setupFiles at the packaged worker. + * globalSetup: ["./vitest.cedar-global.ts"], * setupFiles: ["./vitest.cedar-worker.ts"], * }, * }) * - * // vitest.cedar-worker.ts - * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template/worker"; + * // vitest.cedar-worker.ts — local ESM (pack emits CJS+ESM; top-level await lives here) + * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template"; * await ensureWorkerDatabase(); * ``` * - * Requires `CEDAR_PG_MIGRATE` or `createGlobalSetup({ migrate })`. + * Stock `@cedarjs/pg/vitest` is one shared test DB only. */ -export async function setup(): Promise<() => Promise> { - return runSetup({ migrate: await requireMigrateFromEnv() }); -} - -/** Build a Vitest globalSetup with an in-process migrate hook. */ export function createGlobalSetup(options: SetupTemplateModeOptions) { - return async () => runSetup(options); -} - -async function runSetup(options: SetupTemplateModeOptions): Promise<() => Promise> { - const result = await setupTemplateMode(options); - if (result.status !== "ensured") { - return async () => {}; - } - const root = result.root; return async () => { - await dispose({ root, mode: "test" }); + const result = await setupTemplateMode(options); + if (result.status !== "ensured") { + return async () => {}; + } + return async () => { + await result.dispose(); + }; }; } -export default setup; +/** String path without a migrate hook is unsupported — use `createGlobalSetup`. */ +export default async function setup(): Promise<() => Promise> { + throw new Error( + "@cedarjs/pg/vitest/template requires createGlobalSetup({ migrate }). " + + "Point globalSetup at a local module that exports createGlobalSetup({ migrate }).", + ); +} diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index 097f424..e9d3efd 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -16,8 +16,8 @@ import { buildDatabaseUrl, cloneDatabaseFromTemplate, dropDatabase, + dropDatabasesOwnedByRole, ensureDatabase, - listDatabasesOwnedByRole, setDatabaseIsTemplate, } from "../providers/autopg.ts"; import { ensureHostRunning } from "../providers/host.ts"; @@ -39,27 +39,14 @@ export function urlFromLease(lease: Lease): string { /** * DROP every DB owned by the lease role (TEMPLATE + clones), then forget lease. - * Unsets `IS_TEMPLATE` inside each drop. Never invents a DROP target; never forgets - * without successful DROP(s). Clones first, then the leased datname when owned. + * Provider owns ordering (clones before leased datname) on one admin connection. */ async function dropThenForget(lease: Lease, adminUrl: string): Promise { - const owned = await listDatabasesOwnedByRole({ + const dropped = await dropDatabasesOwnedByRole({ adminUrl, roleName: lease.roleName, + preferLast: lease.databaseName, }); - const ordered = [ - ...owned.filter((name) => name !== lease.databaseName), - ...owned.filter((name) => name === lease.databaseName), - ]; - const dropped: string[] = []; - for (const databaseName of ordered) { - await dropDatabase({ - adminUrl, - databaseName, - roleName: lease.roleName, - }); - dropped.push(databaseName); - } forgetLease(lease); return dropped; } @@ -157,30 +144,6 @@ export type EnsureIfNeededResult = | { status: "skipped"; reason: "external-url"; databaseUrl: string } | ({ status: "ensured" } & EnsureResult); -type EnsureSkipOutcome = - | { status: "skipped"; reason: "disabled" } - | { status: "skipped"; reason: "external-url"; databaseUrl: string } - | { status: "run" }; - -/** Shared skip → env injection for {@link ensureIfNeeded} / {@link cloneFromTemplateIfNeeded}. */ -function resolveEnsureSkipOutcome( - input: ResolveEnsureSkipInput & { mode: DbMode; setEnv?: boolean }, -): EnsureSkipOutcome { - const skip = resolveEnsureSkip({ - url: input.url, - force: input.force, - disabled: input.disabled, - }); - if (!skip.skip) return { status: "run" }; - if (skip.reason === "external-url") { - if (input.setEnv !== false) { - applyDatabaseUrlEnv(skip.databaseUrl, { mode: input.mode }); - } - return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; - } - return { status: "skipped", reason: "disabled" }; -} - /** * Resolve skip policy then ensure. Single entry for hosts (Cedar CLI, Jest, Vitest). * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false. @@ -188,14 +151,20 @@ function resolveEnsureSkipOutcome( export async function ensureIfNeeded( options: EnsureIfNeededOptions, ): Promise { - const outcome = resolveEnsureSkipOutcome({ + const skip = resolveEnsureSkip({ url: options.url, force: options.force, disabled: options.disabled, - mode: options.mode, - setEnv: options.setEnv, }); - if (outcome.status === "skipped") return outcome; + if (skip.skip) { + if (skip.reason === "external-url") { + if (options.setEnv !== false) { + applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode }); + } + return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; + } + return { status: "skipped", reason: "disabled" }; + } const result = await ensure({ root: options.root, @@ -205,10 +174,14 @@ export async function ensureIfNeeded( return { status: "ensured", ...result }; } +async function resolveAdminUrl(adminUrl?: string): Promise { + return adminUrl ?? (await ensureHostRunning()).adminUrl; +} + export type MarkTemplateOptions = { root?: string; - mode?: DbMode; - /** Optional; defaults to `ensureHostRunning().adminUrl`. */ + mode: DbMode; + /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ adminUrl?: string; }; @@ -217,15 +190,15 @@ export type MarkTemplateOptions = { * Requires a lease from `ensure` (no datname override). */ export async function markTemplate( - options: MarkTemplateOptions = {}, + options: MarkTemplateOptions, ): Promise<{ databaseName: string; adminUrl: string }> { const identity = resolveWorktreeIdentity(options.root); - const mode = options.mode ?? "test"; + const mode = options.mode; const lease = readLease(identity.root, mode); if (!lease) { throw new Error(`no ${mode} lease; run ensure before markTemplate`); } - const adminUrl = options.adminUrl ?? (await ensureHostRunning()).adminUrl; + const adminUrl = await resolveAdminUrl(options.adminUrl); await setDatabaseIsTemplate({ adminUrl, databaseName: lease.databaseName, @@ -236,13 +209,18 @@ export async function markTemplate( export type CloneFromTemplateOptions = { root?: string; - mode?: DbMode; + mode: DbMode; + /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ + adminUrl?: string; /** * Suffix for the clone datname (e.g. Jest worker id). * Defaults to `_`. */ name?: string; - /** Inject DATABASE_URL / TEST_DATABASE_URL for this clone (default false). */ + /** + * Inject DATABASE_URL / TEST_DATABASE_URL for this clone (default false). + * Worker adapters pass true; programmatic callers opt in. + */ setEnv?: boolean; }; @@ -253,38 +231,40 @@ export type CloneResult = { roleName: string; templateName: string; port: number; - /** DROP this clone only (leaves TEMPLATE + role if still owned elsewhere). */ - dispose: () => Promise; + /** + * DROP this clone only (leaves TEMPLATE + role if still owned elsewhere). + * Not suite teardown — use role-scoped `dispose` for that. + */ + dropClone: () => Promise; }; /** * Clone the leased TEMPLATE database via admin (`CREATE DATABASE … TEMPLATE`). * Reuses the template role so `databaseUrl` passwords stay valid (scheme v2). * Provider rejects when the leased DB is not marked TEMPLATE. + * Port comes from the lease; admin URL is passed through or rediscovered. */ -export async function cloneFromTemplate( - options: CloneFromTemplateOptions = {}, -): Promise { +export async function cloneFromTemplate(options: CloneFromTemplateOptions): Promise { const identity = resolveWorktreeIdentity(options.root); - const mode = options.mode ?? "test"; + const mode = options.mode; const lease = readLease(identity.root, mode); if (!lease) { throw new Error(`no ${mode} lease; run ensure + markTemplate before cloneFromTemplate`); } - const host = await ensureHostRunning(); + const adminUrl = await resolveAdminUrl(options.adminUrl); const suffix = options.name ?? `${process.pid}_${Date.now().toString(36)}`; const databaseName = buildCloneDatabaseName(lease.databaseName, suffix); await cloneDatabaseFromTemplate({ - adminUrl: host.adminUrl, + adminUrl, templateName: lease.databaseName, databaseName, roleName: lease.roleName, }); const databaseUrl = buildDatabaseUrl({ - port: host.port, + port: lease.port, databaseName, roleName: lease.roleName, }); @@ -294,7 +274,6 @@ export async function cloneFromTemplate( } const roleName = lease.roleName; - const adminUrl = host.adminUrl; return { databaseUrl, @@ -302,8 +281,8 @@ export async function cloneFromTemplate( databaseName, roleName, templateName: lease.databaseName, - port: host.port, - dispose: async () => { + port: lease.port, + dropClone: async () => { await dropDatabase({ adminUrl, databaseName, roleName }); }, }; @@ -317,28 +296,29 @@ export type CloneFromTemplateIfNeededResult = | ({ status: "cloned" } & CloneResult); /** - * Skip-aware clone for test runners. Shares ensure skip + env injection with {@link ensureIfNeeded}. - * Not exported from the package root — adapters own the runner wiring. + * Resolve skip policy then clone. Host entry for worker adapters (same skip + * semantics as `ensureIfNeeded`). On external-url skip, applies DATABASE_URL / + * TEST_DATABASE_URL when `setEnv` is not false. */ export async function cloneFromTemplateIfNeeded( - options: CloneFromTemplateIfNeededOptions = {}, + options: CloneFromTemplateIfNeededOptions, ): Promise { - const mode = options.mode ?? "test"; - const outcome = resolveEnsureSkipOutcome({ + const skip = resolveEnsureSkip({ url: options.url, force: options.force, disabled: options.disabled, - mode, - setEnv: options.setEnv, }); - if (outcome.status === "skipped") return outcome; + if (skip.skip) { + if (skip.reason === "external-url") { + if (options.setEnv !== false) { + applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode }); + } + return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; + } + return { status: "skipped", reason: "disabled" }; + } - const result = await cloneFromTemplate({ - root: options.root, - mode, - name: options.name, - setEnv: options.setEnv !== false, - }); + const result = await cloneFromTemplate(options); return { status: "cloned", ...result }; } @@ -352,10 +332,11 @@ export type DisposeResult = | { dropped: false; reason: "no-lease" | "host-unavailable" }; /** - * Role-scoped teardown: DROP every database owned by the lease role (TEMPLATE + clones), - * then forget the lease. Unsets `IS_TEMPLATE` as needed. No-ops without a valid lease; - * never invents a DROP target beyond role ownership. If the host is unavailable, leaves - * the lease so dispose/gc can retry. + * Role-scoped suite teardown: DROP every database owned by the lease role + * (TEMPLATE + clones), then DROP ROLE and forget the lease. Unsets `IS_TEMPLATE` + * as needed. This is not per-clone cleanup — use `CloneResult.dropClone` for that. + * No-ops without a valid lease; never invents a DROP target beyond role ownership. + * If the host is unavailable, leaves the lease so dispose/gc can retry. */ export async function dispose(options: DisposeOptions = {}): Promise { const identity = resolveWorktreeIdentity(options.root); diff --git a/src/index.ts b/src/index.ts index 85774c4..bb5ac9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { ensureIfNeeded, markTemplate, cloneFromTemplate, + cloneFromTemplateIfNeeded, dispose, gc, urlFromLease, @@ -14,6 +15,8 @@ export type { EnsureIfNeededResult, MarkTemplateOptions, CloneFromTemplateOptions, + CloneFromTemplateIfNeededOptions, + CloneFromTemplateIfNeededResult, CloneResult, DisposeOptions, DisposeResult, diff --git a/src/providers/autopg.ts b/src/providers/autopg.ts index 973f70b..040cf2d 100644 --- a/src/providers/autopg.ts +++ b/src/providers/autopg.ts @@ -226,19 +226,60 @@ export async function cloneDatabaseFromTemplate(opts: { }); } -/** Datnames owned by role (for dispose/GC of TEMPLATE clones). */ -export async function listDatabasesOwnedByRole(opts: { +async function listOwnedDatnames(client: pg.Client, roleName: string): Promise { + const result = await client.query<{ datname: string }>( + `SELECT datname FROM pg_database + WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) + ORDER BY datname`, + [roleName], + ); + return result.rows.map((r) => r.datname); +} + +/** Unset IS_TEMPLATE if needed, terminate backends, DROP DATABASE (no-op if missing). */ +async function dropOneDatabase(client: pg.Client, databaseName: string): Promise { + const db = await client.query<{ datistemplate: boolean }>( + `SELECT datistemplate FROM pg_database WHERE datname = $1`, + [databaseName], + ); + if (!db.rowCount || db.rowCount === 0) return; + if (db.rows[0]?.datistemplate) { + await client.query(`ALTER DATABASE ${quoteIdent(databaseName)} WITH IS_TEMPLATE false`); + } + await client.query( + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid() + `, + [databaseName], + ); + await client.query(`DROP DATABASE IF EXISTS ${quoteIdent(databaseName)}`); +} + +/** + * DROP every database owned by `roleName` on one admin connection, then DROP ROLE. + * When `preferLast` is owned, it is dropped after the other owned datnames (TEMPLATE after clones). + * Never invents a DROP target beyond role ownership. + */ +export async function dropDatabasesOwnedByRole(opts: { adminUrl: string; roleName: string; + preferLast?: string; }): Promise { return withAdminClient(opts.adminUrl, async (client) => { - const result = await client.query<{ datname: string }>( - `SELECT datname FROM pg_database - WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) - ORDER BY datname`, - [opts.roleName], - ); - return result.rows.map((r) => r.datname); + const owned = await listOwnedDatnames(client, opts.roleName); + const ordered = [ + ...owned.filter((name) => name !== opts.preferLast), + ...owned.filter((name) => name === opts.preferLast), + ]; + const dropped: string[] = []; + for (const databaseName of ordered) { + await dropOneDatabase(client, databaseName); + dropped.push(databaseName); + } + await client.query(`DROP ROLE IF EXISTS ${quoteIdent(opts.roleName)}`); + return dropped; }); } @@ -252,26 +293,7 @@ export async function dropDatabase(opts: { roleName: string; }): Promise { await withAdminClient(opts.adminUrl, async (client) => { - const db = await client.query<{ datistemplate: boolean }>( - `SELECT datistemplate FROM pg_database WHERE datname = $1`, - [opts.databaseName], - ); - if (db.rowCount && db.rowCount > 0) { - if (db.rows[0]?.datistemplate) { - await client.query( - `ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE false`, - ); - } - await client.query( - ` - SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid() - `, - [opts.databaseName], - ); - await client.query(`DROP DATABASE IF EXISTS ${quoteIdent(opts.databaseName)}`); - } + await dropOneDatabase(client, opts.databaseName); const owns = await client.query( `SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) LIMIT 1`, [opts.roleName], diff --git a/tests/lifecycle-dispose.test.ts b/tests/lifecycle-dispose.test.ts index 0d9485b..09f79e3 100644 --- a/tests/lifecycle-dispose.test.ts +++ b/tests/lifecycle-dispose.test.ts @@ -63,8 +63,7 @@ test("dispose removes mode env after successful DROP", async () => { ); return { ...actual, - listDatabasesOwnedByRole: vi.fn(async () => ["cpg_cedar_main_test_disposeenv"]), - dropDatabase: vi.fn(async () => {}), + dropDatabasesOwnedByRole: vi.fn(async () => ["cpg_cedar_main_test_disposeenv"]), }; }); @@ -123,7 +122,7 @@ test("dispose leaves lease+registry when host is unavailable", async () => { ); return { ...actual, - dropDatabase: vi.fn(), + dropDatabasesOwnedByRole: vi.fn(), }; }); @@ -144,7 +143,7 @@ test("dispose leaves lease+registry when host is unavailable", async () => { expect(readLease(root, "test")).toEqual(lease); expect(listRegistryLeases().map((l) => l.databaseName)).toContain(lease.databaseName); - expect(autopg.dropDatabase).not.toHaveBeenCalled(); + expect(autopg.dropDatabasesOwnedByRole).not.toHaveBeenCalled(); } finally { vi.doUnmock("../src/providers/host.ts"); vi.doUnmock("../src/providers/autopg.ts"); diff --git a/tests/template-clone.test.ts b/tests/template-clone.test.ts index f837cb3..00f89f7 100644 --- a/tests/template-clone.test.ts +++ b/tests/template-clone.test.ts @@ -114,7 +114,7 @@ test("markTemplate + cloneFromTemplate use admin and lease role password", async isTemplate: true, }); - const worker = await cloneFromTemplate({ root, mode: "test", name: "2" }); + const worker = await cloneFromTemplate({ root, mode: "test", adminUrl, name: "2" }); expect(worker.templateName).toBe(templateName); expect(worker.roleName).toBe(lease.roleName); expect(worker.adminUrl).toBe(adminUrl); @@ -134,7 +134,7 @@ test("markTemplate + cloneFromTemplate use admin and lease role password", async roleName: lease.roleName, }); - await worker.dispose(); + await worker.dropClone(); expect(dropDb).toHaveBeenCalledWith({ adminUrl, databaseName: worker.databaseName, @@ -165,18 +165,13 @@ test("dispose drops clones then template via admin (role-owned DBs)", async () = const cloneA = `${templateName}_c_1`; const cloneB = `${templateName}_c_2`; - const droppedNames: string[] = []; - const dropDb = vi.fn(async (opts: { databaseName: string }) => { - droppedNames.push(opts.databaseName); - }); - const listOwned = vi.fn(async () => [cloneA, templateName, cloneB]); + const dropOwned = vi.fn(async () => [cloneA, cloneB, templateName]); try { await withHostAndAutopgMocks( adminUrl, { - listDatabasesOwnedByRole: listOwned, - dropDatabase: dropDb, + dropDatabasesOwnedByRole: dropOwned, }, async () => { const { dispose } = await import("../src/core/lifecycle.ts"); @@ -187,8 +182,11 @@ test("dispose drops clones then template via admin (role-owned DBs)", async () = databaseName: templateName, droppedDatabases: [cloneA, cloneB, templateName], }); - expect(listOwned).toHaveBeenCalledWith({ adminUrl, roleName: lease.roleName }); - expect(droppedNames).toEqual([cloneA, cloneB, templateName]); + expect(dropOwned).toHaveBeenCalledWith({ + adminUrl, + roleName: lease.roleName, + preferLast: templateName, + }); expect(readLease(root, "test")).toBeNull(); expect(listRegistryLeases().map((l) => l.databaseName)).not.toContain(templateName); }, @@ -212,18 +210,13 @@ test("dispose with only the leased DB still lists owned then drops", async () => const lease = makeLease({ root, databaseName: templateName }); writeLease(lease); - const droppedNames: string[] = []; - const dropDb = vi.fn(async (opts: { databaseName: string }) => { - droppedNames.push(opts.databaseName); - }); - const listOwned = vi.fn(async () => [templateName]); + const dropOwned = vi.fn(async () => [templateName]); try { await withHostAndAutopgMocks( adminUrl, { - listDatabasesOwnedByRole: listOwned, - dropDatabase: dropDb, + dropDatabasesOwnedByRole: dropOwned, }, async () => { const { dispose } = await import("../src/core/lifecycle.ts"); @@ -232,8 +225,12 @@ test("dispose with only the leased DB still lists owned then drops", async () => databaseName: templateName, droppedDatabases: [templateName], }); - expect(listOwned).toHaveBeenCalledOnce(); - expect(droppedNames).toEqual([templateName]); + expect(dropOwned).toHaveBeenCalledOnce(); + expect(dropOwned).toHaveBeenCalledWith({ + adminUrl, + roleName: lease.roleName, + preferLast: templateName, + }); }, ); } finally { @@ -256,18 +253,13 @@ test("dispose drops only role-owned DBs (never invents lease datname)", async () writeLease(lease); const cloneOnly = `${templateName}_c_1`; - const droppedNames: string[] = []; - const dropDb = vi.fn(async (opts: { databaseName: string }) => { - droppedNames.push(opts.databaseName); - }); - const listOwned = vi.fn(async () => [cloneOnly]); + const dropOwned = vi.fn(async () => [cloneOnly]); try { await withHostAndAutopgMocks( adminUrl, { - listDatabasesOwnedByRole: listOwned, - dropDatabase: dropDb, + dropDatabasesOwnedByRole: dropOwned, }, async () => { const { dispose } = await import("../src/core/lifecycle.ts"); @@ -276,7 +268,11 @@ test("dispose drops only role-owned DBs (never invents lease datname)", async () databaseName: templateName, droppedDatabases: [cloneOnly], }); - expect(droppedNames).toEqual([cloneOnly]); + expect(dropOwned).toHaveBeenCalledWith({ + adminUrl, + roleName: lease.roleName, + preferLast: templateName, + }); expect(readLease(root, "test")).toBeNull(); }, ); @@ -297,7 +293,13 @@ test("markTemplate requires a lease", async () => { vi.resetModules(); try { const { markTemplate } = await import("../src/core/lifecycle.ts"); - await expect(markTemplate({ root, mode: "test" })).rejects.toThrow(/no test lease/); + await expect( + markTemplate({ + root, + mode: "test", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + }), + ).rejects.toThrow(/no test lease/); } finally { vi.resetModules(); if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; @@ -307,23 +309,108 @@ test("markTemplate requires a lease", async () => { } }); -test("cloneFromTemplateIfNeeded external-url skip shares applyDatabaseUrlEnv", async () => { +test("markTemplate + cloneFromTemplate rediscover adminUrl when omitted", async () => { + const registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-")); + const root = mkdtempSync(join(tmpdir(), "cedarpg-wt-")); + const prev = process.env.CEDAR_PG_REGISTRY_DIR; + process.env.CEDAR_PG_REGISTRY_DIR = registry; + + const adminUrl = "postgresql://postgres:postgres@127.0.0.1:5433/postgres"; + const templateName = "cpg_cedar_main_test_rediscover"; + writeLease(makeLease({ root, databaseName: templateName })); + + const setTemplate = vi.fn(async () => {}); + const cloneDb = vi.fn(async () => {}); + + try { + await withHostAndAutopgMocks( + adminUrl, + { + setDatabaseIsTemplate: setTemplate, + cloneDatabaseFromTemplate: cloneDb, + }, + async () => { + const { markTemplate, cloneFromTemplate } = await import("../src/core/lifecycle.ts"); + await expect(markTemplate({ root, mode: "test" })).resolves.toEqual({ + databaseName: templateName, + adminUrl, + }); + const worker = await cloneFromTemplate({ root, mode: "test", name: "w" }); + expect(worker.adminUrl).toBe(adminUrl); + expect(setTemplate).toHaveBeenCalledWith({ + adminUrl, + databaseName: templateName, + isTemplate: true, + }); + expect(cloneDb).toHaveBeenCalledWith({ + adminUrl, + templateName, + databaseName: worker.databaseName, + roleName: `${templateName}_role`, + }); + }, + ); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("cloneFromTemplateIfNeeded skips when CEDAR_PG disabled", async () => { + const prevCedar = process.env.CEDAR_PG; + process.env.CEDAR_PG = "0"; + const cloneDb = vi.fn(async () => { + throw new Error("should not clone"); + }); + + try { + await withHostAndAutopgMocks( + "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + { cloneDatabaseFromTemplate: cloneDb }, + async () => { + const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + await expect( + cloneFromTemplateIfNeeded({ mode: "test", name: "1", setEnv: true }), + ).resolves.toEqual({ status: "skipped", reason: "disabled" }); + expect(cloneDb).not.toHaveBeenCalled(); + }, + ); + } finally { + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + } +}); + +test("cloneFromTemplateIfNeeded external-url skip applies DATABASE_URL env", async () => { const prevCedar = process.env.CEDAR_PG; const prevUrl = process.env.TEST_DATABASE_URL; const prevDb = process.env.DATABASE_URL; delete process.env.CEDAR_PG; process.env.TEST_DATABASE_URL = "postgresql://ci:ci@db.example/app"; + const cloneDb = vi.fn(async () => { + throw new Error("should not clone"); + }); try { - const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); - const result = await cloneFromTemplateIfNeeded({ mode: "test", setEnv: true }); - expect(result).toEqual({ - status: "skipped", - reason: "external-url", - databaseUrl: "postgresql://ci:ci@db.example/app", - }); - expect(process.env.DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); - expect(process.env.TEST_DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + await withHostAndAutopgMocks( + "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + { cloneDatabaseFromTemplate: cloneDb }, + async () => { + const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + await expect( + cloneFromTemplateIfNeeded({ mode: "test", name: "1", setEnv: true }), + ).resolves.toEqual({ + status: "skipped", + reason: "external-url", + databaseUrl: "postgresql://ci:ci@db.example/app", + }); + expect(cloneDb).not.toHaveBeenCalled(); + expect(process.env.DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + expect(process.env.TEST_DATABASE_URL).toBe("postgresql://ci:ci@db.example/app"); + }, + ); } finally { if (prevCedar === undefined) delete process.env.CEDAR_PG; else process.env.CEDAR_PG = prevCedar; @@ -333,3 +420,46 @@ test("cloneFromTemplateIfNeeded external-url skip shares applyDatabaseUrlEnv", a else process.env.DATABASE_URL = prevDb; } }); + +test("cloneFromTemplateIfNeeded clones when ensure policy allows", async () => { + const registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-")); + const root = mkdtempSync(join(tmpdir(), "cedarpg-wt-")); + const prev = process.env.CEDAR_PG_REGISTRY_DIR; + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + process.env.CEDAR_PG_REGISTRY_DIR = registry; + delete process.env.CEDAR_PG; + delete process.env.TEST_DATABASE_URL; + + const adminUrl = "postgresql://postgres:postgres@127.0.0.1:5433/postgres"; + const templateName = "cpg_cedar_main_test_ifneeded1"; + const lease = makeLease({ root, databaseName: templateName }); + writeLease(lease); + const cloneDb = vi.fn(async () => {}); + + try { + await withHostAndAutopgMocks(adminUrl, { cloneDatabaseFromTemplate: cloneDb }, async () => { + const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + const result = await cloneFromTemplateIfNeeded({ + root, + mode: "test", + adminUrl, + name: "9", + setEnv: false, + }); + expect(result.status).toBe("cloned"); + if (result.status !== "cloned") throw new Error("expected cloned"); + expect(result.databaseName).toBe(`${templateName}_c_9`); + expect(cloneDb).toHaveBeenCalledOnce(); + }); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index 998e08c..cf38a3e 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -1,9 +1,8 @@ import { expect, test, vi } from "vite-plus/test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import type { EnsureIfNeededResult } from "../src/core/lifecycle.ts"; +import type { + CloneFromTemplateIfNeededResult, + EnsureIfNeededResult, +} from "../src/core/lifecycle.ts"; function ensuredLease( overrides: Partial> = {}, @@ -25,6 +24,22 @@ function ensuredLease( }; } +function clonedWorker( + overrides: Partial> = {}, +): Extract { + return { + status: "cloned", + databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_3", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + databaseName: "cpg_tmpl_c_3", + roleName: "cpg_tmpl_role", + templateName: "cpg_tmpl", + port: 5433, + dropClone: async () => {}, + ...overrides, + }; +} + async function withMockedLifecycle( mocks: { ensureIfNeeded?: ReturnType; @@ -83,6 +98,53 @@ test("setupTemplateMode ensures, migrates, then markTemplate", async () => { }); }); +test("setupTemplateMode disposes and wraps markTemplate failure after migrate", async () => { + const ensureIfNeeded = vi.fn(async () => ensuredLease()); + const markTemplate = vi.fn(async () => { + throw new Error("permission denied"); + }); + const dispose = vi.fn(async () => ({ + dropped: true as const, + databaseName: "cpg_tmpl", + droppedDatabases: ["cpg_tmpl"], + })); + const migrate = vi.fn(async () => {}); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); + await expect(setupTemplateMode({ migrate })).rejects.toThrow( + /template setup failed after ensure; cleaned up lease DB \(cpg_tmpl\).*permission denied/, + ); + expect(migrate).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test" }); + }); +}); + +test("setupTemplateMode disposes when migrate fails before markTemplate", async () => { + const ensureIfNeeded = vi.fn(async () => ensuredLease()); + const markTemplate = vi.fn(async () => ({ + databaseName: "cpg_tmpl", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); + const dispose = vi.fn(async () => ({ + dropped: true as const, + databaseName: "cpg_tmpl", + droppedDatabases: ["cpg_tmpl"], + })); + const migrate = vi.fn(async () => { + throw new Error("migrate boom"); + }); + + await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); + await expect(setupTemplateMode({ migrate })).rejects.toThrow( + /template setup failed after ensure; cleaned up lease DB \(cpg_tmpl\).*migrate boom/, + ); + expect(markTemplate).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test" }); + }); +}); + test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => { const ensureIfNeeded = vi.fn(async () => ({ status: "skipped" as const, @@ -103,24 +165,13 @@ test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => }); }); -test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded", async () => { +test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded with setEnv true", async () => { const prevJest = process.env.JEST_WORKER_ID; const prevCedar = process.env.CEDAR_PG; - const prevUrl = process.env.TEST_DATABASE_URL; process.env.JEST_WORKER_ID = "3"; delete process.env.CEDAR_PG; - delete process.env.TEST_DATABASE_URL; - const cloneFromTemplateIfNeeded = vi.fn(async () => ({ - status: "cloned" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_3", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl_c_3", - roleName: "cpg_tmpl_role", - templateName: "cpg_tmpl", - port: 5433, - dispose: async () => {}, - })); + const cloneFromTemplateIfNeeded = vi.fn(async () => clonedWorker()); try { await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { @@ -138,29 +189,18 @@ test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded", async () => { else process.env.JEST_WORKER_ID = prevJest; if (prevCedar === undefined) delete process.env.CEDAR_PG; else process.env.CEDAR_PG = prevCedar; - if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; - else process.env.TEST_DATABASE_URL = prevUrl; } }); test("ensureWorkerDatabase is idempotent per process", async () => { - const prevCedar = process.env.CEDAR_PG; - const prevUrl = process.env.TEST_DATABASE_URL; const prevJest = process.env.JEST_WORKER_ID; - delete process.env.CEDAR_PG; - delete process.env.TEST_DATABASE_URL; + const prevCedar = process.env.CEDAR_PG; process.env.JEST_WORKER_ID = "1"; + delete process.env.CEDAR_PG; - const cloneFromTemplateIfNeeded = vi.fn(async () => ({ - status: "cloned" as const, - databaseUrl: "postgresql://role:pw@127.0.0.1:5433/cpg_tmpl_c_1", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - databaseName: "cpg_tmpl_c_1", - roleName: "cpg_tmpl_role", - templateName: "cpg_tmpl", - port: 5433, - dispose: async () => {}, - })); + const cloneFromTemplateIfNeeded = vi.fn(async () => + clonedWorker({ databaseName: "cpg_tmpl_c_1" }), + ); try { await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { @@ -170,29 +210,54 @@ test("ensureWorkerDatabase is idempotent per process", async () => { 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; - if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; - else process.env.TEST_DATABASE_URL = prevUrl; + } +}); + +test("ensureWorkerDatabase rejects conflicting root/name after first call", 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 withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { + const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); + await ensureWorkerDatabase({ root: "/tmp/a" }); + expect(() => ensureWorkerDatabase({ root: "/tmp/b" })).toThrow( + /already started with different root\/name/, + ); + 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("vitest template teardown disposes lease root", async () => { - const ensureIfNeeded = vi.fn(async () => ensuredLease()); +test("vitest template teardown uses EnsureResult.dispose", async () => { + const disposeFn = vi.fn(async () => {}); + const ensureIfNeeded = vi.fn(async () => ensuredLease({ dispose: disposeFn })); const markTemplate = vi.fn(async () => ({ databaseName: "cpg_tmpl", adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", })); - const dispose = vi.fn(async () => ({ dropped: false as const, reason: "no-lease" as const })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { const { createGlobalSetup } = await import("../src/adapters/vitest-template.ts"); const teardown = await createGlobalSetup({ migrate })(); await teardown(); - expect(dispose).toHaveBeenCalledWith({ root: "/tmp/wt", mode: "test" }); + expect(disposeFn).toHaveBeenCalledTimes(1); }); }); @@ -212,42 +277,22 @@ test("jest createGlobalSetup wires migrate hook", async () => { }); }); -test("jest template default requires CEDAR_PG_MIGRATE", async () => { - const prev = process.env.CEDAR_PG_MIGRATE; - delete process.env.CEDAR_PG_MIGRATE; - try { - vi.resetModules(); - const mod = await import("../src/adapters/jest-template.ts"); - await expect(mod.default()).rejects.toThrow(/CEDAR_PG_MIGRATE|createGlobalSetup/); - } finally { - if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; - else process.env.CEDAR_PG_MIGRATE = prev; - vi.resetModules(); - } +test("jest template default export requires createGlobalSetup", async () => { + vi.resetModules(); + const mod = await import("../src/adapters/jest-template.ts"); + await expect(mod.default()).rejects.toThrow(/createGlobalSetup/); + vi.resetModules(); }); -test("jest template default export uses CEDAR_PG_MIGRATE", async () => { - const ensureIfNeeded = vi.fn(async () => ensuredLease()); - const markTemplate = vi.fn(async () => ({ - databaseName: "cpg_tmpl", - adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", - })); - - const dir = mkdtempSync(join(tmpdir(), "cedarpg-migrate-")); - const file = join(dir, "migrate.mjs"); - writeFileSync(file, `export default async function migrate() {}\n`); - const prev = process.env.CEDAR_PG_MIGRATE; - process.env.CEDAR_PG_MIGRATE = pathToFileURL(file).href; +test("vitest template default export requires createGlobalSetup", async () => { + vi.resetModules(); + const mod = await import("../src/adapters/vitest-template.ts"); + await expect(mod.default()).rejects.toThrow(/createGlobalSetup/); + vi.resetModules(); +}); - try { - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { - const mod = await import("../src/adapters/jest-template.ts"); - await mod.default(); - expect(markTemplate).toHaveBeenCalledTimes(1); - }); - } finally { - if (prev === undefined) delete process.env.CEDAR_PG_MIGRATE; - else process.env.CEDAR_PG_MIGRATE = prev; - rmSync(dir, { recursive: true, force: true }); - } +test("jest template re-exports ensureWorkerDatabase", async () => { + const { ensureWorkerDatabase: fromJest } = await import("../src/adapters/jest-template.ts"); + const { ensureWorkerDatabase: fromMode } = await import("../src/adapters/template-mode.ts"); + expect(fromJest).toBe(fromMode); }); diff --git a/vite.config.ts b/vite.config.ts index 0950d4e..d4cd05d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,12 +12,10 @@ export default defineConfig({ nx: "src/adapters/nx.ts", vitest: "src/adapters/vitest.ts", "vitest-template": "src/adapters/vitest-template.ts", - "vitest-template-worker": "src/adapters/vitest-template-worker.ts", jest: "src/adapters/jest.ts", "jest-teardown": "src/adapters/jest-teardown.ts", "test-env": "src/adapters/test-env.ts", "jest-template": "src/adapters/jest-template.ts", - "jest-template-worker": "src/adapters/jest-template-worker.ts", }, dts: true, From eb95e074087cdfd906dfe896738b3218932448e2 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Tue, 4 Aug 2026 11:46:01 -0400 Subject: [PATCH 7/7] refactor: extract template module and shared runIfNeeded Unify cloneFromTemplateIfNeeded setEnv defaults with ensureIfNeeded and keep lease lifecycle free of the TEMPLATE protocol. --- README.md | 4 +- src/adapters/template-mode.ts | 9 +- src/core/lifecycle.ts | 191 +++------------------------------- src/core/policy.ts | 37 +++++++ src/core/template.ts | 149 ++++++++++++++++++++++++++ src/index.ts | 21 ++-- tests/template-clone.test.ts | 58 ++++++++++- tests/template-mode.test.ts | 36 ++++--- 8 files changed, 284 insertions(+), 221 deletions(-) create mode 100644 src/core/template.ts diff --git a/README.md b/README.md index 5243849..dc5cdfe 100644 --- a/README.md +++ b/README.md @@ -310,8 +310,8 @@ await dispose({ root: ensured.root, mode: "test" }); // role-scoped: TEMPLATE + ``` `ensure` returns `adminUrl` for migrate hooks / privileged DDL; `markTemplate` / `cloneFromTemplate` accept it or rediscover the host when omitted. -`cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. `setEnv` defaults to false on the core API (worker adapters pass true). -Worker adapters call `cloneFromTemplateIfNeeded` (same skip policy as `ensureIfNeeded`) via `ensureWorkerDatabase`. +`cloneFromTemplate` uses the admin connection internally (`CREATE DATABASE … TEMPLATE`); test roles stay `LOGIN`-only. `setEnv` defaults to false on `cloneFromTemplate`; `cloneFromTemplateIfNeeded` defaults true (same as `ensureIfNeeded`). +Worker adapters call `cloneFromTemplateIfNeeded` (shared skip policy via `runIfNeeded`) via `ensureWorkerDatabase`. `dispose` is role-scoped suite teardown (not `dropClone`): unsets `IS_TEMPLATE` and drops every database owned by the lease role. ## Env diff --git a/src/adapters/template-mode.ts b/src/adapters/template-mode.ts index d20a160..a9a6637 100644 --- a/src/adapters/template-mode.ts +++ b/src/adapters/template-mode.ts @@ -1,10 +1,5 @@ -import { - cloneFromTemplateIfNeeded, - dispose, - ensureIfNeeded, - markTemplate, - type EnsureIfNeededResult, -} from "../core/lifecycle.ts"; +import { dispose, ensureIfNeeded, type EnsureIfNeededResult } from "../core/lifecycle.ts"; +import { cloneFromTemplateIfNeeded, markTemplate } from "../core/template.ts"; export type TemplateMigrateContext = { databaseUrl: string; diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index e9d3efd..38e958e 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { buildCloneDatabaseName, buildDatabaseName, buildRoleName, type DbMode } from "./naming.ts"; +import { buildDatabaseName, buildRoleName, type DbMode } from "./naming.ts"; import { envPath, forgetLease, @@ -10,16 +10,9 @@ import { writeLease, type Lease, } from "./lease.ts"; -import { applyDatabaseUrlEnv, resolveEnsureSkip, type ResolveEnsureSkipInput } from "./policy.ts"; +import { applyDatabaseUrlEnv, runIfNeeded, type ResolveEnsureSkipInput } from "./policy.ts"; import { resolveWorktreeIdentity } from "./worktree.ts"; -import { - buildDatabaseUrl, - cloneDatabaseFromTemplate, - dropDatabase, - dropDatabasesOwnedByRole, - ensureDatabase, - setDatabaseIsTemplate, -} from "../providers/autopg.ts"; +import { buildDatabaseUrl, dropDatabasesOwnedByRole, ensureDatabase } from "../providers/autopg.ts"; import { ensureHostRunning } from "../providers/host.ts"; function writeEnvFile(root: string, mode: DbMode, databaseUrl: string): void { @@ -151,175 +144,15 @@ export type EnsureIfNeededResult = export async function ensureIfNeeded( options: EnsureIfNeededOptions, ): Promise { - const skip = resolveEnsureSkip({ - url: options.url, - force: options.force, - disabled: options.disabled, - }); - if (skip.skip) { - if (skip.reason === "external-url") { - if (options.setEnv !== false) { - applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode }); - } - return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; - } - return { status: "skipped", reason: "disabled" }; - } - - const result = await ensure({ - root: options.root, - mode: options.mode, - setEnv: options.setEnv, - }); - return { status: "ensured", ...result }; -} - -async function resolveAdminUrl(adminUrl?: string): Promise { - return adminUrl ?? (await ensureHostRunning()).adminUrl; -} - -export type MarkTemplateOptions = { - root?: string; - mode: DbMode; - /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ - adminUrl?: string; -}; - -/** - * After migrations, mark the leased DB as a PostgreSQL TEMPLATE so workers can clone it. - * Requires a lease from `ensure` (no datname override). - */ -export async function markTemplate( - options: MarkTemplateOptions, -): Promise<{ databaseName: string; adminUrl: string }> { - const identity = resolveWorktreeIdentity(options.root); - const mode = options.mode; - const lease = readLease(identity.root, mode); - if (!lease) { - throw new Error(`no ${mode} lease; run ensure before markTemplate`); - } - const adminUrl = await resolveAdminUrl(options.adminUrl); - await setDatabaseIsTemplate({ - adminUrl, - databaseName: lease.databaseName, - isTemplate: true, - }); - return { databaseName: lease.databaseName, adminUrl }; -} - -export type CloneFromTemplateOptions = { - root?: string; - mode: DbMode; - /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ - adminUrl?: string; - /** - * Suffix for the clone datname (e.g. Jest worker id). - * Defaults to `_`. - */ - name?: string; - /** - * Inject DATABASE_URL / TEST_DATABASE_URL for this clone (default false). - * Worker adapters pass true; programmatic callers opt in. - */ - setEnv?: boolean; -}; - -export type CloneResult = { - databaseUrl: string; - adminUrl: string; - databaseName: string; - roleName: string; - templateName: string; - port: number; - /** - * DROP this clone only (leaves TEMPLATE + role if still owned elsewhere). - * Not suite teardown — use role-scoped `dispose` for that. - */ - dropClone: () => Promise; -}; - -/** - * Clone the leased TEMPLATE database via admin (`CREATE DATABASE … TEMPLATE`). - * Reuses the template role so `databaseUrl` passwords stay valid (scheme v2). - * Provider rejects when the leased DB is not marked TEMPLATE. - * Port comes from the lease; admin URL is passed through or rediscovered. - */ -export async function cloneFromTemplate(options: CloneFromTemplateOptions): Promise { - const identity = resolveWorktreeIdentity(options.root); - const mode = options.mode; - const lease = readLease(identity.root, mode); - if (!lease) { - throw new Error(`no ${mode} lease; run ensure + markTemplate before cloneFromTemplate`); - } - - const adminUrl = await resolveAdminUrl(options.adminUrl); - const suffix = options.name ?? `${process.pid}_${Date.now().toString(36)}`; - const databaseName = buildCloneDatabaseName(lease.databaseName, suffix); - - await cloneDatabaseFromTemplate({ - adminUrl, - templateName: lease.databaseName, - databaseName, - roleName: lease.roleName, - }); - - const databaseUrl = buildDatabaseUrl({ - port: lease.port, - databaseName, - roleName: lease.roleName, - }); - - if (options.setEnv) { - applyDatabaseUrlEnv(databaseUrl, { mode }); - } - - const roleName = lease.roleName; - - return { - databaseUrl, - adminUrl, - databaseName, - roleName, - templateName: lease.databaseName, - port: lease.port, - dropClone: async () => { - await dropDatabase({ adminUrl, databaseName, roleName }); - }, - }; -} - -export type CloneFromTemplateIfNeededOptions = CloneFromTemplateOptions & ResolveEnsureSkipInput; - -export type CloneFromTemplateIfNeededResult = - | { status: "skipped"; reason: "disabled" } - | { status: "skipped"; reason: "external-url"; databaseUrl: string } - | ({ status: "cloned" } & CloneResult); - -/** - * Resolve skip policy then clone. Host entry for worker adapters (same skip - * semantics as `ensureIfNeeded`). On external-url skip, applies DATABASE_URL / - * TEST_DATABASE_URL when `setEnv` is not false. - */ -export async function cloneFromTemplateIfNeeded( - options: CloneFromTemplateIfNeededOptions, -): Promise { - const skip = resolveEnsureSkip({ - url: options.url, - force: options.force, - disabled: options.disabled, - }); - if (skip.skip) { - if (skip.reason === "external-url") { - if (options.setEnv !== false) { - applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode }); - } - return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; - } - return { status: "skipped", reason: "disabled" }; - } - - const result = await cloneFromTemplate(options); - return { status: "cloned", ...result }; + const outcome = await runIfNeeded(options, () => + ensure({ + root: options.root, + mode: options.mode, + setEnv: options.setEnv, + }), + ); + if (outcome.status === "skipped") return outcome; + return { status: "ensured", ...outcome.value }; } export type DisposeOptions = { diff --git a/src/core/policy.ts b/src/core/policy.ts index 353445e..9096da3 100644 --- a/src/core/policy.ts +++ b/src/core/policy.ts @@ -33,6 +33,43 @@ export function applyDatabaseUrlEnv( } } +export type RunIfNeededOptions = ResolveEnsureSkipInput & { + mode: "dev" | "test"; + /** Default true (same as ensure / clone host wrappers). */ + setEnv?: boolean; +}; + +export type RunIfNeededSkipped = + | { status: "skipped"; reason: "disabled" } + | { status: "skipped"; reason: "external-url"; databaseUrl: string }; + +export type RunIfNeededResult = RunIfNeededSkipped | { status: "ran"; value: T }; + +/** + * Shared host skip+env gate for `ensureIfNeeded` / `cloneFromTemplateIfNeeded`. + * On external-url skip, applies env when `setEnv` is not false. + */ +export async function runIfNeeded( + options: RunIfNeededOptions, + run: () => Promise, +): Promise> { + const skip = resolveEnsureSkip({ + url: options.url, + force: options.force, + disabled: options.disabled, + }); + if (skip.skip) { + if (skip.reason === "external-url") { + if (options.setEnv !== false) { + applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode }); + } + return { status: "skipped", reason: "external-url", databaseUrl: skip.databaseUrl }; + } + return { status: "skipped", reason: "disabled" }; + } + return { status: "ran", value: await run() }; +} + /** * True when the URL looks like a cedarpg provisioned database (`cpg_*` name/role). * These must never be treated as an external escape hatch; always re-ensure so diff --git a/src/core/template.ts b/src/core/template.ts new file mode 100644 index 0000000..d3def5d --- /dev/null +++ b/src/core/template.ts @@ -0,0 +1,149 @@ +import { buildCloneDatabaseName, type DbMode } from "./naming.ts"; +import { readLease } from "./lease.ts"; +import { applyDatabaseUrlEnv, runIfNeeded, type ResolveEnsureSkipInput } from "./policy.ts"; +import { resolveWorktreeIdentity } from "./worktree.ts"; +import { + buildDatabaseUrl, + cloneDatabaseFromTemplate, + dropDatabase, + setDatabaseIsTemplate, +} from "../providers/autopg.ts"; +import { ensureHostRunning } from "../providers/host.ts"; + +async function resolveAdminUrl(adminUrl?: string): Promise { + return adminUrl ?? (await ensureHostRunning()).adminUrl; +} + +export type MarkTemplateOptions = { + root?: string; + mode: DbMode; + /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ + adminUrl?: string; +}; + +/** + * After migrations, mark the leased DB as a PostgreSQL TEMPLATE so workers can clone it. + * Requires a lease from `ensure` (no datname override). + */ +export async function markTemplate( + options: MarkTemplateOptions, +): Promise<{ databaseName: string; adminUrl: string }> { + const identity = resolveWorktreeIdentity(options.root); + const mode = options.mode; + const lease = readLease(identity.root, mode); + if (!lease) { + throw new Error(`no ${mode} lease; run ensure before markTemplate`); + } + const adminUrl = await resolveAdminUrl(options.adminUrl); + await setDatabaseIsTemplate({ + adminUrl, + databaseName: lease.databaseName, + isTemplate: true, + }); + return { databaseName: lease.databaseName, adminUrl }; +} + +export type CloneFromTemplateOptions = { + root?: string; + mode: DbMode; + /** Superuser URL from `ensure`; when omitted, discovers/starts the host. */ + adminUrl?: string; + /** + * Suffix for the clone datname (e.g. Jest worker id). + * Defaults to `_`. + */ + name?: string; + /** + * Inject DATABASE_URL / TEST_DATABASE_URL for this clone (default false). + * Host `cloneFromTemplateIfNeeded` defaults true; worker adapters pass true explicitly. + */ + setEnv?: boolean; +}; + +export type CloneResult = { + databaseUrl: string; + adminUrl: string; + databaseName: string; + roleName: string; + templateName: string; + port: number; + /** + * DROP this clone only (leaves TEMPLATE + role if still owned elsewhere). + * Not suite teardown — use role-scoped `dispose` for that. + */ + dropClone: () => Promise; +}; + +/** + * Clone the leased TEMPLATE database via admin (`CREATE DATABASE … TEMPLATE`). + * Reuses the template role so `databaseUrl` passwords stay valid (scheme v2). + * Provider rejects when the leased DB is not marked TEMPLATE. + * Port comes from the lease; admin URL is passed through or rediscovered. + */ +export async function cloneFromTemplate(options: CloneFromTemplateOptions): Promise { + const identity = resolveWorktreeIdentity(options.root); + const mode = options.mode; + const lease = readLease(identity.root, mode); + if (!lease) { + throw new Error(`no ${mode} lease; run ensure + markTemplate before cloneFromTemplate`); + } + + const adminUrl = await resolveAdminUrl(options.adminUrl); + const suffix = options.name ?? `${process.pid}_${Date.now().toString(36)}`; + const databaseName = buildCloneDatabaseName(lease.databaseName, suffix); + + await cloneDatabaseFromTemplate({ + adminUrl, + templateName: lease.databaseName, + databaseName, + roleName: lease.roleName, + }); + + const databaseUrl = buildDatabaseUrl({ + port: lease.port, + databaseName, + roleName: lease.roleName, + }); + + if (options.setEnv) { + applyDatabaseUrlEnv(databaseUrl, { mode }); + } + + const roleName = lease.roleName; + + return { + databaseUrl, + adminUrl, + databaseName, + roleName, + templateName: lease.databaseName, + port: lease.port, + dropClone: async () => { + await dropDatabase({ adminUrl, databaseName, roleName }); + }, + }; +} + +export type CloneFromTemplateIfNeededOptions = CloneFromTemplateOptions & ResolveEnsureSkipInput; + +export type CloneFromTemplateIfNeededResult = + | { status: "skipped"; reason: "disabled" } + | { status: "skipped"; reason: "external-url"; databaseUrl: string } + | ({ status: "cloned" } & CloneResult); + +/** + * Resolve skip policy then clone. Host entry for worker adapters (same skip + * semantics as `ensureIfNeeded`). Defaults `setEnv` on for skip and clone paths. + */ +export async function cloneFromTemplateIfNeeded( + options: CloneFromTemplateIfNeededOptions, +): Promise { + const outcome = await runIfNeeded(options, () => + cloneFromTemplate({ + ...options, + setEnv: options.setEnv !== false, + }), + ); + if (outcome.status === "skipped") return outcome; + return { status: "cloned", ...outcome.value }; +} diff --git a/src/index.ts b/src/index.ts index bb5ac9a..3dd8688 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,21 @@ -export { - ensure, - ensureIfNeeded, - markTemplate, - cloneFromTemplate, - cloneFromTemplateIfNeeded, - dispose, - gc, - urlFromLease, -} from "./core/lifecycle.ts"; +export { ensure, ensureIfNeeded, dispose, gc, urlFromLease } from "./core/lifecycle.ts"; export type { EnsureOptions, EnsureResult, EnsureIfNeededOptions, EnsureIfNeededResult, + DisposeOptions, + DisposeResult, +} from "./core/lifecycle.ts"; + +export { markTemplate, cloneFromTemplate, cloneFromTemplateIfNeeded } from "./core/template.ts"; +export type { MarkTemplateOptions, CloneFromTemplateOptions, CloneFromTemplateIfNeededOptions, CloneFromTemplateIfNeededResult, CloneResult, - DisposeOptions, - DisposeResult, -} from "./core/lifecycle.ts"; +} from "./core/template.ts"; export { resolveWorktreeIdentity, resolveRoot } from "./core/worktree.ts"; export type { WorktreeIdentity } from "./core/worktree.ts"; diff --git a/tests/template-clone.test.ts b/tests/template-clone.test.ts index 00f89f7..5bbe8d7 100644 --- a/tests/template-clone.test.ts +++ b/tests/template-clone.test.ts @@ -292,7 +292,7 @@ test("markTemplate requires a lease", async () => { vi.resetModules(); try { - const { markTemplate } = await import("../src/core/lifecycle.ts"); + const { markTemplate } = await import("../src/core/template.ts"); await expect( markTemplate({ root, @@ -330,7 +330,7 @@ test("markTemplate + cloneFromTemplate rediscover adminUrl when omitted", async cloneDatabaseFromTemplate: cloneDb, }, async () => { - const { markTemplate, cloneFromTemplate } = await import("../src/core/lifecycle.ts"); + const { markTemplate, cloneFromTemplate } = await import("../src/core/template.ts"); await expect(markTemplate({ root, mode: "test" })).resolves.toEqual({ databaseName: templateName, adminUrl, @@ -370,7 +370,7 @@ test("cloneFromTemplateIfNeeded skips when CEDAR_PG disabled", async () => { "postgresql://postgres:postgres@127.0.0.1:5433/postgres", { cloneDatabaseFromTemplate: cloneDb }, async () => { - const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + const { cloneFromTemplateIfNeeded } = await import("../src/core/template.ts"); await expect( cloneFromTemplateIfNeeded({ mode: "test", name: "1", setEnv: true }), ).resolves.toEqual({ status: "skipped", reason: "disabled" }); @@ -398,7 +398,7 @@ test("cloneFromTemplateIfNeeded external-url skip applies DATABASE_URL env", asy "postgresql://postgres:postgres@127.0.0.1:5433/postgres", { cloneDatabaseFromTemplate: cloneDb }, async () => { - const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + const { cloneFromTemplateIfNeeded } = await import("../src/core/template.ts"); await expect( cloneFromTemplateIfNeeded({ mode: "test", name: "1", setEnv: true }), ).resolves.toEqual({ @@ -439,7 +439,7 @@ test("cloneFromTemplateIfNeeded clones when ensure policy allows", async () => { try { await withHostAndAutopgMocks(adminUrl, { cloneDatabaseFromTemplate: cloneDb }, async () => { - const { cloneFromTemplateIfNeeded } = await import("../src/core/lifecycle.ts"); + const { cloneFromTemplateIfNeeded } = await import("../src/core/template.ts"); const result = await cloneFromTemplateIfNeeded({ root, mode: "test", @@ -463,3 +463,51 @@ test("cloneFromTemplateIfNeeded clones when ensure policy allows", async () => { rmSync(root, { recursive: true, force: true }); } }); + +test("cloneFromTemplateIfNeeded defaults setEnv on for clone path", async () => { + const registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-")); + const root = mkdtempSync(join(tmpdir(), "cedarpg-wt-")); + const prev = process.env.CEDAR_PG_REGISTRY_DIR; + const prevCedar = process.env.CEDAR_PG; + const prevUrl = process.env.TEST_DATABASE_URL; + const prevDb = process.env.DATABASE_URL; + process.env.CEDAR_PG_REGISTRY_DIR = registry; + delete process.env.CEDAR_PG; + delete process.env.TEST_DATABASE_URL; + delete process.env.DATABASE_URL; + + const adminUrl = "postgresql://postgres:postgres@127.0.0.1:5433/postgres"; + const templateName = "cpg_cedar_main_test_ifneeded2"; + writeLease(makeLease({ root, databaseName: templateName })); + + try { + await withHostAndAutopgMocks( + adminUrl, + { cloneDatabaseFromTemplate: vi.fn(async () => {}) }, + async () => { + const { cloneFromTemplateIfNeeded } = await import("../src/core/template.ts"); + const result = await cloneFromTemplateIfNeeded({ + root, + mode: "test", + adminUrl, + name: "8", + }); + expect(result.status).toBe("cloned"); + if (result.status !== "cloned") throw new Error("expected cloned"); + expect(process.env.DATABASE_URL).toBe(result.databaseUrl); + expect(process.env.TEST_DATABASE_URL).toBe(result.databaseUrl); + }, + ); + } finally { + if (prev === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + if (prevCedar === undefined) delete process.env.CEDAR_PG; + else process.env.CEDAR_PG = prevCedar; + if (prevUrl === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = prevUrl; + if (prevDb === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = prevDb; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/template-mode.test.ts b/tests/template-mode.test.ts index cf38a3e..981d2dc 100644 --- a/tests/template-mode.test.ts +++ b/tests/template-mode.test.ts @@ -1,8 +1,6 @@ import { expect, test, vi } from "vite-plus/test"; -import type { - CloneFromTemplateIfNeededResult, - EnsureIfNeededResult, -} from "../src/core/lifecycle.ts"; +import type { EnsureIfNeededResult } from "../src/core/lifecycle.ts"; +import type { CloneFromTemplateIfNeededResult } from "../src/core/template.ts"; function ensuredLease( overrides: Partial> = {}, @@ -40,7 +38,7 @@ function clonedWorker( }; } -async function withMockedLifecycle( +async function withMockedCore( mocks: { ensureIfNeeded?: ReturnType; markTemplate?: ReturnType; @@ -57,16 +55,24 @@ async function withMockedLifecycle( return { ...actual, ensureIfNeeded: mocks.ensureIfNeeded ?? actual.ensureIfNeeded, + dispose: mocks.dispose ?? actual.dispose, + }; + }); + vi.doMock("../src/core/template.ts", async () => { + const actual = + await vi.importActual("../src/core/template.ts"); + return { + ...actual, markTemplate: mocks.markTemplate ?? actual.markTemplate, cloneFromTemplateIfNeeded: mocks.cloneFromTemplateIfNeeded ?? actual.cloneFromTemplateIfNeeded, - dispose: mocks.dispose ?? actual.dispose, }; }); try { return await run(); } finally { vi.doUnmock("../src/core/lifecycle.ts"); + vi.doUnmock("../src/core/template.ts"); vi.resetModules(); } } @@ -79,7 +85,7 @@ test("setupTemplateMode ensures, migrates, then markTemplate", async () => { })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); const result = await setupTemplateMode({ migrate, setEnv: false }); expect(result.status).toBe("ensured"); @@ -110,7 +116,7 @@ test("setupTemplateMode disposes and wraps markTemplate failure after migrate", })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate, dispose }, async () => { const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); await expect(setupTemplateMode({ migrate })).rejects.toThrow( /template setup failed after ensure; cleaned up lease DB \(cpg_tmpl\).*permission denied/, @@ -135,7 +141,7 @@ test("setupTemplateMode disposes when migrate fails before markTemplate", async throw new Error("migrate boom"); }); - await withMockedLifecycle({ ensureIfNeeded, markTemplate, dispose }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate, dispose }, async () => { const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); await expect(setupTemplateMode({ migrate })).rejects.toThrow( /template setup failed after ensure; cleaned up lease DB \(cpg_tmpl\).*migrate boom/, @@ -156,7 +162,7 @@ test("setupTemplateMode skips migrate/mark when ensure is skipped", async () => })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { const { setupTemplateMode } = await import("../src/adapters/template-mode.ts"); const result = await setupTemplateMode({ migrate }); expect(result).toEqual({ status: "skipped", reason: "disabled" }); @@ -174,7 +180,7 @@ test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded with setEnv true const cloneFromTemplateIfNeeded = vi.fn(async () => clonedWorker()); try { - await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { + await withMockedCore({ cloneFromTemplateIfNeeded }, async () => { const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); await ensureWorkerDatabase({ root: "/tmp/wt" }); expect(cloneFromTemplateIfNeeded).toHaveBeenCalledWith({ @@ -203,7 +209,7 @@ test("ensureWorkerDatabase is idempotent per process", async () => { ); try { - await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { + await withMockedCore({ cloneFromTemplateIfNeeded }, async () => { const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); await ensureWorkerDatabase(); await ensureWorkerDatabase(); @@ -228,7 +234,7 @@ test("ensureWorkerDatabase rejects conflicting root/name after first call", asyn ); try { - await withMockedLifecycle({ cloneFromTemplateIfNeeded }, async () => { + await withMockedCore({ cloneFromTemplateIfNeeded }, async () => { const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); await ensureWorkerDatabase({ root: "/tmp/a" }); expect(() => ensureWorkerDatabase({ root: "/tmp/b" })).toThrow( @@ -253,7 +259,7 @@ test("vitest template teardown uses EnsureResult.dispose", async () => { })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { const { createGlobalSetup } = await import("../src/adapters/vitest-template.ts"); const teardown = await createGlobalSetup({ migrate })(); await teardown(); @@ -269,7 +275,7 @@ test("jest createGlobalSetup wires migrate hook", async () => { })); const migrate = vi.fn(async () => {}); - await withMockedLifecycle({ ensureIfNeeded, markTemplate }, async () => { + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { const { createGlobalSetup } = await import("../src/adapters/jest-template.ts"); await createGlobalSetup({ migrate })(); expect(migrate).toHaveBeenCalledTimes(1);