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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Dev leases provision a dedicated role-owned Prisma shadow database on the same admin connection as the main database, with length-safe naming. `AcquireResult.shadowDatabaseUrl`, `cedarpg acquire --json` / `--print-env`, `cedarpg run` / `run --attach`, `.cedarpg/dev.env`, and the dev env loaders expose its URL as `SHADOW_DATABASE_URL`. Opt in with `shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL` in your Prisma config to run `migrate dev` without granting `CREATEDB`. Re-acquire existing dev leases once after upgrading; dispose, fresh acquisition, and orphan GC clean up both databases. Test leases are unchanged.

## 0.3.0

First release off the `beta` dist-tag: `npm install @cedarjs/pg` now installs it as a normal 0.x release (`latest`). Public APIs may still change before 1.0. This is a 0.x minor bump because role / clone naming for long worktree names and `cedarpg run` exit codes on signals change (see below).
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,31 @@ cedarpg gc # drop DBs whose worktree root is gone

`status` and `studio` are read-only. They do not acquire. `studio` walks from cwd up to the worktree.

`cedarpg run` acquires (idempotent role and database DDL), then execs the command. The child always gets `DATABASE_URL` from the lease. In test mode it also gets `TEST_DATABASE_URL`. `--force` only sets `CEDAR_PG_FORCE=1`, so nested adapters do not treat an ambient URL as an escape hatch.
`cedarpg run` acquires (idempotent role and database DDL), then execs the command. The child always gets `DATABASE_URL` from the lease, plus `SHADOW_DATABASE_URL` in dev mode or `TEST_DATABASE_URL` in test mode. `--attach` passes the same URLs from an existing lease. `--force` only sets `CEDAR_PG_FORCE=1`, so nested adapters do not treat an ambient URL as an escape hatch.

`cedarpg run --attach` skips the acquire. It reads the lease a prior `acquire` / `run` wrote, sets the same child env, and execs. It never runs DDL and never starts or revives the host. It fails (nonzero, before the child starts) when there is no lease or nothing is listening on the leased port.

### Prisma migrations in dev

Dev acquisition also provisions a dedicated `<databaseName>_shadow` database, shortening the readable name when needed to preserve the worktree hash within Postgres's 63-character limit. It uses the same lease role and password; the role does not need `CREATEDB`. Both URLs are written to `.cedarpg/dev.env`, so `loadDevEnv` and `@cedarjs/pg/dev-env` load the shadow URL too.

Add `shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL` to your `prisma.config.ts` datasource:

```ts
datasource: {
url: env("DATABASE_URL"),
shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL,
},
```

Use `process.env` for the shadow URL so production commands can load the config when the variable is absent. Prisma's [shadow database configuration](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/shadow-database) lets `migrate dev` use this database instead of trying to create one:

```bash
cedarpg run --mode=dev -- prisma migrate dev --create-only
```

Re-acquire an existing dev lease once after upgrading to provision its shadow and refresh `dev.env`; `--attach` only reads a lease. Re-acquiring preserves both databases. `acquire({ fresh: true })`, `dispose({ mode: "dev" })`, and orphan GC drop both through role ownership. Test leases do not provision a shadow database.

If two targets run `cedarpg acquire` or plain `cedarpg run` on the same worktree, role and database DDL can race. Acquire once, then wrap children with `run --attach`. Any number of those can run concurrently.

## Vite+
Expand Down Expand Up @@ -426,6 +447,7 @@ Yarn Berry or ignore-scripts, when you cannot use the Action. Requires a real `n
| `AUTOPG_PG_USER`, `AUTOPG_PG_PASSWORD` | Autopg superuser for admin URL (default user `postgres`, password `postgres`) |
| `CEDAR_PG=0` | Disable auto-acquire in adapters |
| `TEST_DATABASE_URL` | Default escape hatch for `acquireIfNeeded`. Skip acquire for a real external DB (not `cpg_*`, `file:`, or `{...}` or `<...>` placeholders). Callers may pass `url` (`DATABASE_URL` is common in dev) |
| `SHADOW_DATABASE_URL` | Dedicated Prisma shadow database for managed dev leases; set by acquire, run / attach, and the dev env loaders |
| `CEDAR_PG_FORCE=1` | Ignore the external-URL escape hatch (adapters, `cedarpg run --force`) |
| `CEDAR_PG_EPHEMERAL_HOST` | `1` owned postmaster. `0` never own one (even in CI). Unset and `CI=true` means ephemeral |
| `CEDAR_PG_REGISTRY_DIR` | Override the global lease registry (for `gc`) |
Expand All @@ -445,6 +467,8 @@ await dispose();
loadDevEnv({ overwrite: true }); // override .env DATABASE_URL from .cedarpg/dev.env
```

