@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.
- package/README.md +300 -230
- package/dist/cli.cjs +99 -43
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +85 -29
- package/dist/cli.mjs.map +1 -1
- package/dist/dev-env.cjs +1 -1
- package/dist/dev-env.mjs +1 -1
- package/dist/index.cjs +9 -8
- package/dist/index.d.cts +80 -9
- package/dist/index.d.mts +80 -9
- package/dist/index.mjs +7 -7
- package/dist/jest-teardown.cjs +1 -1
- package/dist/jest-teardown.mjs +1 -1
- package/dist/jest-template.cjs +7 -2
- package/dist/jest-template.cjs.map +1 -1
- package/dist/jest-template.d.cts +7 -2
- package/dist/jest-template.d.mts +7 -2
- package/dist/jest-template.mjs +7 -2
- package/dist/jest-template.mjs.map +1 -1
- package/dist/jest.cjs +1 -1
- package/dist/jest.mjs +1 -1
- package/dist/{lease-BSBBpaR4.mjs → lease-DS1SX8U_.mjs} +23 -3
- package/dist/lease-DS1SX8U_.mjs.map +1 -0
- package/dist/{lease-Bq9wKwQa.cjs → lease-WlmOnNDi.cjs} +41 -3
- package/dist/lease-WlmOnNDi.cjs.map +1 -0
- package/dist/lifecycle-2BZgSVz2.mjs +1072 -0
- package/dist/lifecycle-2BZgSVz2.mjs.map +1 -0
- package/dist/{lifecycle-DEJ4GgWV.cjs → lifecycle-BCeM96tI.cjs} +517 -117
- package/dist/lifecycle-BCeM96tI.cjs.map +1 -0
- package/dist/{load-dev-env-7UjMHhw7.mjs → load-dev-env-BULkXyen.mjs} +2 -2
- package/dist/{load-dev-env-7UjMHhw7.mjs.map → load-dev-env-BULkXyen.mjs.map} +1 -1
- package/dist/{load-dev-env-cII6N4ZP.cjs → load-dev-env-Bl6Ddv1U.cjs} +2 -2
- package/dist/{load-dev-env-cII6N4ZP.cjs.map → load-dev-env-Bl6Ddv1U.cjs.map} +1 -1
- package/dist/{load-mode-env-B0qO9DZx.cjs → load-mode-env-CX9vd56X.cjs} +2 -2
- package/dist/{load-mode-env-B0qO9DZx.cjs.map → load-mode-env-CX9vd56X.cjs.map} +1 -1
- package/dist/{load-mode-env-C8gy4v9V.mjs → load-mode-env-Ce9ujisN.mjs} +2 -2
- package/dist/{load-mode-env-C8gy4v9V.mjs.map → load-mode-env-Ce9ujisN.mjs.map} +1 -1
- package/dist/{load-test-env-C34wpkIX.mjs → load-test-env-BqtA6V-d.mjs} +2 -2
- package/dist/{load-test-env-C34wpkIX.mjs.map → load-test-env-BqtA6V-d.mjs.map} +1 -1
- package/dist/{load-test-env-CkdUjTpV.cjs → load-test-env-Dcgh6YHV.cjs} +2 -2
- package/dist/{load-test-env-CkdUjTpV.cjs.map → load-test-env-Dcgh6YHV.cjs.map} +1 -1
- 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-B68SGSKx.mjs +26 -0
- package/dist/status-B68SGSKx.mjs.map +1 -0
- package/dist/status-Z_BYdxzI.cjs +31 -0
- package/dist/status-Z_BYdxzI.cjs.map +1 -0
- package/dist/studio-Bi6SU9jE.mjs +221 -0
- package/dist/studio-Bi6SU9jE.mjs.map +1 -0
- package/dist/studio-CdWIdnxr.cjs +280 -0
- package/dist/studio-CdWIdnxr.cjs.map +1 -0
- package/dist/{template-D0gJ_MS2.cjs → template-Cd77H7Cr.cjs} +21 -18
- package/dist/template-Cd77H7Cr.cjs.map +1 -0
- package/dist/{template-CGw3C1Ob.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/test-env.cjs +1 -1
- package/dist/test-env.mjs +1 -1
- package/dist/vite-plus.cjs +113 -10
- package/dist/vite-plus.cjs.map +1 -1
- package/dist/vite-plus.d.cts +41 -2
- package/dist/vite-plus.d.mts +41 -2
- package/dist/vite-plus.mjs +110 -7
- 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 +11 -7
- package/scripts/autopg-version +1 -1
- package/scripts/ci-install-autopg.sh +7 -2
- package/scripts/postinstall.js +41 -13
- package/dist/constants-Ct8myrEn.cjs +0 -41
- package/dist/constants-Ct8myrEn.cjs.map +0 -1
- package/dist/constants-NLR0U4NR.mjs +0 -24
- package/dist/constants-NLR0U4NR.mjs.map +0 -1
- package/dist/lease-B3TuX92y.d.mts +0 -24
- package/dist/lease-BSBBpaR4.mjs.map +0 -1
- package/dist/lease-Bq9wKwQa.cjs.map +0 -1
- package/dist/lease-t4I9JahV.d.cts +0 -24
- package/dist/lifecycle-BOo6xBjD.mjs +0 -684
- package/dist/lifecycle-BOo6xBjD.mjs.map +0 -1
- package/dist/lifecycle-DEJ4GgWV.cjs.map +0 -1
- package/dist/nx.cjs +0 -44
- 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 -36
- package/dist/nx.mjs.map +0 -1
- package/dist/tasks-Caud9yHr.cjs +0 -67
- package/dist/tasks-Caud9yHr.cjs.map +0 -1
- package/dist/tasks-CjaZRi_G.d.mts +0 -19
- package/dist/tasks-DdQoP9We.d.cts +0 -19
- package/dist/tasks-KherHLac.mjs +0 -38
- package/dist/tasks-KherHLac.mjs.map +0 -1
- package/dist/template-CGw3C1Ob.mjs.map +0 -1
- package/dist/template-D0gJ_MS2.cjs.map +0 -1
- package/dist/template-mode-BkEs9LnY.cjs +0 -91
- package/dist/template-mode-BkEs9LnY.cjs.map +0 -1
- package/dist/template-mode-CcxV_Iju.d.cts +0 -28
- package/dist/template-mode-CcxV_Iju.d.mts +0 -28
- package/dist/template-mode-IjlFOG4O.mjs +0 -80
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
*
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 =
|
|
257
|
-
|
|
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(`${
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
364
|
-
const
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
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 (
|
|
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
|
-
*
|
|
466
|
-
*
|
|
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
|
|
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
|
|
473
|
-
const
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
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
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
745
|
+
died = /* @__PURE__ */ new Error(`Failed to spawn autopg postmaster.\n${err.message}`);
|
|
507
746
|
});
|
|
508
747
|
child.on("exit", (code, signal) => {
|
|
509
|
-
|
|
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
|
|
516
|
-
port:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
536
|
-
*
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
909
|
+
registered = discoverRegistration(bin);
|
|
544
910
|
} catch {}
|
|
545
|
-
if (
|
|
546
|
-
|
|
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
|
|
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(
|
|
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-
|
|
1256
|
+
//# sourceMappingURL=lifecycle-BCeM96tI.cjs.map
|