@cedarjs/pg 0.2.0-beta.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 (87) hide show
  1. package/README.md +112 -63
  2. package/dist/cli.cjs +15 -9
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.mjs +15 -9
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/index.cjs +3 -3
  7. package/dist/index.d.cts +54 -11
  8. package/dist/index.d.mts +54 -11
  9. package/dist/index.mjs +3 -3
  10. package/dist/jest-teardown.cjs +1 -1
  11. package/dist/jest-teardown.mjs +1 -1
  12. package/dist/jest-template.cjs +5 -3
  13. package/dist/jest-template.cjs.map +1 -1
  14. package/dist/jest-template.d.cts +5 -3
  15. package/dist/jest-template.d.mts +5 -3
  16. package/dist/jest-template.mjs +5 -3
  17. package/dist/jest-template.mjs.map +1 -1
  18. package/dist/jest.cjs +1 -1
  19. package/dist/jest.mjs +1 -1
  20. package/dist/{lifecycle-BvIx0xqq.mjs → lifecycle-2BZgSVz2.mjs} +423 -132
  21. package/dist/lifecycle-2BZgSVz2.mjs.map +1 -0
  22. package/dist/{lifecycle-BbrvFQvg.cjs → lifecycle-BCeM96tI.cjs} +438 -135
  23. package/dist/lifecycle-BCeM96tI.cjs.map +1 -0
  24. package/dist/{naming-C2nGVxPk.d.cts → naming-Df4fz_Fb.d.cts} +3 -2
  25. package/dist/{naming-C2nGVxPk.d.mts → naming-Df4fz_Fb.d.mts} +3 -2
  26. package/dist/{status-Dcnpgnlz.mjs → status-B68SGSKx.mjs} +2 -2
  27. package/dist/{status-Dcnpgnlz.mjs.map → status-B68SGSKx.mjs.map} +1 -1
  28. package/dist/{status-4Uz6A3LB.cjs → status-Z_BYdxzI.cjs} +2 -2
  29. package/dist/{status-4Uz6A3LB.cjs.map → status-Z_BYdxzI.cjs.map} +1 -1
  30. package/dist/{studio-DIFw1qWC.mjs → studio-Bi6SU9jE.mjs} +54 -6
  31. package/dist/studio-Bi6SU9jE.mjs.map +1 -0
  32. package/dist/{studio-oJE30_PO.cjs → studio-CdWIdnxr.cjs} +78 -6
  33. package/dist/studio-CdWIdnxr.cjs.map +1 -0
  34. package/dist/{template-CXMpODzK.cjs → template-Cd77H7Cr.cjs} +21 -18
  35. package/dist/template-Cd77H7Cr.cjs.map +1 -0
  36. package/dist/{template-BiJ-0TIF.mjs → template-DlqHL8Go.mjs} +21 -18
  37. package/dist/template-DlqHL8Go.mjs.map +1 -0
  38. package/dist/template-mode-BqV3GjXI.d.cts +48 -0
  39. package/dist/template-mode-BqV3GjXI.d.mts +48 -0
  40. package/dist/template-mode-DK5cAlv9.mjs +81 -0
  41. package/dist/template-mode-DK5cAlv9.mjs.map +1 -0
  42. package/dist/template-mode-DMO5-z7O.cjs +92 -0
  43. package/dist/template-mode-DMO5-z7O.cjs.map +1 -0
  44. package/dist/vite-plus.cjs +9 -9
  45. package/dist/vite-plus.cjs.map +1 -1
  46. package/dist/vite-plus.d.cts +17 -2
  47. package/dist/vite-plus.d.mts +17 -2
  48. package/dist/vite-plus.mjs +5 -5
  49. package/dist/vite-plus.mjs.map +1 -1
  50. package/dist/vitest-template.cjs +6 -3
  51. package/dist/vitest-template.cjs.map +1 -1
  52. package/dist/vitest-template.d.cts +6 -3
  53. package/dist/vitest-template.d.mts +6 -3
  54. package/dist/vitest-template.mjs +6 -3
  55. package/dist/vitest-template.mjs.map +1 -1
  56. package/dist/vitest.cjs +1 -1
  57. package/dist/vitest.mjs +1 -1
  58. package/package.json +2 -7
  59. package/scripts/autopg-version +1 -1
  60. package/scripts/ci-install-autopg.sh +7 -2
  61. package/scripts/postinstall.js +41 -13
  62. package/dist/lease-B3TuX92y.d.mts +0 -24
  63. package/dist/lease-t4I9JahV.d.cts +0 -24
  64. package/dist/lifecycle-BbrvFQvg.cjs.map +0 -1
  65. package/dist/lifecycle-BvIx0xqq.mjs.map +0 -1
  66. package/dist/nx.cjs +0 -42
  67. package/dist/nx.cjs.map +0 -1
  68. package/dist/nx.d.cts +0 -15
  69. package/dist/nx.d.mts +0 -15
  70. package/dist/nx.mjs +0 -34
  71. package/dist/nx.mjs.map +0 -1
  72. package/dist/studio-DIFw1qWC.mjs.map +0 -1
  73. package/dist/studio-oJE30_PO.cjs.map +0 -1
  74. package/dist/tasks-CNHvvlsN.cjs +0 -67
  75. package/dist/tasks-CNHvvlsN.cjs.map +0 -1
  76. package/dist/tasks-CjaZRi_G.d.mts +0 -19
  77. package/dist/tasks-D5iWIdlo.mjs +0 -38
  78. package/dist/tasks-D5iWIdlo.mjs.map +0 -1
  79. package/dist/tasks-DdQoP9We.d.cts +0 -19
  80. package/dist/template-BiJ-0TIF.mjs.map +0 -1
  81. package/dist/template-CXMpODzK.cjs.map +0 -1
  82. package/dist/template-mode-BTUsdBtA.mjs +0 -94
  83. package/dist/template-mode-BTUsdBtA.mjs.map +0 -1
  84. package/dist/template-mode-CzyVwHsi.d.cts +0 -31
  85. package/dist/template-mode-CzyVwHsi.d.mts +0 -31
  86. package/dist/template-mode-JTOxrEgE.cjs +0 -105
  87. package/dist/template-mode-JTOxrEgE.cjs.map +0 -1
