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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
- TEMPLATE `cloneWorkerDatabase` reuses the worker's `<template>_c_<workerId>` in Postgres across Jest test files. The `0.2.0-beta.0` fix cached the clone on `globalThis`, but Jest resets `globalThis` and the module registry for each test file. So the second file in a worker still ran `CREATE DATABASE` and failed with `database already exists`. The in-memory memo is removed; the database is the source of truth. As a result, calling it again with a different `root` / `name` in one process no longer throws: each call clones or reuses its own name. `smoke:pg` now runs two Jest files in one worker, after a seeded crashed run.
- TEMPLATE workers (`cloneWorkerDatabase`, and `cloneFromTemplate` / `markTemplate` without `adminUrl`) attach to the leased port like `cedarpg run --attach`: they read the lease and probe it once. They no longer rediscover the host on every test file (git, `autopg --version`, `autopg status`) and never start or revive it from a worker; a dark leased port fails with a message to re-run acquire. The outdated-autopg warning now prints once per run from setup instead of once per Jest test file.

- Role and TEMPLATE clone names for long worktree names keep the full `_<mode>_<pathHash8>`. To stay within 63 chars, cedar-pg used to cut the end of the database name before adding `_role`, which kept only 3 hash characters. Two worktrees whose names cut to the same prefix then shared one role (about 1 in 4,096), and TEMPLATE setup (`fresh`) or `dispose` in one dropped the other's databases. Clone names with the default `<pid>_<time>` suffix lost the hash and mode entirely. Now the readable `<repo>_<worktree>` part is cut instead. Short names are unchanged, and existing leases keep their stored role. For long names, `fresh` does not find leftovers created under the old cut role name; drop those by hand if any remain.
- Role and TEMPLATE clone names for long worktree names keep the full `_<mode>_<pathHash8>`. To stay within 63 chars, cedar-pg used to cut the end of the database name before adding `_role`, which kept only 3 hash characters. Two worktrees whose names cut to the same prefix then shared one role (about 1 in 4,096), and TEMPLATE setup (`fresh`) or `dispose` in one dropped the other's databases. Clone names with the default `<pid>_<time>` suffix lost the hash and mode entirely. Now the readable `<repo>_<worktree>` part is cut instead. Short names are unchanged. `acquire` (and `fresh`) keeps the role stored in an existing lease for the same database, so a long-named dev database created before this release keeps its role and its data stays readable; only new databases get the new role name. Without that lease file, `acquire` uses the new name: it does not find leftovers created under the old cut role, and an existing database would be handed to the new role while its tables stay owned by the old one. Drop such databases by hand (or restore the lease) before acquiring.

## 0.2.0-beta.1

Expand Down
12 changes: 9 additions & 3 deletions src/core/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ export type AcquireOptions = {
/**
* Start from an empty database (default false): first DROP every database
* owned by this worktree's role for `mode` — the leased DB, TEMPLATE clones,
* crashed-run leftovers — then create it again. Scoped by the derived role
* name, so it works without a lease file and never touches other worktrees.
* crashed-run leftovers — then create it again. Scoped by the role (the
* lease's when it is for this database, else the derived name), so it works
* without a lease file and never touches other worktrees.
*/
fresh?: boolean;
};
Expand Down Expand Up @@ -84,7 +85,12 @@ export async function acquire(options: AcquireOptions): Promise<AcquireResult> {
const identity = resolveWorktreeIdentity(options.root);
const mode = options.mode;
const databaseName = buildDatabaseName(identity, mode);
const roleName = buildRoleName(databaseName);
// A lease for this database pins its role: objects inside belong to that role,
// and handing the DB to a newly derived name (naming changed for long names in
// 0.2.0-beta.2) would leave the app without access to its own tables.
const leased = readLease(identity.root, mode);
const roleName =
leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);

const host = await ensureHostRunning();
if (options.fresh) {
Expand Down
120 changes: 120 additions & 0 deletions tests/lifecycle-acquire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readLease, writeLease, type Lease } from "../src/core/lease.ts";
import { buildDatabaseName, buildRoleName } from "../src/core/naming.ts";
import { resolveWorktreeIdentity } from "../src/core/worktree.ts";

let registry: string;
let root: string;
let prevRegistry: string | undefined;

