diff --git a/README.md b/README.md index ae2442f..dc5cdfe 100644 --- a/README.md +++ b/README.md @@ -237,24 +237,82 @@ 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. + +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: "/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"); +beforeAll(() => ensureWorkerDatabase()); +``` + +**Vitest (template mode):** ```ts -import { ensure, markTemplate, cloneFromTemplate, dispose } from "@cedarjs/pg"; +// vitest.cedar-global.ts +import { createGlobalSetup } from "@cedarjs/pg/vitest/template"; +export default createGlobalSetup({ + migrate: async ({ databaseUrl }) => { + // migrate once + }, +}); -const ensured = await ensure({ mode: "test" }); -// run migrations once against ensured.databaseUrl (Prisma migrate reset, etc.) -await markTemplate(ensured); +// vitest.config.ts +export default defineConfig({ + test: { + globalSetup: ["./vitest.cedar-global.ts"], + setupFiles: ["./vitest.cedar-worker.ts"], + }, +}); -const worker = await cloneFromTemplate({ name: process.env.JEST_WORKER_ID ?? "1" }); -// worker.databaseUrl — same role credentials; adminUrl for privileged DDL if needed +// vitest.cedar-worker.ts — once per worker process (ESM top-level await) +import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template"; +await ensureWorkerDatabase(); +``` -await dispose({ mode: "test" }); // drops TEMPLATE + all clones owned by the test role +**Programmatic** (core API — no runner adapters): + +```ts +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, +}); +// … tests … +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 DB owned by the test 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 `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 @@ -278,4 +336,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` → 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 af36b04..2a69423 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,11 @@ "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" + }, "./jest": { "types": "./dist/jest.d.mts", "import": "./dist/jest.mjs", @@ -73,6 +78,11 @@ "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" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 602b4d4..57f8690 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -35,7 +35,10 @@ run( ` import { buildDatabaseName, + cloneFromTemplate, + cloneFromTemplateIfNeeded, loadTestEnv, + markTemplate, STATE_DIRNAME, } from '${PACKAGE_NAME}'; import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus'; @@ -43,6 +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 { + 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', @@ -55,6 +66,13 @@ 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 markTemplate !== 'function') throw new Error('missing markTemplate'); +if (typeof cloneFromTemplate !== 'function') throw new Error('missing cloneFromTemplate'); +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.ts b/src/adapters/jest-template.ts new file mode 100644 index 0000000..853eef6 --- /dev/null +++ b/src/adapters/jest-template.ts @@ -0,0 +1,46 @@ +import { + ensureWorkerDatabase, + setupTemplateMode, + type SetupTemplateModeOptions, +} from "./template-mode.ts"; + +export { ensureWorkerDatabase }; +export type { + SetupTemplateModeOptions, + TemplateMigrateFn, + TemplateMigrateContext, +} from "./template-mode.ts"; + +/** + * Jest globalSetup (template mode). + * + * ```js + * // 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()); + * ``` + * + * Stock `@cedarjs/pg/jest` is one shared test DB only. + */ +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/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..a9a6637 --- /dev/null +++ b/src/adapters/template-mode.ts @@ -0,0 +1,116 @@ +import { dispose, ensureIfNeeded, type EnsureIfNeededResult } from "../core/lifecycle.ts"; +import { cloneFromTemplateIfNeeded, markTemplate } from "../core/template.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; runs once, then `markTemplate`. Required. */ + migrate: TemplateMigrateFn; + setEnv?: boolean; +}; + +/** + * 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. + */ +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; + + 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; +} + +export type EnsureWorkerDatabaseOptions = { + root?: string; + /** Clone suffix; defaults to JEST_WORKER_ID / VITEST_POOL_ID / pid. */ + name?: string; +}; + +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). + * 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 { + 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", + name, + setEnv: true, + }); + })().catch((err) => { + workerOnce = undefined; + workerOnceKey = undefined; + throw err; + }); + return workerOnce; +} diff --git a/src/adapters/vitest-template.ts b/src/adapters/vitest-template.ts new file mode 100644 index 0000000..e756ab4 --- /dev/null +++ b/src/adapters/vitest-template.ts @@ -0,0 +1,55 @@ +import { + 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: ["./vitest.cedar-global.ts"], + * setupFiles: ["./vitest.cedar-worker.ts"], + * }, + * }) + * + * // vitest.cedar-worker.ts — local ESM (pack emits CJS+ESM; top-level await lives here) + * import { ensureWorkerDatabase } from "@cedarjs/pg/vitest/template"; + * await ensureWorkerDatabase(); + * ``` + * + * Stock `@cedarjs/pg/vitest` is one shared test DB only. + */ +export function createGlobalSetup(options: SetupTemplateModeOptions) { + return async () => { + const result = await setupTemplateMode(options); + if (result.status !== "ensured") { + return async () => {}; + } + return async () => { + await result.dispose(); + }; + }; +} + +/** 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/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/core/lifecycle.ts b/src/core/lifecycle.ts index fa5f59c..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 { resolveEnsureSkip, type ResolveEnsureSkipInput } from "./policy.ts"; +import { applyDatabaseUrlEnv, runIfNeeded, type ResolveEnsureSkipInput } from "./policy.ts"; import { resolveWorktreeIdentity } from "./worktree.ts"; -import { - buildDatabaseUrl, - cloneDatabaseFromTemplate, - dropDatabase, - ensureDatabase, - listDatabasesOwnedByRole, - 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 { @@ -39,23 +32,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 forget without successful DROP(s). + * 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), lease.databaseName]; - const dropped: string[] = []; - for (const databaseName of ordered) { - await dropDatabase({ - adminUrl, - databaseName, - roleName: lease.roleName, - }); - dropped.push(databaseName); - } forgetLease(lease); return dropped; } @@ -124,10 +108,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 () => { @@ -158,136 +139,20 @@ export type EnsureIfNeededResult = /** * 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({ - url: options.url, - force: options.force, - disabled: options.disabled, - }); - 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" }; - } - - const result = await ensure({ - root: options.root, - mode: options.mode, - setEnv: options.setEnv, - }); - return { status: "ensured", ...result }; -} - -export type MarkTemplateOptions = { - root?: string; - mode?: DbMode; - /** Override; defaults to leased database for mode. */ - databaseName?: string; - 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. - */ -export async function markTemplate( - options: MarkTemplateOptions = {}, -): Promise<{ databaseName: 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) { - 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 }; -} - -export type CloneFromTemplateOptions = { - root?: string; - mode?: DbMode; - /** - * 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). */ - 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). */ - dispose: () => Promise; -}; - -/** - * Clone the leased TEMPLATE database via admin (`CREATE DATABASE … TEMPLATE`). - * Reuses the template role so `databaseUrl` passwords stay valid (scheme v2). - */ -export async function cloneFromTemplate( - options: CloneFromTemplateOptions = {}, -): Promise { - const identity = resolveWorktreeIdentity(options.root); - const mode = options.mode ?? "test"; - const lease = readLease(identity.root, mode); - if (!lease) { - throw new Error(`no ${mode} lease; run ensure + markTemplate before cloneFromTemplate`); - } - - const host = await ensureHostRunning(); - const suffix = options.name ?? `${process.pid}_${Date.now().toString(36)}`; - const databaseName = buildCloneDatabaseName(lease.databaseName, suffix); - - await cloneDatabaseFromTemplate({ - adminUrl: host.adminUrl, - templateName: lease.databaseName, - databaseName, - roleName: lease.roleName, - }); - - const databaseUrl = buildDatabaseUrl({ - port: host.port, - databaseName, - roleName: lease.roleName, - }); - - if (options.setEnv) { - process.env.DATABASE_URL = databaseUrl; - if (mode === "test") { - process.env.TEST_DATABASE_URL = databaseUrl; - } - } - - const roleName = lease.roleName; - const adminUrl = host.adminUrl; - - return { - databaseUrl, - adminUrl, - databaseName, - roleName, - templateName: lease.databaseName, - port: host.port, - dispose: async () => { - await dropDatabase({ adminUrl, databaseName, roleName }); - }, - }; + 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 = { @@ -300,8 +165,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). + * 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 { diff --git a/src/core/policy.ts b/src/core/policy.ts index b279478..9096da3 100644 --- a/src/core/policy.ts +++ b/src/core/policy.ts @@ -19,6 +19,57 @@ 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; + } +} + +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 88dcbf7..3dd8688 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,22 @@ -export { - ensure, - ensureIfNeeded, - markTemplate, - cloneFromTemplate, - dispose, - gc, - urlFromLease, -} from "./core/lifecycle.ts"; +export { ensure, ensureIfNeeded, dispose, gc, urlFromLease } from "./core/lifecycle.ts"; export type { EnsureOptions, EnsureResult, EnsureIfNeededOptions, EnsureIfNeededResult, - MarkTemplateOptions, - CloneFromTemplateOptions, - CloneResult, DisposeOptions, DisposeResult, } from "./core/lifecycle.ts"; +export { markTemplate, cloneFromTemplate, cloneFromTemplateIfNeeded } from "./core/template.ts"; +export type { + MarkTemplateOptions, + CloneFromTemplateOptions, + CloneFromTemplateIfNeededOptions, + CloneFromTemplateIfNeededResult, + CloneResult, +} from "./core/template.ts"; + export { resolveWorktreeIdentity, resolveRoot } from "./core/worktree.ts"; export type { WorktreeIdentity } from "./core/worktree.ts"; @@ -37,6 +35,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..040cf2d 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, ]); @@ -215,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; }); } @@ -241,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/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..09f79e3 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,15 +63,14 @@ test("dispose removes mode env after successful DROP", async () => { ); return { ...actual, - listDatabasesOwnedByRole: vi.fn(async () => [databaseName]), - dropDatabase: vi.fn(async () => {}), + dropDatabasesOwnedByRole: vi.fn(async () => ["cpg_cedar_main_test_disposeenv"]), }; }); 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,8 +122,7 @@ test("dispose leaves lease+registry when host is unavailable", async () => { ); return { ...actual, - listDatabasesOwnedByRole: vi.fn(), - dropDatabase: vi.fn(), + dropDatabasesOwnedByRole: vi.fn(), }; }); @@ -148,8 +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.listDatabasesOwnedByRole).not.toHaveBeenCalled(); - 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 7b89d13..5bbe8d7 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, @@ -115,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); @@ -135,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, @@ -166,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"); @@ -188,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); }, @@ -213,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"); @@ -233,13 +225,288 @@ 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 { + 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("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 dropOwned = vi.fn(async () => [cloneOnly]); + + try { + await withHostAndAutopgMocks( + adminUrl, + { + dropDatabasesOwnedByRole: dropOwned, + }, + async () => { + const { dispose } = await import("../src/core/lifecycle.ts"); + await expect(dispose({ root, mode: "test" })).resolves.toEqual({ + dropped: true, + databaseName: templateName, + droppedDatabases: [cloneOnly], + }); + expect(dropOwned).toHaveBeenCalledWith({ + adminUrl, + roleName: lease.roleName, + preferLast: templateName, + }); + 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/template.ts"); + 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; + else process.env.CEDAR_PG_REGISTRY_DIR = prev; + rmSync(registry, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); + +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/template.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/template.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 { + await withHostAndAutopgMocks( + "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + { cloneDatabaseFromTemplate: cloneDb }, + async () => { + const { cloneFromTemplateIfNeeded } = await import("../src/core/template.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; + 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("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/template.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 }); + } +}); + +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 new file mode 100644 index 0000000..981d2dc --- /dev/null +++ b/tests/template-mode.test.ts @@ -0,0 +1,304 @@ +import { expect, test, vi } from "vite-plus/test"; +import type { EnsureIfNeededResult } from "../src/core/lifecycle.ts"; +import type { CloneFromTemplateIfNeededResult } from "../src/core/template.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, + }; +} + +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 withMockedCore( + mocks: { + ensureIfNeeded?: ReturnType; + markTemplate?: ReturnType; + cloneFromTemplateIfNeeded?: 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, + 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, + }; + }); + try { + return await run(); + } finally { + vi.doUnmock("../src/core/lifecycle.ts"); + vi.doUnmock("../src/core/template.ts"); + vi.resetModules(); + } +} + +test("setupTemplateMode ensures, migrates, then 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 migrate = vi.fn(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"); + 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", + databaseName: "cpg_tmpl", + roleName: "cpg_tmpl_role", + }); + expect(markTemplate).toHaveBeenCalledWith({ + root: "/tmp/wt", + mode: "test", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + }); + }); +}); + +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 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/, + ); + 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 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/, + ); + 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, + reason: "disabled" as const, + })); + const markTemplate = vi.fn(async () => ({ + databaseName: "x", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:5433/postgres", + })); + const migrate = vi.fn(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" }); + expect(migrate).not.toHaveBeenCalled(); + expect(markTemplate).not.toHaveBeenCalled(); + }); +}); + +test("ensureWorkerDatabase clones via cloneFromTemplateIfNeeded with setEnv true", async () => { + const prevJest = process.env.JEST_WORKER_ID; + const prevCedar = process.env.CEDAR_PG; + process.env.JEST_WORKER_ID = "3"; + delete process.env.CEDAR_PG; + + const cloneFromTemplateIfNeeded = vi.fn(async () => clonedWorker()); + + try { + await withMockedCore({ 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", + 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; + } +}); + +test("ensureWorkerDatabase is idempotent per process", async () => { + const prevJest = process.env.JEST_WORKER_ID; + const prevCedar = process.env.CEDAR_PG; + process.env.JEST_WORKER_ID = "1"; + delete process.env.CEDAR_PG; + + const cloneFromTemplateIfNeeded = vi.fn(async () => + clonedWorker({ databaseName: "cpg_tmpl_c_1" }), + ); + + try { + await withMockedCore({ cloneFromTemplateIfNeeded }, async () => { + const { ensureWorkerDatabase } = await import("../src/adapters/template-mode.ts"); + await ensureWorkerDatabase(); + await ensureWorkerDatabase(); + 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("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 withMockedCore({ 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 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 migrate = vi.fn(async () => {}); + + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { + const { createGlobalSetup } = await import("../src/adapters/vitest-template.ts"); + const teardown = await createGlobalSetup({ migrate })(); + await teardown(); + expect(disposeFn).toHaveBeenCalledTimes(1); + }); +}); + +test("jest createGlobalSetup wires migrate hook", 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 migrate = vi.fn(async () => {}); + + await withMockedCore({ ensureIfNeeded, markTemplate }, async () => { + const { createGlobalSetup } = await import("../src/adapters/jest-template.ts"); + await createGlobalSetup({ migrate })(); + expect(migrate).toHaveBeenCalledTimes(1); + expect(markTemplate).toHaveBeenCalledTimes(1); + }); +}); + +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("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(); +}); + +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 50ab082..d4cd05d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,10 +11,13 @@ 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", 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", }, + dts: true, format: ["esm", "cjs"], sourcemap: true,