@cedarjs/pg 0.1.0-alpha.1 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +361 -89
  2. package/dist/cli.cjs +131 -35
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.mjs +118 -22
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/dev-env.cjs +12 -0
  7. package/dist/dev-env.cjs.map +1 -0
  8. package/dist/dev-env.d.cts +1 -0
  9. package/dist/dev-env.d.mts +1 -0
  10. package/dist/dev-env.mjs +14 -0
  11. package/dist/dev-env.mjs.map +1 -0
  12. package/dist/index.cjs +51 -10
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +198 -84
  15. package/dist/index.d.mts +198 -84
  16. package/dist/index.mjs +32 -2
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/jest-teardown.cjs +18 -0
  19. package/dist/jest-teardown.cjs.map +1 -0
  20. package/dist/jest-teardown.d.cts +13 -0
  21. package/dist/jest-teardown.d.mts +14 -0
  22. package/dist/jest-teardown.mjs +18 -0
  23. package/dist/jest-teardown.mjs.map +1 -0
  24. package/dist/jest-template.cjs +44 -0
  25. package/dist/jest-template.cjs.map +1 -0
  26. package/dist/jest-template.d.cts +31 -0
  27. package/dist/jest-template.d.mts +31 -0
  28. package/dist/jest-template.mjs +38 -0
  29. package/dist/jest-template.mjs.map +1 -0
  30. package/dist/jest.cjs +12 -15
  31. package/dist/jest.cjs.map +1 -1
  32. package/dist/jest.d.cts +10 -7
  33. package/dist/jest.d.mts +10 -6
  34. package/dist/jest.mjs +12 -10
  35. package/dist/jest.mjs.map +1 -1
  36. package/dist/lease-B3TuX92y.d.mts +24 -0
  37. package/dist/lease-DS1SX8U_.mjs +221 -0
  38. package/dist/lease-DS1SX8U_.mjs.map +1 -0
  39. package/dist/lease-WlmOnNDi.cjs +310 -0
  40. package/dist/lease-WlmOnNDi.cjs.map +1 -0
  41. package/dist/lease-t4I9JahV.d.cts +24 -0
  42. package/dist/lifecycle-BbrvFQvg.cjs +953 -0
  43. package/dist/lifecycle-BbrvFQvg.cjs.map +1 -0
  44. package/dist/lifecycle-BvIx0xqq.mjs +781 -0
  45. package/dist/lifecycle-BvIx0xqq.mjs.map +1 -0
  46. package/dist/load-dev-env-BULkXyen.mjs +10 -0
  47. package/dist/load-dev-env-BULkXyen.mjs.map +1 -0
  48. package/dist/load-dev-env-Bl6Ddv1U.cjs +15 -0
  49. package/dist/load-dev-env-Bl6Ddv1U.cjs.map +1 -0
  50. package/dist/load-mode-env-CX9vd56X.cjs +39 -0
  51. package/dist/load-mode-env-CX9vd56X.cjs.map +1 -0
  52. package/dist/load-mode-env-Ce9ujisN.mjs +28 -0
  53. package/dist/load-mode-env-Ce9ujisN.mjs.map +1 -0
  54. package/dist/load-test-env-BqtA6V-d.mjs +18 -0
  55. package/dist/load-test-env-BqtA6V-d.mjs.map +1 -0
  56. package/dist/load-test-env-Dcgh6YHV.cjs +23 -0
  57. package/dist/load-test-env-Dcgh6YHV.cjs.map +1 -0
  58. package/dist/naming-C2nGVxPk.d.cts +39 -0
  59. package/dist/naming-C2nGVxPk.d.mts +39 -0
  60. package/dist/nx.cjs +25 -18
  61. package/dist/nx.cjs.map +1 -1
  62. package/dist/nx.d.cts +9 -20
  63. package/dist/nx.d.mts +9 -20
  64. package/dist/nx.mjs +18 -18
  65. package/dist/nx.mjs.map +1 -1
  66. package/dist/status-4Uz6A3LB.cjs +31 -0
  67. package/dist/status-4Uz6A3LB.cjs.map +1 -0
  68. package/dist/status-Dcnpgnlz.mjs +26 -0
  69. package/dist/status-Dcnpgnlz.mjs.map +1 -0
  70. package/dist/studio-DIFw1qWC.mjs +173 -0
  71. package/dist/studio-DIFw1qWC.mjs.map +1 -0
  72. package/dist/studio-oJE30_PO.cjs +208 -0
  73. package/dist/studio-oJE30_PO.cjs.map +1 -0
  74. package/dist/tasks-CNHvvlsN.cjs +67 -0
  75. package/dist/tasks-CNHvvlsN.cjs.map +1 -0
  76. package/dist/tasks-CjaZRi_G.d.mts +19 -0
  77. package/dist/tasks-D5iWIdlo.mjs +38 -0
  78. package/dist/tasks-D5iWIdlo.mjs.map +1 -0
  79. package/dist/tasks-DdQoP9We.d.cts +19 -0
  80. package/dist/template-BiJ-0TIF.mjs +88 -0
  81. package/dist/template-BiJ-0TIF.mjs.map +1 -0
  82. package/dist/template-CXMpODzK.cjs +105 -0
  83. package/dist/template-CXMpODzK.cjs.map +1 -0
  84. package/dist/template-mode-BTUsdBtA.mjs +94 -0
  85. package/dist/template-mode-BTUsdBtA.mjs.map +1 -0
  86. package/dist/template-mode-CzyVwHsi.d.cts +31 -0
  87. package/dist/template-mode-CzyVwHsi.d.mts +31 -0
  88. package/dist/template-mode-JTOxrEgE.cjs +105 -0
  89. package/dist/template-mode-JTOxrEgE.cjs.map +1 -0
  90. package/dist/test-env.cjs +16 -0
  91. package/dist/test-env.cjs.map +1 -0
  92. package/dist/test-env.d.cts +1 -0
  93. package/dist/test-env.d.mts +1 -0
  94. package/dist/test-env.mjs +18 -0
  95. package/dist/test-env.mjs.map +1 -0
  96. package/dist/vite-plus.cjs +113 -35
  97. package/dist/vite-plus.cjs.map +1 -1
  98. package/dist/vite-plus.d.cts +26 -46
  99. package/dist/vite-plus.d.mts +26 -46
  100. package/dist/vite-plus.mjs +110 -32
  101. package/dist/vite-plus.mjs.map +1 -1
  102. package/dist/vitest-template.cjs +48 -0
  103. package/dist/vitest-template.cjs.map +1 -0
  104. package/dist/vitest-template.d.cts +31 -0
  105. package/dist/vitest-template.d.mts +31 -0
  106. package/dist/vitest-template.mjs +42 -0
  107. package/dist/vitest-template.mjs.map +1 -0
  108. package/dist/vitest.cjs +10 -10
  109. package/dist/vitest.cjs.map +1 -1
  110. package/dist/vitest.d.cts +7 -3
  111. package/dist/vitest.d.mts +7 -2
  112. package/dist/vitest.mjs +10 -5
  113. package/dist/vitest.mjs.map +1 -1
  114. package/package.json +46 -13
  115. package/scripts/autopg-version +1 -0
  116. package/scripts/ci-install-autopg.sh +73 -0
  117. package/scripts/postinstall.js +35 -9
  118. package/dist/constants-BY97wXjA.mjs +0 -24
  119. package/dist/constants-BY97wXjA.mjs.map +0 -1
  120. package/dist/constants-CNZn5Xro.cjs +0 -41
  121. package/dist/constants-CNZn5Xro.cjs.map +0 -1
  122. package/dist/lifecycle-BGYtVXtx.cjs +0 -743
  123. package/dist/lifecycle-BGYtVXtx.cjs.map +0 -1
  124. package/dist/lifecycle-CT_8AWPv.mjs +0 -577
  125. package/dist/lifecycle-CT_8AWPv.mjs.map +0 -1
  126. package/dist/tasks-13P6tMth.mjs +0 -16
  127. package/dist/tasks-13P6tMth.mjs.map +0 -1
  128. package/dist/tasks-B8ryV9xo.cjs +0 -21
  129. package/dist/tasks-B8ryV9xo.cjs.map +0 -1
@@ -0,0 +1,781 @@
1
+ import { a as leaseDir, f as resolveWorktreeIdentity, i as isOrphanLease, l as readLease, m as PASSWORD_SALT_PREFIX, n as envPath, r as forgetLease, s as listRegistryLeases, u as writeLease } from "./lease-DS1SX8U_.mjs";
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, statfsSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
+ import pg from "pg";
8
+ import { createConnection } from "node:net";
9
+ //#region src/core/naming.ts
10
+ const PG_MAX = 63;
11
+ const PREFIX = "cpg_";
12
+ /**
13
+ * Build an observable Postgres database name:
14
+ * cpg_<repoSlug>_<worktreeSlug>_<mode>_<pathHash8>
15
+ *
16
+ * Never drops `mode` or `pathHash`. Truncates slugs to fit ≤63 chars.
17
+ */
18
+ function buildDatabaseName(identity, mode) {
19
+ const modePart = mode;
20
+ const hashPart = identity.pathHash;
21
+ const fixed = 6 + modePart.length + 1 + hashPart.length;
22
+ let budget = PG_MAX - fixed;
23
+ if (budget < 2) return `${PREFIX}${modePart}_${hashPart}`.slice(0, PG_MAX);
24
+ let repo = identity.repoSlug;
25
+ let wt = identity.worktreeSlug;
26
+ while (repo.length + wt.length > budget) if (repo.length >= wt.length && repo.length > 1) repo = repo.slice(0, -1);
27
+ else if (wt.length > 1) wt = wt.slice(0, -1);
28
+ else break;
29
+ const name = `${PREFIX}${repo}_${wt}_${modePart}_${hashPart}`;
30
+ if (name.length > PG_MAX) return name.slice(0, PG_MAX);
31
+ return name;
32
+ }
33
+ function buildRoleName(databaseName) {
34
+ const suffix = "_role";
35
+ const maxBase = PG_MAX - 5;
36
+ return `${databaseName.slice(0, maxBase)}${suffix}`;
37
+ }
38
+ /**
39
+ * Build a worker clone datname from a TEMPLATE database name.
40
+ * Layout: `<template>_c_<suffix>` truncated to ≤63 chars (keeps suffix).
41
+ */
42
+ function buildCloneDatabaseName(templateName, suffix) {
43
+ const safe = suffix.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24);
44
+ const tag = safe.length > 0 ? safe : "w";
45
+ const sep = "_c_";
46
+ const maxTemplate = PG_MAX - 3 - tag.length;
47
+ return `${templateName.slice(0, Math.max(1, maxTemplate))}${sep}${tag}`.slice(0, PG_MAX);
48
+ }
49
+ //#endregion
50
+ //#region src/core/policy.ts
51
+ /**
52
+ * Inject DATABASE_URL (and TEST_DATABASE_URL for test mode) from a resolved URL.
53
+ * Single env path for acquire, clone, and external-url skip.
54
+ */
55
+ function applyDatabaseUrlEnv(databaseUrl, options = {}) {
56
+ process.env.DATABASE_URL = databaseUrl;
57
+ if ((options.mode ?? "test") === "test") process.env.TEST_DATABASE_URL = databaseUrl;
58
+ }
59
+ /**
60
+ * Shared host skip+env gate for `acquireIfNeeded` / `cloneFromTemplateIfNeeded`.
61
+ * On external-url skip, applies env when `setEnv` is not false.
62
+ */
63
+ async function runIfNeeded(options, run) {
64
+ const skip = resolveAcquireSkip({
65
+ url: options.url,
66
+ force: options.force,
67
+ disabled: options.disabled
68
+ });
69
+ if (skip.skip) {
70
+ if (skip.reason === "external-url") {
71
+ if (options.setEnv !== false) applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode });
72
+ return {
73
+ status: "skipped",
74
+ reason: "external-url",
75
+ databaseUrl: skip.databaseUrl
76
+ };
77
+ }
78
+ return {
79
+ status: "skipped",
80
+ reason: "disabled"
81
+ };
82
+ }
83
+ return {
84
+ status: "ran",
85
+ value: await run()
86
+ };
87
+ }
88
+ /**
89
+ * True when the URL looks like a cedarpg provisioned database (`cpg_*` name/role).
90
+ * These must never be treated as an external escape hatch; always re-acquire so
91
+ * disposed/stale shell env cannot skip provisioning.
92
+ */
93
+ function isCedarPgManagedUrl(url) {
94
+ if (!url || url.startsWith("file:")) return false;
95
+ try {
96
+ const parsed = new URL(url);
97
+ const databaseName = decodeURIComponent((parsed.pathname.replace(/^\//, "").split("?")[0] ?? "").trim());
98
+ const user = decodeURIComponent(parsed.username);
99
+ return databaseName.startsWith("cpg_") || user.startsWith("cpg_");
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+ /**
105
+ * True when `url` still looks like an unset dotenv/template value
106
+ * (e.g. `postgresql://{yourMachine}@localhost:5432/app_test` or
107
+ * `postgresql://<user>@localhost/db`), not a real external database.
108
+ */
109
+ function isUnsetTemplateUrl(url) {
110
+ if (/\{[^}]+\}/.test(url)) return true;
111
+ if (/<[^>]+>/.test(url)) return true;
112
+ return false;
113
+ }
114
+ /**
115
+ * True when `url` is a real external database and acquire should be skipped.
116
+ * Sqlite `file:` URLs, cedarpg `cpg_*` URLs, and unset template placeholders
117
+ * are not external.
118
+ */
119
+ function isExternalDatabaseEscapeHatch(url) {
120
+ if (!url) return false;
121
+ if (url.startsWith("file:")) return false;
122
+ if (isCedarPgManagedUrl(url)) return false;
123
+ if (isUnsetTemplateUrl(url)) return false;
124
+ return true;
125
+ }
126
+ /**
127
+ * Decide whether acquire should run.
128
+ * Adapters may call with no args (env defaults). Cedar opt-in should pass `disabled: false`
129
+ * and the relevant `url` (`DATABASE_URL` or `TEST_DATABASE_URL`).
130
+ */
131
+ function resolveAcquireSkip(input = {}) {
132
+ if (input.disabled ?? (process.env.CEDAR_PG === "0" || process.env.CEDAR_PG === "false")) return {
133
+ skip: true,
134
+ reason: "disabled"
135
+ };
136
+ if (input.force ?? process.env.CEDAR_PG_FORCE === "1") return { skip: false };
137
+ const url = input.url !== void 0 ? input.url : process.env.TEST_DATABASE_URL;
138
+ if (url && isExternalDatabaseEscapeHatch(url)) return {
139
+ skip: true,
140
+ reason: "external-url",
141
+ databaseUrl: url
142
+ };
143
+ return { skip: false };
144
+ }
145
+ //#endregion
146
+ //#region src/providers/autopg.ts
147
+ const INSTALL_HINT = "autopg is required. Install with:\n curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/main/install.sh | bash\nThen ensure ~/.local/bin is on PATH, or set AUTOPG_BIN.";
148
+ /** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
149
+ const ROLE_PASSWORD_SCHEME = "v2";
150
+ function candidateBins() {
151
+ const out = [];
152
+ if (process.env.AUTOPG_BIN) out.push(process.env.AUTOPG_BIN);
153
+ out.push("autopg");
154
+ out.push(join(homedir(), ".local", "bin", "autopg"));
155
+ return out;
156
+ }
157
+ function resolveAutopgBin() {
158
+ for (const bin of candidateBins()) {
159
+ if (bin === "autopg") {
160
+ const which = spawnSync("which", ["autopg"], { encoding: "utf8" });
161
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
162
+ continue;
163
+ }
164
+ if (existsSync(bin)) return bin;
165
+ }
166
+ return null;
167
+ }
168
+ function requireAutopgBin() {
169
+ const bin = resolveAutopgBin();
170
+ if (!bin) throw new Error(INSTALL_HINT);
171
+ return bin;
172
+ }
173
+ /**
174
+ * Parse `autopg status --json` → the **registered** port. Throws only when the
175
+ * output is not autopg status JSON.
176
+ *
177
+ * Registration is not liveness: autopg reports a port for a stopped host too,
178
+ * and its `status` string is supervisor-specific (pm2 `online`, systemd-user /
179
+ * launchd differ). Liveness is a TCP accept on the port, proven by the caller —
180
+ * `acquire` does that before it connects.
181
+ */
182
+ function parseHostStatus(json) {
183
+ let parsed;
184
+ try {
185
+ parsed = JSON.parse(json);
186
+ } catch {
187
+ throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
188
+ }
189
+ if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
190
+ return { port: parsed.port };
191
+ }
192
+ /**
193
+ * Admin URL for an autopg host on `port`.
194
+ *
195
+ * Credentials follow autopg's own defaults / env chain (not "any local Postgres"):
196
+ * - user: `AUTOPG_PG_USER` / `PGSERVE_PG_USER` / `postgres`
197
+ * - password: `AUTOPG_PG_PASSWORD` / `PGSERVE_PG_PASSWORD` / `postgres`
198
+ *
199
+ * Port is never scanned: callers pass the port from `autopg status` (attach) or the
200
+ * ephemeral recipe (`55432`). That keeps us off unrelated local servers (e.g. brew on 5432).
201
+ */
202
+ function adminUrlFor(port, env = process.env) {
203
+ return `postgresql://${encodeURIComponent(env.AUTOPG_PG_USER || env.PGSERVE_PG_USER || "postgres")}:${encodeURIComponent(env.AUTOPG_PG_PASSWORD || env.PGSERVE_PG_PASSWORD || "postgres")}@127.0.0.1:${port}/postgres`;
204
+ }
205
+ /**
206
+ * Discover the registered autopg host (port + admin URL) via `autopg status --json`.
207
+ * Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
208
+ * (or use `acquire`, which does) before connecting.
209
+ */
210
+ function discoverHost(bin = requireAutopgBin()) {
211
+ let status;
212
+ try {
213
+ status = execFileSync(bin, ["status", "--json"], {
214
+ encoding: "utf8",
215
+ stdio: [
216
+ "ignore",
217
+ "pipe",
218
+ "pipe"
219
+ ]
220
+ });
221
+ } catch (err) {
222
+ const detail = err instanceof Error ? err.message : String(err);
223
+ throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
224
+ }
225
+ const { port } = parseHostStatus(status);
226
+ return {
227
+ port,
228
+ adminUrl: adminUrlFor(port),
229
+ bin
230
+ };
231
+ }
232
+ function quoteIdent(name) {
233
+ return `"${name.replace(/"/g, "\"\"")}"`;
234
+ }
235
+ function quoteLiteral(value) {
236
+ return `'${value.replace(/'/g, "''")}'`;
237
+ }
238
+ async function withAdminClient(adminUrl, fn) {
239
+ const client = new pg.Client({ connectionString: adminUrl });
240
+ await client.connect();
241
+ try {
242
+ return await fn(client);
243
+ } finally {
244
+ await client.end();
245
+ }
246
+ }
247
+ /**
248
+ * Deterministic local-only password for an app role (Prisma/TCP need it;
249
+ * autopg hba uses `password` for 127.0.0.1).
250
+ *
251
+ * Keyed by `roleName` (not databaseName) so TEMPLATE clones that reuse the
252
+ * same role keep working when `buildDatabaseUrl` is called with a new database.
253
+ */
254
+ function rolePasswordFor(roleName) {
255
+ return createHash("sha256").update(`${PASSWORD_SALT_PREFIX}\0${roleName}`).digest("hex").slice(0, 32);
256
+ }
257
+ /**
258
+ * Idempotently CREATE ROLE + CREATE DATABASE with cedar-pg owned names.
259
+ */
260
+ async function ensureDatabase(opts) {
261
+ const password = opts.password ?? rolePasswordFor(opts.roleName);
262
+ await withAdminClient(opts.adminUrl, async (client) => {
263
+ if ((await client.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [opts.roleName])).rowCount === 0) await client.query(`CREATE ROLE ${quoteIdent(opts.roleName)} WITH LOGIN PASSWORD ${quoteLiteral(password)}`);
264
+ else await client.query(`ALTER ROLE ${quoteIdent(opts.roleName)} WITH LOGIN PASSWORD ${quoteLiteral(password)}`);
265
+ if ((await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [opts.databaseName])).rowCount === 0) await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} OWNER ${quoteIdent(opts.roleName)}`);
266
+ else await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} OWNER TO ${quoteIdent(opts.roleName)}`);
267
+ });
268
+ }
269
+ /**
270
+ * Mark (or unmark) a database as a PostgreSQL template (`IS_TEMPLATE`).
271
+ * Template DBs cannot be dropped until unset.
272
+ */
273
+ async function setDatabaseIsTemplate(opts) {
274
+ const flag = opts.isTemplate ? "true" : "false";
275
+ await withAdminClient(opts.adminUrl, async (client) => {
276
+ await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
277
+ });
278
+ }
279
+ /**
280
+ * CREATE DATABASE … TEMPLATE … OWNER via admin connection.
281
+ * Test roles are LOGIN-only; workers cannot CREATE DATABASE themselves.
282
+ */
283
+ async function cloneDatabaseFromTemplate(opts) {
284
+ await withAdminClient(opts.adminUrl, async (client) => {
285
+ const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
286
+ if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
287
+ if (!tmpl.rows[0]?.datistemplate) throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`);
288
+ const exists = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [opts.databaseName]);
289
+ if (exists.rowCount && exists.rowCount > 0) throw new Error(`database already exists: ${opts.databaseName}`);
290
+ await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} WITH TEMPLATE ${quoteIdent(opts.templateName)} OWNER ${quoteIdent(opts.roleName)}`);
291
+ });
292
+ }
293
+ async function listOwnedDatnames(client, roleName) {
294
+ return (await client.query(`SELECT datname FROM pg_database
295
+ WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1)
296
+ ORDER BY datname`, [roleName])).rows.map((r) => r.datname);
297
+ }
298
+ /** Unset IS_TEMPLATE if needed, terminate backends, DROP DATABASE (no-op if missing). */
299
+ async function dropOneDatabase(client, databaseName) {
300
+ const db = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [databaseName]);
301
+ if (!db.rowCount || db.rowCount === 0) return;
302
+ if (db.rows[0]?.datistemplate) await client.query(`ALTER DATABASE ${quoteIdent(databaseName)} WITH IS_TEMPLATE false`);
303
+ await client.query(`
304
+ SELECT pg_terminate_backend(pid)
305
+ FROM pg_stat_activity
306
+ WHERE datname = $1 AND pid <> pg_backend_pid()
307
+ `, [databaseName]);
308
+ await client.query(`DROP DATABASE IF EXISTS ${quoteIdent(databaseName)}`);
309
+ }
310
+ /**
311
+ * DROP every database owned by `roleName` on one admin connection, then DROP ROLE.
312
+ * When `preferLast` is owned, it is dropped after the other owned datnames (TEMPLATE after clones).
313
+ * Never invents a DROP target beyond role ownership.
314
+ */
315
+ async function dropDatabasesOwnedByRole(opts) {
316
+ return withAdminClient(opts.adminUrl, async (client) => {
317
+ const owned = await listOwnedDatnames(client, opts.roleName);
318
+ const ordered = [...owned.filter((name) => name !== opts.preferLast), ...owned.filter((name) => name === opts.preferLast)];
319
+ const dropped = [];
320
+ for (const databaseName of ordered) {
321
+ await dropOneDatabase(client, databaseName);
322
+ dropped.push(databaseName);
323
+ }
324
+ await client.query(`DROP ROLE IF EXISTS ${quoteIdent(opts.roleName)}`);
325
+ return dropped;
326
+ });
327
+ }
328
+ /**
329
+ * DROP DATABASE (unset IS_TEMPLATE, force terminate backends) + DROP ROLE
330
+ * when the role owns no remaining databases.
331
+ */
332
+ async function dropDatabase(opts) {
333
+ await withAdminClient(opts.adminUrl, async (client) => {
334
+ await dropOneDatabase(client, opts.databaseName);
335
+ if ((await client.query(`SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) LIMIT 1`, [opts.roleName])).rowCount === 0) await client.query(`DROP ROLE IF EXISTS ${quoteIdent(opts.roleName)}`);
336
+ });
337
+ }
338
+ function buildDatabaseUrl(opts) {
339
+ const password = opts.password ?? rolePasswordFor(opts.roleName);
340
+ return `postgresql://${encodeURIComponent(opts.roleName)}:${encodeURIComponent(password)}@127.0.0.1:${opts.port}/${opts.databaseName}`;
341
+ }
342
+ //#endregion
343
+ //#region src/providers/host.ts
344
+ const EPHEMERAL_PORT = 55432;
345
+ const HOST_READY_MS = 3e4;
346
+ const HOST_POLL_MS = 200;
347
+ /**
348
+ * autopg verbs that can bring the *registered* local host up, cheapest fix first.
349
+ *
350
+ * `restart` exit 0 is not evidence of a listener: when pm2 is missing or does not
351
+ * list `autopg-server`, autopg still prints "respawned daemon" and returns 0.
352
+ * That is why every verb is followed by a TCP wait, not trusted on exit status.
353
+ *
354
+ * `install` covers a never-registered machine (postinstall ships the binary only)
355
+ * and a reboot whose pm2 list is empty (`pm2 start`). On an already-registered
356
+ * host it is a no-op for the server process but may `pm2 start` the autopg UI —
357
+ * so it runs only after `restart` failed to produce a listener.
358
+ */
359
+ const LOCAL_START = [{
360
+ argv: ["restart"],
361
+ readyMs: 1e4
362
+ }, {
363
+ argv: ["install"],
364
+ readyMs: HOST_READY_MS
365
+ }];
366
+ /** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
367
+ const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
368
+ /**
369
+ * Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
370
+ * Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
371
+ */
372
+ const EPHEMERAL_SHM_HINT = "Ephemeral autopg uses /dev/shm with --ram. If initdb fails with Disk quota / No space (Postgres 53100):\n 1. Enlarge tmpfs (cloud VMs often default to ~64MB): sudo mount -o remount,size=6G /dev/shm\n 2. Clear leftovers from OOM-killed runs (your test data only):\n rm -rf /dev/shm/cedar-pg-* /dev/shm/pgserve-* /dev/shm/PostgreSQL.*";
373
+ /**
374
+ * Resolve how to start a host when none is listening.
375
+ *
376
+ * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
377
+ * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own a postmaster, local autopg only (even in CI)
378
+ * - unset + `CI=true` → ephemeral
379
+ * - otherwise → local
380
+ */
381
+ function resolveHostStartPolicy(env = process.env) {
382
+ const force = env.CEDAR_PG_EPHEMERAL_HOST;
383
+ if (force === "1") return "ephemeral";
384
+ if (force === "0") return "local";
385
+ if (env.CI === "true") return "ephemeral";
386
+ return "local";
387
+ }
388
+ function ephemeralHostRecipe(ctx = {}) {
389
+ const platform = ctx.platform ?? process.platform;
390
+ const shmAvailable = ctx.shmAvailable ?? (platform === "linux" && existsSync("/dev/shm"));
391
+ const uid = ctx.uid ?? process.getuid?.() ?? 0;
392
+ const port = ctx.port ?? EPHEMERAL_PORT;
393
+ const useRam = platform === "linux" && shmAvailable;
394
+ const dataDir = useRam ? `/dev/shm/cedar-pg-${uid}` : join(ctx.tmpDir ?? tmpdir(), "cedar-pg-host");
395
+ return {
396
+ dataDir,
397
+ port,
398
+ postmasterArgs: [
399
+ "postmaster",
400
+ ...useRam ? ["--ram"] : [],
401
+ "--port",
402
+ String(port),
403
+ "--socket-dir",
404
+ dataDir,
405
+ "--data",
406
+ dataDir
407
+ ]
408
+ };
409
+ }
410
+ function discoveryFromRecipe(bin, recipe) {
411
+ return {
412
+ port: recipe.port,
413
+ adminUrl: adminUrlFor(recipe.port),
414
+ bin
415
+ };
416
+ }
417
+ function sleep(ms) {
418
+ return new Promise((resolve) => setTimeout(resolve, ms));
419
+ }
420
+ /** True when stderr/stdout looks like a /dev/shm quota or ENOSPC failure. */
421
+ function looksLikeShmSpaceError(text) {
422
+ return /Disk quota exceeded|No space left on device|ENOSPC|\b53100\b/i.test(text);
423
+ }
424
+ function formatHostStartError(detail, useRamShm = false) {
425
+ return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
426
+ }
427
+ /**
428
+ * Remove leftover ephemeral data dirs under `/dev/shm` (and the recipe dataDir).
429
+ * Safe for OOM-killed CI/cloud runs that leave `cedar-pg-*` / `pgserve-*` /
430
+ * `PostgreSQL.*` filling tmpfs.
431
+ *
432
+ * Only called on ephemeral cold start, i.e. when the recipe port has no listener —
433
+ * never while a host owns those dirs.
434
+ */
435
+ function pruneStaleEphemeralDataDirs(opts) {
436
+ const removed = [];
437
+ const tryRm = (path) => {
438
+ if (!existsSync(path)) return;
439
+ try {
440
+ rmSync(path, {
441
+ recursive: true,
442
+ force: true
443
+ });
444
+ removed.push(path);
445
+ } catch {}
446
+ };
447
+ tryRm(opts.dataDir);
448
+ const shmRoot = opts.shmRoot ?? (existsSync("/dev/shm") ? "/dev/shm" : void 0);
449
+ if (!shmRoot) return removed;
450
+ let entries = [];
451
+ try {
452
+ entries = readdirSync(shmRoot);
453
+ } catch {
454
+ return removed;
455
+ }
456
+ for (const name of entries) if (name.startsWith("cedar-pg-") || name.startsWith("pgserve-") || name.startsWith("PostgreSQL.")) tryRm(join(shmRoot, name));
457
+ return removed;
458
+ }
459
+ /** Free bytes on `path`, or `null` if unavailable. */
460
+ function freeBytesOn(path) {
461
+ try {
462
+ const s = statfsSync(path);
463
+ return Number(s.bavail) * Number(s.bsize);
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+ /** Run a one-shot autopg verb. Returns failure detail, or `null` on exit 0. */
469
+ function runAutopg(bin, argv) {
470
+ const result = spawnSync(bin, argv, {
471
+ encoding: "utf8",
472
+ stdio: [
473
+ "ignore",
474
+ "pipe",
475
+ "pipe"
476
+ ]
477
+ });
478
+ if (result.error) return result.error.message;
479
+ if (result.status === 0) return null;
480
+ return `exit ${result.status}\n${(result.stderr || result.stdout || "").trim()}`.trim();
481
+ }
482
+ /** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
483
+ function canConnect(port) {
484
+ return new Promise((resolve) => {
485
+ const socket = createConnection({
486
+ host: "127.0.0.1",
487
+ port
488
+ }, () => {
489
+ socket.end();
490
+ resolve(true);
491
+ });
492
+ socket.on("error", () => {
493
+ resolve(false);
494
+ });
495
+ });
496
+ }
497
+ function killOwnedChild(child) {
498
+ const pid = child.pid;
499
+ if (pid != null) try {
500
+ process.kill(-pid, "SIGTERM");
501
+ } catch {
502
+ try {
503
+ child.kill("SIGTERM");
504
+ } catch {}
505
+ }
506
+ child.unref();
507
+ }
508
+ /**
509
+ * The single liveness primitive: TCP accept on 127.0.0.1:port.
510
+ *
511
+ * Resolves `true` as soon as something is listening, `false` when the grace
512
+ * period expires, and throws only what {@link WaitForListenerOptions.failure}
513
+ * returns. Used for attach (one probe), local restart, and ephemeral start.
514
+ */
515
+ async function waitForListener(opts) {
516
+ const probe = opts.canConnect ?? canConnect;
517
+ const now = opts.now ?? Date.now;
518
+ const pause = opts.sleep ?? sleep;
519
+ const pollMs = opts.pollMs ?? HOST_POLL_MS;
520
+ const deadline = now() + (opts.readyMs ?? 0);
521
+ for (;;) {
522
+ const fail = opts.failure?.();
523
+ if (fail) throw fail;
524
+ if (await probe(opts.port)) return true;
525
+ if (now() >= deadline) return false;
526
+ await pause(pollMs);
527
+ }
528
+ }
529
+ /**
530
+ * Attach to the registered autopg host once TCP accepts on its port.
531
+ *
532
+ * Registration is not liveness: a stopped pm2 host still reports a port, and
533
+ * attaching to it is the `ECONNREFUSED 127.0.0.1:25432` bug. `readyMs` > 0 is
534
+ * the grace period after asking autopg to start.
535
+ */
536
+ async function attachLiveHost(bin, readyMs = 0) {
537
+ let discovered;
538
+ try {
539
+ discovered = discoverHost(bin);
540
+ } catch {
541
+ return null;
542
+ }
543
+ return await waitForListener({
544
+ port: discovered.port,
545
+ readyMs
546
+ }) ? discovered : null;
547
+ }
548
+ /**
549
+ * Bring the user's registered autopg host up and attach to it — same port, same
550
+ * `~/.autopg/data`, still there after this process exits. cedar-pg never runs a
551
+ * second local Postgres.
552
+ */
553
+ async function startLocalHost(bin, registeredPort) {
554
+ const failures = [];
555
+ for (const { argv, readyMs } of LOCAL_START) {
556
+ const label = `autopg ${argv.join(" ")}`;
557
+ const failure = runAutopg(bin, [...argv]);
558
+ if (failure) {
559
+ failures.push(`${label}: ${failure}`);
560
+ continue;
561
+ }
562
+ const attached = await attachLiveHost(bin, readyMs);
563
+ if (attached) return attached;
564
+ failures.push(`${label}: no listener within ${readyMs}ms`);
565
+ }
566
+ const where = registeredPort != null ? `autopg host is registered on 127.0.0.1:${registeredPort} but nothing is listening.\n` : "";
567
+ throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
568
+ }
569
+ /**
570
+ * Ephemeral host owned by this process/job: detached `autopg postmaster` with
571
+ * fully ignored stdio (caller exit must not close pipes under the daemon).
572
+ *
573
+ * Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
574
+ * with `supervisor mismatch` next to a local pm2 install. Reuses a listener
575
+ * already on the recipe port, and prunes stale RAM dirs before a cold start.
576
+ */
577
+ async function startEphemeralHost(bin) {
578
+ const recipe = ephemeralHostRecipe();
579
+ if (await canConnect(recipe.port)) return discoveryFromRecipe(bin, recipe);
580
+ const useRamShm = recipe.postmasterArgs.includes("--ram");
581
+ pruneStaleEphemeralDataDirs({ dataDir: recipe.dataDir });
582
+ if (useRamShm) {
583
+ const free = freeBytesOn("/dev/shm");
584
+ if (free != null && free < 536870912) process.stderr.write(`[cedar-pg] warning: /dev/shm has ~${Math.round(free / (1024 * 1024))}MB free (recommend ≥${Math.round(EPHEMERAL_SHM_MIN_FREE_BYTES / (1024 * 1024))}MB for --ram).\n${EPHEMERAL_SHM_HINT}\n`);
585
+ }
586
+ mkdirSync(recipe.dataDir, { recursive: true });
587
+ const child = spawn(bin, recipe.postmasterArgs, {
588
+ detached: true,
589
+ stdio: "ignore"
590
+ });
591
+ let died = null;
592
+ child.on("error", (err) => {
593
+ died = new Error(formatHostStartError(`Failed to spawn autopg postmaster.\n${err.message}`, useRamShm));
594
+ });
595
+ child.on("exit", (code, signal) => {
596
+ died ??= new Error(formatHostStartError(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`, useRamShm));
597
+ });
598
+ try {
599
+ if (!await waitForListener({
600
+ port: recipe.port,
601
+ readyMs: HOST_READY_MS,
602
+ failure: () => died
603
+ })) throw new Error(formatHostStartError(`autopg postmaster did not accept 127.0.0.1:${recipe.port} within ${HOST_READY_MS}ms.`, useRamShm));
604
+ } catch (err) {
605
+ killOwnedChild(child);
606
+ throw err;
607
+ }
608
+ child.unref();
609
+ return discoveryFromRecipe(bin, recipe);
610
+ }
611
+ /**
612
+ * Attach to a listening autopg host, else start one per
613
+ * {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
614
+ */
615
+ async function ensureHostRunning(bin = requireAutopgBin()) {
616
+ let registered = null;
617
+ try {
618
+ registered = discoverHost(bin);
619
+ } catch {}
620
+ if (registered && await waitForListener({ port: registered.port })) return registered;
621
+ return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered?.port);
622
+ }
623
+ //#endregion
624
+ //#region src/core/lifecycle.ts
625
+ function writeEnvFile(root, mode, databaseUrl) {
626
+ mkdirSync(leaseDir(root), {
627
+ recursive: true,
628
+ mode: 448
629
+ });
630
+ let body = `DATABASE_URL=${databaseUrl}\n`;
631
+ if (mode === "test") body += `TEST_DATABASE_URL=${databaseUrl}\n`;
632
+ writeFileSync(envPath(root, mode), body, { mode: 384 });
633
+ }
634
+ function urlFromLease(lease) {
635
+ return buildDatabaseUrl({
636
+ port: lease.port,
637
+ databaseName: lease.databaseName,
638
+ roleName: lease.roleName
639
+ });
640
+ }
641
+ /**
642
+ * DROP every DB owned by the lease role (TEMPLATE + clones), then forget lease.
643
+ * Provider owns ordering (clones before leased datname) on one admin connection.
644
+ */
645
+ async function dropThenForget(lease, adminUrl) {
646
+ const dropped = await dropDatabasesOwnedByRole({
647
+ adminUrl,
648
+ roleName: lease.roleName,
649
+ preferLast: lease.databaseName
650
+ });
651
+ forgetLease(lease);
652
+ return dropped;
653
+ }
654
+ /**
655
+ * Acquire a worktree-scoped database and return connection info.
656
+ *
657
+ * - `dev`: keep DB across restarts.
658
+ * - `test`: DROP when `dispose()` is awaited (callers / test runners own teardown).
659
+ */
660
+ async function acquire(options) {
661
+ const identity = resolveWorktreeIdentity(options.root);
662
+ const mode = options.mode;
663
+ const databaseName = buildDatabaseName(identity, mode);
664
+ const roleName = buildRoleName(databaseName);
665
+ const host = await ensureHostRunning();
666
+ await ensureDatabase({
667
+ adminUrl: host.adminUrl,
668
+ databaseName,
669
+ roleName
670
+ });
671
+ const databaseUrl = buildDatabaseUrl({
672
+ port: host.port,
673
+ databaseName,
674
+ roleName
675
+ });
676
+ writeLease({
677
+ schemaVersion: 1,
678
+ mode,
679
+ root: identity.root,
680
+ repoSlug: identity.repoSlug,
681
+ worktreeSlug: identity.worktreeSlug,
682
+ pathHash: identity.pathHash,
683
+ databaseName,
684
+ roleName,
685
+ port: host.port,
686
+ pid: process.pid,
687
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
688
+ });
689
+ writeEnvFile(identity.root, mode, databaseUrl);
690
+ if (options.setEnv !== false) applyDatabaseUrlEnv(databaseUrl, { mode });
691
+ const disposeFn = async () => {
692
+ await dispose({
693
+ root: identity.root,
694
+ mode
695
+ });
696
+ };
697
+ return {
698
+ databaseUrl,
699
+ adminUrl: host.adminUrl,
700
+ databaseName,
701
+ roleName,
702
+ repoSlug: identity.repoSlug,
703
+ worktreeSlug: identity.worktreeSlug,
704
+ pathHash: identity.pathHash,
705
+ root: identity.root,
706
+ mode,
707
+ port: host.port,
708
+ dispose: disposeFn
709
+ };
710
+ }
711
+ /**
712
+ * Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
713
+ * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
714
+ */
715
+ async function acquireIfNeeded(options) {
716
+ const outcome = await runIfNeeded(options, () => acquire({
717
+ root: options.root,
718
+ mode: options.mode,
719
+ setEnv: options.setEnv
720
+ }));
721
+ if (outcome.status === "skipped") return outcome;
722
+ return {
723
+ status: "acquired",
724
+ ...outcome.value
725
+ };
726
+ }
727
+ /**
728
+ * Role-scoped suite teardown: DROP every database owned by the lease role
729
+ * (TEMPLATE + clones), then DROP ROLE and forget the lease. Unsets `IS_TEMPLATE`
730
+ * as needed. This is not per-clone cleanup — use `CloneResult.dropClone` for that.
731
+ * No-ops without a valid lease; never invents a DROP target beyond role ownership.
732
+ * If the host is unavailable, leaves the lease so dispose/gc can retry.
733
+ */
734
+ async function dispose(options = {}) {
735
+ const identity = resolveWorktreeIdentity(options.root);
736
+ const mode = options.mode ?? "test";
737
+ const lease = readLease(identity.root, mode);
738
+ if (!lease) return {
739
+ dropped: false,
740
+ reason: "no-lease"
741
+ };
742
+ let host;
743
+ try {
744
+ host = await ensureHostRunning();
745
+ } catch {
746
+ return {
747
+ dropped: false,
748
+ reason: "host-unavailable"
749
+ };
750
+ }
751
+ const droppedDatabases = await dropThenForget(lease, host.adminUrl);
752
+ return {
753
+ dropped: true,
754
+ databaseName: lease.databaseName,
755
+ droppedDatabases
756
+ };
757
+ }
758
+ /**
759
+ * Drop databases whose registered worktree root no longer exists on disk.
760
+ * Registry entries are removed only after a successful DROP (owned DBs + lease DB).
761
+ */
762
+ async function gc() {
763
+ const dropped = [];
764
+ const orphans = listRegistryLeases().filter(isOrphanLease);
765
+ if (orphans.length === 0) return { dropped };
766
+ let host;
767
+ try {
768
+ host = await ensureHostRunning();
769
+ } catch {
770
+ return { dropped };
771
+ }
772
+ for (const lease of orphans) try {
773
+ const names = await dropThenForget(lease, host.adminUrl);
774
+ dropped.push(...names);
775
+ } catch {}
776
+ return { dropped };
777
+ }
778
+ //#endregion
779
+ export { buildCloneDatabaseName as C, runIfNeeded as S, buildRoleName as T, setDatabaseIsTemplate as _, urlFromLease as a, isExternalDatabaseEscapeHatch as b, ROLE_PASSWORD_SCHEME as c, discoverHost as d, dropDatabase as f, rolePasswordFor as g, resolveAutopgBin as h, gc as i, buildDatabaseUrl as l, requireAutopgBin as m, acquireIfNeeded as n, ensureHostRunning as o, parseHostStatus as p, dispose as r, INSTALL_HINT as s, acquire as t, cloneDatabaseFromTemplate as u, applyDatabaseUrlEnv as v, buildDatabaseName as w, resolveAcquireSkip as x, isCedarPgManagedUrl as y };
780
+
781
+ //# sourceMappingURL=lifecycle-BvIx0xqq.mjs.map