@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
@@ -20,8 +20,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
20
20
  enumerable: true
21
21
  }) : target, mod));
22
22
  //#endregion
23
- const require_constants = require("./constants-Ct8myrEn.cjs");
24
- const require_lease = require("./lease-Bq9wKwQa.cjs");
23
+ const require_lease = require("./lease-WlmOnNDi.cjs");
25
24
  let node_fs = require("node:fs");
26
25
  let node_os = require("node:os");
27
26
  let node_path = require("node:path");
@@ -54,21 +53,28 @@ function buildDatabaseName(identity, mode) {
54
53
  if (name.length > PG_MAX) return name.slice(0, PG_MAX);
55
54
  return name;
56
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`. */
57
68
  function buildRoleName(databaseName) {
58
- const suffix = "_role";
59
- const maxBase = PG_MAX - 5;
60
- return `${databaseName.slice(0, maxBase)}${suffix}`;
69
+ return appendKeepingHash(databaseName, "_role");
61
70
  }
62
71
  /**
63
72
  * Build a worker clone datname from a TEMPLATE database name.
64
- * Layout: `<template>_c_<suffix>` truncated to ≤63 chars (keeps suffix).
73
+ * Layout: `<template>_c_<suffix>`, cut by `appendKeepingHash` (keeps mode, hash, and suffix).
65
74
  */
66
75
  function buildCloneDatabaseName(templateName, suffix) {
67
76
  const safe = suffix.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24);
68
- const tag = safe.length > 0 ? safe : "w";
69
- const sep = "_c_";
70
- const maxTemplate = PG_MAX - 3 - tag.length;
71
- return `${templateName.slice(0, Math.max(1, maxTemplate))}${sep}${tag}`.slice(0, PG_MAX);
77
+ return appendKeepingHash(templateName, `_c_${safe.length > 0 ? safe : "w"}`);
72
78
  }
73
79
  //#endregion
74
80
  //#region src/core/policy.ts
@@ -168,6 +174,8 @@ function resolveAcquireSkip(input = {}) {
168
174
  }
169
175
  //#endregion
170
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";
171
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.";
172
180
  /** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
173
181
  const ROLE_PASSWORD_SCHEME = "v2";
@@ -194,8 +202,19 @@ function requireAutopgBin() {
194
202
  if (!bin) throw new Error(INSTALL_HINT);
195
203
  return bin;
196
204
  }
205
+ function optionalNonEmptyString(value) {
206
+ return typeof value === "string" && value.length > 0 ? value : void 0;
207
+ }
197
208
  /**
198
- * Parse `autopg status --json` output. Requires a numeric port and running !== false.
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.
211
+ *
212
+ * Registration is not liveness: autopg reports a port for a stopped host too,
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`).
199
218
  */
200
219
  function parseHostStatus(json) {
201
220
  let parsed;
@@ -205,8 +224,16 @@ function parseHostStatus(json) {
205
224
  throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
206
225
  }
207
226
  if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
208
- if (parsed.running === false) throw new Error(`autopg host is not running.\n${INSTALL_HINT}`);
209
- 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;
210
237
  }
211
238
  /**
212
239
  * Admin URL for an autopg host on `port`.
@@ -221,13 +248,44 @@ function parseHostStatus(json) {
221
248
  function adminUrlFor(port, env = process.env) {
222
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`;
223
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
+ }
224
260
  /**
225
- * Discover a live autopg host via `autopg status --json`. Throws if the host is not proven live.
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.
226
264
  */
227
- function discoverHost(bin = requireAutopgBin()) {
228
- 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) {
229
287
  try {
230
- status = (0, node_child_process.execFileSync)(bin, ["status", "--json"], {
288
+ return (0, node_child_process.execFileSync)(bin, ["status", "--json"], {
231
289
  encoding: "utf8",
232
290
  stdio: [
233
291
  "ignore",
@@ -239,22 +297,70 @@ function discoverHost(bin = requireAutopgBin()) {
239
297
  const detail = err instanceof Error ? err.message : String(err);
240
298
  throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
241
299
  }
242
- 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));
243
308
  return {
244
- port,
245
- adminUrl: adminUrlFor(port),
246
- bin
309
+ host: {
310
+ port,
311
+ adminUrl: adminUrlFor(port),
312
+ bin
313
+ },
314
+ ...paths
247
315
  };
248
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
+ }
249
325
  function quoteIdent(name) {
250
326
  return `"${name.replace(/"/g, "\"\"")}"`;
251
327
  }
252
328
  function quoteLiteral(value) {
253
329
  return `'${value.replace(/'/g, "''")}'`;
254
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
+ }
255
353
  async function withAdminClient(adminUrl, fn) {
256
- const client = new pg.default.Client({ connectionString: adminUrl });
257
- 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
+ });
258
364
  try {
259
365
  return await fn(client);
260
366
  } finally {
@@ -269,7 +375,7 @@ async function withAdminClient(adminUrl, fn) {
269
375
  * same role keep working when `buildDatabaseUrl` is called with a new database.
270
376
  */
271
377
  function rolePasswordFor(roleName) {
272
- return (0, node_crypto.createHash)("sha256").update(`${require_constants.PASSWORD_SALT_PREFIX}\0${roleName}`).digest("hex").slice(0, 32);
378
+ return (0, node_crypto.createHash)("sha256").update(`${require_lease.PASSWORD_SALT_PREFIX}\0${roleName}`).digest("hex").slice(0, 32);
273
379
  }
274
380
  /**
275
381
  * Idempotently CREATE ROLE + CREATE DATABASE with cedar-pg owned names.
@@ -293,18 +399,59 @@ async function setDatabaseIsTemplate(opts) {
293
399
  await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
294
400
  });
295
401
  }
402
+ /** Postgres `duplicate_database`: CREATE DATABASE hit an existing datname. */
403
+ const DUPLICATE_DATABASE = "42P04";
296
404
  /**
297
405
  * CREATE DATABASE … TEMPLATE … OWNER via admin connection.
298
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.
299
411
  */
300
412
  async function cloneDatabaseFromTemplate(opts) {
301
413
  await withAdminClient(opts.adminUrl, async (client) => {
302
414
  const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
303
415
  if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
304
416
  if (!tmpl.rows[0]?.datistemplate) throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`);
305
- const exists = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [opts.databaseName]);
306
- if (exists.rowCount && exists.rowCount > 0) throw new Error(`database already exists: ${opts.databaseName}`);
307
- 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;
308
455
  });
309
456
  }
310
457
  async function listOwnedDatnames(client, roleName) {
@@ -358,29 +505,33 @@ function buildDatabaseUrl(opts) {
358
505
  }
359
506
  //#endregion
360
507
  //#region src/providers/host.ts
361
- /** Fixed CI TCP port so install registration and postmaster listen on the same port. */
362
508
  const EPHEMERAL_PORT = 55432;
363
- const OWNED_POSTMASTER_READY_MS = 3e4;
364
- const OWNED_POSTMASTER_POLL_MS = 200;
509
+ const HOST_READY_MS = 3e4;
510
+ const HOST_POLL_MS = 200;
511
+ /** `autopg restart` exits 0 only once ready; grace covers older autopg that returned early. */
512
+ const LOCAL_RESTART_READY_MS = 1e4;
513
+ /** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
514
+ const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
365
515
  /**
366
- * Resolve whether to start an owned ephemeral postmaster or use local pm2 install.
516
+ * Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
517
+ * Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
518
+ */
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).";
520
+ /**
521
+ * Resolve how to start a host when none is listening.
367
522
  *
368
- * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral
369
- * - `CEDAR_PG_EPHEMERAL_HOST=0` → local (even when `CI=true`)
523
+ * - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
524
+ * - `CEDAR_PG_EPHEMERAL_HOST=0` → never own an ephemeral postmaster, local registered host only (even in CI)
370
525
  * - unset + `CI=true` → ephemeral
371
526
  * - otherwise → local
372
527
  */
373
- function resolveEphemeralHostPolicy(env = process.env) {
528
+ function resolveHostStartPolicy(env = process.env) {
374
529
  const force = env.CEDAR_PG_EPHEMERAL_HOST;
375
530
  if (force === "1") return "ephemeral";
376
531
  if (force === "0") return "local";
377
532
  if (env.CI === "true") return "ephemeral";
378
533
  return "local";
379
534
  }
380
- /**
381
- * Fixed CI recipe: shared `--port` on install + postmaster, `--no-pm2 --no-ui`,
382
- * and `--ram` + `/dev/shm/cedar-pg-<uid>` on Linux when shm exists.
383
- */
384
535
  function ephemeralHostRecipe(ctx = {}) {
385
536
  const platform = ctx.platform ?? process.platform;
386
537
  const shmAvailable = ctx.shmAvailable ?? (platform === "linux" && (0, node_fs.existsSync)("/dev/shm"));
@@ -391,15 +542,6 @@ function ephemeralHostRecipe(ctx = {}) {
391
542
  return {
392
543
  dataDir,
393
544
  port,
394
- installArgs: [
395
- "install",
396
- "--no-pm2",
397
- "--no-ui",
398
- "--port",
399
- String(port),
400
- "--data",
401
- dataDir
402
- ],
403
545
  postmasterArgs: [
404
546
  "postmaster",
405
547
  ...useRam ? ["--ram"] : [],
@@ -412,7 +554,6 @@ function ephemeralHostRecipe(ctx = {}) {
412
554
  ]
413
555
  };
414
556
  }
415
- /** Discovery from the recipe port — TCP readiness is the authority, not `autopg status`. */
416
557
  function discoveryFromRecipe(bin, recipe) {
417
558
  return {
418
559
  port: recipe.port,
@@ -423,8 +564,25 @@ function discoveryFromRecipe(bin, recipe) {
423
564
  function sleep(ms) {
424
565
  return new Promise((resolve) => setTimeout(resolve, ms));
425
566
  }
426
- function runInstall(bin, args) {
427
- const install = (0, node_child_process.spawnSync)(bin, args, {
567
+ /** True when stderr/stdout looks like a /dev/shm quota or ENOSPC failure. */
568
+ function looksLikeShmSpaceError(text) {
569
+ return /Disk quota exceeded|No space left on device|ENOSPC|\b53100\b/i.test(text);
570
+ }
571
+ function formatHostStartError(detail, useRamShm = false) {
572
+ return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
573
+ }
574
+ /** Free bytes on `path`, or `null` if unavailable. */
575
+ function freeBytesOn(path) {
576
+ try {
577
+ const s = (0, node_fs.statfsSync)(path);
578
+ return Number(s.bavail) * Number(s.bsize);
579
+ } catch {
580
+ return null;
581
+ }
582
+ }
583
+ /** Run a one-shot autopg verb. Returns failure detail, or `null` on exit 0. */
584
+ function runAutopg(bin, argv) {
585
+ const result = (0, node_child_process.spawnSync)(bin, argv, {
428
586
  encoding: "utf8",
429
587
  stdio: [
430
588
  "ignore",
@@ -432,7 +590,10 @@ function runInstall(bin, args) {
432
590
  "pipe"
433
591
  ]
434
592
  });
435
- if (install.status !== 0) throw new Error(`Failed to start autopg host (exit ${install.status}).\n${install.stderr || install.stdout || ""}\n${INSTALL_HINT}`);
593
+ if (result.error) return result.error.message;
594
+ if (result.status === 0) return null;
595
+ const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
596
+ return `exit ${result.status}\n${output.trim()}`.trim();
436
597
  }
437
598
  /** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
438
599
  function canConnect(port) {
@@ -449,7 +610,6 @@ function canConnect(port) {
449
610
  });
450
611
  });
451
612
  }
452
- /** Kill a detached owned postmaster (process group when possible) and drop our handle. */
453
613
  function killOwnedChild(child) {
454
614
  const pid = child.pid;
455
615
  if (pid != null) try {
@@ -462,89 +622,294 @@ function killOwnedChild(child) {
462
622
  child.unref();
463
623
  }
464
624
  /**
465
- * Poll until TCP accepts on port, or throw on timeout / {@link WaitForOwnedPostmasterOptions.failure}.
466
- * Exported for tests — the readiness seam for owned ephemeral start.
625
+ * The single liveness primitive: TCP accept on 127.0.0.1:port.
626
+ *
627
+ * Resolves `true` as soon as something is listening, `false` when the grace
628
+ * period expires, and throws only what {@link WaitForListenerOptions.failure}
629
+ * returns. Used for attach (one probe), local restart, and ephemeral start.
467
630
  */
468
- async function waitForOwnedPostmaster(opts) {
631
+ async function waitForListener(opts) {
469
632
  const probe = opts.canConnect ?? canConnect;
470
633
  const now = opts.now ?? Date.now;
471
634
  const pause = opts.sleep ?? sleep;
472
- const readyMs = opts.readyMs ?? OWNED_POSTMASTER_READY_MS;
473
- const pollMs = opts.pollMs ?? OWNED_POSTMASTER_POLL_MS;
474
- const deadline = now() + readyMs;
475
- let lastDetail = "TCP not accepting";
476
- while (now() < deadline) {
635
+ const pollMs = opts.pollMs ?? HOST_POLL_MS;
636
+ const deadline = now() + (opts.readyMs ?? 0);
637
+ for (;;) {
477
638
  const fail = opts.failure?.();
478
639
  if (fail) throw fail;
479
- if (await probe(opts.port)) return;
480
- lastDetail = `127.0.0.1:${opts.port} not accepting connections`;
640
+ if (await probe(opts.port)) return true;
641
+ if (now() >= deadline) return false;
481
642
  await pause(pollMs);
482
643
  }
483
- throw new Error(`Timed out waiting for autopg host after ${readyMs}ms.\n${lastDetail}\n${INSTALL_HINT}`);
484
644
  }
485
645
  /**
486
- * Install + detached postmaster with fully ignored stdio so the CI job owns lifetime
487
- * (caller exit must not close pipes under the daemon).
646
+ * Attach to the registered autopg host once TCP accepts on its port.
488
647
  *
489
- * Readiness is TCP accept on the recipe port — not `autopg status`, which succeeds
490
- * after `install --no-pm2` before postmaster is listening. On success, discovery is
491
- * recipe-derived (same port). On failure, the spawned child is killed.
648
+ * Registration is not liveness: a stopped pm2 host still reports a port, and
649
+ * attaching to it is the `ECONNREFUSED 127.0.0.1:25432` bug. `readyMs` > 0 is
650
+ * the grace period after asking autopg to start.
492
651
  */
493
- async function startEphemeralHost(bin) {
494
- const recipe = ephemeralHostRecipe();
495
- (0, node_fs.mkdirSync)(recipe.dataDir, { recursive: true });
496
- runInstall(bin, recipe.installArgs);
497
- const state = {
498
- exit: null,
499
- spawnError: null
500
- };
501
- const child = (0, node_child_process.spawn)(bin, recipe.postmasterArgs, {
502
- detached: true,
503
- stdio: "ignore"
504
- });
652
+ async function attachLiveHost(bin, readyMs = 0) {
653
+ let discovered;
654
+ try {
655
+ discovered = discoverHost(bin);
656
+ } catch {
657
+ return null;
658
+ }
659
+ return await waitForListener({
660
+ port: discovered.port,
661
+ readyMs
662
+ }) ? discovered : null;
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
+ }
669
+ /**
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.
673
+ */
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 });
716
+ }
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;
505
744
  child.on("error", (err) => {
506
- state.spawnError = err;
745
+ died = /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${err.message}`);
507
746
  });
508
747
  child.on("exit", (code, signal) => {
509
- state.exit = {
510
- code,
511
- signal
512
- };
748
+ died ??= /* @__PURE__ */ new Error(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`);
513
749
  });
514
750
  try {
515
- await waitForOwnedPostmaster({
516
- port: recipe.port,
517
- failure: () => {
518
- if (state.spawnError) return /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${state.spawnError.message}\n${INSTALL_HINT}`);
519
- if (state.exit) return /* @__PURE__ */ new Error(`autopg postmaster exited before ready (code=${state.exit.code}, signal=${state.exit.signal}).\nautopg is required. Install with:
520
- curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/main/install.sh | bash
521
- Then ensure ~/.local/bin is on PATH, or set AUTOPG_BIN.`);
522
- return null;
523
- }
524
- });
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.`);
525
756
  } catch (err) {
526
757
  killOwnedChild(child);
527
758
  throw err;
528
759
  }
529
760
  child.unref();
530
- return discoveryFromRecipe(bin, recipe);
531
761
  }
532
762
  /**
533
- * Ensure the host postmaster is up.
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.
534
766
  *
535
- * - Attaches when `autopg status --json` shows a live host.
536
- * - Otherwise starts from {@link resolveEphemeralHostPolicy}: local pm2 `install`,
537
- * or opinionated ephemeral (`--no-pm2 --no-ui` + detached postmaster).
538
- * - CI job owns ephemeral postmaster lifetime on success (no cedar-pg host dispose).
539
- * - Failed ephemeral start kills the spawned child so ensure is atomic for the caller.
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);
823
+ if (attached) return attached;
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
+ }
838
+ }
839
+ const where = registered != null ? `autopg host is registered on 127.0.0.1:${registered.host.port} but nothing is listening.\n` : "";
840
+ throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
841
+ }
842
+ /**
843
+ * Ephemeral host owned by this process/job: detached `autopg postmaster` with
844
+ * fully ignored stdio (caller exit must not close pipes under the daemon).
845
+ *
846
+ * Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
847
+ * with `supervisor mismatch` next to a local pm2 install. Reuses a listener
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.
851
+ */
852
+ async function startEphemeralHost(bin, recipe = ephemeralHostRecipe()) {
853
+ const { dataDir, port } = recipe;
854
+ if (await canConnect(port)) return discoveryFromRecipe(bin, recipe);
855
+ const useRamShm = recipe.postmasterArgs.includes("--ram");
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
+ });
876
+ if (useRamShm) {
877
+ const free = freeBytesOn("/dev/shm");
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`);
879
+ }
880
+ (0, node_fs.mkdirSync)(recipe.dataDir, { recursive: true });
881
+ try {
882
+ await spawnDetachedPostmaster({
883
+ bin,
884
+ args: recipe.postmasterArgs,
885
+ port: recipe.port,
886
+ readyMs: HOST_READY_MS
887
+ });
888
+ } catch (err) {
889
+ if (liveDataDirOwner(recipe.dataDir) != null) return;
890
+ throw new Error(formatHostStartError(errorDetail(err), useRamShm));
891
+ }
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);
900
+ }
901
+ /**
902
+ * Attach to a listening autopg host, else start one per
903
+ * {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
540
904
  */
