@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as leaseDir, f as resolveWorktreeIdentity, i as isOrphanLease, l as readLease, m as PASSWORD_SALT_PREFIX, n as envPath, r as forgetLease, s as listRegistryLeases, u as writeLease } from "./lease-DS1SX8U_.mjs";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { a as leaseDir, d as resolveRoot, f as resolveWorktreeIdentity, i as isOrphanLease, l as readLease, m as PASSWORD_SALT_PREFIX, n as envPath, p as CLI_NAME, r as forgetLease, s as listRegistryLeases, u as writeLease } from "./lease-DS1SX8U_.mjs";
|
|
2
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, statfsSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
@@ -30,21 +30,28 @@ function buildDatabaseName(identity, mode) {
|
|
|
30
30
|
if (name.length > PG_MAX) return name.slice(0, PG_MAX);
|
|
31
31
|
return name;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Append `suffix` to a database name, staying ≤63 chars. When it does not fit,
|
|
35
|
+
* cut the readable head (`cpg_<repo>_<worktree>`) and keep the trailing
|
|
36
|
+
* `_<mode>_<pathHash8>` whole: that hash is what keeps worktrees apart.
|
|
37
|
+
*/
|
|
38
|
+
function appendKeepingHash(databaseName, suffix) {
|
|
39
|
+
const full = `${databaseName}${suffix}`;
|
|
40
|
+
if (full.length <= PG_MAX) return full;
|
|
41
|
+
const tail = /_[^_]+_[^_]+$/.exec(databaseName)?.[0] ?? "";
|
|
42
|
+
return `${databaseName.slice(0, Math.max(0, PG_MAX - tail.length - suffix.length)).replace(/_+$/, "")}${tail}${suffix}`.slice(0, PG_MAX);
|
|
43
|
+
}
|
|
44
|
+
/** Role for a database name: `<databaseName>_role`, cut by `appendKeepingHash`. */
|
|
33
45
|
function buildRoleName(databaseName) {
|
|
34
|
-
|
|
35
|
-
const maxBase = PG_MAX - 5;
|
|
36
|
-
return `${databaseName.slice(0, maxBase)}${suffix}`;
|
|
46
|
+
return appendKeepingHash(databaseName, "_role");
|
|
37
47
|
}
|
|
38
48
|
/**
|
|
39
49
|
* Build a worker clone datname from a TEMPLATE database name.
|
|
40
|
-
* Layout: `<template>_c_<suffix
|
|
50
|
+
* Layout: `<template>_c_<suffix>`, cut by `appendKeepingHash` (keeps mode, hash, and suffix).
|
|
41
51
|
*/
|
|
42
52
|
function buildCloneDatabaseName(templateName, suffix) {
|
|
43
53
|
const safe = suffix.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24);
|
|
44
|
-
|
|
45
|
-
const sep = "_c_";
|
|
46
|
-
const maxTemplate = PG_MAX - 3 - tag.length;
|
|
47
|
-
return `${templateName.slice(0, Math.max(1, maxTemplate))}${sep}${tag}`.slice(0, PG_MAX);
|
|
54
|
+
return appendKeepingHash(templateName, `_c_${safe.length > 0 ? safe : "w"}`);
|
|
48
55
|
}
|
|
49
56
|
//#endregion
|
|
50
57
|
//#region src/core/policy.ts
|
|
@@ -144,6 +151,8 @@ function resolveAcquireSkip(input = {}) {
|
|
|
144
151
|
}
|
|
145
152
|
//#endregion
|
|
146
153
|
//#region src/providers/autopg.ts
|
|
154
|
+
/** autopg release cedar-pg is tested against (`scripts/autopg-version`, inlined at build). */
|
|
155
|
+
const AUTOPG_PINNED_VERSION = "v3.2.2";
|
|
147
156
|
const INSTALL_HINT = "autopg is required. Install with:\n curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/main/install.sh | bash\nThen ensure ~/.local/bin is on PATH, or set AUTOPG_BIN.";
|
|
148
157
|
/** Password scheme v2: sha256(PASSWORD_SALT_PREFIX + "\\0" + roleName) hex[:32]. Frozen for URL rebuild. */
|
|
149
158
|
const ROLE_PASSWORD_SCHEME = "v2";
|
|
@@ -170,14 +179,19 @@ function requireAutopgBin() {
|
|
|
170
179
|
if (!bin) throw new Error(INSTALL_HINT);
|
|
171
180
|
return bin;
|
|
172
181
|
}
|
|
182
|
+
function optionalNonEmptyString(value) {
|
|
183
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
184
|
+
}
|
|
173
185
|
/**
|
|
174
|
-
* Parse `autopg status --json` → the **registered** port
|
|
175
|
-
* output is not autopg status JSON.
|
|
186
|
+
* Parse `autopg status --json` → the **registered** port (and data/socket/logs
|
|
187
|
+
* dirs when present). Throws only when the output is not autopg status JSON.
|
|
176
188
|
*
|
|
177
189
|
* Registration is not liveness: autopg reports a port for a stopped host too,
|
|
178
|
-
* and its `status`
|
|
179
|
-
*
|
|
180
|
-
*
|
|
190
|
+
* and its `status` / `ready` describe the supervisor (autopg ≥ v3.2: `ready` needs
|
|
191
|
+
* pm2 `online`; pm2's raw state is `supervisorStatus`). Neither, nor `runtime.live`,
|
|
192
|
+
* is the attach gate — a bare postmaster (e.g. one cedar-pg revived) is
|
|
193
|
+
* query-ready while status stays `stopped` / `ready: false`.
|
|
194
|
+
* Liveness is a TCP accept on the port, proven by the caller (`acquire`).
|
|
181
195
|
*/
|
|
182
196
|
function parseHostStatus(json) {
|
|
183
197
|
let parsed;
|
|
@@ -187,7 +201,16 @@ function parseHostStatus(json) {
|
|
|
187
201
|
throw new Error(`autopg status --json returned invalid JSON.\n${INSTALL_HINT}`);
|
|
188
202
|
}
|
|
189
203
|
if (typeof parsed.port !== "number") throw new Error(`autopg status --json missing numeric port.\n${INSTALL_HINT}`);
|
|
190
|
-
|
|
204
|
+
const result = { port: parsed.port };
|
|
205
|
+
for (const key of [
|
|
206
|
+
"dataDir",
|
|
207
|
+
"socketDir",
|
|
208
|
+
"logsDir"
|
|
209
|
+
]) {
|
|
210
|
+
const value = optionalNonEmptyString(parsed[key]);
|
|
211
|
+
if (value) result[key] = value;
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
191
214
|
}
|
|
192
215
|
/**
|
|
193
216
|
* Admin URL for an autopg host on `port`.
|
|
@@ -202,15 +225,44 @@ function parseHostStatus(json) {
|
|
|
202
225
|
function adminUrlFor(port, env = process.env) {
|
|
203
226
|
return `postgresql://${encodeURIComponent(env.AUTOPG_PG_USER || env.PGSERVE_PG_USER || "postgres")}:${encodeURIComponent(env.AUTOPG_PG_PASSWORD || env.PGSERVE_PG_PASSWORD || "postgres")}@127.0.0.1:${port}/postgres`;
|
|
204
227
|
}
|
|
228
|
+
/** `[major, minor, patch]` from `autopg --version` output or a `vX.Y.Z` tag; `null` if absent. */
|
|
229
|
+
function parseAutopgVersion(text) {
|
|
230
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
231
|
+
return m ? [
|
|
232
|
+
Number(m[1]),
|
|
233
|
+
Number(m[2]),
|
|
234
|
+
Number(m[3])
|
|
235
|
+
] : null;
|
|
236
|
+
}
|
|
205
237
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
238
|
+
* Upgrade warning when `autopg --version` output is older than `pin`. `null` when
|
|
239
|
+
* current, newer, or unreadable — never nag on what cannot be parsed. Upgrading
|
|
240
|
+
* is the user's call: it restarts the host every worktree shares.
|
|
209
241
|
*/
|
|
210
|
-
function
|
|
211
|
-
|
|
242
|
+
function outdatedAutopgWarning(versionOutput, pin = AUTOPG_PINNED_VERSION) {
|
|
243
|
+
const have = parseAutopgVersion(versionOutput);
|
|
244
|
+
const want = parseAutopgVersion(pin);
|
|
245
|
+
if (!have || !want) return null;
|
|
246
|
+
if ((have[0] - want[0] || have[1] - want[1] || have[2] - want[2]) >= 0) return null;
|
|
247
|
+
return `[cedar-pg] warning: autopg ${have.join(".")} is older than ${pin}, the version @cedarjs/pg is tested with.\nUpgrade (restarts the shared host; open connections from other worktrees drop):
|
|
248
|
+
curl -fsSL https://raw.githubusercontent.com/automagik-dev/autopg/${pin}/install.sh | AUTOPG_VERSION=${pin} bash\n autopg update
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
/** `autopg --version` stdout; `""` when it cannot run (treated as unknown). */
|
|
252
|
+
function readAutopgVersion(bin) {
|
|
253
|
+
return spawnSync(bin, ["--version"], {
|
|
254
|
+
encoding: "utf8",
|
|
255
|
+
stdio: [
|
|
256
|
+
"ignore",
|
|
257
|
+
"pipe",
|
|
258
|
+
"ignore"
|
|
259
|
+
],
|
|
260
|
+
timeout: 5e3
|
|
261
|
+
}).stdout ?? "";
|
|
262
|
+
}
|
|
263
|
+
function readStatusJson(bin) {
|
|
212
264
|
try {
|
|
213
|
-
|
|
265
|
+
return execFileSync(bin, ["status", "--json"], {
|
|
214
266
|
encoding: "utf8",
|
|
215
267
|
stdio: [
|
|
216
268
|
"ignore",
|
|
@@ -222,22 +274,70 @@ function discoverHost(bin = requireAutopgBin()) {
|
|
|
222
274
|
const detail = err instanceof Error ? err.message : String(err);
|
|
223
275
|
throw new Error(`Failed to query autopg status.\n${detail}\n${INSTALL_HINT}`);
|
|
224
276
|
}
|
|
225
|
-
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Discover the registered autopg host (attach target + reported data/socket/logs
|
|
280
|
+
* dirs) via `autopg status --json`. Throws when autopg cannot be queried; does
|
|
281
|
+
* **not** prove a listener — probe TCP (or use `acquire`, which does) before connecting.
|
|
282
|
+
*/
|
|
283
|
+
function discoverRegistration(bin = requireAutopgBin()) {
|
|
284
|
+
const { port, ...paths } = parseHostStatus(readStatusJson(bin));
|
|
226
285
|
return {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
286
|
+
host: {
|
|
287
|
+
port,
|
|
288
|
+
adminUrl: adminUrlFor(port),
|
|
289
|
+
bin
|
|
290
|
+
},
|
|
291
|
+
...paths
|
|
230
292
|
};
|
|
231
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Discover the registered autopg host (port + admin URL) via `autopg status --json`.
|
|
296
|
+
* Throws when autopg cannot be queried; does **not** prove a listener — probe TCP
|
|
297
|
+
* (or use `acquire`, which does) before connecting.
|
|
298
|
+
*/
|
|
299
|
+
function discoverHost(bin = requireAutopgBin()) {
|
|
300
|
+
return discoverRegistration(bin).host;
|
|
301
|
+
}
|
|
232
302
|
function quoteIdent(name) {
|
|
233
303
|
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
234
304
|
}
|
|
235
305
|
function quoteLiteral(value) {
|
|
236
306
|
return `'${value.replace(/'/g, "''")}'`;
|
|
237
307
|
}
|
|
308
|
+
/** Grace for a postmaster that accepts TCP but still answers 57P03 (startup / recovery). */
|
|
309
|
+
const ADMIN_CONNECT_READY_MS = 3e4;
|
|
310
|
+
const ADMIN_CONNECT_POLL_MS = 200;
|
|
311
|
+
/**
|
|
312
|
+
* Retry `connect` while Postgres answers `57P03` (cannot_connect_now: "the
|
|
313
|
+
* database system is starting up" / in recovery). TCP accept — the host
|
|
314
|
+
* liveness gate — comes before query-ready, so a freshly revived or crash-
|
|
315
|
+
* recovering postmaster needs this. Any other error is thrown immediately.
|
|
316
|
+
*/
|
|
317
|
+
async function connectWhileStartingUp(connect, opts = {}) {
|
|
318
|
+
const now = opts.now ?? Date.now;
|
|
319
|
+
const pause = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
320
|
+
const deadline = now() + (opts.readyMs ?? ADMIN_CONNECT_READY_MS);
|
|
321
|
+
for (;;) {
|
|
322
|
+
try {
|
|
323
|
+
return await connect();
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err.code !== "57P03" || now() >= deadline) throw err;
|
|
326
|
+
}
|
|
327
|
+
await pause(opts.pollMs ?? ADMIN_CONNECT_POLL_MS);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
238
330
|
async function withAdminClient(adminUrl, fn) {
|
|
239
|
-
const client =
|
|
240
|
-
|
|
331
|
+
const client = await connectWhileStartingUp(async () => {
|
|
332
|
+
const attempt = new pg.Client({ connectionString: adminUrl });
|
|
333
|
+
try {
|
|
334
|
+
await attempt.connect();
|
|
335
|
+
return attempt;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
await attempt.end().catch(() => {});
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
241
341
|
try {
|
|
242
342
|
return await fn(client);
|
|
243
343
|
} finally {
|
|
@@ -276,18 +376,59 @@ async function setDatabaseIsTemplate(opts) {
|
|
|
276
376
|
await client.query(`ALTER DATABASE ${quoteIdent(opts.databaseName)} WITH IS_TEMPLATE ${flag}`);
|
|
277
377
|
});
|
|
278
378
|
}
|
|
379
|
+
/** Postgres `duplicate_database`: CREATE DATABASE hit an existing datname. */
|
|
380
|
+
const DUPLICATE_DATABASE = "42P04";
|
|
279
381
|
/**
|
|
280
382
|
* CREATE DATABASE … TEMPLATE … OWNER via admin connection.
|
|
281
383
|
* Test roles are LOGIN-only; workers cannot CREATE DATABASE themselves.
|
|
384
|
+
*
|
|
385
|
+
* With `reuse`, an existing `databaseName` owned by `roleName` is kept as-is:
|
|
386
|
+
* a worker clone made earlier in this run by another test file.
|
|
387
|
+
* An existing datname owned by any other role always fails: it is not ours.
|
|
282
388
|
*/
|
|
283
389
|
async function cloneDatabaseFromTemplate(opts) {
|
|
284
390
|
await withAdminClient(opts.adminUrl, async (client) => {
|
|
285
391
|
const tmpl = await client.query(`SELECT datistemplate FROM pg_database WHERE datname = $1`, [opts.templateName]);
|
|
286
392
|
if (!tmpl.rowCount) throw new Error(`template database not found: ${opts.templateName}`);
|
|
287
393
|
if (!tmpl.rows[0]?.datistemplate) throw new Error(`database is not a TEMPLATE; run markTemplate first: ${opts.templateName}`);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
394
|
+
try {
|
|
395
|
+
await client.query(`CREATE DATABASE ${quoteIdent(opts.databaseName)} WITH TEMPLATE ${quoteIdent(opts.templateName)} OWNER ${quoteIdent(opts.roleName)}`);
|
|
396
|
+
return;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err.code !== DUPLICATE_DATABASE) throw err;
|
|
399
|
+
}
|
|
400
|
+
const owner = (await client.query(`SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = $1`, [opts.databaseName])).rows[0]?.owner;
|
|
401
|
+
if (opts.reuse && owner === opts.roleName) return;
|
|
402
|
+
throw new Error(`database already exists: ${opts.databaseName} (owned by ${owner ?? "unknown"})`);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Empty every user table in `databaseName` with one
|
|
407
|
+
* `TRUNCATE … RESTART IDENTITY`, so serial / identity columns start at 1 again.
|
|
408
|
+
*
|
|
409
|
+
* Runs as admin against that database. Skips system schemas, temp tables, and
|
|
410
|
+
* tables an extension owns (e.g. PostGIS `spatial_ref_sys`); partitions are covered by
|
|
411
|
+
* their parent. No `CASCADE`: every user table is in the one statement, so no
|
|
412
|
+
* FK target is missing from it.
|
|
413
|
+
*/
|
|
414
|
+
async function truncateUserTables(opts) {
|
|
415
|
+
const url = new URL(opts.adminUrl);
|
|
416
|
+
url.pathname = `/${opts.databaseName}`;
|
|
417
|
+
return withAdminClient(url.toString(), async (client) => {
|
|
418
|
+
const tables = (await client.query(`SELECT format('%I.%I', n.nspname, c.relname) AS name
|
|
419
|
+
FROM pg_class c
|
|
420
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
421
|
+
WHERE c.relkind IN ('r', 'p')
|
|
422
|
+
AND NOT c.relispartition
|
|
423
|
+
AND c.relpersistence <> 't'
|
|
424
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
425
|
+
AND NOT EXISTS (
|
|
426
|
+
SELECT 1 FROM pg_depend d
|
|
427
|
+
WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e'
|
|
428
|
+
)
|
|
429
|
+
ORDER BY 1`)).rows.map((r) => r.name);
|
|
430
|
+
if (tables.length > 0) await client.query(`TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY`);
|
|
431
|
+
return tables;
|
|
291
432
|
});
|
|
292
433
|
}
|
|
293
434
|
async function listOwnedDatnames(client, roleName) {
|
|
@@ -344,37 +485,20 @@ function buildDatabaseUrl(opts) {
|
|
|
344
485
|
const EPHEMERAL_PORT = 55432;
|
|
345
486
|
const HOST_READY_MS = 3e4;
|
|
346
487
|
const HOST_POLL_MS = 200;
|
|
347
|
-
/**
|
|
348
|
-
|
|
349
|
-
*
|
|
350
|
-
* `restart` exit 0 is not evidence of a listener: when pm2 is missing or does not
|
|
351
|
-
* list `autopg-server`, autopg still prints "respawned daemon" and returns 0.
|
|
352
|
-
* That is why every verb is followed by a TCP wait, not trusted on exit status.
|
|
353
|
-
*
|
|
354
|
-
* `install` covers a never-registered machine (postinstall ships the binary only)
|
|
355
|
-
* and a reboot whose pm2 list is empty (`pm2 start`). On an already-registered
|
|
356
|
-
* host it is a no-op for the server process but may `pm2 start` the autopg UI —
|
|
357
|
-
* so it runs only after `restart` failed to produce a listener.
|
|
358
|
-
*/
|
|
359
|
-
const LOCAL_START = [{
|
|
360
|
-
argv: ["restart"],
|
|
361
|
-
readyMs: 1e4
|
|
362
|
-
}, {
|
|
363
|
-
argv: ["install"],
|
|
364
|
-
readyMs: HOST_READY_MS
|
|
365
|
-
}];
|
|
488
|
+
/** `autopg restart` exits 0 only once ready; grace covers older autopg that returned early. */
|
|
489
|
+
const LOCAL_RESTART_READY_MS = 1e4;
|
|
366
490
|
/** Soft minimum free bytes on /dev/shm before RAM-backed ephemeral start (warns only). */
|
|
367
491
|
const EPHEMERAL_SHM_MIN_FREE_BYTES = 512 * 1024 * 1024;
|
|
368
492
|
/**
|
|
369
493
|
* Hint when ephemeral `--ram` initdb fails for space / leftover dirs under `/dev/shm`.
|
|
370
494
|
* Cloud VMs often ship with a tiny default tmpfs (e.g. 64MB).
|
|
371
495
|
*/
|
|
372
|
-
const EPHEMERAL_SHM_HINT = "Ephemeral autopg uses /dev/shm with --ram. If initdb fails with Disk quota / No space (Postgres 53100):\n 1. Enlarge tmpfs (cloud VMs often default to ~64MB): sudo mount -o remount,size=6G /dev/shm\n 2.
|
|
496
|
+
const EPHEMERAL_SHM_HINT = "Ephemeral autopg uses /dev/shm with --ram. If initdb fails with Disk quota / No space (Postgres 53100):\n 1. Enlarge tmpfs (cloud VMs often default to ~64MB): sudo mount -o remount,size=6G /dev/shm\n 2. cedar-pg already removes its own leftover dir (/dev/shm/cedar-pg-<uid>) on cold start.\n Never rm /dev/shm/PostgreSQL.*: those segments belong to every running Postgres,\n and deleting a live one breaks new connections to that host (58P01).";
|
|
373
497
|
/**
|
|
374
498
|
* Resolve how to start a host when none is listening.
|
|
375
499
|
*
|
|
376
500
|
* - `CEDAR_PG_EPHEMERAL_HOST=1` → ephemeral owned postmaster
|
|
377
|
-
* - `CEDAR_PG_EPHEMERAL_HOST=0` → never own
|
|
501
|
+
* - `CEDAR_PG_EPHEMERAL_HOST=0` → never own an ephemeral postmaster, local registered host only (even in CI)
|
|
378
502
|
* - unset + `CI=true` → ephemeral
|
|
379
503
|
* - otherwise → local
|
|
380
504
|
*/
|
|
@@ -424,38 +548,6 @@ function looksLikeShmSpaceError(text) {
|
|
|
424
548
|
function formatHostStartError(detail, useRamShm = false) {
|
|
425
549
|
return `Failed to start autopg host.\n${detail}\n${INSTALL_HINT}${useRamShm && looksLikeShmSpaceError(detail) ? `\n${EPHEMERAL_SHM_HINT}` : ""}`;
|
|
426
550
|
}
|
|
427
|
-
/**
|
|
428
|
-
* Remove leftover ephemeral data dirs under `/dev/shm` (and the recipe dataDir).
|
|
429
|
-
* Safe for OOM-killed CI/cloud runs that leave `cedar-pg-*` / `pgserve-*` /
|
|
430
|
-
* `PostgreSQL.*` filling tmpfs.
|
|
431
|
-
*
|
|
432
|
-
* Only called on ephemeral cold start, i.e. when the recipe port has no listener —
|
|
433
|
-
* never while a host owns those dirs.
|
|
434
|
-
*/
|
|
435
|
-
function pruneStaleEphemeralDataDirs(opts) {
|
|
436
|
-
const removed = [];
|
|
437
|
-
const tryRm = (path) => {
|
|
438
|
-
if (!existsSync(path)) return;
|
|
439
|
-
try {
|
|
440
|
-
rmSync(path, {
|
|
441
|
-
recursive: true,
|
|
442
|
-
force: true
|
|
443
|
-
});
|
|
444
|
-
removed.push(path);
|
|
445
|
-
} catch {}
|
|
446
|
-
};
|
|
447
|
-
tryRm(opts.dataDir);
|
|
448
|
-
const shmRoot = opts.shmRoot ?? (existsSync("/dev/shm") ? "/dev/shm" : void 0);
|
|
449
|
-
if (!shmRoot) return removed;
|
|
450
|
-
let entries = [];
|
|
451
|
-
try {
|
|
452
|
-
entries = readdirSync(shmRoot);
|
|
453
|
-
} catch {
|
|
454
|
-
return removed;
|
|
455
|
-
}
|
|
456
|
-
for (const name of entries) if (name.startsWith("cedar-pg-") || name.startsWith("pgserve-") || name.startsWith("PostgreSQL.")) tryRm(join(shmRoot, name));
|
|
457
|
-
return removed;
|
|
458
|
-
}
|
|
459
551
|
/** Free bytes on `path`, or `null` if unavailable. */
|
|
460
552
|
function freeBytesOn(path) {
|
|
461
553
|
try {
|
|
@@ -477,7 +569,8 @@ function runAutopg(bin, argv) {
|
|
|
477
569
|
});
|
|
478
570
|
if (result.error) return result.error.message;
|
|
479
571
|
if (result.status === 0) return null;
|
|
480
|
-
|
|
572
|
+
const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
|
|
573
|
+
return `exit ${result.status}\n${output.trim()}`.trim();
|
|
481
574
|
}
|
|
482
575
|
/** True when something accepts TCP on 127.0.0.1:port (postmaster live, not just admin.json). */
|
|
483
576
|
function canConnect(port) {
|
|
@@ -545,25 +638,182 @@ async function attachLiveHost(bin, readyMs = 0) {
|
|
|
545
638
|
readyMs
|
|
546
639
|
}) ? discovered : null;
|
|
547
640
|
}
|
|
641
|
+
/** Appended by the revived registered postmaster (next to autopg's own pm2 logs). */
|
|
642
|
+
const REVIVED_POSTMASTER_LOG = "cedarpg-postmaster.log";
|
|
643
|
+
function errorDetail(err) {
|
|
644
|
+
return err instanceof Error ? err.message : String(err);
|
|
645
|
+
}
|
|
548
646
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
647
|
+
* Live PID from `<dataDir>/postmaster.pid`, else `null`. Postgres writes it before
|
|
648
|
+
* binding, so a live owner means another postmaster (pm2 still recovering, or a
|
|
649
|
+
* concurrent acquire) already holds the data dir — wait for it, never compete.
|
|
552
650
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
651
|
+
function liveDataDirOwner(dataDir) {
|
|
652
|
+
return livePidIn(join(dataDir, "postmaster.pid"));
|
|
653
|
+
}
|
|
654
|
+
/** Live PID from the first line of `file`, else `null` (missing, garbage, or dead). */
|
|
655
|
+
function livePidIn(file) {
|
|
656
|
+
let pid;
|
|
657
|
+
try {
|
|
658
|
+
pid = Number.parseInt(readFileSync(file, "utf8"), 10);
|
|
659
|
+
} catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
663
|
+
try {
|
|
664
|
+
process.kill(pid, 0);
|
|
665
|
+
return pid;
|
|
666
|
+
} catch (err) {
|
|
667
|
+
return err.code === "EPERM" ? pid : null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Exclusive cold-start lock `<dataDir>.lock` holding our PID. Returns `null` once
|
|
672
|
+
* we hold it, else the live holder's PID. `postmaster.pid` alone cannot guard an
|
|
673
|
+
* ephemeral cold start: it does not exist yet while a peer runs initdb, which is
|
|
674
|
+
* exactly when a second `rm -rf` would destroy the peer's cluster.
|
|
675
|
+
*
|
|
676
|
+
* The PID is written to a private file and hard-linked into place, so the lock
|
|
677
|
+
* never exists without its PID. A dead holder's lock is stale and taken over.
|
|
678
|
+
*/
|
|
679
|
+
function tryColdStartLock(lockPath) {
|
|
680
|
+
const mine = `${lockPath}.${process.pid}`;
|
|
681
|
+
writeFileSync(mine, String(process.pid));
|
|
682
|
+
try {
|
|
683
|
+
for (;;) {
|
|
684
|
+
try {
|
|
685
|
+
linkSync(mine, lockPath);
|
|
686
|
+
return null;
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (err.code !== "EEXIST") throw err;
|
|
689
|
+
}
|
|
690
|
+
const holder = livePidIn(lockPath);
|
|
691
|
+
if (holder != null) return holder;
|
|
692
|
+
rmSync(lockPath, { force: true });
|
|
561
693
|
}
|
|
562
|
-
|
|
694
|
+
} finally {
|
|
695
|
+
rmSync(mine, { force: true });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Detached `autopg postmaster`. Success: child is unref'd (survives this process).
|
|
700
|
+
* Failure: child is killed, throws a one-line detail (caller wraps / aggregates).
|
|
701
|
+
* `logFile` (appended) keeps output from a postmaster nothing else supervises;
|
|
702
|
+
* otherwise stdio is fully ignored (caller exit must not close pipes under it).
|
|
703
|
+
*/
|
|
704
|
+
async function spawnDetachedPostmaster(opts) {
|
|
705
|
+
const { readyMs } = opts;
|
|
706
|
+
const logFd = opts.logFile ? openSync(opts.logFile, "a") : null;
|
|
707
|
+
let child;
|
|
708
|
+
try {
|
|
709
|
+
child = spawn(opts.bin, opts.args, {
|
|
710
|
+
detached: true,
|
|
711
|
+
stdio: logFd == null ? "ignore" : [
|
|
712
|
+
"ignore",
|
|
713
|
+
logFd,
|
|
714
|
+
logFd
|
|
715
|
+
]
|
|
716
|
+
});
|
|
717
|
+
} finally {
|
|
718
|
+
if (logFd != null) closeSync(logFd);
|
|
719
|
+
}
|
|
720
|
+
let died = null;
|
|
721
|
+
child.on("error", (err) => {
|
|
722
|
+
died = /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${err.message}`);
|
|
723
|
+
});
|
|
724
|
+
child.on("exit", (code, signal) => {
|
|
725
|
+
died ??= /* @__PURE__ */ new Error(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`);
|
|
726
|
+
});
|
|
727
|
+
try {
|
|
728
|
+
if (!await waitForListener({
|
|
729
|
+
port: opts.port,
|
|
730
|
+
readyMs,
|
|
731
|
+
failure: () => died
|
|
732
|
+
})) throw new Error(`autopg postmaster did not accept 127.0.0.1:${opts.port} within ${readyMs}ms.`);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
killOwnedChild(child);
|
|
735
|
+
throw err;
|
|
736
|
+
}
|
|
737
|
+
child.unref();
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Detached `autopg postmaster` on the registered port / data dir (socket dir when
|
|
741
|
+
* autopg reports one; otherwise the postmaster resolves autopg's own default).
|
|
742
|
+
* Returns failure detail, or `null` once TCP accepts.
|
|
743
|
+
*
|
|
744
|
+
* No reported `dataDir` → not attempted: a postmaster without `--data` is not
|
|
745
|
+
* persistent, and guessing autopg's path is how a second Postgres happens.
|
|
746
|
+
*/
|
|
747
|
+
async function reviveRegisteredPostmaster(bin, reg) {
|
|
748
|
+
const { dataDir, socketDir, logsDir } = reg;
|
|
749
|
+
const { port } = reg.host;
|
|
750
|
+
if (!dataDir) return "autopg postmaster: not attempted (autopg status --json reports no dataDir)";
|
|
751
|
+
const args = [
|
|
752
|
+
"postmaster",
|
|
753
|
+
"--port",
|
|
754
|
+
String(port),
|
|
755
|
+
"--data",
|
|
756
|
+
dataDir,
|
|
757
|
+
...socketDir ? ["--socket-dir", socketDir] : []
|
|
758
|
+
];
|
|
759
|
+
const label = `autopg ${args.join(" ")}`;
|
|
760
|
+
const logFile = logsDir ? join(logsDir, REVIVED_POSTMASTER_LOG) : void 0;
|
|
761
|
+
if (liveDataDirOwner(dataDir) == null) try {
|
|
762
|
+
mkdirSync(dataDir, {
|
|
763
|
+
recursive: true,
|
|
764
|
+
mode: 448
|
|
765
|
+
});
|
|
766
|
+
if (logsDir) mkdirSync(logsDir, { recursive: true });
|
|
767
|
+
await spawnDetachedPostmaster({
|
|
768
|
+
bin,
|
|
769
|
+
args,
|
|
770
|
+
port,
|
|
771
|
+
readyMs: HOST_READY_MS,
|
|
772
|
+
...logFile ? { logFile } : {}
|
|
773
|
+
});
|
|
774
|
+
return null;
|
|
775
|
+
} catch (err) {
|
|
776
|
+
if (liveDataDirOwner(dataDir) == null) return `${label}: ${errorDetail(err)}${logFile ? `\nPostmaster log: ${logFile}` : ""}`;
|
|
777
|
+
}
|
|
778
|
+
if (await waitForListener({
|
|
779
|
+
port,
|
|
780
|
+
readyMs: HOST_READY_MS
|
|
781
|
+
})) return null;
|
|
782
|
+
return `${label}: postmaster pid ${liveDataDirOwner(dataDir) ?? "?"} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Revive the user's registered autopg host — same port, same `~/.autopg/data`,
|
|
786
|
+
* still running after this process exits. Never a second local Postgres.
|
|
787
|
+
*
|
|
788
|
+
* `restart` drives pm2 only and exits 0 once ready; without pm2 (or under another
|
|
789
|
+
* supervisor) it exits 1. Either way TCP is the gate: if still dark,
|
|
790
|
+
* {@link reviveRegisteredPostmaster} on the registered port/data. Do not
|
|
791
|
+
* `install` on an already-registered host (wants pm2, refuses a port change).
|
|
792
|
+
* `install` is only for a never-registered machine.
|
|
793
|
+
*/
|
|
794
|
+
async function startLocalHost(bin, registered) {
|
|
795
|
+
const failures = [];
|
|
796
|
+
const restartFailure = runAutopg(bin, ["restart"]);
|
|
797
|
+
if (restartFailure) failures.push(`autopg restart: ${restartFailure}`);
|
|
798
|
+
else {
|
|
799
|
+
const attached = await attachLiveHost(bin, LOCAL_RESTART_READY_MS);
|
|
563
800
|
if (attached) return attached;
|
|
564
|
-
failures.push(
|
|
801
|
+
failures.push(`autopg restart: no listener within ${LOCAL_RESTART_READY_MS}ms`);
|
|
802
|
+
}
|
|
803
|
+
if (registered) {
|
|
804
|
+
const failure = await reviveRegisteredPostmaster(bin, registered);
|
|
805
|
+
if (!failure) return registered.host;
|
|
806
|
+
failures.push(failure);
|
|
807
|
+
} else {
|
|
808
|
+
const installFailure = runAutopg(bin, ["install"]);
|
|
809
|
+
if (installFailure) failures.push(`autopg install: ${installFailure}`);
|
|
810
|
+
else {
|
|
811
|
+
const attached = await attachLiveHost(bin, HOST_READY_MS);
|
|
812
|
+
if (attached) return attached;
|
|
813
|
+
failures.push(`autopg install: no listener within ${HOST_READY_MS}ms`);
|
|
814
|
+
}
|
|
565
815
|
}
|
|
566
|
-
const where =
|
|
816
|
+
const where = registered != null ? `autopg host is registered on 127.0.0.1:${registered.host.port} but nothing is listening.\n` : "";
|
|
567
817
|
throw new Error(formatHostStartError(`${where}${failures.join("\n")}`));
|
|
568
818
|
}
|
|
569
819
|
/**
|
|
@@ -572,53 +822,71 @@ async function startLocalHost(bin, registeredPort) {
|
|
|
572
822
|
*
|
|
573
823
|
* Never runs `autopg install` — that rewrites `~/.autopg/admin.json` and fails
|
|
574
824
|
* with `supervisor mismatch` next to a local pm2 install. Reuses a listener
|
|
575
|
-
* already on the recipe port
|
|
825
|
+
* already on the recipe port. Concurrent cold starts (Nx running several
|
|
826
|
+
* `db:ready` on one runner) serialize on {@link tryColdStartLock}: only the
|
|
827
|
+
* holder prunes and starts; everyone else waits for the listener.
|
|
576
828
|
*/
|
|
577
|
-
async function startEphemeralHost(bin) {
|
|
578
|
-
const
|
|
579
|
-
if (await canConnect(
|
|
829
|
+
async function startEphemeralHost(bin, recipe = ephemeralHostRecipe()) {
|
|
830
|
+
const { dataDir, port } = recipe;
|
|
831
|
+
if (await canConnect(port)) return discoveryFromRecipe(bin, recipe);
|
|
580
832
|
const useRamShm = recipe.postmasterArgs.includes("--ram");
|
|
581
|
-
|
|
833
|
+
const lockPath = `${dataDir}.lock`;
|
|
834
|
+
const holder = tryColdStartLock(lockPath);
|
|
835
|
+
if (holder == null) try {
|
|
836
|
+
if (!await canConnect(port) && liveDataDirOwner(dataDir) == null) await coldStartEphemeral(bin, recipe, useRamShm);
|
|
837
|
+
} finally {
|
|
838
|
+
rmSync(lockPath, { force: true });
|
|
839
|
+
}
|
|
840
|
+
if (await waitForListener({
|
|
841
|
+
port,
|
|
842
|
+
readyMs: HOST_READY_MS
|
|
843
|
+
})) return discoveryFromRecipe(bin, recipe);
|
|
844
|
+
const owner = holder != null ? `cold start pid ${holder}` : `postmaster pid ${liveDataDirOwner(dataDir) ?? "?"}`;
|
|
845
|
+
throw new Error(formatHostStartError(`${owner} owns ${dataDir} but did not accept 127.0.0.1:${port} within ${HOST_READY_MS}ms`, useRamShm));
|
|
846
|
+
}
|
|
847
|
+
/** Prune our dead leftover data dir and start the postmaster. Caller holds the cold-start lock. */
|
|
848
|
+
async function coldStartEphemeral(bin, recipe, useRamShm) {
|
|
849
|
+
rmSync(recipe.dataDir, {
|
|
850
|
+
recursive: true,
|
|
851
|
+
force: true
|
|
852
|
+
});
|
|
582
853
|
if (useRamShm) {
|
|
583
854
|
const free = freeBytesOn("/dev/shm");
|
|
584
855
|
if (free != null && free < 536870912) process.stderr.write(`[cedar-pg] warning: /dev/shm has ~${Math.round(free / (1024 * 1024))}MB free (recommend ≥${Math.round(EPHEMERAL_SHM_MIN_FREE_BYTES / (1024 * 1024))}MB for --ram).\n${EPHEMERAL_SHM_HINT}\n`);
|
|
585
856
|
}
|
|
586
857
|
mkdirSync(recipe.dataDir, { recursive: true });
|
|
587
|
-
const child = spawn(bin, recipe.postmasterArgs, {
|
|
588
|
-
detached: true,
|
|
589
|
-
stdio: "ignore"
|
|
590
|
-
});
|
|
591
|
-
let died = null;
|
|
592
|
-
child.on("error", (err) => {
|
|
593
|
-
died = new Error(formatHostStartError(`Failed to spawn autopg postmaster.\n${err.message}`, useRamShm));
|
|
594
|
-
});
|
|
595
|
-
child.on("exit", (code, signal) => {
|
|
596
|
-
died ??= new Error(formatHostStartError(`autopg postmaster exited before ready (code=${code}, signal=${signal}).`, useRamShm));
|
|
597
|
-
});
|
|
598
858
|
try {
|
|
599
|
-
|
|
859
|
+
await spawnDetachedPostmaster({
|
|
860
|
+
bin,
|
|
861
|
+
args: recipe.postmasterArgs,
|
|
600
862
|
port: recipe.port,
|
|
601
|
-
readyMs: HOST_READY_MS
|
|
602
|
-
|
|
603
|
-
})) throw new Error(formatHostStartError(`autopg postmaster did not accept 127.0.0.1:${recipe.port} within ${HOST_READY_MS}ms.`, useRamShm));
|
|
863
|
+
readyMs: HOST_READY_MS
|
|
864
|
+
});
|
|
604
865
|
} catch (err) {
|
|
605
|
-
|
|
606
|
-
throw err;
|
|
866
|
+
if (liveDataDirOwner(recipe.dataDir) != null) return;
|
|
867
|
+
throw new Error(formatHostStartError(errorDetail(err), useRamShm));
|
|
607
868
|
}
|
|
608
|
-
|
|
609
|
-
|
|
869
|
+
}
|
|
870
|
+
let autopgVersionChecked = false;
|
|
871
|
+
/** Once per process: warn (never fail, never upgrade) when autopg is older than the pin. */
|
|
872
|
+
function warnIfAutopgOutdated(bin) {
|
|
873
|
+
if (autopgVersionChecked) return;
|
|
874
|
+
autopgVersionChecked = true;
|
|
875
|
+
const warning = outdatedAutopgWarning(readAutopgVersion(bin));
|
|
876
|
+
if (warning) process.stderr.write(warning);
|
|
610
877
|
}
|
|
611
878
|
/**
|
|
612
879
|
* Attach to a listening autopg host, else start one per
|
|
613
880
|
* {@link resolveHostStartPolicy}. Internal: callers use `acquire` / `adminUrl`.
|
|
614
881
|
*/
|
|
615
882
|
async function ensureHostRunning(bin = requireAutopgBin()) {
|
|
883
|
+
warnIfAutopgOutdated(bin);
|
|
616
884
|
let registered = null;
|
|
617
885
|
try {
|
|
618
|
-
registered =
|
|
886
|
+
registered = discoverRegistration(bin);
|
|
619
887
|
} catch {}
|
|
620
|
-
if (registered && await waitForListener({ port: registered.port })) return registered;
|
|
621
|
-
return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered
|
|
888
|
+
if (registered && await waitForListener({ port: registered.host.port })) return registered.host;
|
|
889
|
+
return resolveHostStartPolicy() === "ephemeral" ? startEphemeralHost(bin) : startLocalHost(bin, registered);
|
|
622
890
|
}
|
|
623
891
|
//#endregion
|
|
624
892
|
//#region src/core/lifecycle.ts
|
|
@@ -661,8 +929,14 @@ async function acquire(options) {
|
|
|
661
929
|
const identity = resolveWorktreeIdentity(options.root);
|
|
662
930
|
const mode = options.mode;
|
|
663
931
|
const databaseName = buildDatabaseName(identity, mode);
|
|
664
|
-
const
|
|
932
|
+
const leased = readLease(identity.root, mode);
|
|
933
|
+
const roleName = leased?.databaseName === databaseName ? leased.roleName : buildRoleName(databaseName);
|
|
665
934
|
const host = await ensureHostRunning();
|
|
935
|
+
if (options.fresh) await dropDatabasesOwnedByRole({
|
|
936
|
+
adminUrl: host.adminUrl,
|
|
937
|
+
roleName,
|
|
938
|
+
preferLast: databaseName
|
|
939
|
+
});
|
|
666
940
|
await ensureDatabase({
|
|
667
941
|
adminUrl: host.adminUrl,
|
|
668
942
|
databaseName,
|
|
@@ -709,6 +983,23 @@ async function acquire(options) {
|
|
|
709
983
|
};
|
|
710
984
|
}
|
|
711
985
|
/**
|
|
986
|
+
* Attach-only: connection info from the lease a prior `acquire` wrote. Never
|
|
987
|
+
* acquires, never runs role/database DDL, never starts or revives the host —
|
|
988
|
+
* safe for many concurrent children after one `db:ready`. Throws when there is
|
|
989
|
+
* no lease or nothing accepts TCP on the leased port.
|
|
990
|
+
*/
|
|
991
|
+
async function attach(options) {
|
|
992
|
+
const root = resolveRoot(options.root);
|
|
993
|
+
const { mode } = options;
|
|
994
|
+
const lease = readLease(root, mode);
|
|
995
|
+
if (!lease) throw new Error(`no ${mode} lease in ${root}; run \`${CLI_NAME} acquire --mode=${mode}\` (or your db:ready target) first — attach never acquires`);
|
|
996
|
+
if (!await waitForListener({ port: lease.port })) throw new Error(`${mode} lease ${lease.databaseName} points at 127.0.0.1:${lease.port}, but nothing is listening; re-run \`${CLI_NAME} acquire --mode=${mode}\` to bring the host back`);
|
|
997
|
+
return {
|
|
998
|
+
lease,
|
|
999
|
+
databaseUrl: urlFromLease(lease)
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
712
1003
|
* Resolve skip policy then acquire. Single entry for hosts (Cedar CLI, Jest, Vitest).
|
|
713
1004
|
* On external-url skip, applies DATABASE_URL / TEST_DATABASE_URL when `setEnv` is not false.
|
|
714
1005
|
*/
|
|
@@ -716,7 +1007,8 @@ async function acquireIfNeeded(options) {
|
|
|
716
1007
|
const outcome = await runIfNeeded(options, () => acquire({
|
|
717
1008
|
root: options.root,
|
|
718
1009
|
mode: options.mode,
|
|
719
|
-
setEnv: options.setEnv
|
|
1010
|
+
setEnv: options.setEnv,
|
|
1011
|
+
fresh: options.fresh
|
|
720
1012
|
}));
|
|
721
1013
|
if (outcome.status === "skipped") return outcome;
|
|
722
1014
|
return {
|
|
@@ -732,9 +1024,8 @@ async function acquireIfNeeded(options) {
|
|
|
732
1024
|
* If the host is unavailable, leaves the lease so dispose/gc can retry.
|
|
733
1025
|
*/
|
|
734
1026
|
async function dispose(options = {}) {
|
|
735
|
-
const identity = resolveWorktreeIdentity(options.root);
|
|
736
1027
|
const mode = options.mode ?? "test";
|
|
737
|
-
const lease = readLease(
|
|
1028
|
+
const lease = readLease(resolveRoot(options.root), mode);
|
|
738
1029
|
if (!lease) return {
|
|
739
1030
|
dropped: false,
|
|
740
1031
|
reason: "no-lease"
|
|
@@ -776,6 +1067,6 @@ async function gc() {
|
|
|
776
1067
|
return { dropped };
|
|
777
1068
|
}
|
|
778
1069
|
//#endregion
|
|
779
|
-
export {
|
|
1070
|
+
export { resolveAcquireSkip as C, buildRoleName as D, buildDatabaseName as E, isExternalDatabaseEscapeHatch as S, buildCloneDatabaseName as T, rolePasswordFor as _, gc as a, applyDatabaseUrlEnv as b, ROLE_PASSWORD_SCHEME as c, cloneDatabaseFromTemplate as d, discoverHost as f, resolveAutopgBin as g, requireAutopgBin as h, dispose as i, adminUrlFor as l, parseHostStatus as m, acquireIfNeeded as n, urlFromLease as o, dropDatabase as p, attach as r, INSTALL_HINT as s, acquire as t, buildDatabaseUrl as u, setDatabaseIsTemplate as v, runIfNeeded as w, isCedarPgManagedUrl as x, truncateUserTables as y };
|
|
780
1071
|
|
|
781
|
-
//# sourceMappingURL=lifecycle-
|
|
1072
|
+
//# sourceMappingURL=lifecycle-2BZgSVz2.mjs.map
|