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 status --mode=dev          # lease name, port, DATABASE_URL, env path
cedarpg studio --mode=dev          # Prisma/Drizzle Studio (--prisma / --drizzle; from cwd)
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, cedarPgDev } from "@cedarjs/pg/vite-plus";

export default defineConfig({
  plugins: [cedarPgDev()],
  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"],
      },
    },
  },
});

cedarPgDev() does not acquire — keep dependsOn: ['db:acquire']. On listen it prints a status panel (TTY, non-CI). Vite CLI shortcuts (key then Enter; also listed under h):

Key Action
d Reprint cedar-pg status (name, port, DATABASE_URL, env file)
s Open Prisma Studio or Drizzle Kit Studio with the lease DATABASE_URL

Options: cedarPgDev({ mode, root, cwd, studio: "prisma" \| "drizzle" \| false }). Studio walks from cwd (default Vite config.root) up to the worktree so an Nx apps/… package is found without moving the lease. prisma wins when both ORMs sit in the same package. Shortcuts bind on Vite 8 / vite-plus. Vite 7 / Cedar prints the listen panel only. Use cedarpg status / cedarpg studio for Nx and other non-Vite hosts.

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 (local recovery, CI ephemeral)

cedar-pg attaches to the autopg host as soon as TCP accepts on the port from autopg status --json. Registration is not liveness: an installed-but-stopped host still reports port 25432, and attaching to it was the ECONNREFUSED 127.0.0.1:25432 bug.

When nothing is listening, cedar-pg brings your autopg host up — autopg restart (autopg's start verb), then autopg install if the host was never registered — and attaches once TCP accepts. Same singleton, same port, same ~/.autopg/data, still running after your process exits. If neither verb produces a listener, acquire fails with what it tried instead of connecting to a dead port. cedar-pg never runs a second local Postgres.

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 → detached postmaster (--ram on Linux /dev/shm); does not rewrite ~/.autopg
const { databaseUrl } = await acquire({ mode: "test" });
Signal Effect (attach always wins when something is listening)
CEDAR_PG_EPHEMERAL_HOST=1 Ephemeral: owned postmaster on 55432
CEDAR_PG_EPHEMERAL_HOST=0 Never own a postmaster (even when CI=true): local autopg host only, or fail
unset + CI=true Ephemeral
unset Local: autopg restart, then autopg install, then fail

Ephemeral recipe (not configurable via cedar-pg):

  • detached autopg postmaster --port 55432 --socket-dir DIR --data DIR
  • does not run autopg install (that rewrites ~/.autopg/admin.json and conflicts with a local pm2 host)
  • 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 TCP already accepts on the discovered autopg port — or, in ephemeral mode, on the recipe port (55432) — 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 owned postmaster; 0 never own one (even in CI); unset + CI=true → ephemeral
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
ECONNREFUSED 127.0.0.1:25432 on cedarpg acquire autopg is registered but not listening. cedar-pg no longer treats registration as live: it runs autopg restart (then install) and attaches once TCP accepts. restart exiting 0 is not proof — when pm2 is missing it still prints “respawned daemon”. If both verbs fail, install pm2 / fix the supervisor (pm2 logs autopg-server).
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