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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

- CLI: `cedarpg run --mode=dev|test -- <cmd…>` ensures then overwrites child `DATABASE_URL` (Nx/e2e/dev)
- CLI: `ensure --force` / `run --force` sets `CEDAR_PG_FORCE=1` (escape hatch only; `run` always injects child env)
- Shared `cedarPgLifecycleTargets` for Vite+ / Nx; Nx adds `cedarPgRunCommand` + `relativeEnvFile`
- `createEnsureTask({ afterEnsure })` on `@cedarjs/pg` (db:ready compose; not Nx-specific)
- `loadDevEnv({ overwrite })` + `@cedarjs/pg/dev-env`; `loadTestEnv` accepts `{ overwrite: true }`
- Public: `envFilePath(root, mode)` for stable `.cedarpg/<mode>.env` paths

## 0.1.0-alpha.0

Initial alpha of **cedar-pg**, published on npm as `@cedarjs/pg` (CLI: `cedarpg`).
Expand Down
71 changes: 68 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,68 @@ yarn add @cedarjs/pg@file:../cedar-pg
```bash
cedarpg ensure --mode=dev
cedarpg ensure --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` ensures (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 ensure task into dependents
(Vite+ `env: [...]` does). **Canonical fix:** wrap the child with `cedarpg run`.
Secondary: point Nx `envFile` at `.cedarpg/<mode>.env` after ensure.

```ts
import { cedarPgNxTargets, cedarPgRunCommand, relativeEnvFile } from "@cedarjs/pg/nx";

cedarPgNxTargets();
// { "db:ensure": { command: "cedarpg ensure --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"
```

```json
{
"targets": {
"dev": {
"command": "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts"
},
"db:ensure": { "command": "cedarpg ensure --mode=dev" },
"serve": {
"dependsOn": ["db:ensure"],
"command": "node dist/server.js",
"options": { "envFile": ".cedarpg/dev.env" }
}
}
}
```

For a db:ready-style migrate hook (same compose shape as Jest `createGlobalSetup`):

```ts
// tools/db-ready.ts
import { createEnsureTask } from "@cedarjs/pg";

await createEnsureTask({
mode: "dev",
afterEnsure: 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

```ts
Expand Down Expand Up @@ -179,16 +236,24 @@ if (process.env.CEDAR_PG === "1" || process.env.CEDAR_PG === "true") {
setupFiles: [require.resolve("@cedarjs/pg/test-env")],
```

Use exported `STATE_DIRNAME` (`.cedarpg`) / `loadTestEnv(root?)` instead of hardcoding the lease dir.
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` / ensure `{ force }` (external-URL escape hatch).

## Programmatic API

```ts
import { ensure, dispose, loadTestEnv, STATE_DIRNAME } from "@cedarjs/pg";
import { ensure, dispose, loadTestEnv, loadDevEnv, envFilePath, STATE_DIRNAME } from "@cedarjs/pg";

const { databaseUrl, adminUrl, databaseName, dispose: drop } = await ensure({ mode: "test" });
// … tests …
await drop();

loadDevEnv({ overwrite: true }); // override .env DATABASE_URL from .cedarpg/dev.env
```

### Host startup (CI ephemeral)
Expand Down Expand Up @@ -322,7 +387,7 @@ Worker adapters call `cloneFromTemplateIfNeeded` (shared skip policy via `runIfN
| `AUTOPG_PG_USER` / `_PASSWORD` | Autopg superuser for admin URL (default `postgres` / `postgres`) |
| `CEDAR_PG=0` | Disable auto-ensure in adapters |
| `TEST_DATABASE_URL` | Escape hatch: skip ensure for real external DBs (not `cpg_*` / `file:` / `{…}` / `<…>` template placeholders) |
| `CEDAR_PG_FORCE=1` | Ignore external-URL escape hatch (use for real external DBs / Jest when you still want ensure) |
| `CEDAR_PG_FORCE=1` | Ignore external-URL escape hatch (adapters + `cedarpg ensure --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 |
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"import": "./dist/test-env.mjs",
"require": "./dist/test-env.cjs"
},
"./dev-env": {
"types": "./dist/dev-env.d.mts",
"import": "./dist/dev-env.mjs",
"require": "./dist/dev-env.cjs"
},
"./jest/template": {
"types": "./dist/jest-template.d.mts",
"import": "./dist/jest-template.mjs",
Expand Down
20 changes: 20 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,20 @@ import {
buildDatabaseName,
cloneFromTemplate,
cloneFromTemplateIfNeeded,
createEnsureTask,
envFilePath,
loadDevEnv,
loadTestEnv,
markTemplate,
STATE_DIRNAME,
} from '${PACKAGE_NAME}';
import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus';
import { cedarPgNxTargets, cedarPgRunCommand, relativeEnvFile } from '${PACKAGE_NAME}/nx';
import vitestSetup from '${PACKAGE_NAME}/vitest';
import jestSetup from '${PACKAGE_NAME}/jest';
import jestTeardown from '${PACKAGE_NAME}/jest-teardown';
import '${PACKAGE_NAME}/test-env';
import '${PACKAGE_NAME}/dev-env';
import {
createGlobalSetup as createJestTemplateSetup,
ensureWorkerDatabase,
Expand All @@ -61,10 +66,22 @@ const name = buildDatabaseName(
if (name !== 'cpg_cedar_feat_dev_abcd1234') throw new Error('bad name ' + name);
const tasks = cedarPgTasks();
if (!tasks['db:ensure']) throw new Error('missing db:ensure');
const nxTargets = cedarPgNxTargets();
if (!nxTargets['db:ensure']) throw new Error('missing nx db:ensure');
if (JSON.stringify(tasks) !== JSON.stringify(nxTargets)) {
throw new Error('vite-plus and nx lifecycle targets drifted');
}
if (!cedarPgRunCommand('dev', 'echo ok').includes('run --mode=dev')) {
throw new Error('cedarPgRunCommand missing run');
}
if (relativeEnvFile('dev') !== '.cedarpg/dev.env') throw new Error('bad relativeEnvFile');
if (typeof createEnsureTask !== 'function') throw new Error('missing createEnsureTask');
if (typeof vitestSetup !== 'function') throw new Error('vitest setup export missing');
if (typeof jestSetup !== 'function') throw new Error('jest setup export missing');
if (typeof jestTeardown !== 'function') throw new Error('jest-teardown export missing');
if (typeof loadTestEnv !== 'function') throw new Error('loadTestEnv export missing');
if (typeof loadDevEnv !== 'function') throw new Error('loadDevEnv export missing');
if (typeof envFilePath !== 'function') throw new Error('envFilePath export missing');
if (STATE_DIRNAME !== '.cedarpg') throw new Error('bad STATE_DIRNAME ' + STATE_DIRNAME);
if (typeof markTemplate !== 'function') throw new Error('missing markTemplate');
if (typeof cloneFromTemplate !== 'function') throw new Error('missing cloneFromTemplate');
Expand All @@ -87,6 +104,9 @@ const help = run("node", [join(tmp, "node_modules", PACKAGE_NAME, "dist/cli.mjs"
if (!help.stdout?.includes(`${CLI_BIN} ensure`)) {
throw new Error(`CLI help missing ${CLI_BIN} ensure`);
}
if (!help.stdout?.includes(`${CLI_BIN} run`)) {
throw new Error(`CLI help missing ${CLI_BIN} run`);
}

console.log("==> published files exclude smoke harness");
const packed = run("tar", ["-tzf", tarballPath], { silent: true }).stdout ?? "";
Expand Down
10 changes: 10 additions & 0 deletions src/adapters/dev-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { loadDevEnv } from "./load-dev-env.ts";

/**
* `setupFiles`-style entry: load `.cedarpg/dev.env` with overwrite.
*
* ```ts
* import "@cedarjs/pg/dev-env";
* ```
*/
loadDevEnv({ overwrite: true });
39 changes: 39 additions & 0 deletions src/adapters/ensure-task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ensureIfNeeded, type EnsureResult } from "../core/lifecycle.ts";
import type { DbMode } from "../core/naming.ts";

export type EnsureTaskContext = Pick<
EnsureResult,
"databaseUrl" | "adminUrl" | "databaseName" | "roleName" | "root" | "mode" | "port"
>;

export type CreateEnsureTaskOptions = {
mode: DbMode;
root?: string;
/** Ignore external-URL escape hatch (`CEDAR_PG_FORCE=1` also works). */
force?: boolean;
setEnv?: boolean;
/** App-owned migrate/seed after a successful ensure. */
afterEnsure?: (ctx: EnsureTaskContext) => void | Promise<void>;
};

/** ensure → optional afterEnsure (db:ready / migrate compose). */
export function createEnsureTask(options: CreateEnsureTaskOptions): () => Promise<void> {
return async () => {
const result = await ensureIfNeeded({
root: options.root,
mode: options.mode,
setEnv: options.setEnv !== false,
force: options.force,
});
if (result.status !== "ensured" || !options.afterEnsure) return;
await options.afterEnsure({
databaseUrl: result.databaseUrl,
adminUrl: result.adminUrl,
databaseName: result.databaseName,
roleName: result.roleName,
root: result.root,
mode: result.mode,
port: result.port,
});
};
}
8 changes: 8 additions & 0 deletions src/adapters/load-dev-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { loadModeEnv, normalizeLoadOptions, type LoadModeEnvOptions } from "./load-mode-env.ts";

export type LoadDevEnvOptions = LoadModeEnvOptions;

/** Load `.cedarpg/dev.env` into `process.env`. Use `{ overwrite: true }` to beat `.env`. */
export function loadDevEnv(rootOrOptions?: string | LoadDevEnvOptions): void {
loadModeEnv("dev", normalizeLoadOptions(rootOrOptions));
}
41 changes: 41 additions & 0 deletions src/adapters/load-mode-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { existsSync, readFileSync } from "node:fs";
import { envPath, leasePath } from "../core/lease.ts";
import type { DbMode } from "../core/naming.ts";
import { resolveRoot } from "../core/worktree.ts";

export type LoadModeEnvOptions = {
root?: string;
/** Overwrite existing `process.env` keys (default: only fill undefined). */
overwrite?: boolean;
};

export function normalizeLoadOptions(
rootOrOptions?: string | LoadModeEnvOptions,
): LoadModeEnvOptions {
if (typeof rootOrOptions === "string" || rootOrOptions === undefined) {
return { root: rootOrOptions };
}
return rootOrOptions;
}

/** Load `.cedarpg/<mode>.env` when a matching lease exists; no-op if stale/missing. */
export function loadModeEnv(mode: DbMode, options: LoadModeEnvOptions = {}): void {
const resolved = resolveRoot(options.root);
if (!existsSync(leasePath(resolved, mode))) return;

const file = envPath(resolved, mode);
if (!existsSync(file)) return;

const overwrite = options.overwrite === true;
for (const line of readFileSync(file, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq);
const value = trimmed.slice(eq + 1);
if (overwrite || process.env[key] === undefined) {
process.env[key] = value;
}
}
}
26 changes: 5 additions & 21 deletions src/adapters/load-test-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { envPath, leasePath } from "../core/lease.ts";
import { resolveRoot } from "../core/worktree.ts";
import { loadModeEnv, normalizeLoadOptions, type LoadModeEnvOptions } from "./load-mode-env.ts";

export type LoadTestEnvOptions = LoadModeEnvOptions;

/**
* Load `.cedarpg/test.env` into `process.env` (worker-side).
Expand All @@ -11,22 +11,6 @@ import { resolveRoot } from "../core/worktree.ts";
* No-ops unless a matching `test.json` lease exists, so a leftover env after dispose
* cannot inject a dropped DATABASE_URL.
*/
export function loadTestEnv(root?: string): void {
const resolved = resolveRoot(root);
if (!existsSync(leasePath(resolved, "test"))) return;

const file = envPath(resolved, "test");
if (!existsSync(file)) return;

for (const line of readFileSync(file, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq);
const value = trimmed.slice(eq + 1);
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
export function loadTestEnv(rootOrOptions?: string | LoadTestEnvOptions): void {
loadModeEnv("test", normalizeLoadOptions(rootOrOptions));
}
61 changes: 45 additions & 16 deletions src/adapters/nx.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,60 @@
/**
* Nx target command hints. Wire into project.json / package.json:
* Nx targets. `dependsOn` does not forward ensure env (unlike Vite+ `env: [...]`);
* wrap children with `cedarpg run`, or set `envFile` to `.cedarpg/<mode>.env`.
*
* ```json
* {
* "targets": {
* "dev": {
* "command": "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts"
* },
* "db:ensure": { "command": "cedarpg ensure --mode=dev" },
* "test": {
* "dependsOn": ["db:ensure-test"],
* "command": "vitest run"
* "serve": {
* "dependsOn": ["db:ensure"],
* "command": "node dist/server.js",
* "options": { "envFile": ".cedarpg/dev.env" }
* }
* }
* }
* ```
*
* Prefer the CLI (`cedarpg` from `@cedarjs/pg`) for ensure/dispose;
* there is no separate Nx runtime wrapper.
*/

import { CLI_NAME } from "../core/constants.ts";
import { cedarPgCommands } from "./tasks.ts";
import { CLI_NAME, STATE_DIRNAME } from "../core/constants.ts";
import { envFilePath } from "../core/lease.ts";
import type { DbMode } from "../core/naming.ts";
import {
cedarPgLifecycleTargets,
cedarPgRunCommand,
type CedarPgLifecycleTarget,
type CedarPgLifecycleTargetsOptions,
CEDAR_PG_TASK_DISPOSE_TEST,
CEDAR_PG_TASK_ENSURE_DEV,
CEDAR_PG_TASK_ENSURE_TEST,
} from "./tasks.ts";

export {
CEDAR_PG_TASK_ENSURE_DEV as CEDAR_PG_NX_ENSURE_DEV,
CEDAR_PG_TASK_ENSURE_TEST as CEDAR_PG_NX_ENSURE_TEST,
CEDAR_PG_TASK_DISPOSE_TEST as CEDAR_PG_NX_DISPOSE_TEST,
cedarPgLifecycleTargets as cedarPgNxTargets,
cedarPgRunCommand,
envFilePath,
};

/** Relative path for Nx `envFile` / dotenv. */
export function relativeEnvFile(mode: DbMode): string {
return `${STATE_DIRNAME}/${mode}.env`;
}

export type NxTargetHint = CedarPgLifecycleTarget;
export type CedarPgNxTargetsOptions = CedarPgLifecycleTargetsOptions;

/** Suggested target definitions for project.json / package.json nx targets. */
/** @deprecated Prefer `cedarPgNxTargets()`. */
export function nxTargetHints(bin = CLI_NAME): Record<string, { command: string }> {
const cmds = cedarPgCommands(bin);
return {
"db:ensure": { command: cmds.ensureDev },
"db:ensure-test": { command: cmds.ensureTest },
"db:dispose-test": { command: cmds.disposeTest },
};
return Object.fromEntries(
Object.entries(cedarPgLifecycleTargets({ bin })).map(([name, def]) => [
name,
{ command: def.command },
]),
);
}
Loading
Loading