@cedarjs/pg 0.2.0-alpha.0 → 0.3.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 (112) hide show
  1. package/README.md +300 -230
  2. package/dist/cli.cjs +99 -43
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.mjs +85 -29
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/dev-env.cjs +1 -1
  7. package/dist/dev-env.mjs +1 -1
  8. package/dist/index.cjs +9 -8
  9. package/dist/index.d.cts +80 -9
  10. package/dist/index.d.mts +80 -9
  11. package/dist/index.mjs +7 -7
  12. package/dist/jest-teardown.cjs +1 -1
  13. package/dist/jest-teardown.mjs +1 -1
  14. package/dist/jest-template.cjs +7 -2
  15. package/dist/jest-template.cjs.map +1 -1
  16. package/dist/jest-template.d.cts +7 -2
  17. package/dist/jest-template.d.mts +7 -2
  18. package/dist/jest-template.mjs +7 -2
  19. package/dist/jest-template.mjs.map +1 -1
  20. package/dist/jest.cjs +1 -1
  21. package/dist/jest.mjs +1 -1
  22. package/dist/{lease-BSBBpaR4.mjs → lease-DS1SX8U_.mjs} +23 -3
  23. package/dist/lease-DS1SX8U_.mjs.map +1 -0
  24. package/dist/{lease-Bq9wKwQa.cjs → lease-WlmOnNDi.cjs} +41 -3
  25. package/dist/lease-WlmOnNDi.cjs.map +1 -0
  26. package/dist/lifecycle-2BZgSVz2.mjs +1072 -0
  27. package/dist/lifecycle-2BZgSVz2.mjs.map +1 -0
  28. package/dist/{lifecycle-DEJ4GgWV.cjs → lifecycle-BCeM96tI.cjs} +517 -117
  29. package/dist/lifecycle-BCeM96tI.cjs.map +1 -0
  30. package/dist/{load-dev-env-7UjMHhw7.mjs → load-dev-env-BULkXyen.mjs} +2 -2
  31. package/dist/{load-dev-env-7UjMHhw7.mjs.map → load-dev-env-BULkXyen.mjs.map} +1 -1
  32. package/dist/{load-dev-env-cII6N4ZP.cjs → load-dev-env-Bl6Ddv1U.cjs} +2 -2
  33. package/dist/{load-dev-env-cII6N4ZP.cjs.map → load-dev-env-Bl6Ddv1U.cjs.map} +1 -1
  34. package/dist/{load-mode-env-B0qO9DZx.cjs → load-mode-env-CX9vd56X.cjs} +2 -2
  35. package/dist/{load-mode-env-B0qO9DZx.cjs.map → load-mode-env-CX9vd56X.cjs.map} +1 -1
  36. package/dist/{load-mode-env-C8gy4v9V.mjs → load-mode-env-Ce9ujisN.mjs} +2 -2
  37. package/dist/{load-mode-env-C8gy4v9V.mjs.map → load-mode-env-Ce9ujisN.mjs.map} +1 -1
  38. package/dist/{load-test-env-C34wpkIX.mjs → load-test-env-BqtA6V-d.mjs} +2 -2
  39. package/dist/{load-test-env-C34wpkIX.mjs.map → load-test-env-BqtA6V-d.mjs.map} +1 -1
  40. package/dist/{load-test-env-CkdUjTpV.cjs → load-test-env-Dcgh6YHV.cjs} +2 -2
  41. package/dist/{load-test-env-CkdUjTpV.cjs.map → load-test-env-Dcgh6YHV.cjs.map} +1 -1
  42. package/dist/{naming-C2nGVxPk.d.cts → naming-Df4fz_Fb.d.cts} +3 -2
  43. package/dist/{naming-C2nGVxPk.d.mts → naming-Df4fz_Fb.d.mts} +3 -2
  44. package/dist/status-B68SGSKx.mjs +26 -0
  45. package/dist/status-B68SGSKx.mjs.map +1 -0
  46. package/dist/status-Z_BYdxzI.cjs +31 -0
  47. package/dist/status-Z_BYdxzI.cjs.map +1 -0
  48. package/dist/studio-Bi6SU9jE.mjs +221 -0
  49. package/dist/studio-Bi6SU9jE.mjs.map +1 -0
  50. package/dist/studio-CdWIdnxr.cjs +280 -0
  51. package/dist/studio-CdWIdnxr.cjs.map +1 -0
  52. package/dist/{template-D0gJ_MS2.cjs → template-Cd77H7Cr.cjs} +21 -18
  53. package/dist/template-Cd77H7Cr.cjs.map +1 -0
  54. package/dist/{template-CGw3C1Ob.mjs → template-DlqHL8Go.mjs} +21 -18
  55. package/dist/template-DlqHL8Go.mjs.map +1 -0
  56. package/dist/template-mode-BqV3GjXI.d.cts +48 -0
  57. package/dist/template-mode-BqV3GjXI.d.mts +48 -0
  58. package/dist/template-mode-DK5cAlv9.mjs +81 -0
  59. package/dist/template-mode-DK5cAlv9.mjs.map +1 -0
  60. package/dist/template-mode-DMO5-z7O.cjs +92 -0
  61. package/dist/template-mode-DMO5-z7O.cjs.map +1 -0
  62. package/dist/test-env.cjs +1 -1
  63. package/dist/test-env.mjs +1 -1
  64. package/dist/vite-plus.cjs +113 -10
  65. package/dist/vite-plus.cjs.map +1 -1
  66. package/dist/vite-plus.d.cts +41 -2
  67. package/dist/vite-plus.d.mts +41 -2
  68. package/dist/vite-plus.mjs +110 -7
  69. package/dist/vite-plus.mjs.map +1 -1
  70. package/dist/vitest-template.cjs +6 -3
  71. package/dist/vitest-template.cjs.map +1 -1
  72. package/dist/vitest-template.d.cts +6 -3
  73. package/dist/vitest-template.d.mts +6 -3
  74. package/dist/vitest-template.mjs +6 -3
  75. package/dist/vitest-template.mjs.map +1 -1
  76. package/dist/vitest.cjs +1 -1
  77. package/dist/vitest.mjs +1 -1
  78. package/package.json +11 -7
  79. package/scripts/autopg-version +1 -1
  80. package/scripts/ci-install-autopg.sh +7 -2
  81. package/scripts/postinstall.js +41 -13
  82. package/dist/constants-Ct8myrEn.cjs +0 -41
  83. package/dist/constants-Ct8myrEn.cjs.map +0 -1
  84. package/dist/constants-NLR0U4NR.mjs +0 -24
  85. package/dist/constants-NLR0U4NR.mjs.map +0 -1
  86. package/dist/lease-B3TuX92y.d.mts +0 -24
  87. package/dist/lease-BSBBpaR4.mjs.map +0 -1
  88. package/dist/lease-Bq9wKwQa.cjs.map +0 -1
  89. package/dist/lease-t4I9JahV.d.cts +0 -24
  90. package/dist/lifecycle-BOo6xBjD.mjs +0 -684
  91. package/dist/lifecycle-BOo6xBjD.mjs.map +0 -1
  92. package/dist/lifecycle-DEJ4GgWV.cjs.map +0 -1
  93. package/dist/nx.cjs +0 -44
  94. package/dist/nx.cjs.map +0 -1
  95. package/dist/nx.d.cts +0 -15
  96. package/dist/nx.d.mts +0 -15
  97. package/dist/nx.mjs +0 -36
  98. package/dist/nx.mjs.map +0 -1
  99. package/dist/tasks-Caud9yHr.cjs +0 -67
  100. package/dist/tasks-Caud9yHr.cjs.map +0 -1
  101. package/dist/tasks-CjaZRi_G.d.mts +0 -19
  102. package/dist/tasks-DdQoP9We.d.cts +0 -19
  103. package/dist/tasks-KherHLac.mjs +0 -38
  104. package/dist/tasks-KherHLac.mjs.map +0 -1
  105. package/dist/template-CGw3C1Ob.mjs.map +0 -1
  106. package/dist/template-D0gJ_MS2.cjs.map +0 -1
  107. package/dist/template-mode-BkEs9LnY.cjs +0 -91
  108. package/dist/template-mode-BkEs9LnY.cjs.map +0 -1
  109. package/dist/template-mode-CcxV_Iju.d.cts +0 -28
  110. package/dist/template-mode-CcxV_Iju.d.mts +0 -28
  111. package/dist/template-mode-IjlFOG4O.mjs +0 -80
  112. package/dist/template-mode-IjlFOG4O.mjs.map +0 -1