Dev `AcquireResult` also includes `shadowDatabaseUrl`; it is absent in test mode.

Unit tests in this repo do not start Postgres. CI runs `vp run smoke:pg` for Vitest and Jest adapters against real Postgres (ephemeral cold-start when the runner has no live host; attach wins otherwise).

## Develop this package
Expand All @@ -464,6 +488,7 @@ vp run smoke:pg # pack, then Vitest and Jest adapters against real ephemeral

| Symptom | Fix |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prisma `migrate dev` fails with `P3014` / permission denied to create database | Re-acquire the dev lease after upgrading, then add `shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL` to `prisma.config.ts` and run through `cedarpg run --mode=dev`. See [Prisma migrations in dev](#prisma-migrations-in-dev). |
| `ECONNREFUSED 127.0.0.1:25432` on `cedarpg acquire` | autopg is registered but not listening. cedar-pg attaches only after TCP accepts. It runs `autopg restart`, then detached `autopg postmaster` on the registered port/data if still dark — including when pm2 is missing and `restart` exits 1. A revived postmaster logs to `~/.autopg/logs/cedarpg-postmaster.log`. `status=stopped` with `runtime.live=true` is healthy once TCP accepts; cedar-pg will not `install` pm2. If revive fails, the error lists what was tried. |
| `database already exists: ..._c_<workerId>` in Jest | Upgrade `@cedarjs/pg`. Older releases cached the clone on `globalThis`, which Jest resets for every test file. `cloneWorkerDatabase` now reuses the worker's clone from Postgres (`reuse: true`), and template setup drops crashed-run leftovers first. If the error says `owned by <other role>`, a database outside this worktree's lease has that name: drop it or pass another `name`. |
| Acquire skipped. Tests hit shared or stale Postgres | A real `.env` `TEST_DATABASE_URL` (or a `url` the caller passed) trips the escape hatch. Set `CEDAR_PG_FORCE=1` once in `jest.config.js`, or `force: true`, or `cedarpg run --force`. |
Expand Down
17 changes: 13 additions & 4 deletions scripts/smoke-pg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Adapter + real Postgres smoke: pack → install tarball in a temp consumer →
* run Vitest, Jest (stock + multi-file TEMPLATE), and the CLI through @cedarjs/pg.
*
* Sets CI=true + CEDAR_PG_EPHEMERAL_HOST=1 so policy starts an owned ephemeral
* Sets CI=true + defaults CEDAR_PG_EPHEMERAL_HOST=1 so policy starts an owned ephemeral
* postmaster when nothing is listening — what empty CI runners exercise (the
* workflow installs the binary only, via ci-install-autopg.sh). Attach still
* wins if a host is already live.
Expand Down Expand Up @@ -35,7 +35,7 @@ const tmp = installConsumer({
private: true,
type: "module",
},
npmPackages: ["vitest@4.1.9", "jest@29.7.0", "pg@8.16.3"],
npmPackages: ["vitest@4.1.9", "jest@29.7.0", "pg@8.16.3", "prisma@7.0.0"],
});