@@ -1,5 +1,5 @@
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";
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
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createHash } from "node:crypto";
@@ -30,21 +30,28 @@ function buildDatabaseName(identity, mode) {
30
30
  if (name.length > PG_MAX) return name.slice(0, PG_MAX);
31
31
  return name;
32
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`. */
33
45
  function buildRoleName(databaseName) {
34
- const suffix = "_role";
35
- const maxBase = PG_MAX - 5;
36
- return `${databaseName.slice(0, maxBase)}${suffix}`;
46
+ return appendKeepingHash(databaseName, "_role");
37
47
  }
38
48
  /**
39
49
  * Build a worker clone datname from a TEMPLATE database name.
40
- * Layout: `<template>_c_<suffix>` truncated to ≤63 chars (keeps suffix).
50
+ * Layout: `<template>_c_<suffix>`, cut by `appendKeepingHash` (keeps mode, hash, and suffix).
41
51
  */
42
52
  function buildCloneDatabaseName(templateName, suffix) {
43
53
  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);
54
+ return appendKeepingHash(templateName, `_c_${safe.length > 0 ? safe : "w"}`);
48
55
  }
49
56
  //#endregion
50
57
  //#region src/core/policy.ts
@@ -144,6 +151,8 @@ function resolveAcquireSkip(input = {}) {
144
151
  }
145
152
  //#endregion
146
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";
147
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.";
148
157
  /** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
149
158
  const ROLE_PASSWORD_SCHEME = "v2";
@@ -170,14 +179,19 @@ function requireAutopgBin() {
170
179
  if (!bin) throw new Error(INSTALL_HINT);
171
180
  return bin;
172
181
  }
182
+ function optionalNonEmptyString(value) {
183
+ return typeof value === "string" && value.length > 0 ? value : void 0;
184
+ }
173
185
  /**
174
- * Parse `autopg status --json` → the **registered** port. Throws only when the
175
- * output is not autopg status JSON.
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.
176
188
  *
177
189
  * 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.
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`).
181
195
  */
182
196
  function parseHostStatus(json) {
183
197
  let parsed;
@@ -187,7 +201,16 @@ function parseHostStatus(json) {
187
201
  throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
188
202
  }
189
203
  if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
190
- return { port: parsed.port };
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;
191
214
  }
192
215
  /**
193
216
  * Admin URL for an autopg host on `port`.
@@ -202,15 +225,44 @@ function parseHostStatus(json) {
202
225
  function adminUrlFor(port, env = process.env) {
203
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`;
204
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
+ }
205
237
  /**
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.
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.
209
241
  */
