Skip to content

Repository files navigation

cedar-pg

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 the alpha dist-tag.

What you get (via autopg)

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).

Development & testing

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

What cedar-pg adds

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 \l as cpg_…)
  • dev DBs persist across restarts; test DBs drop on dispose
  • First-class Vite+ Task + Nx / Vitest / Jest adapters
  • postinstall ensures the pinned autopg binary when missing
Layer Responsibility
autopg Embedded Postgres host (concurrent, zero-config, auto-provision)
cedar-pg Per-worktree CREATE DATABASE / role, DATABASE_URL, dispose + GC

Install

npm install -D @cedarjs/pg@alpha
# or: pnpm add -D @cedarjs/pg@alpha
# or: yarn add -D @cedarjs/pg@alpha

Database names (observability)

cpg_<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

Prerequisites

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" bash

Typical flow: autopg daemon (or your usual host install) once per machine → cedarpg acquire per worktree → connect with the printed DATABASE_URL.

Develop this package (Vite+)

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

Local consume (without npm)

# 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.tgz

CLI

cedarpg 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 consumer adapter

Nx dependsOn alone does not forward env from an acquire task into dependents (Vite+ env: [...] does). Canonical Nx shape:

  1. createAcquireTask (or db:ready) — acquire + app migrate once
  2. Wrap API/dev/e2e children with cedarpg run --mode=dev --force -- <cmd> so the child gets the worktree DATABASE_URL even when .env has a real URL

Do not run concurrent cedarpg acquire / run on the same worktree from multiple Nx targets (role/DB DDL races). Prefer one db:ready dependency, then run wrappers.

Secondary: point Nx envFile at .cedarpg/<mode>.env after acquire (still loses to ambient .env unless you also force / overwrite).

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": {
    "db:ready": { "command": "tsx tools/db-ready.ts", "cache": false },
    "dev": {
      "dependsOn": ["db:ready"],
      "command": "cedarpg run --mode=dev --force -- yarn tsx scripts/apiServer/dev.ts"
    },
    "serve": {
      "dependsOn": ["db:ready"],
      "command": "cedarpg run --mode=dev --force -- node dist/server.js"
    }
  }
}

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",
  // Apps with a real .env DATABASE_URL almost always need this
  force: true,
  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+ consumer adapter

// 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 / Jest adapters

// 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")],
};

Framework hosts (CedarJS, custom globalSetup)

If your runner already owns globalSetup (e.g. Prisma push/migrate after acquire), do not replace it with @cedarjs/pg/jest. Compose instead:

  1. In your globalSetup: call acquireIfNeeded when opted in, then run migrations.
  2. Add setupFiles: [require.resolve('@cedarjs/pg/test-env')] so Jest workers see DATABASE_URL.
  3. In your globalTeardown: call dispose({ 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).

Programmatic API

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.env

Host startup (CI ephemeral)

By 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/shm exists → also --ram and DIR=/dev/shm/cedar-pg-<uid>
  • otherwise → disk DIR under the OS temp dir (still owned, no pm2)
  • Ready when TCP accepts on the recipe port (not merely autopg status after install)
  • Before cold-start, if the recipe port is not live, cedar-pg prunes leftover /dev/shm/cedar-pg-*, pgserve-*, and PostgreSQL.* (OOM-killed runs filling tmpfs). Safe on isolated CI VMs; on shared self-hosted runners another job’s leftovers could match those globs.

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.

Cloud / small VMs often ship /dev/shm at ~64MB — too small for --ram. Remount before tests if needed (sudo mount -o remount,size=6G /dev/shm). See Troubleshooting.

CI setup (GitHub Actions)

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@main

See .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.

Yarn Berry / ignore-scripts consumers (copy-paste when you cannot use the Action). Requires a real node_modules tree (nodeLinker: node-modules / pnpm); default Yarn PnP has no node_modules/@cedarjs/pg/… path — resolve via yarn node / require.resolve instead, or prefer the Action.

- name: Ensure autopg binary
  run: |
    set -euo pipefail
    echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
    export PATH="${HOME}/.local/bin:${PATH}"
    bash node_modules/@cedarjs/pg/scripts/ci-install-autopg.sh
  env:
    GH_TOKEN: ${{ github.token }}

Migrate-once + TEMPLATE clones (Jest / Vitest)

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
// When .env has a real TEST_DATABASE_URL / DATABASE_URL, set FORCE once here
// so it inherits into globalSetup + workers (dotenv will not override existing keys).
process.env.CEDAR_PG_FORCE = "1";

module.exports = {
  globalSetup: "<rootDir>/jest.cedar-global.cjs",
  globalTeardown: require.resolve("@cedarjs/pg/jest-teardown"),
  // Prefer setupFilesAfterEnv so you can use beforeAll (Jest globals).
  // Both setupFiles and setupFilesAfterEnv run once per test file; the module-level
  // memo only dedupes within that load. Default unique clone names still work if
  // the module reloads (one clone per file).
  setupFilesAfterEnv: ["<rootDir>/jest.cedar-worker.cjs"],
};

// jest.cedar-worker.cjs — runs once per test file (beforeAll); memo is per module load
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 + role

acquire 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.

Env

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

Alpha caveats

  • Public API may change before a stable 1.0.0 release (current publish is 0.2.0-alpha.x on the alpha dist-tag).
  • End-to-end Postgres flows assume a working local autopg host; unit tests do not start Postgres. CI runs vp run smoke:pg for 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 → markTemplatecloneFromTemplate → role-scoped dispose. Optional @cedarjs/pg/jest/template + @cedarjs/pg/vitest/template adapters orchestrate that pipeline via createGlobalSetup({ migrate }); migrate stays app-owned.

Troubleshooting

Symptom Fix
database already exists: …_c_<workerId> in Jest Use current @cedarjs/pg (unique default clone names). Prefer setupFilesAfterEnv + beforeAll (Jest globals). Both hooks run per test file — memo is per module load, not process-wide. Avoid bare JEST_WORKER_ID as an explicit name.
Acquire skipped; tests hit shared / stale Postgres Real .env TEST_DATABASE_URL / DATABASE_URL trips the escape hatch. Set CEDAR_PG_FORCE=1 once in jest.config.js, or force: true / cedarpg run --force.
Disk quota exceeded / No space left on device / Postgres 53100 on ephemeral start Enlarge /dev/shm (sudo mount -o remount,size=6G /dev/shm). On isolated runners only: rm -rf /dev/shm/cedar-pg-* /dev/shm/pgserve-* /dev/shm/PostgreSQL.*. Cold-start also prunes these when the recipe port is dead.
autopg: command not found in CI with Yarn YARN_ENABLE_SCRIPTS=false CEDAR_PG_INSTALL_AUTOPG=1 is not enough when lifecycle scripts are off. With nodeLinker: node-modules, run bash node_modules/@cedarjs/pg/scripts/ci-install-autopg.sh and put ~/.local/bin on PATH (or use setup-autopg). PnP: resolve the script path via Yarn, or prefer the Action.
Nx child still uses .env DATABASE_URL dependsOn does not forward acquire env. Wrap with cedarpg run --mode=dev --force -- <cmd>, or loadDevEnv({ overwrite: true }).
Role/DB errors under parallel Nx targets Do not run concurrent acquire / run on the same worktree. One db:ready, then run wrappers.

About

Worktree-isolated local Postgres for Vite+/Nx/Cedar. Powered by autopg

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages