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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 70 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<rootDir>/jest.cedar-global.cjs",
globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"),
setupFilesAfterEnv: ["<rootDir>/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:<port>/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

Expand All @@ -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.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
18 changes: 18 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,25 @@ run(
`
import {
buildDatabaseName,
cloneFromTemplate,
cloneFromTemplateIfNeeded,
loadTestEnv,
markTemplate,
STATE_DIRNAME,
} from '${PACKAGE_NAME}';
import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus';
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',
Expand All @@ -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(','));
`,
],
Expand Down
46 changes: 46 additions & 0 deletions src/adapters/jest-template.ts
Original file line number Diff line number Diff line change
@@ -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: "<rootDir>/jest.cedar-global.cjs",
* globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"),
* setupFilesAfterEnv: ["<rootDir>/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<void> {
throw new Error(
"@cedarjs/pg/jest/template requires createGlobalSetup({ migrate }). " +
"Point globalSetup at a local module that exports createGlobalSetup({ migrate }).",
);
}
4 changes: 3 additions & 1 deletion src/adapters/jest.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down
116 changes: 116 additions & 0 deletions src/adapters/template-mode.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

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<EnsureIfNeededResult> {
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<void> | 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<void> {
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;
}
55 changes: 55 additions & 0 deletions src/adapters/vitest-template.ts
Original file line number Diff line number Diff line change
@@ -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<void>> {
throw new Error(
"@cedarjs/pg/vitest/template requires createGlobalSetup({ migrate }). " +
"Point globalSetup at a local module that exports createGlobalSetup({ migrate }).",
);
}
Loading
Loading