@@ -0,0 +1,1072 @@
1
+ import { a as leaseDir, d as resolveRoot, f as resolveWorktreeIdentity, i as isOrphanLease, l as readLease, m as PASSWORD_SALT_PREFIX, n as envPath, p as CLI_NAME, r as forgetLease, s as listRegistryLeases, u as writeLease } from "./lease-DS1SX8U_.mjs";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, 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
+ /**
34
+ * Append `suffix` to a database name, staying ≤63 chars. When it does not fit,
35
+ * cut the readable head (`cpg_<repo>_<worktree>`) and keep the trailing
36
+ * `_<mode>_<pathHash8>` whole: that hash is what keeps worktrees apart.
37
+ */
38
+ function appendKeepingHash(databaseName, suffix) {
39
+ const full = `${databaseName}${suffix}`;
40
+ if (full.length <= PG_MAX) return full;
41
+ const tail = /_[^_]+_[^_]+$/.exec(databaseName)?.[0] ?? "";
42
+ return `${databaseName.slice(0, Math.max(0, PG_MAX - tail.length - suffix.length)).replace(/_+$/, "")}${tail}${suffix}`.slice(0, PG_MAX);
43
+ }
44
+ /** Role for a database name: `<databaseName>_role`, cut by `appendKeepingHash`. */
45
+ function buildRoleName(databaseName) {
46
+ return appendKeepingHash(databaseName, "_role");
47
+ }
48
+ /**
49
+ * Build a worker clone datname from a TEMPLATE database name.
50
+ * Layout: `<template>_c_<suffix>`, cut by `appendKeepingHash` (keeps mode, hash, and suffix).
51
+ */
52
+ function buildCloneDatabaseName(templateName, suffix) {
53
+ const safe = suffix.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24);
54
+ return appendKeepingHash(templateName, `_c_${safe.length > 0 ? safe : "w"}`);
55
+ }
56
+ //#endregion
57
+ //#region src/core/policy.ts
58
+ /**
59
+ * Inject DATABASE_URL (and TEST_DATABASE_URL for test mode) from a resolved URL.
60
+ * Single env path for acquire, clone, and external-url skip.
61
+ */
62
+ function applyDatabaseUrlEnv(databaseUrl, options = {}) {
63
+ process.env.DATABASE_URL = databaseUrl;
64
+ if ((options.mode ?? "test") === "test") process.env.TEST_DATABASE_URL = databaseUrl;
65
+ }
66
+ /**
67
+ * Shared host skip+env gate for `acquireIfNeeded` / `cloneFromTemplateIfNeeded`.
68
+ * On external-url skip, applies env when `setEnv` is not false.
69
+ */
70
+ async function runIfNeeded(options, run) {
71
+ const skip = resolveAcquireSkip({
72
+ url: options.url,
73
+ force: options.force,
74
+ disabled: options.disabled
75
+ });
76
+ if (skip.skip) {
77
+ if (skip.reason === "external-url") {
78
+ if (options.setEnv !== false) applyDatabaseUrlEnv(skip.databaseUrl, { mode: options.mode });
79
+ return {
80
+ status: "skipped",
81
+ reason: "external-url",
82
+ databaseUrl: skip.databaseUrl
83
+ };
84
+ }
85
+ return {
86
+ status: "skipped",
87
+ reason: "disabled"
88
+ };
89
+ }
90
+ return {
91
+ status: "ran",
92
+ value: await run()
93
+ };
94
+ }
95
+ /**
96
+ * True when the URL looks like a cedarpg provisioned database (`cpg_*` name/role).
97
+ * These must never be treated as an external escape hatch; always re-acquire so
98
+ * disposed/stale shell env cannot skip provisioning.
99
+ */
100
+ function isCedarPgManagedUrl(url) {
101
+ if (!url || url.startsWith("file:")) return false;
102
+ try {
103
+ const parsed = new URL(url);
104
+ const databaseName = decodeURIComponent((parsed.pathname.replace(/^\//, "").split("?")[0] ?? "").trim());
105
+ const user = decodeURIComponent(parsed.username);
106
+ return databaseName.startsWith("cpg_") || user.startsWith("cpg_");
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+ /**
112
+ * True when `url` still looks like an unset dotenv/template value
113
+ * (e.g. `postgresql://{yourMachine}@localhost:5432/app_test` or
114
+ * `postgresql://<user>@localhost/db`), not a real external database.
115
+ */
116
+ function isUnsetTemplateUrl(url) {
117
+ if (/\{[^}]+\}/.test(url)) return true;
118
+ if (/<[^>]+>/.test(url)) return true;
119
+ return false;
120
+ }
121
+ /**
122
+ * True when `url` is a real external database and acquire should be skipped.
123
+ * Sqlite `file:` URLs, cedarpg `cpg_*` URLs, and unset template placeholders
124
+ * are not external.
125
+ */
126
+ function isExternalDatabaseEscapeHatch(url) {
127
+ if (!url) return false;
128
+ if (url.startsWith("file:")) return false;
129
+ if (isCedarPgManagedUrl(url)) return false;
130
+ if (isUnsetTemplateUrl(url)) return false;
131
+ return true;
132
+ }
133
+ /**
134
+ * Decide whether acquire should run.
135
+ * Adapters may call with no args (env defaults). Cedar opt-in should pass `disabled: false`
136
+ * and the relevant `url` (`DATABASE_URL` or `TEST_DATABASE_URL`).
137
+ */
138
+ function resolveAcquireSkip(input = {}) {
139
+ if (input.disabled ?? (process.env.CEDAR_PG === "0" || process.env.CEDAR_PG === "false")) return {
140
+ skip: true,
141
+ reason: "disabled"
142
+ };
143
+ if (input.force ?? process.env.CEDAR_PG_FORCE === "1") return { skip: false };
144
+ const url = input.url !== void 0 ? input.url : process.env.TEST_DATABASE_URL;
145
+ if (url && isExternalDatabaseEscapeHatch(url)) return {
146
+ skip: true,
147
+ reason: "external-url",
148
+ databaseUrl: url
149
+ };
150
+ return { skip: false };
151
+ }
152
+ //#endregion
153
+ //#region src/providers/autopg.ts
154
+ /** autopg release cedar-pg is tested against (`scripts/autopg-version`, inlined at build). */
155
+ const AUTOPG_PINNED_VERSION = "v3.2.2";
156
+ 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.";
157
+ /** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
158
+ const ROLE_PASSWORD_SCHEME = "v2";
159
+ function candidateBins() {
160
+ const out = [];
161
+ if (process.env.AUTOPG_BIN) out.push(process.env.AUTOPG_BIN);
162
+ out.push("autopg");
163
+ out.push(join(homedir(), ".local", "bin", "autopg"));
164
+ return out;
165
+ }
166
+ function resolveAutopgBin() {
167
+ for (const bin of candidateBins()) {
168
+ if (bin === "autopg") {
169
+ const which = spawnSync("which", ["autopg"], { encoding: "utf8" });
170
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
171
+ continue;
172
+ }
173
+ if (existsSync(bin)) return bin;
174
+ }
175
+ return null;
176
+ }
177
+ function requireAutopgBin() {
178
+ const bin = resolveAutopgBin();
179
+ if (!bin) throw new Error(INSTALL_HINT);
180
+ return bin;
181
+ }
182
+ function optionalNonEmptyString(value) {
183
+ return typeof value === "string" && value.length > 0 ? value : void 0;
184
+ }
185
+ /**
186
+ * Parse `autopg status --json` → the **registered** port (and data/socket/logs
187
+ * dirs when present). Throws only when the output is not autopg status JSON.
188
+ *
189
+ * Registration is not liveness: autopg reports a port for a stopped host too,
190
+ * and its `status` / `ready` describe the supervisor (autopg ≥ v3.2: `ready` needs
191
+ * pm2 `online`; pm2's raw state is `supervisorStatus`). Neither, nor `runtime.live`,
192
+ * is the attach gate — a bare postmaster (e.g. one cedar-pg revived) is
193
+ * query-ready while status stays `stopped` / `ready: false`.
194
+ * Liveness is a TCP accept on the port, proven by the caller (`acquire`).
195
+ */
196
+ function parseHostStatus(json) {
197
+ let parsed;
198
+ try {
199
+ parsed = JSON.parse(json);
200
+ } catch {
201
+ throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
202
+ }
203
+ if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
204
+ const result = { port: parsed.port };
205
+ for (const key of [
206
+ "dataDir",
207
+ "socketDir",
208
+ "logsDir"
209
+ ]) {
210
+ const value = optionalNonEmptyString(parsed[key]);
211
+ if (value) result[key] = value;
212
+ }
213
+ return result;
214
+ }
215
+ /**
216
+ * Admin URL for an autopg host on `port`.
217
+ *
218
+ * Credentials follow autopg's own defaults / env chain (not "any local Postgres"):
219
+ * - user: `AUTOPG_PG_USER` / `PGSERVE_PG_USER` / `postgres`
220
+ * - password: `AUTOPG_PG_PASSWORD` / `PGSERVE_PG_PASSWORD` / `postgres`
221
+ *
222
+ * Port is never scanned: callers pass the port from `autopg status` (attach) or the
223
+ * ephemeral recipe (`55432`). That keeps us off unrelated local servers (e.g. brew on 5432).
224
+ */
225
+ function adminUrlFor(port, env = process.env) {
226
+ 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`;
227
+ }
228
+ /** `[major, minor, patch]` from `autopg --version` output or a `vX.Y.Z` tag; `null` if absent. */
229
+ function parseAutopgVersion(text) {
230
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
231
+ return m ? [
232
+ Number(m[1]),
233
+ Number(m[2]),
234
+ Number(m[3])
235
+ ] : null;
236
+ }
237
+ /**
238
+ * Upgrade warning when `autopg --version` output is older than `pin`. `null` when
239
+ * current, newer, or unreadable — never nag on what cannot be parsed. Upgrading
240
+ * is the user's call: it restarts the host every worktree shares.
241
+ */
242
+ function outdatedAutopgWarning(versionOutput, pin = AUTOPG_PINNED_VERSION) {
243
+ const have = parseAutopgVersion(versionOutput);
244
+ const want = parseAutopgVersion(pin);
245
+ if (!have || !want) return null;
246
+ if ((have[0] - want[0] || have[1] - want[1] || have[2] - want[2]) >= 0) return null;
247
+ return `[cedar-pg] warning: autopg ${have.join(".")} is older than ${pin}, the version @cedarjs/pg is tested with.\nUpgrade (restarts the shared host; open connections from other worktrees drop):
248
+ curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/${pin}/install.sh | AUTOPG_VERSION=${pin} bash\n autopg update
249
+ `;
250
+ }
251
+ /** `autopg --version` stdout; `""` when it cannot run (treated as unknown). */
252
+ function readAutopgVersion(bin) {
253
+ return spawnSync(bin, ["--version"], {
254
+ encoding: "utf8",
255
+ stdio: [
256
+ "ignore",
257
+ "pipe",
258
+ "ignore"
259
+ ],
260
+ timeout: 5e3
261
+ }).stdout ?? "";
262
+ }
263
+ function readStatusJson(bin) {
264
+ try {
265
+ return execFileSync(bin, ["status", "--json"], {
266
+ encoding: "utf8",
267
+ stdio: [
268
+ "ignore",
269
+ "pipe",
270
+ "pipe"
271
+ ]
272
+ });
273
+ } catch (err) {
274
+ const detail = err instanceof Error ? err.message : String(err);
275
+ throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
276
+ }
277
+ }
278
+ /**
279
+ * Discover the registered autopg host (attach target + reported data/socket/logs
280
+ * dirs) via `autopg status --json`. Throws when autopg cannot be queried; does
281
+ * **not** prove a listener — probe TCP (or use `acquire`, which does) before connecting.
282
+ */
283
+ function discoverRegistration(bin = requireAutopgBin()) {
284
+ const { port, ...paths } = parseHostStatus(readStatusJson(bin));
285
+ return {
286
+ host: {
287
+ port,
288
+ adminUrl: adminUrlFor(port),
289
+ bin
290
+ },
291
+ ...paths
292
+ };
293
+ }
294
+ /**
295
+ * Discover the registered autopg host (port + admin URL) via `autopg status --json`.
296
+ * Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
297
+ * (or use `acquire`, which does) before connecting.
298
+ */
299
+ function discoverHost(bin = requireAutopgBin()) {
300
+ return discoverRegistration(bin).host;
301
+ }
302
+ function quoteIdent(name) {
303
+ return `"${name.replace(/"/g, "\"\"")}"`;
304
+ }
305
+ function quoteLiteral(value) {
306
+ return `'${value.replace(/'/g, "''")}'`;
307
+ }
308
+ /** Grace for a postmaster that accepts TCP but still answers 57P03 (startup / recovery). */
309
+ const ADMIN_CONNECT_READY_MS = 3e4;
310
+ const ADMIN_CONNECT_POLL_MS = 200;
311
+ /**
312
+ * Retry `connect` while Postgres answers `57P03` (cannot_connect_now: "the
313
+ * database system is starting up" / in recovery). TCP accept — the host
314
+ * liveness gate — comes before query-ready, so a freshly revived or crash-
315
+ * recovering postmaster needs this. Any other error is thrown immediately.
316
+ */
317
+ async function connectWhileStartingUp(connect, opts = {}) {
318
+ const now = opts.now ?? Date.now;
319
+ const pause = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
320
+ const deadline = now() + (opts.readyMs ?? ADMIN_CONNECT_READY_MS);
321
+ for (;;) {
322
+ try {
323
+ return await connect();
324
+ } catch (err) {
325
+ if (err.code !== "57P03" || now() >= deadline) throw err;
326
+ }
327
+ await pause(opts.pollMs ?? ADMIN_CONNECT_POLL_MS);
328
+ }
329
+ }
330
+ async function withAdminClient(adminUrl, fn) {
331
+ const client = await connectWhileStartingUp(async () => {
332
+ const attempt = new pg.Client({ connectionString: adminUrl });
333
+ try {
334
+ await attempt.connect();
335
+ return attempt;
336
+ } catch (err) {
337
+ await attempt.end().catch(() => {});
338
+ throw err;
339
+ }
340
+ });
341
+ try {
342
+ return await fn(client);
343
+ } finally {
344
+ await client.end();
345
+ }
346
+ }
347
+ /**
348
+ * Deterministic local-only password for an app role (Prisma/TCP need it;
349
+ * autopg hba uses `password` for 127.0.0.1).
350
+ *
351
+ * Keyed by `roleName` (not databaseName) so TEMPLATE clones that reuse the
352
+ * same role keep working when `buildDatabaseUrl` is called with a new database.
353
+ */
354
+ function rolePasswordFor(roleName) {
355
+ return createHash("sha256").update(`${PASSWORD_SALT_PREFIX}\0${roleName}`).digest("hex").slice(0, 32);
356
+ }
357
+ /**
358
+ * Idempotently CREATE ROLE + CREATE DATABASE with cedar-pg owned names.
359
+ */
360
+ async function ensureDatabase(opts) {
361
+ const password = opts.password ?? rolePasswordFor(opts.roleName);
362
+ await withAdminClient(opts.adminUrl, async (client) => {
363
+ 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)}`);
364
+ else await client.query(`ALTER ROLE ${quoteIdent(opts.roleName)} WITH LOGIN PASSWORD ${quoteLiteral(password)}`);
365
+ 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)}`);
366
+ else await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} OWNER TO ${quoteIdent(opts.roleName)}`);
367
+ });
368
+ }
369
+ /**
370
+ * Mark (or unmark) a database as a PostgreSQL template (`IS_TEMPLATE`).
371
+ * Template DBs cannot be dropped until unset.
372
+ */
373
+ async function setDatabaseIsTemplate(opts) {
374
+ const flag = opts.isTemplate ? "true" : "false";
375
+ await withAdminClient(opts.adminUrl, async (client) => {
376
+ await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
377
+ });
378
+ }
379
+ /** Postgres `duplicate_database`: CREATE DATABASE hit an existing datname. */
380
+ const DUPLICATE_DATABASE = "42P04";
381
+ /**
382
+ * CREATE DATABASE … TEMPLATE … OWNER via admin connection.
383
+ * Test roles are LOGIN-only; workers cannot CREATE DATABASE themselves.
384
+ *
385
+ * With `reuse`, an existing `databaseName` owned by `roleName` is kept as-is:
386
+ * a worker clone made earlier in this run by another test file.
387
+ * An existing datname owned by any other role always fails: it is not ours.
388
+ */
389
+ async function cloneDatabaseFromTemplate(opts) {
390
+ await withAdminClient(opts.adminUrl, async (client) => {
391
+ const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
392
+ if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
393
+ if (!tmpl.rows[0]?.datistemplate) throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`);
394
+ try {
395
+ await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} WITH TEMPLATE ${quoteIdent(opts.templateName)} OWNER ${quoteIdent(opts.roleName)}`);
396
+ return;
397
+ } catch (err) {
398
+ if (err.code !== DUPLICATE_DATABASE) throw err;
399
+ }
400
+ const owner = (await client.query(`SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = $1`, [opts.databaseName])).rows[0]?.owner;
401
+ if (opts.reuse && owner === opts.roleName) return;
402
+ throw new Error(`database already exists: ${opts.databaseName} (owned by ${owner ?? "unknown"})`);
403
+ });
404
+ }
405
+ /**
406
+ * Empty every user table in `databaseName` with one
407
+ * `TRUNCATE … RESTART IDENTITY`, so serial / identity columns start at 1 again.
408
+ *
409
+ * Runs as admin against that database. Skips system schemas, temp tables, and
410
+ * tables an extension owns (e.g. PostGIS `spatial_ref_sys`); partitions are covered by
411
+ * their parent. No `CASCADE`: every user table is in the one statement, so no
412
+ * FK target is missing from it.
413
+ */
414
+ async function truncateUserTables(opts) {
415
+ const url = new URL(opts.adminUrl);
416
+ url.pathname = `/${opts.databaseName}`;
417
+ return withAdminClient(url.toString(), async (client) => {
418
+ const tables = (await client.query(`SELECT format('%I.%I', n.nspname, c.relname) AS name
419
+ FROM pg_class c
420
+ JOIN pg_namespace n ON n.oid = c.relnamespace
421
+ WHERE c.relkind IN ('r', 'p')
422
+ AND NOT c.relispartition
423
+ AND c.relpersistence <> 't'
424
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
425
+ AND NOT EXISTS (
426
+ SELECT 1 FROM pg_depend d
427
+ WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e'
428
+ )
429
+ ORDER BY 1`)).rows.map((r) => r.name);
430
+ if (tables.length > 0) await client.query(`TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY`);
431
+ return tables;
432
+ });
433
+ }
434
+ async function listOwnedDatnames(client, roleName) {
435
+ return (await client.query(`SELECT datname FROM pg_database
436
+ WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1)
437
+ ORDER BY datname`, [roleName])).rows.map((r) => r.datname);
438
+ }
439
+ /** Unset IS_TEMPLATE if needed, terminate backends, DROP DATABASE (no-op if missing). */
440
+ async function dropOneDatabase(client, databaseName) {
441
+ const db = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [databaseName]);
442
+ if (!db.rowCount || db.rowCount === 0) return;
443
+ if (db.rows[0]?.datistemplate) await client.query(`ALTER DATABASE ${quoteIdent(databaseName)} WITH IS_TEMPLATE false`);
444
+ await client.query(`
445
+ SELECT pg_terminate_backend(pid)
446
+ FROM pg_stat_activity
447
+ WHERE datname = $1 AND pid <> pg_backend_pid()
448
+ `, [databaseName]);
449
+ await client.query(`DROP DATABASE IF EXISTS ${quoteIdent(databaseName)}`);
450
+ }
451
+ /**
452
+ * DROP every database owned by `roleName` on one admin connection, then DROP ROLE.
453
+ * When `preferLast` is owned, it is dropped after the other owned datnames (TEMPLATE after clones).
454
+ * Never invents a DROP target beyond role ownership.
455
+ */
456
+ async function dropDatabasesOwnedByRole(opts) {
457
+ return withAdminClient(opts.adminUrl, async (client) => {
458
+ const owned = await listOwnedDatnames(client, opts.roleName);
459
+ const ordered = [...owned.filter((name) => name !== opts.preferLast), ...owned.filter((name) => name === opts.preferLast)];
460
+ const dropped = [];
461
+ for (const databaseName of ordered) {
462
+ await dropOneDatabase(client, databaseName);
463
+ dropped.push(databaseName);
464
+ }
465
+ await client.query(`DROP ROLE IF EXISTS ${quoteIdent(opts.roleName)}`);
466
+ return dropped;
467
+ });
468
+ }
469
+ /**
470
+ * DROP DATABASE (unset IS_TEMPLATE, force terminate backends) + DROP ROLE
471
+ * when the role owns no remaining databases.
472
+ */
473
+ async function dropDatabase(opts) {
474
+ await withAdminClient(opts.adminUrl, async (client) => {
475
+ await dropOneDatabase(client, opts.databaseName);
476
+ 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)}`);
477
+ });
478
+ }
479
+ function buildDatabaseUrl(opts) {
480
+ const password = opts.password ?? rolePasswordFor(opts.roleName);
481
+ return `postgresql://${encodeURIComponent(opts.roleName)}:${encodeURIComponent(password)}@127.0.0.1:${opts.port}/${opts.databaseName}`;
482
+ }
483
+ //#endregion
484
+ //#region src/providers/host.ts
485
+ const EPHEMERAL_PORT = 55432;
486
+ const HOST_READY_MS = 3e4;
487
+ const HOST_POLL_MS = 200;
488
+ /** `autopg restart` exits 0 only once ready; grace covers older autopg that returned early. */
489
+ const LOCAL_RESTART_READY_MS = 1e4;
490
+ /** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
491
+ const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
492
+ /**
493
+ * Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
494
+ * Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
495
+ */
496
+ 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. cedar-pg already removes its own leftover dir (/dev/shm/cedar-pg-<uid>) on cold start.\n Never rm /dev/shm/PostgreSQL.*: those segments belong to every running Postgres,\n and deleting a live one breaks new connections to that host (58P01).";
497
+ /**
498
+ * Resolve how to start a host when none is listening.
499
+ *
500
+ * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
501
+ * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own an ephemeral postmaster, local registered host only (even in CI)
502
+ * - unset + `CI=true` → ephemeral
503
+ * - otherwise → local
504
+ */
505
+ function resolveHostStartPolicy(env = process.env) {
506
+ const force = env.CEDAR_PG_EPHEMERAL_HOST;
507
+ if (force === "1") return "ephemeral";
508
+ if (force === "0") return "local";
509
+ if (env.CI === "true") return "ephemeral";
510
+ return "local";
511
+ }
512
+ function ephemeralHostRecipe(ctx = {}) {
513
+ const platform = ctx.platform ?? process.platform;
514
+ const shmAvailable = ctx.shmAvailable ?? (platform === "linux" && existsSync("/dev/shm"));
515
+ const uid = ctx.uid ?? process.getuid?.() ?? 0;
516
+ const port = ctx.port ?? EPHEMERAL_PORT;
517
+ const useRam = platform === "linux" && shmAvailable;
518
+ const dataDir = useRam ? `/dev/shm/cedar-pg-${uid}` : join(ctx.tmpDir ?? tmpdir(), "cedar-pg-host");
519
+ return {
520
+ dataDir,
521
+ port,
522
+ postmasterArgs: [
523
+ "postmaster",
524
+ ...useRam ? ["--ram"] : [],
525
+ "--port",
526
+ String(port),
527
+ "--socket-dir",
528
+ dataDir,
529
+ "--data",
530
+ dataDir
531
+ ]
532
+ };
533
+ }
534
+ function discoveryFromRecipe(bin, recipe) {
535
+ return {
536
+ port: recipe.port,
537
+ adminUrl: adminUrlFor(recipe.port),
538
+ bin
539
+ };
540
+ }
541
+ function sleep(ms) {
542
+ return new Promise((resolve) => setTimeout(resolve, ms));
543
+ }
544
+ /** True when stderr/stdout looks like a /dev/shm quota or ENOSPC failure. */
545
+ function looksLikeShmSpaceError(text) {
546
+ return /Disk quota exceeded|No space left on device|ENOSPC|\b53100\b/i.test(text);
547
+ }
548
+ function formatHostStartError(detail, useRamShm = false) {
549
+ return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
550
+ }
551
+ /** Free bytes on `path`, or `null` if unavailable. */
552
+ function freeBytesOn(path) {
553
+ try {
554
+ const s = statfsSync(path);
555
+ return Number(s.bavail) * Number(s.bsize);
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+ /** Run a one-shot autopg verb. Returns failure detail, or `null` on exit 0. */
561
+ function runAutopg(bin, argv) {
562
+ const result = spawnSync(bin, argv, {
563
+ encoding: "utf8",
564
+ stdio: [
565
+ "ignore",
566
+ "pipe",
567
+ "pipe"
568
+ ]
569
+ });
570
+ if (result.error) return result.error.message;
571
+ if (result.status === 0) return null;
572
+ const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
573
+ return `exit ${result.status}\n${output.trim()}`.trim();
574
+ }
575
+ /** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
576
+ function canConnect(port) {
577
+ return new Promise((resolve) => {
578
+ const socket = createConnection({
579
+ host: "127.0.0.1",
580
+ port
581
+ }, () => {
582
+ socket.end();
583
+ resolve(true);
584
+ });
585
+ socket.on("error", () => {
586
+ resolve(false);
587
+ });
588
+ });
589
+ }
590
+ function killOwnedChild(child) {
591
+ const pid = child.pid;
592
+ if (pid != null) try {
593
+ process.kill(-pid, "SIGTERM");
594
+ } catch {
595
+ try {
596
+ child.kill("SIGTERM");
597
+ } catch {}
598
+ }
599
+ child.unref();
600
+ }
601
+ /**
602
+ * The single liveness primitive: TCP accept on 127.0.0.1:port.
603
+ *
604
+ * Resolves `true` as soon as something is listening, `false` when the grace
605
+ * period expires, and throws only what {@link WaitForListenerOptions.failure}
606
+ * returns. Used for attach (one probe), local restart, and ephemeral start.
607
+ */
608
+ async function waitForListener(opts) {
609
+ const probe = opts.canConnect ?? canConnect;
610
+ const now = opts.now ?? Date.now;
611
+ const pause = opts.sleep ?? sleep;
612
+ const pollMs = opts.pollMs ?? HOST_POLL_MS;
613
+ const deadline = now() + (opts.readyMs ?? 0);
614
+ for (;;) {
615
+ const fail = opts.failure?.();
616
+ if (fail) throw fail;
617
+ if (await probe(opts.port)) return true;
618
+ if (now() >= deadline) return false;
619
+ await pause(pollMs);
620
+ }
621
+ }
622
+ /**
623
+ * Attach to the registered autopg host once TCP accepts on its port.
624
+ *
625
+ * Registration is not liveness: a stopped pm2 host still reports a port, and
626
+ * attaching to it is the `ECONNREFUSED 127.0.0.1:25432` bug. `readyMs` > 0 is
627
+ * the grace period after asking autopg to start.
628
+ */
629
+ async function attachLiveHost(bin, readyMs = 0) {
630
+ let discovered;
631
+ try {
632
+ discovered = discoverHost(bin);
633
+ } catch {
634
+ return null;
635
+ }
636
+ return await waitForListener({
637
+ port: discovered.port,
638
+ readyMs
639
+ }) ? discovered : null;
640
+ }
641
+ /** Appended by the revived registered postmaster (next to autopg's own pm2 logs). */
642
+ const REVIVED_POSTMASTER_LOG = "cedarpg-postmaster.log";
643
+ function errorDetail(err) {
644
+ return err instanceof Error ? err.message : String(err);
645
+ }
646
+ /**
647
+ * Live PID from `<dataDir>/postmaster.pid`, else `null`. Postgres writes it before
648
+ * binding, so a live owner means another postmaster (pm2 still recovering, or a
649
+ * concurrent acquire) already holds the data dir — wait for it, never compete.
650
+ */
651
+ function liveDataDirOwner(dataDir) {
652
+ return livePidIn(join(dataDir, "postmaster.pid"));
653
+ }
654
+ /** Live PID from the first line of `file`, else `null` (missing, garbage, or dead). */
655
+ function livePidIn(file) {
656
+ let pid;
657
+ try {
658
+ pid = Number.parseInt(readFileSync(file, "utf8"), 10);
659
+ } catch {
660
+ return null;
661
+ }
662
+ if (!Number.isInteger(pid) || pid <= 0) return null;
663
+ try {
664
+ process.kill(pid, 0);
665
+ return pid;
666
+ } catch (err) {
667
+ return err.code === "EPERM" ? pid : null;
668
+ }
669
+ }
670
+ /**
671
+ * Exclusive cold-start lock `<dataDir>.lock` holding our PID. Returns `null` once
672
+ * we hold it, else the live holder's PID. `postmaster.pid` alone cannot guard an
673
+ * ephemeral cold start: it does not exist yet while a peer runs initdb, which is
674
+ * exactly when a second `rm -rf` would destroy the peer's cluster.
675
+ *
676
+ * The PID is written to a private file and hard-linked into place, so the lock
677
+ * never exists without its PID. A dead holder's lock is stale and taken over.
678
+ */
679
+ function tryColdStartLock(lockPath) {
680
+ const mine = `${lockPath}.${process.pid}`;
681
+ writeFileSync(mine, String(process.pid));
682
+ try {
683
+ for (;;) {
684
+ try {
685
+ linkSync(mine, lockPath);
686
+ return null;
687
+ } catch (err) {
688
+ if (err.code !== "EEXIST") throw err;
689
+ }
690
+ const holder = livePidIn(lockPath);
691
+ if (holder != null) return holder;
692
+ rmSync(lockPath, { force: true });
693
+ }
694
+ } finally {
695
+ rmSync(mine, { force: true });
696
+ }
697
+ }
698
+ /**
699
+ * Detached `autopg postmaster`. Success: child is unref'd (survives this process).
700
+ * Failure: child is killed, throws a one-line detail (caller wraps / aggregates).
701
+ * `logFile` (appended) keeps output from a postmaster nothing else supervises;
702
+ * otherwise stdio is fully ignored (caller exit must not close pipes under it).
703
+ */
704
+ async function spawnDetachedPostmaster(opts) {
705
+ const { readyMs } = opts;
706
+ const logFd = opts.logFile ? openSync(opts.logFile, "a") : null;
707
+ let child;
708
+ try {
709
+ child = spawn(opts.bin, opts.args, {
710
+ detached: true,
711
+ stdio: logFd == null ? "ignore" : [
712
+ "ignore",
713
+ logFd,
714
+ logFd
715
+ ]
716
+ });
717
+ } finally {
718
+ if (logFd != null) closeSync(logFd);
719
+ }
720
+ let died = null;
721
+ child.on("error", (err) => {
722
+ died = /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${err.message}`);
723
+ });
724
+ child.on("exit", (code, signal) => {
725
+ died ??= /* @__PURE__ */ new Error(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`);
726
+ });
727
+ try {
728
+ if (!await waitForListener({
729
+ port: opts.port,
730
+ readyMs,
731
+ failure: () => died
732
+ })) throw new Error(`autopg postmaster did not accept 127.0.0.1:${opts.port} within ${readyMs}ms.`);
733
+ } catch (err) {
734
+ killOwnedChild(child);
735
+ throw err;
736
+ }
737
+ child.unref();
738
+ }
739
+ /**
740
+ * Detached `autopg postmaster` on the registered port / data dir (socket dir when
741
+ * autopg reports one; otherwise the postmaster resolves autopg's own default).
742
+ * Returns failure detail, or `null` once TCP accepts.
743
+ *
744
+ * No reported `dataDir` → not attempted: a postmaster without `--data` is not
745
+ * persistent, and guessing autopg's path is how a second Postgres happens.
746
+ */
747
+ async function reviveRegisteredPostmaster(bin, reg) {
748
+ const { dataDir, socketDir, logsDir } = reg;
749
+ const { port } = reg.host;
750
+ if (!dataDir) return "autopg postmaster: not attempted (autopg status --json reports no dataDir)";
751
+ const args = [
752
+ "postmaster",
753
+ "--port",
754
+ String(port),
755
+ "--data",
756
+ dataDir,
757
+ ...socketDir ? ["--socket-dir", socketDir] : []
758
+ ];
759
+ const label = `autopg ${args.join(" ")}`;
760
+ const logFile = logsDir ? join(logsDir, REVIVED_POSTMASTER_LOG) : void 0;
761
+ if (liveDataDirOwner(dataDir) == null) try {
762
+ mkdirSync(dataDir, {
763
+ recursive: true,
764
+ mode: 448
765
+ });
766
+ if (logsDir) mkdirSync(logsDir, { recursive: true });
767
+ await spawnDetachedPostmaster({
768
+ bin,
769
+ args,
770
+ port,
771
+ readyMs: HOST_READY_MS,
772
+ ...logFile ? { logFile } : {}
773
+ });
774
+ return null;
775
+ } catch (err) {
776
+ if (liveDataDirOwner(dataDir) == null) return `${label}: ${errorDetail(err)}${logFile ? `\nPostmaster log: ${logFile}` : ""}`;
777
+ }
778
+ if (await waitForListener({
779
+ port,
780
+ readyMs: HOST_READY_MS
781
+ })) return null;
782
+ return `${label}: postmaster pid ${liveDataDirOwner(dataDir) ?? "?"} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`;
783
+ }
784
+ /**
785
+ * Revive the user's registered autopg host — same port, same `~/.autopg/data`,
786
+ * still running after this process exits. Never a second local Postgres.
787
+ *
788
+ * `restart` drives pm2 only and exits 0 once ready; without pm2 (or under another
789
+ * supervisor) it exits 1. Either way TCP is the gate: if still dark,
790
+ * {@link reviveRegisteredPostmaster} on the registered port/data. Do not
791
+ * `install` on an already-registered host (wants pm2, refuses a port change).
792
+ * `install` is only for a never-registered machine.
793
+ */
794
+ async function startLocalHost(bin, registered) {
795
+ const failures = [];
796
+ const restartFailure = runAutopg(bin, ["restart"]);
797
+ if (restartFailure) failures.push(`autopg restart: ${restartFailure}`);
798
+ else {
799
+ const attached = await attachLiveHost(bin, LOCAL_RESTART_READY_MS);
800
+ if (attached) return attached;
801
+ failures.push(`autopg restart: no listener within ${LOCAL_RESTART_READY_MS}ms`);
802
+ }
803
+ if (registered) {
804
+ const failure = await reviveRegisteredPostmaster(bin, registered);
805
+ if (!failure) return registered.host;
806
+ failures.push(failure);
807
+ } else {
808
+ const installFailure = runAutopg(bin, ["install"]);
809
+ if (installFailure) failures.push(`autopg install: ${installFailure}`);
810
+ else {
811
+ const attached = await attachLiveHost(bin, HOST_READY_MS);
812
+ if (attached) return attached;
813
+ failures.push(`autopg install: no listener within ${HOST_READY_MS}ms`);
814
+ }
815
+ }
816
+ const where = registered != null ? `autopg host is registered on 127.0.0.1:${registered.host.port} but nothing is listening.\n` : "";
817
+ throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
818
+ }
819
+ /**
820
+ * Ephemeral host owned by this process/job: detached `autopg postmaster` with
821
+ * fully ignored stdio (caller exit must not close pipes under the daemon).
822
+ *
823
+ * Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
824
+ * with `supervisor mismatch` next to a local pm2 install. Reuses a listener
825
+ * already on the recipe port. Concurrent cold starts (Nx running several
826
+ * `db:ready` on one runner) serialize on {@link tryColdStartLock}: only the
827
+ * holder prunes and starts; everyone else waits for the listener.
828
+ */
829
+ async function startEphemeralHost(bin, recipe = ephemeralHostRecipe()) {
830
+ const { dataDir, port } = recipe;
831
+ if (await canConnect(port)) return discoveryFromRecipe(bin, recipe);
832
+ const useRamShm = recipe.postmasterArgs.includes("--ram");
833
+ const lockPath = `${dataDir}.lock`;
834
+ const holder = tryColdStartLock(lockPath);
835
+ if (holder == null) try {
836
+ if (!await canConnect(port) && liveDataDirOwner(dataDir) == null) await coldStartEphemeral(bin, recipe, useRamShm);
837
+ } finally {
838
+ rmSync(lockPath, { force: true });
839
+ }
840
+ if (await waitForListener({
841
+ port,
842
+ readyMs: HOST_READY_MS
843
+ })) return discoveryFromRecipe(bin, recipe);
844
+ const owner = holder != null ? `cold start pid ${holder}` : `postmaster pid ${liveDataDirOwner(dataDir) ?? "?"}`;
845
+ throw new Error(formatHostStartError(`${owner} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`, useRamShm));
846
+ }
847
+ /** Prune our dead leftover data dir and start the postmaster. Caller holds the cold-start lock. */
848
+ async function coldStartEphemeral(bin, recipe, useRamShm) {
849
+ rmSync(recipe.dataDir, {
850
+ recursive: true,
851
+ force: true
852
+ });
853
+ if (useRamShm) {
854
+ const free = freeBytesOn("/dev/shm");
855
+ 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`);
856
+ }
857
+ mkdirSync(recipe.dataDir, { recursive: true });
858
+ try {
859
+ await spawnDetachedPostmaster({
860
+ bin,
861
+ args: recipe.postmasterArgs,
862
+ port: recipe.port,
863
+ readyMs: HOST_READY_MS
864
+ });
865
+ } catch (err) {
866
+ if (liveDataDirOwner(recipe.dataDir) != null) return;
867
+ throw new Error(formatHostStartError(errorDetail(err), useRamShm));
868
+ }
869
+ }
870
+ let autopgVersionChecked = false;
871
+ /** Once per process: warn (never fail, never upgrade) when autopg is older than the pin. */
872
+ function warnIfAutopgOutdated(bin) {
873
+ if (autopgVersionChecked) return;
874
+ autopgVersionChecked = true;
875
+ const warning = outdatedAutopgWarning(readAutopgVersion(bin));
876
+ if (warning) process.stderr.write(warning);
877
+ }
878
+ /**
879
+ * Attach to a listening autopg host, else start one per
880
+ * {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
881
+ */
882
+ async function ensureHostRunning(bin = requireAutopgBin()) {
883
+ warnIfAutopgOutdated(bin);
884
+ let registered = null;
885
+ try {
886
+ registered = discoverRegistration(bin);
887
+ } catch {}
888
+ if (registered && await waitForListener({ port: registered.host.port })) return registered.host;
889
+ return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered);
890
+ }
891
+ //#endregion
892
+ //#region src/core/lifecycle.ts
893
+ function writeEnvFile(root, mode, databaseUrl) {
894
+ mkdirSync(leaseDir(root), {
895
+ recursive: true,
896
+ mode: 448
897
+ });
898
+ let body = `DATABASE_URL=${databaseUrl}\n`;
899
+ if (mode === "test") body += `TEST_DATABASE_URL=${databaseUrl}\n`;
900
+ writeFileSync(envPath(root, mode), body, { mode: 384 });
901
+ }
902
+ function urlFromLease(lease) {
903
+ return buildDatabaseUrl({
904
+ port: lease.port,
905
+ databaseName: lease.databaseName,
906
+ roleName: lease.roleName
907
+ });
908
+ }
909
+ /**
910
+ * DROP every DB owned by the lease role (TEMPLATE + clones), then forget lease.
911
+ * Provider owns ordering (clones before leased datname) on one admin connection.
912
+ */
913
+ async function dropThenForget(lease, adminUrl) {
914
+ const dropped = await dropDatabasesOwnedByRole({
915
+ adminUrl,
916
+ roleName: lease.roleName,
917
+ preferLast: lease.databaseName
918
+ });
919
+ forgetLease(lease);
920
+ return dropped;
921
+ }
922
+ /**
923
+ * Acquire a worktree-scoped database and return connection info.
924
+ *
925
+ * - `dev`: keep DB across restarts.
926
+ * - `test`: DROP when `dispose()` is awaited (callers / test runners own teardown).
927
+ */
928
+ async function acquire(options) {
929
+ const identity = resolveWorktreeIdentity(options.root);
930
+ const mode = options.mode;
931
+ const databaseName = buildDatabaseName(identity, mode);
932
+ const leased = readLease(identity.root, mode);
933
+ const roleName = leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);
934
+ const host = await ensureHostRunning();
935
+ if (options.fresh) await dropDatabasesOwnedByRole({
936
+ adminUrl: host.adminUrl,
937
+ roleName,
938
+ preferLast: databaseName
939
+ });
940
+ await ensureDatabase({
941
+ adminUrl: host.adminUrl,
942
+ databaseName,
943
+ roleName
944
+ });
945
+ const databaseUrl = buildDatabaseUrl({
946
+ port: host.port,
947
+ databaseName,
948
+ roleName
949
+ });
950
+ writeLease({
951
+ schemaVersion: 1,
952
+ mode,
953
+ root: identity.root,
954
+ repoSlug: identity.repoSlug,
955
+ worktreeSlug: identity.worktreeSlug,
956
+ pathHash: identity.pathHash,
957
+ databaseName,
958
+ roleName,
959
+ port: host.port,
960
+ pid: process.pid,
961
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
962
+ });
963
+ writeEnvFile(identity.root, mode, databaseUrl);
964
+ if (options.setEnv !== false) applyDatabaseUrlEnv(databaseUrl, { mode });
965
+ const disposeFn = async () => {
966
+ await dispose({
967
+ root: identity.root,
968
+ mode
969
+ });
970
+ };
971
+ return {
972
+ databaseUrl,
973
+ adminUrl: host.adminUrl,
974
+ databaseName,
975
+ roleName,
976
+ repoSlug: identity.repoSlug,
977
+ worktreeSlug: identity.worktreeSlug,
978
+ pathHash: identity.pathHash,
979
+ root: identity.root,
980
+ mode,
981
+ port: host.port,
982
+ dispose: disposeFn
983
+ };
984
+ }
985
+ /**
986
+ * Attach-only: connection info from the lease a prior `acquire` wrote. Never
987
+ * acquires, never runs role/database DDL, never starts or revives the host —
988
+ * safe for many concurrent children after one `db:ready`. Throws when there is
989
+ * no lease or nothing accepts TCP on the leased port.
990
+ */
991
+ async function attach(options) {
992
+ const root = resolveRoot(options.root);
993
+ const { mode } = options;
994
+ const lease = readLease(root, mode);
995
+ if (!lease) throw new Error(`no ${mode} lease in ${root}; run \`${CLI_NAME} acquire --mode=${mode}\` (or your db:ready target) first — attach never acquires`);
996
+ if (!await waitForListener({ port: lease.port })) throw new Error(`${mode} lease ${lease.databaseName} points at 127.0.0.1:${lease.port}, but nothing is listening; re-run \`${CLI_NAME} acquire --mode=${mode}\` to bring the host back`);
997
+ return {
998
+ lease,
999
+ databaseUrl: urlFromLease(lease)
1000
+ };
1001
+ }
1002
+ /**
1003
+ * Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
1004
+ * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
1005
+ */
1006
+ async function acquireIfNeeded(options) {
1007
+ const outcome = await runIfNeeded(options, () => acquire({
1008
+ root: options.root,
1009
+ mode: options.mode,
1010
+ setEnv: options.setEnv,
1011
+ fresh: options.fresh
1012
+ }));
1013
+ if (outcome.status === "skipped") return outcome;
1014
+ return {
1015
+ status: "acquired",
1016
+ ...outcome.value
1017
+ };
1018
+ }
1019
+ /**
1020
+ * Role-scoped suite teardown: DROP every database owned by the lease role
1021
+ * (TEMPLATE + clones), then DROP ROLE and forget the lease. Unsets `IS_TEMPLATE`
1022
+ * as needed. This is not per-clone cleanup — use `CloneResult.dropClone` for that.
1023
+ * No-ops without a valid lease; never invents a DROP target beyond role ownership.
1024
+ * If the host is unavailable, leaves the lease so dispose/gc can retry.
1025
+ */
1026
+ async function dispose(options = {}) {
1027
+ const mode = options.mode ?? "test";
1028
+ const lease = readLease(resolveRoot(options.root), mode);
1029
+ if (!lease) return {
1030
+ dropped: false,
1031
+ reason: "no-lease"
1032
+ };
1033
+ let host;
1034
+ try {
1035
+ host = await ensureHostRunning();
1036
+ } catch {
1037
+ return {
1038
+ dropped: false,
1039
+ reason: "host-unavailable"
1040
+ };
1041
+ }
1042
+ const droppedDatabases = await dropThenForget(lease, host.adminUrl);
1043
+ return {
1044
+ dropped: true,
1045
+ databaseName: lease.databaseName,
1046
+ droppedDatabases
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Drop databases whose registered worktree root no longer exists on disk.
1051
+ * Registry entries are removed only after a successful DROP (owned DBs + lease DB).
1052
+ */
1053
+ async function gc() {
1054
+ const dropped = [];
1055
+ const orphans = listRegistryLeases().filter(isOrphanLease);
1056
+ if (orphans.length === 0) return { dropped };
1057
+ let host;
1058
+ try {
1059
+ host = await ensureHostRunning();
1060
+ } catch {
1061
+ return { dropped };
1062
+ }
1063
+ for (const lease of orphans) try {
1064
+ const names = await dropThenForget(lease, host.adminUrl);
1065
+ dropped.push(...names);
1066
+ } catch {}
1067
+ return { dropped };
1068
+ }
1069
+ //#endregion
1070
+ export { resolveAcquireSkip as C, buildRoleName as D, buildDatabaseName as E, isExternalDatabaseEscapeHatch as S, buildCloneDatabaseName as T, rolePasswordFor as _, gc as a, applyDatabaseUrlEnv as b, ROLE_PASSWORD_SCHEME as c, cloneDatabaseFromTemplate as d, discoverHost as f, resolveAutopgBin as g, requireAutopgBin as h, dispose as i, adminUrlFor as l, parseHostStatus as m, acquireIfNeeded as n, urlFromLease as o, dropDatabase as p, attach as r, INSTALL_HINT as s, acquire as t, buildDatabaseUrl as u, setDatabaseIsTemplate as v, runIfNeeded as w, isCedarPgManagedUrl as x, truncateUserTables as y };
1071
+
1072
+ //# sourceMappingURL=lifecycle-2BZgSVz2.mjs.map