@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
@@ -53,21 +53,28 @@ function buildDatabaseName(identity, mode) {
53
53
  if (name.length > PG_MAX) return name.slice(0, PG_MAX);
54
54
  return name;
55
55
  }
56
+ /**
57
+ * Append `suffix` to a database name, staying ≤63 chars. When it does not fit,
58
+ * cut the readable head (`cpg_<repo>_<worktree>`) and keep the trailing
59
+ * `_<mode>_<pathHash8>` whole: that hash is what keeps worktrees apart.
60
+ */
61
+ function appendKeepingHash(databaseName, suffix) {
62
+ const full = `${databaseName}${suffix}`;
63
+ if (full.length <= PG_MAX) return full;
64
+ const tail = /_[^_]+_[^_]+$/.exec(databaseName)?.[0] ?? "";
65
+ return `${databaseName.slice(0, Math.max(0, PG_MAX - tail.length - suffix.length)).replace(/_+$/, "")}${tail}${suffix}`.slice(0, PG_MAX);
66
+ }
67
+ /** Role for a database name: `<databaseName>_role`, cut by `appendKeepingHash`. */
56
68
  function buildRoleName(databaseName) {
57
- const suffix = "_role";
58
- const maxBase = PG_MAX - 5;
59
- return `${databaseName.slice(0, maxBase)}${suffix}`;
69
+ return appendKeepingHash(databaseName, "_role");
60
70
  }
61
71
  /**
62
72
  * Build a worker clone datname from a TEMPLATE database name.
63
- * Layout: `<template>_c_<suffix>` truncated to ≤63 chars (keeps suffix).
73
+ * Layout: `<template>_c_<suffix>`, cut by `appendKeepingHash` (keeps mode, hash, and suffix).
64
74
  */
65
75
  function buildCloneDatabaseName(templateName, suffix) {
66
76
  const safe = suffix.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24);
67
- const tag = safe.length > 0 ? safe : "w";
68
- const sep = "_c_";
69
- const maxTemplate = PG_MAX - 3 - tag.length;
70
- return `${templateName.slice(0, Math.max(1, maxTemplate))}${sep}${tag}`.slice(0, PG_MAX);
77
+ return appendKeepingHash(templateName, `_c_${safe.length > 0 ? safe : "w"}`);
71
78
  }
72
79
  //#endregion
73
80
  //#region src/core/policy.ts
@@ -167,6 +174,8 @@ function resolveAcquireSkip(input = {}) {
167
174
  }
168
175
  //#endregion
169
176
  //#region src/providers/autopg.ts
177
+ /** autopg release cedar-pg is tested against (`scripts/autopg-version`, inlined at build). */
178
+ const AUTOPG_PINNED_VERSION = "v3.2.2";
170
179
  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.";
171
180
  /** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
172
181
  const ROLE_PASSWORD_SCHEME = "v2";
@@ -193,14 +202,19 @@ function requireAutopgBin() {
193
202
  if (!bin) throw new Error(INSTALL_HINT);
194
203
  return bin;
195
204
  }
205
+ function optionalNonEmptyString(value) {
206
+ return typeof value === "string" && value.length > 0 ? value : void 0;
207
+ }
196
208
  /**
197
- * Parse `autopg status --json` → the **registered** port. Throws only when the
198
- * output is not autopg status JSON.
209
+ * Parse `autopg status --json` → the **registered** port (and data/socket/logs
210
+ * dirs when present). Throws only when the output is not autopg status JSON.
199
211
  *
200
212
  * Registration is not liveness: autopg reports a port for a stopped host too,
201
- * and its `status` string is supervisor-specific (pm2 `online`, systemd-user /
202
- * launchd differ). Liveness is a TCP accept on the port, proven by the caller —
203
- * `acquire` does that before it connects.
213
+ * and its `status` / `ready` describe the supervisor (autopg ≥ v3.2: `ready` needs
214
+ * pm2 `online`; pm2's raw state is `supervisorStatus`). Neither, nor `runtime.live`,
215
+ * is the attach gate — a bare postmaster (e.g. one cedar-pg revived) is
216
+ * query-ready while status stays `stopped` / `ready: false`.
217
+ * Liveness is a TCP accept on the port, proven by the caller (`acquire`).
204
218
  */
205
219
  function parseHostStatus(json) {
206
220
  let parsed;
@@ -210,7 +224,16 @@ function parseHostStatus(json) {
210
224
  throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
211
225
  }
212
226
  if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
213
- return { port: parsed.port };
227
+ const result = { port: parsed.port };
228
+ for (const key of [
229
+ "dataDir",
230
+ "socketDir",
231
+ "logsDir"
232
+ ]) {
233
+ const value = optionalNonEmptyString(parsed[key]);
234
+ if (value) result[key] = value;
235
+ }
236
+ return result;
214
237
  }
215
238
  /**
216
239
  * Admin URL for an autopg host on `port`.
@@ -225,15 +248,44 @@ function parseHostStatus(json) {
225
248
  function adminUrlFor(port, env = process.env) {
226
249
  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
250
  }
251
+ /** `[major, minor, patch]` from `autopg --version` output or a `vX.Y.Z` tag; `null` if absent. */
252
+ function parseAutopgVersion(text) {
253
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
254
+ return m ? [
255
+ Number(m[1]),
256
+ Number(m[2]),
257
+ Number(m[3])
258
+ ] : null;
259
+ }
228
260
  /**
229
- * Discover the registered autopg host (port + admin URL) via `autopg status --json`.
230
- * Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
231
- * (or use `acquire`, which does) before connecting.
261
+ * Upgrade warning when `autopg --version` output is older than `pin`. `null` when
262
+ * current, newer, or unreadable — never nag on what cannot be parsed. Upgrading
263
+ * is the user's call: it restarts the host every worktree shares.
232
264
  */
233
- function discoverHost(bin = requireAutopgBin()) {
234
- let status;
265
+ function outdatedAutopgWarning(versionOutput, pin = AUTOPG_PINNED_VERSION) {
266
+ const have = parseAutopgVersion(versionOutput);
267
+ const want = parseAutopgVersion(pin);
268
+ if (!have || !want) return null;
269
+ if ((have[0] - want[0] || have[1] - want[1] || have[2] - want[2]) >= 0) return null;
270
+ 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):
271
+ curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/${pin}/install.sh | AUTOPG_VERSION=${pin} bash\n autopg update
272
+ `;
273
+ }
274
+ /** `autopg --version` stdout; `""` when it cannot run (treated as unknown). */
275
+ function readAutopgVersion(bin) {
276
+ return (0, node_child_process.spawnSync)(bin, ["--version"], {
277
+ encoding: "utf8",
278
+ stdio: [
279
+ "ignore",
280
+ "pipe",
281
+ "ignore"
282
+ ],
283
+ timeout: 5e3
284
+ }).stdout ?? "";
285
+ }
286
+ function readStatusJson(bin) {
235
287
  try {
236
- status = (0, node_child_process.execFileSync)(bin, ["status", "--json"], {
288
+ return (0, node_child_process.execFileSync)(bin, ["status", "--json"], {
237
289
  encoding: "utf8",
238
290
  stdio: [
239
291
  "ignore",
@@ -245,22 +297,70 @@ function discoverHost(bin = requireAutopgBin()) {
245
297
  const detail = err instanceof Error ? err.message : String(err);
246
298
  throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
247
299
  }
248
- const { port } = parseHostStatus(status);
300
+ }
301
+ /**
302
+ * Discover the registered autopg host (attach target + reported data/socket/logs
303
+ * dirs) via `autopg status --json`. Throws when autopg cannot be queried; does
304
+ * **not** prove a listener — probe TCP (or use `acquire`, which does) before connecting.
305
+ */
306
+ function discoverRegistration(bin = requireAutopgBin()) {
307
+ const { port, ...paths } = parseHostStatus(readStatusJson(bin));
249
308
  return {
250
- port,
251
- adminUrl: adminUrlFor(port),
252
- bin
309
+ host: {
310
+ port,
311
+ adminUrl: adminUrlFor(port),
312
+ bin
313
+ },
314
+ ...paths
253
315
  };
254
316
  }
317
+ /**
318
+ * Discover the registered autopg host (port + admin URL) via `autopg status --json`.
319
+ * Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
320
+ * (or use `acquire`, which does) before connecting.
321
+ */
322
+ function discoverHost(bin = requireAutopgBin()) {
323
+ return discoverRegistration(bin).host;
324
+ }
255
325
  function quoteIdent(name) {
256
326
  return `"${name.replace(/"/g, "\"\"")}"`;
257
327
  }
258
328
  function quoteLiteral(value) {
259
329
  return `'${value.replace(/'/g, "''")}'`;
260
330
  }