beforeEach(() => {
registry = mkdtempSync(join(tmpdir(), "cedarpg-reg-"));
// Long basename → a 63-char database name, where pre-#44 role naming cut the hash.
root = mkdtempSync(join(tmpdir(), "cedarpg-wt-a-long-worktree-name-for-the-role-upgrade-"));
prevRegistry = process.env.CEDAR_PG_REGISTRY_DIR;
process.env.CEDAR_PG_REGISTRY_DIR = registry;

vi.resetModules();
vi.doMock("../src/providers/host.ts", async () => {
const actual = await vi.importActual<typeof import("../src/providers/host.ts")>(
"../src/providers/host.ts",
);
return {
...actual,
ensureHostRunning: async () => ({
adminUrl: "postgresql://postgres@127.0.0.1:5432/postgres",
port: 5432,
bin: "autopg",
}),
};
});
vi.doMock("../src/providers/autopg.ts", async () => {
const actual = await vi.importActual<typeof import("../src/providers/autopg.ts")>(
"../src/providers/autopg.ts",
);
return {
...actual,
ensureDatabase: vi.fn(async () => {}),
dropDatabasesOwnedByRole: vi.fn(async () => []),
};
});
});

afterEach(() => {
vi.doUnmock("../src/providers/host.ts");
vi.doUnmock("../src/providers/autopg.ts");
vi.resetModules();
if (prevRegistry === undefined) delete process.env.CEDAR_PG_REGISTRY_DIR;
else process.env.CEDAR_PG_REGISTRY_DIR = prevRegistry;
rmSync(registry, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
});

function seedLease(databaseName: string, roleName: string): Lease {
const identity = resolveWorktreeIdentity(root);
const lease: Lease = {
schemaVersion: 1,
mode: "dev",
root: identity.root,
repoSlug: identity.repoSlug,
worktreeSlug: identity.worktreeSlug,
pathHash: identity.pathHash,
databaseName,
roleName,
port: 5432,
pid: 1,
createdAt: "2026-01-01T00:00:00.000Z",
};
writeLease(lease);
return lease;
}

test("acquire keeps the lease's role for the same database (pre-#44 cut role survives)", async () => {
const databaseName = buildDatabaseName(resolveWorktreeIdentity(root), "dev");
const oldRole = `${databaseName.slice(0, 58)}_role`;
expect(databaseName).toHaveLength(63);
expect(buildRoleName(databaseName)).not.toBe(oldRole);
seedLease(databaseName, oldRole);

const { acquire } = await import("../src/core/lifecycle.ts");
const autopg = await import("../src/providers/autopg.ts");
const result = await acquire({ root, mode: "dev", setEnv: false, fresh: true });

expect(result.roleName).toBe(oldRole);
expect(result.databaseUrl).toContain(`${oldRole}:`);
expect(autopg.ensureDatabase).toHaveBeenCalledWith(
expect.objectContaining({ databaseName, roleName: oldRole }),
);
expect(autopg.dropDatabasesOwnedByRole).toHaveBeenCalledWith(
expect.objectContaining({ roleName: oldRole }),
);
expect(readLease(root, "dev")?.roleName).toBe(oldRole);
});

test("acquire derives the role when the lease is for a different database", async () => {
const databaseName = buildDatabaseName(resolveWorktreeIdentity(root), "dev");
seedLease("cpg_other_db_dev_00000000", "cpg_other_db_dev_00000000_role");

const { acquire } = await import("../src/core/lifecycle.ts");
const autopg = await import("../src/providers/autopg.ts");
const result = await acquire({ root, mode: "dev", setEnv: false });

expect(result.databaseName).toBe(databaseName);
expect(result.roleName).toBe(buildRoleName(databaseName));
expect(autopg.ensureDatabase).toHaveBeenCalledWith(
expect.objectContaining({ databaseName, roleName: buildRoleName(databaseName) }),
);
expect(readLease(root, "dev")?.roleName).toBe(buildRoleName(databaseName));
});

test("acquire derives the role without a lease", async () => {
const databaseName = buildDatabaseName(resolveWorktreeIdentity(root), "dev");

const { acquire } = await import("../src/core/lifecycle.ts");
const result = await acquire({ root, mode: "dev", setEnv: false });

expect(result.roleName).toBe(buildRoleName(databaseName));
});
Loading