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: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Keep logic in the canonical layer. Prefer reuse over one-off branches in adapter
| Skip policy (`CEDAR_PG=0`, external URL, force) | `src/core/policy.ts` (`resolveAcquireSkip`, `runIfNeeded`, `applyDatabaseUrlEnv`) |
| TEMPLATE mark / clone | `src/core/template.ts` |
| Lease read/write / registry | `src/core/lease.ts` |
| Host attach vs ephemeral CI start | `src/providers/host.ts` (internal; `ensureHostRunning` is **not** a public export) |
| Host attach / local recovery / ephemeral start | `src/providers/host.ts` (internal; `ensureHostRunning` is **not** a public export) |
| SQL / autopg CLI / URLs / role password | `src/providers/autopg.ts` |
| Shared Vite+/Nx task strings | `src/adapters/tasks.ts` (`cedarPgLifecycleTargets`, `cedarPgRunCommand`) |
| Runner TEMPLATE orchestration + migrate hook | `src/adapters/template-mode.ts` → thin Jest/Vitest wrappers |
Expand All @@ -89,7 +89,7 @@ Keep logic in the canonical layer. Prefer reuse over one-off branches in adapter

**Migrate stays app-owned.** Stock `@cedarjs/pg/jest` / `vitest` only acquire/dispose. TEMPLATE adapters require `createGlobalSetup({ migrate })`; string-resolving the package entry without a migrate hook must throw.

**Host bootstrap stays internal.** Callers use `acquire` (and `adminUrl`). Do not re-export `ensureHostRunning` or grow a public host-options bag; ephemeral behavior is env-driven (`CI`, `CEDAR_PG_EPHEMERAL_HOST`).
**Host bootstrap stays internal.** Callers use `acquire` (and `adminUrl`). Do not re-export `ensureHostRunning` or grow a public host-options bag; ephemeral behavior is env-driven (`CI`, `CEDAR_PG_EPHEMERAL_HOST`). Liveness is a TCP accept, never `autopg status` alone. Local recovery revives the **registered** host (`autopg restart`, then `autopg install`) — an owned postmaster is ephemeral/CI-only, and `CEDAR_PG_EPHEMERAL_HOST=0` disables it everywhere. Do not add a second local Postgres port/data dir.

## Env and policy (easy to get wrong)

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@

### Fixed

- Attach liveness is a TCP accept, never `autopg status` alone: `cedarpg acquire` no longer connects to a registered-but-stopped host (`ECONNREFUSED 127.0.0.1:25432`).
- Local host recovery brings the **registered** autopg host back: `autopg restart`, then `autopg install` if still no listener, then attach once TCP accepts (error names the registered port and lists what was tried). `restart` exiting 0 is not treated as live. Same port and `~/.autopg/data`; cedar-pg never starts a second local Postgres.
- `CEDAR_PG_EPHEMERAL_HOST=0` now has one meaning: never start an owned postmaster (even under `CI=true`) — local autopg host only, or fail.
- Ephemeral host: start detached `autopg postmaster` only. Do not run `install --no-pm2` (that rewrites `~/.autopg/admin.json` and fails with `supervisor mismatch` next to a local pm2 install).
- `@cedarjs/pg/vite-plus` no longer statically imports `vite`, so the optional peer can be absent (smoke / Nx-only installs)
- TEMPLATE `cloneWorkerDatabase`: default clone name is unique per call (`<worker>_<pid>_<time>`) so Jest `setupFiles` (module reload per file) no longer hits `database already exists` on `_c_<workerId>`
- Ephemeral host: prune stale `/dev/shm/cedar-pg-*` / `pgserve-*` / `PostgreSQL.*` when the recipe port is dead; append remount/cleanup hints on Disk quota / ENOSPC / 53100

### Changed

- `parseHostStatus` / `discoverHost` (public) report the **registered** port and no longer throw on a stopped host — supervisor-specific `status` strings (pm2 `online` vs systemd-user / launchd) are not a liveness model. Probe TCP, or use `acquire`, before connecting.

### Docs