541
905
  async function ensureHostRunning(bin = requireAutopgBin()) {
906
+ warnIfAutopgOutdated(bin);
907
+ let registered = null;
542
908
  try {
543
- return discoverHost(bin);
909
+ registered = discoverRegistration(bin);
544
910
  } catch {}
545
- if (resolveEphemeralHostPolicy() === "ephemeral") return startEphemeralHost(bin);
546
- runInstall(bin, ["install"]);
547
- return discoverHost(bin);
911
+ if (registered && await waitForListener({ port: registered.host.port })) return registered.host;
912
+ return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered);
548
913
  }
549
914
  //#endregion
550
915
  //#region src/core/lifecycle.ts
@@ -587,8 +952,14 @@ async function acquire(options) {
587
952
  const identity = require_lease.resolveWorktreeIdentity(options.root);
588
953
  const mode = options.mode;
589
954
  const databaseName = buildDatabaseName(identity, mode);
590
- const roleName = buildRoleName(databaseName);
955
+ const leased = require_lease.readLease(identity.root, mode);
956
+ const roleName = leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);
591
957
  const host = await ensureHostRunning();
958
+ if (options.fresh) await dropDatabasesOwnedByRole({
959
+ adminUrl: host.adminUrl,
960
+ roleName,
961
+ preferLast: databaseName
962
+ });
592
963
  await ensureDatabase({
593
964
  adminUrl: host.adminUrl,
594
965
  databaseName,
@@ -635,6 +1006,23 @@ async function acquire(options) {
635
1006
  };
636
1007
  }
637
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
+ /**
638
1026
  * Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
639
1027
  * On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
640
1028
  */
@@ -642,7 +1030,8 @@ async function acquireIfNeeded(options) {
642
1030
  const outcome = await runIfNeeded(options, () => acquire({
643
1031
  root: options.root,
644
1032
  mode: options.mode,
645
- setEnv: options.setEnv
1033
+ setEnv: options.setEnv,
1034
+ fresh: options.fresh
646
1035
  }));
647
1036
  if (outcome.status === "skipped") return outcome;
648
1037
  return {
@@ -658,9 +1047,8 @@ async function acquireIfNeeded(options) {
658
1047
  * If the host is unavailable, leaves the lease so dispose/gc can retry.
659
1048
  */
660
1049
  async function dispose(options = {}) {
661
- const identity = require_lease.resolveWorktreeIdentity(options.root);
662
1050
  const mode = options.mode ?? "test";
663
- const lease = require_lease.readLease(identity.root, mode);
1051
+ const lease = require_lease.readLease(require_lease.resolveRoot(options.root), mode);
664
1052
  if (!lease) return {
665
1053
  dropped: false,
666
1054
  reason: "no-lease"
@@ -726,12 +1114,24 @@ Object.defineProperty(exports, "acquireIfNeeded", {
726
1114
  return acquireIfNeeded;
727
1115
  }
728
1116
  });
1117
+ Object.defineProperty(exports, "adminUrlFor", {
1118
+ enumerable: true,
1119
+ get: function() {
1120
+ return adminUrlFor;
1121
+ }
1122
+ });
729
1123
  Object.defineProperty(exports, "applyDatabaseUrlEnv", {
730
1124
  enumerable: true,
731
1125
  get: function() {
732
1126
  return applyDatabaseUrlEnv;
733
1127
  }
734
1128
  });
1129
+ Object.defineProperty(exports, "attach", {
1130
+ enumerable: true,
1131
+ get: function() {
1132
+ return attach;
1133
+ }
1134
+ });
735
1135
  Object.defineProperty(exports, "buildCloneDatabaseName", {
736
1136
  enumerable: true,
737
1137
  get: function() {
@@ -780,12 +1180,6 @@ Object.defineProperty(exports, "dropDatabase", {
780
1180
  return dropDatabase;
781
1181
  }
782
1182
  });
783
- Object.defineProperty(exports, "ensureHostRunning", {
784
- enumerable: true,
785
- get: function() {
786
- return ensureHostRunning;
787
- }
788
- });
789
1183
  Object.defineProperty(exports, "gc", {
790
1184
  enumerable: true,
791
1185
  get: function() {
@@ -846,6 +1240,12 @@ Object.defineProperty(exports, "setDatabaseIsTemplate", {
846
1240
  return setDatabaseIsTemplate;
847
1241
  }
848
1242
  });
1243
+ Object.defineProperty(exports, "truncateUserTables", {
1244
+ enumerable: true,
1245
+ get: function() {
1246
+ return truncateUserTables;
1247
+ }
1248
+ });
849
1249
  Object.defineProperty(exports, "urlFromLease", {
850
1250
  enumerable: true,
851
1251
  get: function() {
@@ -853,4 +1253,4 @@ Object.defineProperty(exports, "urlFromLease", {
853
1253
  }
854
1254
  });
855
1255
 
856
- //# sourceMappingURL=lifecycle-DEJ4GgWV.cjs.map
1256
+ //# sourceMappingURL=lifecycle-BCeM96tI.cjs.map