cpSync(FIXTURES, tmp, { recursive: true });
Expand All @@ -49,9 +49,15 @@ const pathEnv = ensureAutopgBinary(process.env);
const smokeEnv = {
...pathEnv,
CI: "true",
CEDAR_PG_EPHEMERAL_HOST: "1",
CEDAR_PG_EPHEMERAL_HOST: process.env.CEDAR_PG_EPHEMERAL_HOST ?? "1",
};
for (const key of ["DATABASE_URL", "TEST_DATABASE_URL", "CEDAR_PG", "CEDAR_PG_FORCE"]) {
for (const key of [
"DATABASE_URL",
"TEST_DATABASE_URL",
"SHADOW_DATABASE_URL",
"CEDAR_PG",
"CEDAR_PG_FORCE",
]) {
delete smokeEnv[key];
}

Expand Down Expand Up @@ -104,4 +110,7 @@ if (orphanAttach.status === 0 || !/no test lease/.test(orphanAttach.stderr)) {
throw new Error("cedarpg run --attach without a lease must fail with 'no test lease'");
}

console.log("==> dev shadow database + Prisma migrate dev");
run("node", ["dev-shadow.mjs"], { cwd: tmp, env: smokeEnv });

console.log("smoke-pg: PASS");
155 changes: 155 additions & 0 deletions scripts/smoke-pg/dev-shadow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pg from "pg";
import { acquire, dispose, gc, loadDevEnv } from "@cedarjs/pg";

// A long worktree name exercises shadow-name truncation against real Postgres.
const root = mkdtempSync(join(tmpdir(), "cedar-pg-shadow-long-worktree-name-"));
const registry = mkdtempSync(join(tmpdir(), "cedar-pg-shadow-registry-"));
process.env.CEDAR_PG_REGISTRY_DIR = registry;
const cli = join(process.cwd(), "node_modules/@cedarjs/pg/dist/cli.mjs");
let lease;
let admin;

function runCli(args, env = process.env) {
const result = spawnSync("node", [cli, ...args], { env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
return result.stdout;
}

function runPrisma(args, env, attach = false) {
runCli(
[
"run",
...(attach ? ["--attach"] : []),
"--root",
root,
"--mode=dev",
"--",
"node",
"node_modules/prisma/build/index.js",
"migrate",
...args,
],
env,
);
}

async function queryDatabase(url, sql) {
const client = new pg.Client({ connectionString: url });
try {
await client.connect();
return await client.query(sql);
} finally {
await client.end();
}
}

async function owned() {
const { rows } = await admin.query(
"SELECT datname FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1)",
[lease.roleName],
);
return rows.map(({ datname }) => datname).sort();
}

try {
lease = await acquire({ root, mode: "dev", setEnv: false });
const shadow = new URL(lease.shadowDatabaseUrl);
assert.notEqual(lease.databaseUrl, lease.shadowDatabaseUrl);
assert.ok(shadow.pathname.endsWith("_dev_" + lease.pathHash + "_shadow"));
assert.ok(shadow.pathname.slice(1).length <= 63);
const expectedNames = [lease.databaseName, shadow.pathname.slice(1)].sort();
admin = new pg.Client({ connectionString: lease.adminUrl });
await admin.connect();
assert.deepEqual(await owned(), expectedNames);
const { rows: roles } = await admin.query("SELECT rolcreatedb FROM pg_roles WHERE rolname = $1", [
lease.roleName,
]);
assert.equal(roles[0].rolcreatedb, false);

// Re-acquire must preserve data in both databases, including an upgraded lease.
for (const url of [lease.databaseUrl, lease.shadowDatabaseUrl]) {
await queryDatabase(url, "CREATE TABLE retained (id integer)");
}
delete process.env.SHADOW_DATABASE_URL;
loadDevEnv({ root, overwrite: true });
assert.equal(process.env.SHADOW_DATABASE_URL, lease.shadowDatabaseUrl);
assert.equal(process.env.DATABASE_URL, lease.databaseUrl);
const reused = await acquire({ root, mode: "dev", setEnv: false });
assert.equal(reused.shadowDatabaseUrl, lease.shadowDatabaseUrl);
for (const url of [reused.databaseUrl, reused.shadowDatabaseUrl]) {
await queryDatabase(url, "SELECT * FROM retained");
}

const checks = [
"node",
"-e",
"const assert = require('node:assert/strict'); assert.equal(process.env.DATABASE_URL, process.env.EXPECT_MAIN); assert.equal(process.env.SHADOW_DATABASE_URL, process.env.EXPECT_SHADOW)",
];
const env = {
...process.env,
DATABASE_URL: "ambient-main",
SHADOW_DATABASE_URL: "ambient-shadow",
EXPECT_MAIN: lease.databaseUrl,
EXPECT_SHADOW: lease.shadowDatabaseUrl,
};
for (const flags of [[], ["--attach"]]) {
runCli(["run", ...flags, "--root", root, "--mode=dev", "--", ...checks], env);
}
const json = JSON.parse(runCli(["acquire", "--root", root, "--mode=dev", "--json"]));
assert.equal(json.shadowDatabaseUrl, lease.shadowDatabaseUrl);
const printed = runCli(["acquire", "--root", root, "--mode=dev", "--print-env"]);
assert.equal(printed, readFileSync(join(root, ".cedarpg/dev.env"), "utf8"));

lease = await acquire({ root, mode: "dev", fresh: true, setEnv: false });
assert.deepEqual(await owned(), expectedNames);
for (const url of [lease.databaseUrl, lease.shadowDatabaseUrl]) {
const { rows } = await queryDatabase(
url,
"SELECT to_regclass('public.retained') AS table_name",
);
assert.equal(rows[0].table_name, null);
}

// Prisma config opts in exactly as documented; no CREATEDB grant is needed.
writeFileSync(
join(process.cwd(), "prisma.config.ts"),
`import { defineConfig, env } from "prisma/config";
export default defineConfig({ schema: "shadow.prisma", migrations: { path: "shadow-migrations" }, datasource: { url: env("DATABASE_URL"), shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL } });\n`,
);
writeFileSync(
join(process.cwd(), "shadow.prisma"),
`datasource db {\n provider = "postgresql"\n}\nmodel ShadowSmoke {\n id Int @id @default(autoincrement())\n}\n`,
);
runPrisma(["dev", "--create-only", "--name", "init"], { ...env, CI: "" });
// Deploy then diff a second migration so Prisma replays history in the shadow.
runPrisma(["deploy"], env, true);
writeFileSync(
join(process.cwd(), "shadow.prisma"),
`datasource db {\n provider = "postgresql"\n}\nmodel ShadowSmoke {\n id Int @id @default(autoincrement())\n label String?\n}\n`,
);
runPrisma(["dev", "--create-only", "--name", "label"], { ...env, CI: "" }, true);

const dropped = await dispose({ root, mode: "dev" });
assert.equal(dropped.dropped, true);
assert.deepEqual(dropped.droppedDatabases.sort(), expectedNames);
assert.deepEqual(await owned(), []);
lease = await acquire({ root, mode: "dev", setEnv: false });
rmSync(root, { recursive: true, force: true });
const collected = await gc();
assert.deepEqual(collected.dropped.sort(), expectedNames);
assert.deepEqual(await owned(), []);
console.log("dev shadow: PASS (reuse, URLs, Prisma migrate dev, fresh, dispose, GC)");
} finally {
if (lease) {
if (existsSync(root)) await dispose({ root, mode: "dev" });
else await gc();
}
await admin?.end();
rmSync(root, { recursive: true, force: true });
rmSync(registry, { recursive: true, force: true });
}
10 changes: 8 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Modes:
test Drop DB on dispose / test teardown

run:
Acquire, set DATABASE_URL (+ TEST_DATABASE_URL in test) on the child, exec <cmd…>.
Acquire, set DATABASE_URL (+ SHADOW_DATABASE_URL in dev, TEST_DATABASE_URL in test)
on the child, exec <cmd…>.
--force sets CEDAR_PG_FORCE (escape hatch); child env overwrite is always on.
--attach reads the existing lease instead: no acquire, no role/DB DDL, fails
when there is no lease or the host is not listening. Use it for children that
Expand Down Expand Up @@ -177,6 +178,9 @@ async function main(): Promise<number> {
});
if (args.printEnv) {
process.stdout.write(`DATABASE_URL=${result.databaseUrl}\n`);
if (result.shadowDatabaseUrl) {
process.stdout.write(`SHADOW_DATABASE_URL=${result.shadowDatabaseUrl}\n`);
}
if (mode === "test") {
process.stdout.write(`TEST_DATABASE_URL=${result.databaseUrl}\n`);
}
Expand All @@ -186,6 +190,7 @@ async function main(): Promise<number> {
`${JSON.stringify(
{
databaseUrl: result.databaseUrl,
shadowDatabaseUrl: result.shadowDatabaseUrl,
adminUrl: result.adminUrl,
databaseName: result.databaseName,
roleName: result.roleName,
Expand All @@ -212,14 +217,15 @@ async function main(): Promise<number> {
if (args.cmd === "run") {
if (args.force) process.env.CEDAR_PG_FORCE = "1";
const mode = args.mode ?? "dev";
const { databaseUrl } = args.attach
const { databaseUrl, shadowDatabaseUrl } = args.attach
? await attach({ root: args.root, mode })
: await acquire({ root: args.root, mode, setEnv: true });
const childEnv: NodeJS.ProcessEnv = {
...process.env,
DATABASE_URL: databaseUrl,
};
if (mode === "test") childEnv.TEST_DATABASE_URL = databaseUrl;
if (shadowDatabaseUrl) childEnv.SHADOW_DATABASE_URL = shadowDatabaseUrl;
return await runChild(args.child ?? [], childEnv);
}

Expand Down
Loading
Loading