- Nx canonical shape (`db:ready` + `cedarpg run --force`), Jest `CEDAR_PG_FORCE` + `setupFilesAfterEnv`, Yarn ignore-scripts CI recipe, `/dev/shm` troubleshooting, alpha caveat version
Expand Down
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,38 +281,40 @@ await drop();
loadDevEnv({ overwrite: true }); // override .env DATABASE_URL from .cedarpg/dev.env
```

### Host startup (CI ephemeral)
### Host startup (local recovery, 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.
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:

```ts
import { acquire } from "@cedarjs/pg";

// CI=true → install --no-pm2 --no-ui + detached postmaster (--ram on Linux /dev/shm)
// CI=true → detached postmaster (--ram on Linux /dev/shm); does not rewrite ~/.autopg
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` |
| 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):

- `autopg install --no-pm2 --no-ui --port 55432 --data DIR`
- 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 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.
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](#troubleshooting).

Expand Down Expand Up @@ -440,7 +442,7 @@ Worker adapters call `cloneFromTemplateIfNeeded` (shared skip policy via `runIfN
| `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_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 |
Expand All @@ -457,11 +459,12 @@ Worker adapters call `cloneFromTemplateIfNeeded` (shared skip policy via `runIfN

## 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. |
| 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. |
28 changes: 24 additions & 4 deletions scripts/smoke-pg.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/env node
/**
* Adapter + real Postgres smoke: pack → install tarball in a temp consumer →
* run Vitest and Jest through @cedarjs/pg adapters.
* run Vitest, Jest, and the CLI through @cedarjs/pg.
*
* Sets CI=true + CEDAR_PG_EPHEMERAL_HOST=1 so policy prefers ephemeral when no
* host is live. Attach still wins if a host is already live — cold ephemeral
* start is what empty CI runners exercise (workflow runs ci-install-autopg.sh).
* Sets CI=true + 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.
*/
import { cpSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -64,4 +65,23 @@ run("npx", ["jest", "--config", "jest.config.cjs", "--runInBand"], {
env: smokeEnv,
});

console.log("==> cedarpg acquire / run / dispose");
run("git", ["init"], { cwd: tmp, env: smokeEnv, silent: true });
const cli = join(tmp, "node_modules", PACKAGE_NAME, "dist/cli.mjs");
run("node", [cli, "acquire", "--mode=test", "--json"], { cwd: tmp, env: smokeEnv });
run(
"node",
[
cli,
"run",
"--mode=test",
"--",
"node",
"-e",
"if (!process.env.DATABASE_URL.includes('/cpg_')) process.exit(1)",
],
{ cwd: tmp, env: smokeEnv },
);
run("node", [cli, "dispose", "--mode=test"], { cwd: tmp, env: smokeEnv });

console.log("smoke-pg: PASS");
19 changes: 12 additions & 7 deletions src/providers/autopg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,24 @@ export function requireAutopgBin(): string {
}

/**
* Parse `autopg status --json` output. Requires a numeric port and running !== false.
* Parse `autopg status --json` → the **registered** port. Throws only when the
* output is not autopg status JSON.
*
* Registration is not liveness: autopg reports a port for a stopped host too,
* and its `status` string is supervisor-specific (pm2 `online`, systemd-user /
* launchd differ). Liveness is a TCP accept on the port, proven by the caller —
* `acquire` does that before it connects.
*/
export function parseHostStatus(json: string): { port: number } {
let parsed: { port?: unknown; running?: unknown };
let parsed: { port?: unknown };
try {
parsed = JSON.parse(json) as { port?: unknown; running?: unknown };
parsed = JSON.parse(json) as typeof parsed;
} catch {
throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
}
if (typeof parsed.port !== "number") {
throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
}
if (parsed.running === false) {
throw new Error(`autopg host is not running.\n${INSTALL_HINT}`);
}
return { port: parsed.port };
}

Expand All @@ -86,7 +89,9 @@ export function adminUrlFor(port: number, env: NodeJS.ProcessEnv = process.env):
}

/**
* Discover a live autopg host via `autopg status --json`. Throws if the host is not proven live.
* Discover the registered autopg host (port + admin URL) via `autopg status --json`.
* Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
* (or use `acquire`, which does) before connecting.
*/
export function discoverHost(bin = requireAutopgBin()): AutopgDiscovery {
let status: string;
Expand Down
Loading
Loading