331
+ /** Grace for a postmaster that accepts TCP but still answers 57P03 (startup / recovery). */
332
+ const ADMIN_CONNECT_READY_MS = 3e4;
333
+ const ADMIN_CONNECT_POLL_MS = 200;
334
+ /**
335
+ * Retry `connect` while Postgres answers `57P03` (cannot_connect_now: "the
336
+ * database system is starting up" / in recovery). TCP accept — the host
337
+ * liveness gate — comes before query-ready, so a freshly revived or crash-
338
+ * recovering postmaster needs this. Any other error is thrown immediately.
339
+ */
340
+ async function connectWhileStartingUp(connect, opts = {}) {
341
+ const now = opts.now ?? Date.now;
342
+ const pause = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
343
+ const deadline = now() + (opts.readyMs ?? ADMIN_CONNECT_READY_MS);
344
+ for (;;) {
345
+ try {
346
+ return await connect();
347
+ } catch (err) {
348
+ if (err.code !== "57P03" || now() >= deadline) throw err;
349
+ }
350
+ await pause(opts.pollMs ?? ADMIN_CONNECT_POLL_MS);
351
+ }
352
+ }
261
353
  async function withAdminClient(adminUrl, fn) {
262
- const client = new pg.default.Client({ connectionString: adminUrl });
263
- await client.connect();
354
+ const client = await connectWhileStartingUp(async () => {
355
+ const attempt = new pg.default.Client({ connectionString: adminUrl });
356
+ try {
357
+ await attempt.connect();
358
+ return attempt;
359
+ } catch (err) {
360
+ await attempt.end().catch(() => {});
361
+ throw err;
362
+ }
363
+ });
264
364
  try {
265
365
  return await fn(client);
266
366
  } finally {
@@ -299,18 +399,59 @@ async function setDatabaseIsTemplate(opts) {
299
399
  await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
300
400
  });
301
401
  }
402
+ /** Postgres `duplicate_database`: CREATE DATABASE hit an existing datname. */
403
+ const DUPLICATE_DATABASE = "42P04";
302
404
  /**
303
405
  * CREATE DATABASE … TEMPLATE … OWNER via admin connection.
304
406
  * Test roles are LOGIN-only; workers cannot CREATE DATABASE themselves.
407
+ *
408
+ * With `reuse`, an existing `databaseName` owned by `roleName` is kept as-is:
409
+ * a worker clone made earlier in this run by another test file.
410
+ * An existing datname owned by any other role always fails: it is not ours.
305
411
  */