210
- function discoverHost(bin = requireAutopgBin()) {
211
- let status;
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) {
212
264
  try {
213
- status = execFileSync(bin, ["status", "--json"], {
265
+ return execFileSync(bin, ["status", "--json"], {
214
266
  encoding: "utf8",
215
267
  stdio: [
216
268
  "ignore",
@@ -222,22 +274,70 @@ function discoverHost(bin = requireAutopgBin()) {
222
274
  const detail = err instanceof Error ? err.message : String(err);
223
275
  throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
224
276
  }
225
- const { port } = parseHostStatus(status);
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));
226
285
  return {
227
- port,
228
- adminUrl: adminUrlFor(port),
229
- bin
286
+ host: {
287
+ port,
288
+ adminUrl: adminUrlFor(port),
289
+ bin
290
+ },
291
+ ...paths
230
292
  };
231
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
+ }
232
302
  function quoteIdent(name) {
233
303
  return `"${name.replace(/"/g, "\"\"")}"`;
234
304
  }
235
305
  function quoteLiteral(value) {
236
306
  return `'${value.replace(/'/g, "''")}'`;
237
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
+ }
238
330
  async function withAdminClient(adminUrl, fn) {
239
- const client = new pg.Client({ connectionString: adminUrl });
240
- await client.connect();
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
+ });
241
341
  try {
242
342
  return await fn(client);
243
343
  } finally {
@@ -276,18 +376,59 @@ async function setDatabaseIsTemplate(opts) {
276
376
  await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
277
377
  });
278
378
  }
379
+ /** Postgres `duplicate_database`: CREATE DATABASE hit an existing datname. */
380
+ const DUPLICATE_DATABASE = "42P04";
279
381
  /**
280
382
  * CREATE DATABASE … TEMPLATE … OWNER via admin connection.
281
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.
282
388
  */
283
389
  async function cloneDatabaseFromTemplate(opts) {
284
390
  await withAdminClient(opts.adminUrl, async (client) => {
285
391
  const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
286
392
  if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
287
393
  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)}`);
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;
291
432
  });
292
433
  }
293
434
  async function listOwnedDatnames(client, roleName) {
@@ -344,37 +485,20 @@ function buildDatabaseUrl(opts) {
344
485
  const EPHEMERAL_PORT = 55432;
345
486
  const HOST_READY_MS = 3e4;
346
487
  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
- }];
488
+ /** `autopg restart` exits 0 only once ready; grace covers older autopg that returned early. */
489
+ const LOCAL_RESTART_READY_MS = 1e4;
366
490
  /** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
367
491
  const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
368
492
  /**
369
493
  * Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
370
494
  * Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
371
495
  */
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.*";
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).";
373
497
  /**
374
498
  * Resolve how to start a host when none is listening.
375
499
  *
376
500
  * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
377
- * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own a postmaster, local autopg only (even in CI)
501
+ * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own an ephemeral postmaster, local registered host only (even in CI)
378
502
  * - unset + `CI=true` → ephemeral
379
503
  * - otherwise → local
380
504
  */
@@ -424,38 +548,6 @@ function looksLikeShmSpaceError(text) {
424
548
  function formatHostStartError(detail, useRamShm = false) {
425
549
  return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
426
550
  }
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
551
  /** Free bytes on `path`, or `null` if unavailable. */
460
552
  function freeBytesOn(path) {
461
553
  try {
@@ -477,7 +569,8 @@ function runAutopg(bin, argv) {
477
569
  });
478
570
  if (result.error) return result.error.message;
479
571
  if (result.status === 0) return null;
480
- return `exit ${result.status}\n${(result.stderr || result.stdout || "").trim()}`.trim();
572
+ const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
573
+ return `exit ${result.status}\n${output.trim()}`.trim();
481
574
  }
482
575
  /** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
483
576
  function canConnect(port) {
@@ -545,25 +638,182 @@ async function attachLiveHost(bin, readyMs = 0) {
545
638
  readyMs
546
639
  }) ? discovered : null;
547
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
+ }
548
646
  /**
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.
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.
552
650
  */
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;
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 });
561
693
  }
562
- const attached = await attachLiveHost(bin, readyMs);
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);
563
800
  if (attached) return attached;
564
- failures.push(`${label}: no listener within ${readyMs}ms`);
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
+ }
565
815
  }
566
- const where = registeredPort != null ? `autopg host is registered on 127.0.0.1:${registeredPort} but nothing is listening.\n` : "";
816
+ const where = registered != null ? `autopg host is registered on 127.0.0.1:${registered.host.port} but nothing is listening.\n` : "";
567
817
  throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
568
818
  }
569
819
  /**
@@ -572,53 +822,71 @@ async function startLocalHost(bin, registeredPort) {
572
822
  *
573
823
  * Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
574
824
  * 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.
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.
576
828
  */
577
- async function startEphemeralHost(bin) {
578
- const recipe = ephemeralHostRecipe();
579
- if (await canConnect(recipe.port)) return discoveryFromRecipe(bin, recipe);
829
+ async function startEphemeralHost(bin, recipe = ephemeralHostRecipe()) {
830
+ const { dataDir, port } = recipe;
831
+ if (await canConnect(port)) return discoveryFromRecipe(bin, recipe);
580
832
  const useRamShm = recipe.postmasterArgs.includes("--ram");
581
- pruneStaleEphemeralDataDirs({ dataDir: recipe.dataDir });
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
+ });
582
853
  if (useRamShm) {
583
854
  const free = freeBytesOn("/dev/shm");
584
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`);
585
856
  }
586
857
  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
858
  try {
599
- if (!await waitForListener({
859
+ await spawnDetachedPostmaster({
860
+ bin,
861
+ args: recipe.postmasterArgs,
600
862
  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));
863
+ readyMs: HOST_READY_MS
864
+ });
604
865
  } catch (err) {
605
- killOwnedChild(child);
606
- throw err;
866
+ if (liveDataDirOwner(recipe.dataDir) != null) return;
867
+ throw new Error(formatHostStartError(errorDetail(err), useRamShm));
607
868
  }
608
- child.unref();
609
- return discoveryFromRecipe(bin, recipe);
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);
610
877
  }
