@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.
- package/README.md +112 -63
- package/dist/cli.cjs +15 -9
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +15 -9
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.d.cts +54 -11
- package/dist/index.d.mts +54 -11
- package/dist/index.mjs +3 -3
- package/dist/jest-teardown.cjs +1 -1
- package/dist/jest-teardown.mjs +1 -1
- package/dist/jest-template.cjs +5 -3
- package/dist/jest-template.cjs.map +1 -1
- package/dist/jest-template.d.cts +5 -3
- package/dist/jest-template.d.mts +5 -3
- package/dist/jest-template.mjs +5 -3
- package/dist/jest-template.mjs.map +1 -1
- package/dist/jest.cjs +1 -1
- package/dist/jest.mjs +1 -1
- package/dist/{lifecycle-BvIx0xqq.mjs → lifecycle-2BZgSVz2.mjs} +423 -132
- package/dist/lifecycle-2BZgSVz2.mjs.map +1 -0
- package/dist/{lifecycle-BbrvFQvg.cjs → lifecycle-BCeM96tI.cjs} +438 -135
- package/dist/lifecycle-BCeM96tI.cjs.map +1 -0
- package/dist/{naming-C2nGVxPk.d.cts → naming-Df4fz_Fb.d.cts} +3 -2
- package/dist/{naming-C2nGVxPk.d.mts → naming-Df4fz_Fb.d.mts} +3 -2
- package/dist/{status-Dcnpgnlz.mjs → status-B68SGSKx.mjs} +2 -2
- package/dist/{status-Dcnpgnlz.mjs.map → status-B68SGSKx.mjs.map} +1 -1
- package/dist/{status-4Uz6A3LB.cjs → status-Z_BYdxzI.cjs} +2 -2
- package/dist/{status-4Uz6A3LB.cjs.map → status-Z_BYdxzI.cjs.map} +1 -1
- package/dist/{studio-DIFw1qWC.mjs → studio-Bi6SU9jE.mjs} +54 -6
- package/dist/studio-Bi6SU9jE.mjs.map +1 -0
- package/dist/{studio-oJE30_PO.cjs → studio-CdWIdnxr.cjs} +78 -6
- package/dist/studio-CdWIdnxr.cjs.map +1 -0
- package/dist/{template-CXMpODzK.cjs → template-Cd77H7Cr.cjs} +21 -18
- package/dist/template-Cd77H7Cr.cjs.map +1 -0
- package/dist/{template-BiJ-0TIF.mjs → template-DlqHL8Go.mjs} +21 -18
- package/dist/template-DlqHL8Go.mjs.map +1 -0
- package/dist/template-mode-BqV3GjXI.d.cts +48 -0
- package/dist/template-mode-BqV3GjXI.d.mts +48 -0
- package/dist/template-mode-DK5cAlv9.mjs +81 -0
- package/dist/template-mode-DK5cAlv9.mjs.map +1 -0
- package/dist/template-mode-DMO5-z7O.cjs +92 -0
- package/dist/template-mode-DMO5-z7O.cjs.map +1 -0
- package/dist/vite-plus.cjs +9 -9
- package/dist/vite-plus.cjs.map +1 -1
- package/dist/vite-plus.d.cts +17 -2
- package/dist/vite-plus.d.mts +17 -2
- package/dist/vite-plus.mjs +5 -5
- package/dist/vite-plus.mjs.map +1 -1
- package/dist/vitest-template.cjs +6 -3
- package/dist/vitest-template.cjs.map +1 -1
- package/dist/vitest-template.d.cts +6 -3
- package/dist/vitest-template.d.mts +6 -3
- package/dist/vitest-template.mjs +6 -3
- package/dist/vitest-template.mjs.map +1 -1
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/package.json +2 -7
- package/scripts/autopg-version +1 -1
- package/scripts/ci-install-autopg.sh +7 -2
- package/scripts/postinstall.js +41 -13
- package/dist/lease-B3TuX92y.d.mts +0 -24
- package/dist/lease-t4I9JahV.d.cts +0 -24
- package/dist/lifecycle-BbrvFQvg.cjs.map +0 -1
- package/dist/lifecycle-BvIx0xqq.mjs.map +0 -1
- package/dist/nx.cjs +0 -42
- package/dist/nx.cjs.map +0 -1
- package/dist/nx.d.cts +0 -15
- package/dist/nx.d.mts +0 -15
- package/dist/nx.mjs +0 -34
- package/dist/nx.mjs.map +0 -1
- package/dist/studio-DIFw1qWC.mjs.map +0 -1
- package/dist/studio-oJE30_PO.cjs.map +0 -1
- package/dist/tasks-CNHvvlsN.cjs +0 -67
- package/dist/tasks-CNHvvlsN.cjs.map +0 -1
- package/dist/tasks-CjaZRi_G.d.mts +0 -19
- package/dist/tasks-D5iWIdlo.mjs +0 -38
- package/dist/tasks-D5iWIdlo.mjs.map +0 -1
- package/dist/tasks-DdQoP9We.d.cts +0 -19
- package/dist/template-BiJ-0TIF.mjs.map +0 -1
- package/dist/template-CXMpODzK.cjs.map +0 -1
- package/dist/template-mode-BTUsdBtA.mjs +0 -94
- package/dist/template-mode-BTUsdBtA.mjs.map +0 -1
- package/dist/template-mode-CzyVwHsi.d.cts +0 -31
- package/dist/template-mode-CzyVwHsi.d.mts +0 -31
- package/dist/template-mode-JTOxrEgE.cjs +0 -105
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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`
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
263
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
602
|
-
if (await canConnect(
|
|
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
|
-
|
|
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
|
-
|
|
882
|
+
await spawnDetachedPostmaster({
|
|
883
|
+
bin,
|
|
884
|
+
args: recipe.postmasterArgs,
|
|
623
885
|
port: recipe.port,
|
|
624
|
-
readyMs: HOST_READY_MS
|
|
625
|
-
|
|
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
|
-
|
|
629
|
-
throw err;
|
|
889
|
+
if (liveDataDirOwner(recipe.dataDir) != null) return;
|
|
890
|
+
throw new Error(formatHostStartError(errorDetail(err), useRamShm));
|
|
630
891
|
}
|
|
631
|
-
|
|
632
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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-
|
|
1256
|
+
//# sourceMappingURL=lifecycle-BCeM96tI.cjs.map
|