306
412
  async function cloneDatabaseFromTemplate(opts) {
307
413
  await withAdminClient(opts.adminUrl, async (client) => {
308
414
  const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
309
415
  if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
310
416
  if (!tmpl.rows[0]?.datistemplate) throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`);
311
- const exists = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [opts.databaseName]);
312
- if (exists.rowCount && exists.rowCount > 0) throw new Error(`database already exists: ${opts.databaseName}`);
313
- await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} WITH TEMPLATE ${quoteIdent(opts.templateName)} OWNER ${quoteIdent(opts.roleName)}`);
417
+ try {
418
+ await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} WITH TEMPLATE ${quoteIdent(opts.templateName)} OWNER ${quoteIdent(opts.roleName)}`);
419
+ return;
420
+ } catch (err) {
421
+ if (err.code !== DUPLICATE_DATABASE) throw err;
422
+ }
423
+ const owner = (await client.query(`SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = $1`, [opts.databaseName])).rows[0]?.owner;
424
+ if (opts.reuse && owner === opts.roleName) return;
425
+ throw new Error(`database already exists: ${opts.databaseName} (owned by ${owner ?? "unknown"})`);
426
+ });
427
+ }
428
+ /**
429
+ * Empty every user table in `databaseName` with one
430
+ * `TRUNCATE … RESTART IDENTITY`, so serial / identity columns start at 1 again.
431
+ *
432
+ * Runs as admin against that database. Skips system schemas, temp tables, and
433
+ * tables an extension owns (e.g. PostGIS `spatial_ref_sys`); partitions are covered by
434
+ * their parent. No `CASCADE`: every user table is in the one statement, so no
435
+ * FK target is missing from it.
436
+ */
437
+ async function truncateUserTables(opts) {
438
+ const url = new URL(opts.adminUrl);
439
+ url.pathname = `/${opts.databaseName}`;
440
+ return withAdminClient(url.toString(), async (client) => {
441
+ const tables = (await client.query(`SELECT format('%I.%I', n.nspname, c.relname) AS name
442
+ FROM pg_class c
443
+ JOIN pg_namespace n ON n.oid = c.relnamespace
444
+ WHERE c.relkind IN ('r', 'p')
445
+ AND NOT c.relispartition
446
+ AND c.relpersistence <> 't'
447
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
448
+ AND NOT EXISTS (
449
+ SELECT 1 FROM pg_depend d
450
+ WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e'
451
+ )
452
+ ORDER BY 1`)).rows.map((r) => r.name);
453
+ if (tables.length > 0) await client.query(`TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY`);
454
+ return tables;
314
455
  });
315
456
  }
316
457
  async function listOwnedDatnames(client, roleName) {
@@ -367,37 +508,20 @@ function buildDatabaseUrl(opts) {
367
508
  const EPHEMERAL_PORT = 55432;
368
509
  const HOST_READY_MS = 3e4;
369
510
  const HOST_POLL_MS = 200;
370
- /**
371
- * autopg verbs that can bring the *registered* local host up, cheapest fix first.
372
- *
373
- * `restart` exit 0 is not evidence of a listener: when pm2 is missing or does not
374
- * list `autopg-server`, autopg still prints "respawned daemon" and returns 0.
375
- * That is why every verb is followed by a TCP wait, not trusted on exit status.
376
- *
377
- * `install` covers a never-registered machine (postinstall ships the binary only)
378
- * and a reboot whose pm2 list is empty (`pm2 start`). On an already-registered
379
- * host it is a no-op for the server process but may `pm2 start` the autopg UI —
380
- * so it runs only after `restart` failed to produce a listener.
381
- */
382
- const LOCAL_START = [{
383
- argv: ["restart"],
384
- readyMs: 1e4
385
- }, {
386
- argv: ["install"],
387
- readyMs: HOST_READY_MS
388
- }];
511
+ /** `autopg restart` exits 0 only once ready; grace covers older autopg that returned early. */
512
+ const LOCAL_RESTART_READY_MS = 1e4;
389
513
  /** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
390
514
  const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
391
515
  /**
392
516
  * Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
393
517
  * Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
394
518
  */
395
- 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.*";
519
+ 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).";
396
520
  /**
397
521
  * Resolve how to start a host when none is listening.
398
522
  *
399
523
  * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
400
- * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own a postmaster, local autopg only (even in CI)
524
+ * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own an ephemeral postmaster, local registered host only (even in CI)
401
525
  * - unset + `CI=true` → ephemeral
402
526
  * - otherwise → local
403
527
  */
@@ -447,38 +571,6 @@ function looksLikeShmSpaceError(text) {
447
571
  function formatHostStartError(detail, useRamShm = false) {
448
572
  return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
449
573
  }
450
- /**
451
- * Remove leftover ephemeral data dirs under `/dev/shm` (and the recipe dataDir).
452
- * Safe for OOM-killed CI/cloud runs that leave `cedar-pg-*` / `pgserve-*` /
453
- * `PostgreSQL.*` filling tmpfs.
454
- *
455
- * Only called on ephemeral cold start, i.e. when the recipe port has no listener —
456
- * never while a host owns those dirs.
457
- */
458
- function pruneStaleEphemeralDataDirs(opts) {
459
- const removed = [];
460
- const tryRm = (path) => {
461
- if (!(0, node_fs.existsSync)(path)) return;
462
- try {
463
- (0, node_fs.rmSync)(path, {
464
- recursive: true,
465
- force: true
466
- });
467
- removed.push(path);
468
- } catch {}
469
- };
470
- tryRm(opts.dataDir);
471
- const shmRoot = opts.shmRoot ?? ((0, node_fs.existsSync)("/dev/shm") ? "/dev/shm" : void 0);
472
- if (!shmRoot) return removed;
473
- let entries = [];
474
- try {
475
- entries = (0, node_fs.readdirSync)(shmRoot);
476
- } catch {
477
- return removed;
478
- }
479
- for (const name of entries) if (name.startsWith("cedar-pg-") || name.startsWith("pgserve-") || name.startsWith("PostgreSQL.")) tryRm((0, node_path.join)(shmRoot, name));
480
- return removed;
481
- }
482
574
  /** Free bytes on `path`, or `null` if unavailable. */
483
575
  function freeBytesOn(path) {
484
576
  try {
@@ -500,7 +592,8 @@ function runAutopg(bin, argv) {
500
592
  });
501
593
  if (result.error) return result.error.message;
502
594
  if (result.status === 0) return null;
503
- return `exit ${result.status}\n${(result.stderr || result.stdout || "").trim()}`.trim();
595
+ const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
596
+ return `exit ${result.status}\n${output.trim()}`.trim();
504
597
  }
505
598
  /** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
506
599
  function canConnect(port) {
@@ -568,25 +661,182 @@ async function attachLiveHost(bin, readyMs = 0) {
568
661
  readyMs
569
662
  }) ? discovered : null;
570
663
  }
664
+ /** Appended by the revived registered postmaster (next to autopg's own pm2 logs). */
665
+ const REVIVED_POSTMASTER_LOG = "cedarpg-postmaster.log";
666
+ function errorDetail(err) {
667
+ return err instanceof Error ? err.message : String(err);
668
+ }
571
669
  /**
572
- * Bring the user's registered autopg host up and attach to it — same port, same
573
- * `~/.autopg/data`, still there after this process exits. cedar-pg never runs a
574
- * second local Postgres.
670
+ * Live PID from `<dataDir>/postmaster.pid`, else `null`. Postgres writes it before
671
+ * binding, so a live owner means another postmaster (pm2 still recovering, or a
672
+ * concurrent acquire) already holds the data dir — wait for it, never compete.
575
673
  */
576
- async function startLocalHost(bin, registeredPort) {
577
- const failures = [];
578
- for (const { argv, readyMs } of LOCAL_START) {
579
- const label = `autopg ${argv.join(" ")}`;
580
- const failure = runAutopg(bin, [...argv]);
581
- if (failure) {
582
- failures.push(`${label}: ${failure}`);
583
- continue;
674
+ function liveDataDirOwner(dataDir) {
675
+ return livePidIn((0, node_path.join)(dataDir, "postmaster.pid"));
676
+ }
677
+ /** Live PID from the first line of `file`, else `null` (missing, garbage, or dead). */
678
+ function livePidIn(file) {
679
+ let pid;
680
+ try {
681
+ pid = Number.parseInt((0, node_fs.readFileSync)(file, "utf8"), 10);
682
+ } catch {
683
+ return null;
684
+ }
685
+ if (!Number.isInteger(pid) || pid <= 0) return null;
686
+ try {
687
+ process.kill(pid, 0);
688
+ return pid;
689
+ } catch (err) {
690
+ return err.code === "EPERM" ? pid : null;
691
+ }
692
+ }
693
+ /**
694
+ * Exclusive cold-start lock `<dataDir>.lock` holding our PID. Returns `null` once
695
+ * we hold it, else the live holder's PID. `postmaster.pid` alone cannot guard an
696
+ * ephemeral cold start: it does not exist yet while a peer runs initdb, which is
697
+ * exactly when a second `rm -rf` would destroy the peer's cluster.
698
+ *
699
+ * The PID is written to a private file and hard-linked into place, so the lock
700
+ * never exists without its PID. A dead holder's lock is stale and taken over.
701
+ */
702
+ function tryColdStartLock(lockPath) {
703
+ const mine = `${lockPath}.${process.pid}`;
704
+ (0, node_fs.writeFileSync)(mine, String(process.pid));
705
+ try {
706
+ for (;;) {
707
+ try {
708
+ (0, node_fs.linkSync)(mine, lockPath);
709
+ return null;
710
+ } catch (err) {
711
+ if (err.code !== "EEXIST") throw err;
712
+ }
713
+ const holder = livePidIn(lockPath);
714
+ if (holder != null) return holder;
715
+ (0, node_fs.rmSync)(lockPath, { force: true });
584
716
  }
585
- const attached = await attachLiveHost(bin, readyMs);
717
+ } finally {
718
+ (0, node_fs.rmSync)(mine, { force: true });
719
+ }
720
+ }
721
+ /**
722
+ * Detached `autopg postmaster`. Success: child is unref'd (survives this process).
723
+ * Failure: child is killed, throws a one-line detail (caller wraps / aggregates).
724
+ * `logFile` (appended) keeps output from a postmaster nothing else supervises;
725
+ * otherwise stdio is fully ignored (caller exit must not close pipes under it).
726
+ */
727
+ async function spawnDetachedPostmaster(opts) {
728
+ const { readyMs } = opts;
729
+ const logFd = opts.logFile ? (0, node_fs.openSync)(opts.logFile, "a") : null;
730
+ let child;
731
+ try {
732
+ child = (0, node_child_process.spawn)(opts.bin, opts.args, {
733
+ detached: true,
734
+ stdio: logFd == null ? "ignore" : [
735
+ "ignore",
736
+ logFd,
737
+ logFd
738
+ ]
739
+ });
740
+ } finally {
741
+ if (logFd != null) (0, node_fs.closeSync)(logFd);
742
+ }
743
+ let died = null;
744
+ child.on("error", (err) => {
745
+ died = /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${err.message}`);
746
+ });
747
+ child.on("exit", (code, signal) => {
748
+ died ??= /* @__PURE__ */ new Error(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`);
749
+ });
750
+ try {
751
+ if (!await waitForListener({
752
+ port: opts.port,
753
+ readyMs,
754
+ failure: () => died
755
+ })) throw new Error(`autopg postmaster did not accept 127.0.0.1:${opts.port} within ${readyMs}ms.`);
756
+ } catch (err) {
757
+ killOwnedChild(child);
758
+ throw err;
759
+ }
760
+ child.unref();
761
+ }
762
+ /**
763
+ * Detached `autopg postmaster` on the registered port / data dir (socket dir when
764
+ * autopg reports one; otherwise the postmaster resolves autopg's own default).
765
+ * Returns failure detail, or `null` once TCP accepts.
766
+ *
767
+ * No reported `dataDir` → not attempted: a postmaster without `--data` is not
768
+ * persistent, and guessing autopg's path is how a second Postgres happens.
769
+ */
770
+ async function reviveRegisteredPostmaster(bin, reg) {
771
+ const { dataDir, socketDir, logsDir } = reg;
772
+ const { port } = reg.host;
773
+ if (!dataDir) return "autopg postmaster: not attempted (autopg status --json reports no dataDir)";
774
+ const args = [
775
+ "postmaster",
776
+ "--port",
777
+ String(port),
778
+ "--data",
779
+ dataDir,
780
+ ...socketDir ? ["--socket-dir", socketDir] : []
781
+ ];
782
+ const label = `autopg ${args.join(" ")}`;
783
+ const logFile = logsDir ? (0, node_path.join)(logsDir, REVIVED_POSTMASTER_LOG) : void 0;
784
+ if (liveDataDirOwner(dataDir) == null) try {
785
+ (0, node_fs.mkdirSync)(dataDir, {
786
+ recursive: true,
787
+ mode: 448
788
+ });
789
+ if (logsDir) (0, node_fs.mkdirSync)(logsDir, { recursive: true });
790
+ await spawnDetachedPostmaster({
791
+ bin,
792
+ args,
793
+ port,
794
+ readyMs: HOST_READY_MS,
795
+ ...logFile ? { logFile } : {}
796
+ });
797
+ return null;
798
+ } catch (err) {
799
+ if (liveDataDirOwner(dataDir) == null) return `${label}: ${errorDetail(err)}${logFile ? `\nPostmaster log: ${logFile}` : ""}`;
800
+ }
801
+ if (await waitForListener({
802
+ port,
803
+ readyMs: HOST_READY_MS
804
+ })) return null;
805
+ return `${label}: postmaster pid ${liveDataDirOwner(dataDir) ?? "?"} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`;
806
+ }
807
+ /**
808
+ * Revive the user's registered autopg host — same port, same `~/.autopg/data`,
809
+ * still running after this process exits. Never a second local Postgres.
810
+ *
811
+ * `restart` drives pm2 only and exits 0 once ready; without pm2 (or under another
812
+ * supervisor) it exits 1. Either way TCP is the gate: if still dark,
813
+ * {@link reviveRegisteredPostmaster} on the registered port/data. Do not
814
+ * `install` on an already-registered host (wants pm2, refuses a port change).
815
+ * `install` is only for a never-registered machine.
816
+ */
817
+ async function startLocalHost(bin, registered) {
818
+ const failures = [];
819
+ const restartFailure = runAutopg(bin, ["restart"]);
820
+ if (restartFailure) failures.push(`autopg restart: ${restartFailure}`);
821
+ else {
822
+ const attached = await attachLiveHost(bin, LOCAL_RESTART_READY_MS);
586
823
  if (attached) return attached;
587
- failures.push(`${label}: no listener within ${readyMs}ms`);
824
+ failures.push(`autopg restart: no listener within ${LOCAL_RESTART_READY_MS}ms`);
825
+ }
826
+ if (registered) {
827
+ const failure = await reviveRegisteredPostmaster(bin, registered);
828
+ if (!failure) return registered.host;
829
+ failures.push(failure);
830
+ } else {
831
+ const installFailure = runAutopg(bin, ["install"]);
832
+ if (installFailure) failures.push(`autopg install: ${installFailure}`);
833
+ else {
834
+ const attached = await attachLiveHost(bin, HOST_READY_MS);
835
+ if (attached) return attached;
836
+ failures.push(`autopg install: no listener within ${HOST_READY_MS}ms`);
837
+ }
588
838
  }
589
- const where = registeredPort != null ? `autopg host is registered on 127.0.0.1:${registeredPort} but nothing is listening.\n` : "";
839
+ const where = registered != null ? `autopg host is registered on 127.0.0.1:${registered.host.port} but nothing is listening.\n` : "";
590
840
  throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
591
841
  }
592
842
  /**
@@ -595,53 +845,71 @@ async function startLocalHost(bin, registeredPort) {
595
845
  *
596
846
  * Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
597
847
  * with `supervisor mismatch` next to a local pm2 install. Reuses a listener
598
- * already on the recipe port, and prunes stale RAM dirs before a cold start.
848
+ * already on the recipe port. Concurrent cold starts (Nx running several
849
+ * `db:ready` on one runner) serialize on {@link tryColdStartLock}: only the
850
+ * holder prunes and starts; everyone else waits for the listener.
599
851
  */
600
- async function startEphemeralHost(bin) {
601
- const recipe = ephemeralHostRecipe();
602
- if (await canConnect(recipe.port)) return discoveryFromRecipe(bin, recipe);
852
+ async function startEphemeralHost(bin, recipe = ephemeralHostRecipe()) {
853
+ const { dataDir, port } = recipe;
854
+ if (await canConnect(port)) return discoveryFromRecipe(bin, recipe);
603
855
  const useRamShm = recipe.postmasterArgs.includes("--ram");
604
- pruneStaleEphemeralDataDirs({ dataDir: recipe.dataDir });
856
+ const lockPath = `${dataDir}.lock`;
857
+ const holder = tryColdStartLock(lockPath);
858
+ if (holder == null) try {
859
+ if (!await canConnect(port) && liveDataDirOwner(dataDir) == null) await coldStartEphemeral(bin, recipe, useRamShm);
860
+ } finally {
861
+ (0, node_fs.rmSync)(lockPath, { force: true });
862
+ }
863
+ if (await waitForListener({
864
+ port,
865
+ readyMs: HOST_READY_MS
866
+ })) return discoveryFromRecipe(bin, recipe);
867
+ const owner = holder != null ? `cold start pid ${holder}` : `postmaster pid ${liveDataDirOwner(dataDir) ?? "?"}`;
868
+ throw new Error(formatHostStartError(`${owner} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`, useRamShm));
869
+ }
870
+ /** Prune our dead leftover data dir and start the postmaster. Caller holds the cold-start lock. */
871
+ async function coldStartEphemeral(bin, recipe, useRamShm) {
872
+ (0, node_fs.rmSync)(recipe.dataDir, {
873
+ recursive: true,
874
+ force: true
875
+ });
605
876
  if (useRamShm) {
606
877
  const free = freeBytesOn("/dev/shm");
607
878
  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`);
608
879
  }
609
880
  (0, node_fs.mkdirSync)(recipe.dataDir, { recursive: true });
610
- const child = (0, node_child_process.spawn)(bin, recipe.postmasterArgs, {
611
- detached: true,
612
- stdio: "ignore"
613
- });
614
- let died = null;
615
- child.on("error", (err) => {
616
- died = new Error(formatHostStartError(`Failed to spawn autopg postmaster.\n${err.message}`, useRamShm));
617
- });
618
- child.on("exit", (code, signal) => {
619
- died ??= new Error(formatHostStartError(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`, useRamShm));
620
- });
621
881
  try {
622
- if (!await waitForListener({
882
+ await spawnDetachedPostmaster({
883
+ bin,
884
+ args: recipe.postmasterArgs,
623
885
  port: recipe.port,
624
- readyMs: HOST_READY_MS,
625
- failure: () => died
626
- })) throw new Error(formatHostStartError(`autopg postmaster did not accept 127.0.0.1:${recipe.port} within ${HOST_READY_MS}ms.`, useRamShm));
886
+ readyMs: HOST_READY_MS
887
+ });
627
888
  } catch (err) {
628
- killOwnedChild(child);
629
- throw err;
889
+ if (liveDataDirOwner(recipe.dataDir) != null) return;
890
+ throw new Error(formatHostStartError(errorDetail(err), useRamShm));
630
891
  }
631
- child.unref();
632
- return discoveryFromRecipe(bin, recipe);
892
+ }
893
+ let autopgVersionChecked = false;
894
+ /** Once per process: warn (never fail, never upgrade) when autopg is older than the pin. */
895
+ function warnIfAutopgOutdated(bin) {
896
+ if (autopgVersionChecked) return;
897
+ autopgVersionChecked = true;
898
+ const warning = outdatedAutopgWarning(readAutopgVersion(bin));
899
+ if (warning) process.stderr.write(warning);
633
900
  }
634
901
  /**
635
902
  * Attach to a listening autopg host, else start one per
636
903
  * {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
637
904
  */
638
905
  async function ensureHostRunning(bin = requireAutopgBin()) {
906
+ warnIfAutopgOutdated(bin);
639
907
  let registered = null;
640
908
  try {
641
- registered = discoverHost(bin);
909
+ registered = discoverRegistration(bin);
642
910
  } catch {}
643
- if (registered && await waitForListener({ port: registered.port })) return registered;
644
- return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered?.port);
911
+ if (registered && await waitForListener({ port: registered.host.port })) return registered.host;
912
+ return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered);
645
913
  }
646
914
  //#endregion
647
915
  //#region src/core/lifecycle.ts
@@ -684,8 +952,14 @@ async function acquire(options) {
684
952
  const identity = require_lease.resolveWorktreeIdentity(options.root);
685
953
  const mode = options.mode;
686
954
  const databaseName = buildDatabaseName(identity, mode);
687
- const roleName = buildRoleName(databaseName);
955
+ const leased = require_lease.readLease(identity.root, mode);
956
+ const roleName = leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);
688
957
  const host = await ensureHostRunning();
958
+ if (options.fresh) await dropDatabasesOwnedByRole({
959
+ adminUrl: host.adminUrl,
960
+ roleName,
961
+ preferLast: databaseName
962
+ });
689
963
  await ensureDatabase({
690
964
  adminUrl: host.adminUrl,
691
965
  databaseName,
@@ -732,6 +1006,23 @@ async function acquire(options) {
732
1006
  };
733
1007
  }
734
1008
  /**
1009
+ * Attach-only: connection info from the lease a prior `acquire` wrote. Never
1010
+ * acquires, never runs role/database DDL, never starts or revives the host —
1011
+ * safe for many concurrent children after one `db:ready`. Throws when there is
1012
+ * no lease or nothing accepts TCP on the leased port.
1013
+ */
1014
+ async function attach(options) {
1015
+ const root = require_lease.resolveRoot(options.root);
1016
+ const { mode } = options;
1017
+ const lease = require_lease.readLease(root, mode);
1018
+ if (!lease) throw new Error(`no ${mode} lease in ${root}; run \`${require_lease.CLI_NAME} acquire --mode=${mode}\` (or your db:ready target) first — attach never acquires`);
1019
+ 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 \`${require_lease.CLI_NAME} acquire --mode=${mode}\` to bring the host back`);
1020
+ return {
1021
+ lease,
1022
+ databaseUrl: urlFromLease(lease)
1023
+ };
1024
+ }
1025
+ /**
735
1026
  * Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
736
1027
  * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
737
1028
  */
@@ -739,7 +1030,8 @@ async function acquireIfNeeded(options) {
739
1030
  const outcome = await runIfNeeded(options, () => acquire({
740
1031
  root: options.root,
741
1032
  mode: options.mode,
742
- setEnv: options.setEnv
1033
+ setEnv: options.setEnv,
1034
+ fresh: options.fresh
743
1035
  }));
744
1036
  if (outcome.status === "skipped") return outcome;
745
1037
  return {
@@ -755,9 +1047,8 @@ async function acquireIfNeeded(options) {
755
1047
  * If the host is unavailable, leaves the lease so dispose/gc can retry.
756
1048
  */
757
1049
  async function dispose(options = {}) {
758
- const identity = require_lease.resolveWorktreeIdentity(options.root);
759
1050
  const mode = options.mode ?? "test";
760
- const lease = require_lease.readLease(identity.root, mode);
1051
+ const lease = require_lease.readLease(require_lease.resolveRoot(options.root), mode);
761
1052
  if (!lease) return {
762
1053
  dropped: false,
763
1054
  reason: "no-lease"
@@ -823,12 +1114,24 @@ Object.defineProperty(exports, "acquireIfNeeded", {
823
1114
  return acquireIfNeeded;
824
1115
  }
825
1116
  });
1117
+ Object.defineProperty(exports, "adminUrlFor", {
1118
+ enumerable: true,
1119
+ get: function() {
1120
+ return adminUrlFor;
1121
+ }
1122
+ });
826
1123
  Object.defineProperty(exports, "applyDatabaseUrlEnv", {
827
1124
  enumerable: true,
828
1125
  get: function() {
829
1126
  return applyDatabaseUrlEnv;
830
1127
  }
831
1128
  });
1129
+ Object.defineProperty(exports, "attach", {
1130
+ enumerable: true,
1131
+ get: function() {
1132
+ return attach;
1133
+ }
1134
+ });
832
1135
  Object.defineProperty(exports, "buildCloneDatabaseName", {
833
1136
  enumerable: true,
834
1137
  get: function() {
@@ -877,12 +1180,6 @@ Object.defineProperty(exports, "dropDatabase", {
877
1180
  return dropDatabase;
878
1181
  }
879
1182
  });
880
- Object.defineProperty(exports, "ensureHostRunning", {
881
- enumerable: true,
882
- get: function() {
883
- return ensureHostRunning;
884
- }
885
- });
886
1183
  Object.defineProperty(exports, "gc", {
887
1184
  enumerable: true,
888
1185
  get: function() {
@@ -943,6 +1240,12 @@ Object.defineProperty(exports, "setDatabaseIsTemplate", {
943
1240
  return setDatabaseIsTemplate;
944
1241
  }
945
1242
  });
1243
+ Object.defineProperty(exports, "truncateUserTables", {
1244
+ enumerable: true,
1245
+ get: function() {
1246
+ return truncateUserTables;
1247
+ }
1248
+ });
946
1249
  Object.defineProperty(exports, "urlFromLease", {
947
1250
  enumerable: true,
948
1251
  get: function() {
@@ -950,4 +1253,4 @@ Object.defineProperty(exports, "urlFromLease", {
950
1253
  }
951
1254
  });
952
1255
 
953
- //# sourceMappingURL=lifecycle-BbrvFQvg.cjs.map
1256
+ //# sourceMappingURL=lifecycle-BCeM96tI.cjs.map