611
878
  /**
612
879
  * Attach to a listening autopg host, else start one per
613
880
  * {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
614
881
  */
615
882
  async function ensureHostRunning(bin = requireAutopgBin()) {
883
+ warnIfAutopgOutdated(bin);
616
884
  let registered = null;
617
885
  try {
618
- registered = discoverHost(bin);
886
+ registered = discoverRegistration(bin);
619
887
  } catch {}
620
- if (registered && await waitForListener({ port: registered.port })) return registered;
621
- return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered?.port);
888
+ if (registered && await waitForListener({ port: registered.host.port })) return registered.host;
889
+ return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered);
622
890
  }
623
891
  //#endregion
624
892
  //#region src/core/lifecycle.ts
@@ -661,8 +929,14 @@ async function acquire(options) {
661
929
  const identity = resolveWorktreeIdentity(options.root);
662
930
  const mode = options.mode;
663
931
  const databaseName = buildDatabaseName(identity, mode);
664
- const roleName = buildRoleName(databaseName);
932
+ const leased = readLease(identity.root, mode);
933
+ const roleName = leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);
665
934
  const host = await ensureHostRunning();
935
+ if (options.fresh) await dropDatabasesOwnedByRole({
936
+ adminUrl: host.adminUrl,
937
+ roleName,
938
+ preferLast: databaseName
939
+ });
666
940
  await ensureDatabase({
667
941
  adminUrl: host.adminUrl,
668
942
  databaseName,
@@ -709,6 +983,23 @@ async function acquire(options) {
709
983
  };
710
984
  }
711
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
+ /**
712
1003
  * Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
713
1004
  * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
714
1005
  */
@@ -716,7 +1007,8 @@ async function acquireIfNeeded(options) {
716
1007
  const outcome = await runIfNeeded(options, () => acquire({
717
1008
  root: options.root,
718
1009
  mode: options.mode,
719
- setEnv: options.setEnv
1010
+ setEnv: options.setEnv,
1011
+ fresh: options.fresh
720
1012
  }));
721
1013
  if (outcome.status === "skipped") return outcome;
722
1014
  return {
@@ -732,9 +1024,8 @@ async function acquireIfNeeded(options) {
732
1024
  * If the host is unavailable, leaves the lease so dispose/gc can retry.
733
1025
  */
734
1026
  async function dispose(options = {}) {
735
- const identity = resolveWorktreeIdentity(options.root);
736
1027
  const mode = options.mode ?? "test";
737
- const lease = readLease(identity.root, mode);
1028
+ const lease = readLease(resolveRoot(options.root), mode);
738
1029
  if (!lease) return {
739
1030
  dropped: false,
740
1031
  reason: "no-lease"
@@ -776,6 +1067,6 @@ async function gc() {
776
1067
  return { dropped };
777
1068
  }
778
1069
  //#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 };
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 };
780
1071
 
781
- //# sourceMappingURL=lifecycle-BvIx0xqq.mjs.map
1072
+ //# sourceMappingURL=lifecycle-2BZgSVz2.mjs.map