Worktree-isolated local Postgres for Vite+, Nx, and CedarJS, powered by autopg.
Published on npm as @cedarjs/pg.
Alpha (
0.2.0-alpha.0): APIs may change. Install with thealphadist-tag.
autopg runs embedded PostgreSQL 18 (not WASM) with real concurrent connections. No credentials, zero config, and databases are provisioned on first use. Any client works (psql, node-postgres, Prisma, Drizzle, TypeORM).
| Use case | What you get |
|---|---|
| Local development | PostgreSQL without Docker |
| Integration testing | Real PostgreSQL, not mocks |
| CI/CD pipelines | Fresh databases per test run |
| E2E testing | Isolated database for Playwright / Cypress |
cedar-pg gives each git worktree its own database and role on that autopg host (readable names, leases, teardown) so parallel checkouts do not share one DB:
- 1 database per git worktree (visible in
\lascpg_…) - dev DBs persist across restarts; test DBs drop on dispose
- First-class Vite+ Task + Nx / Vitest / Jest adapters
postinstallensures the pinnedautopgbinary when missing
| Layer | Responsibility |
|---|---|
| autopg | Embedded Postgres host (concurrent, zero-config, auto-provision) |
| cedar-pg | Per-worktree CREATE DATABASE / role, DATABASE_URL, dispose + GC |
npm install -D @cedarjs/pg@alpha
# or: pnpm add -D @cedarjs/pg@alpha
# or: yarn add -D @cedarjs/pg@alphacpg_<repo>_<worktree>_<mode>_<pathHash8>
Examples:
| Name | Meaning |
|---|---|
cpg_cedar_cedar_dev_a1b2c3d4 |
main cedar checkout, dev |
cpg_cedar_feat_auth_test_e5f67890 |
worktree feat-auth, test |
A running autopg host (installed automatically by postinstall, or manually).
The release pin lives in scripts/autopg-version (single source of truth for postinstall, CI binary install, and docs). Bump that file to upgrade:
# local / non-CI (upstream install.sh; may use pm2)
VER=$(tr -d '[:space:]' < scripts/autopg-version)
curl -fsSL "https://raw.githubusercontent.com/automagik-dev/autopg/${VER}/install.sh" \
| AUTOPG_VERSION="$VER" bashTypical flow: autopg daemon (or your usual host install) once per machine → cedarpg acquire per worktree → connect with the printed DATABASE_URL.
vp install
vp check
vp test
vp pack # → dist/ (dts + esm + cjs)
vp run smoke # build → npm-pack tarball → install + resolve exports
vp run smoke:pg # pack → Vitest + Jest adapters against real ephemeral Postgres# in this repo
vp pack
# in your app / Cedar
yarn add @cedarjs/pg@file:../cedar-pg
# or: pnpm pack && yarn add ./cedarjs-pg-0.2.0-alpha.0.tgzcedarpg acquire --mode=dev
cedarpg acquire --mode=test --print-env
cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts
cedarpg run --mode=test -- vitest run
cedarpg dispose --mode=test
cedarpg print-url --mode=dev
cedarpg gc # drop DBs whose worktree root is gone (uses ~/.cedarpg/registry)cedarpg run acquires (or attaches the lease), force-sets DATABASE_URL (and
TEST_DATABASE_URL in test mode) in the child process, then execs the command.
Use it for Nx / e2e / API wrappers — local .env URLs do not win inside the child.
Nx dependsOn alone does not forward env from an acquire task into dependents
(Vite+ env: [...] does). Canonical fix: wrap the child with cedarpg run.
Secondary: point Nx envFile at .cedarpg/<mode>.env after acquire.
import { cedarPgNxTargets, cedarPgRunCommand, relativeEnvFile } from "@cedarjs/pg/nx";
cedarPgNxTargets();
// { "db:acquire": { command: "cedarpg acquire --mode=dev", cache: false }, … }
cedarPgRunCommand("dev", "yarn tsx scripts/apiServer/dev.ts");
// "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts"
relativeEnvFile("dev"); // ".cedarpg/dev.env"{
"targets": {
"dev": {
"command": "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts"
},
"db:acquire": { "command": "cedarpg acquire --mode=dev" },
"serve": {
"dependsOn": ["db:acquire"],
"command": "node dist/server.js",
"options": { "envFile": ".cedarpg/dev.env" }
}
}
}For a db:ready-style migrate hook (same compose shape as Jest createGlobalSetup):
// tools/db-ready.ts
import { createAcquireTask } from "@cedarjs/pg";
await createAcquireTask({
mode: "dev",
afterAcquire: async ({ databaseUrl }) => {
// prisma migrate deploy / drizzle push / …
},
})();Fallbacks when you cannot wrap with run: loadDevEnv({ overwrite: true }) or
import "@cedarjs/pg/dev-env". Absolute path helper: envFilePath(root, mode).
// vite.config.ts
import { defineConfig } from "vite-plus";
import { cedarPgTasks } from "@cedarjs/pg/vite-plus";
export default defineConfig({
run: {
tasks: {
...cedarPgTasks(),
test: {
command: "vp test",
dependsOn: ["db:acquire-test"],
env: ["DATABASE_URL", "TEST_DATABASE_URL"],
},
dev: {
command: "vp dev",
dependsOn: ["db:acquire"],
env: ["DATABASE_URL"],
},
},
},
});// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globalSetup: ["@cedarjs/pg/vitest"],
},
});// jest.config.cjs — standalone apps
module.exports = {
globalSetup: require.resolve("@cedarjs/pg/jest"),
globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"),
// Jest globalSetup is a separate process — workers load DATABASE_URL from .cedarpg/test.env
setupFiles: [require.resolve("@cedarjs/pg/test-env")],
};If your runner already owns globalSetup (e.g. Prisma push/migrate after acquire), do not replace it with @cedarjs/pg/jest. Compose instead:
- In your
globalSetup: callacquireIfNeededwhen opted in, then run migrations. - Add
setupFiles: [require.resolve('@cedarjs/pg/test-env')]so Jest workers seeDATABASE_URL. - In your
globalTeardown: calldispose({ mode: 'test', root }).
// framework globalSetup (sketch)
import { acquireIfNeeded } from "@cedarjs/pg";
if (process.env.CEDAR_PG === "1" || process.env.CEDAR_PG === "true") {
await acquireIfNeeded({
root: projectRoot, // e.g. getPaths().base
mode: "test",
setEnv: true, // this process (prisma) — workers use @cedarjs/pg/test-env
url: process.env.TEST_DATABASE_URL,
force: process.env.CEDAR_PG_FORCE === "1",
disabled: false, // framework opt-in; adapters alone use CEDAR_PG=0 opt-out
});
}
// … prisma db push / migrate …// jest-preset
setupFiles: [require.resolve("@cedarjs/pg/test-env")],Use exported STATE_DIRNAME (.cedarpg) / loadTestEnv / loadDevEnv /
envFilePath(root, mode) instead of hardcoding the lease dir.
loadTestEnv / loadDevEnv only fill undefined keys by default. Pass
{ overwrite: true } (or import @cedarjs/pg/dev-env) when a local .env
DATABASE_URL / TEST_DATABASE_URL should lose to cedar-pg. That is not the
same as CEDAR_PG_FORCE / acquire { force } (external-URL escape hatch).
import { acquire, dispose, loadTestEnv, loadDevEnv, envFilePath, STATE_DIRNAME } from "@cedarjs/pg";
const { databaseUrl, adminUrl, databaseName, dispose: drop } = await acquire({ mode: "test" });
// … tests …
await drop();
loadDevEnv({ overwrite: true }); // override .env DATABASE_URL from .cedarpg/dev.envBy default cedar-pg attaches to a live autopg host (autopg status). If none is live it runs bare autopg install (pm2) — fine for local machines, hostile to GitHub Actions (no pm2) and slower than RAM-backed CI.
In CI, cedar-pg starts an opinionated ephemeral host automatically when CI=true (or when forced). Callers just use acquire — no host options bag:
import { acquire } from "@cedarjs/pg";
// CI=true → install --no-pm2 --no-ui + detached postmaster (--ram on Linux /dev/shm)
const { databaseUrl } = await acquire({ mode: "test" });| Signal | Effect |
|---|---|
CEDAR_PG_EPHEMERAL_HOST=1 |
Prefer ephemeral start when no host is live (attach still wins) |
CEDAR_PG_EPHEMERAL_HOST=0 |
Force local attach / pm2 install (even if CI=true) |
unset + CI=true |
Prefer ephemeral when no host is live |
| otherwise | Local: attach if live, else bare autopg install |
Ephemeral recipe (not configurable via cedar-pg):
autopg install --no-pm2 --no-ui --port 55432 --data DIR- detached
autopg postmaster --port 55432 --socket-dir DIR --data DIR - Linux when
/dev/shmexists → also--ramandDIR=/dev/shm/cedar-pg-<uid> - otherwise → disk
DIRunder the OS temp dir (still owned, no pm2) - Ready when TCP accepts on the recipe port (not merely
autopg statusafter install)
If a host is already live, cedar-pg attaches and does not start another. The CI job owns ephemeral postmaster lifetime (runner teardown / /dev/shm); there is no cedar-pg host dispose API.
Prefer the composite action (cache + attested binary install, no pm2). Version defaults to this repo’s scripts/autopg-version:
- uses: actions/checkout@v6
# In cedar-pg:
- uses: ./.github/actions/setup-autopg
# From another repo (pin to a tag when publishing the action):
# - uses: cedarjs/cedar-pg/.github/actions/setup-autopg@mainSee .github/actions/setup-autopg for inputs (version, cache, token) and outputs.
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.
Stock @cedarjs/pg/jest and @cedarjs/pg/vitest only run acquireIfNeeded + 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):
// 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 { cloneWorkerDatabase } = require("@cedarjs/pg/jest/template");
beforeAll(() => cloneWorkerDatabase());Vitest (template mode):
// vitest.cedar-global.ts
import { createGlobalSetup } from "@cedarjs/pg/vitest/template";
export default createGlobalSetup({
migrate: async ({ databaseUrl }) => {
// migrate once
},
});
// vitest.config.ts
export default defineConfig({
test: {
globalSetup: ["./vitest.cedar-global.ts"],
setupFiles: ["./vitest.cedar-worker.ts"],
},
});
// vitest.cedar-worker.ts — once per worker process (ESM top-level await)
import { cloneWorkerDatabase } from "@cedarjs/pg/vitest/template";
await cloneWorkerDatabase();Programmatic (core API — no runner adapters):
import { acquire, markTemplate, cloneFromTemplate, dispose } from "@cedarjs/pg";
const acquired = await acquire({ mode: "test" });
await migrate({ databaseUrl: acquired.databaseUrl, adminUrl: acquired.adminUrl });
await markTemplate({ root: acquired.root, mode: "test", adminUrl: acquired.adminUrl });
const worker = await cloneFromTemplate({
root: acquired.root,
mode: "test",
name: "1",
setEnv: true,
});
// … tests …
await worker.dropClone(); // optional: drop one clone only
await dispose({ root: acquired.root, mode: "test" }); // role-scoped: TEMPLATE + all clones + roleacquire 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 acquireIfNeeded).
Worker adapters call cloneFromTemplateIfNeeded (shared skip policy via runIfNeeded) via cloneWorkerDatabase.
dispose is role-scoped suite teardown (not dropClone): unsets IS_TEMPLATE and drops every database owned by the lease role.
| Var | Meaning |
|---|---|
AUTOPG_BIN |
Path to autopg |
AUTOPG_PG_USER / _PASSWORD |
Autopg superuser for admin URL (default postgres / postgres) |
CEDAR_PG=0 |
Disable auto-acquire in adapters |
TEST_DATABASE_URL |
Escape hatch: skip acquire for real external DBs (not cpg_* / file: / {…} / <…> template placeholders) |
CEDAR_PG_FORCE=1 |
Ignore external-URL escape hatch (adapters + cedarpg acquire --force / run --force) |
CEDAR_PG_EPHEMERAL_HOST |
1 force / 0 disable ephemeral host (auto when CI=true) |
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 |
- Public API may change before
0.1.0. - End-to-end Postgres flows assume a working local
autopghost; unit tests do not start Postgres. CI runsvp run smoke:pgfor Vitest/Jest adapters against real Postgres (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:
acquire→ app migrate →markTemplate→cloneFromTemplate→ role-scopeddispose. Optional@cedarjs/pg/jest/template+@cedarjs/pg/vitest/templateadapters orchestrate that pipeline viacreateGlobalSetup({ migrate }); migrate stays app-owned.