basepad 0.0.0-stage → 0.0.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.
@@ -0,0 +1,632 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { copyFile, mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { BasepadError, exec, must, shellQuote } from "../internal/exec.js";
7
+ import { freePort, waitFor } from "../internal/ports.js";
8
+ import { runningPort } from "../internal/processes.js";
9
+ import { serviceUser } from "../internal/service-user.js";
10
+ import { install } from "../tools.js";
11
+ import { PGVECTOR_VERSIONS } from "../versions.js";
12
+ /**
13
+ * A Postgres server of the environment's own: mise's Postgres with pgvector placed in it, its data
14
+ * in the home, on loopback only, trust auth, no unix socket. Each database carries its owner
15
+ * checkout's path as its comment, so a second checkout under the same name is refused.
16
+ */
17
+ const HOST = "127.0.0.1";
18
+ const USER = "postgres";
19
+ /** For client libraries that insist on a password; the server trusts loopback and ignores it. */
20
+ const PASSWORD = "basepad";
21
+ /** How long `up` waits for the server to answer before it gives up. */
22
+ const READY_MS = 60_000;
23
+ /**
24
+ * A Postgres server of the project's own. `version` is 17 or 18; it defaults to 18. `worktree` is
25
+ * what a worktree's database starts as; it defaults to `copy`.
26
+ */
27
+ export function postgres(options = {}) {
28
+ return { kind: "postgres", version: options.version ?? 18, worktree: options.worktree ?? "copy" };
29
+ }
30
+ /** Postgres silently truncates identifiers past this many bytes. */
31
+ const IDENTIFIER_MAX = 63;
32
+ export const postgresKind = {
33
+ schema: z.strictObject({
34
+ kind: z.literal("postgres"),
35
+ version: z.union([z.literal(17), z.literal(18)], {
36
+ error: "Postgres version must be 17 or 18",
37
+ }),
38
+ worktree: z.enum(["copy", "empty", "shared"], {
39
+ error: "Postgres `worktree` must be copy, empty or shared",
40
+ }),
41
+ }),
42
+ names(service, { name, key, dir, suffix }) {
43
+ const main = `${name}_${key}`.replaceAll("-", "_");
44
+ const mainBytes = Buffer.byteLength(main);
45
+ if (mainBytes > IDENTIFIER_MAX) {
46
+ throw new BasepadError(`\`name\` and service \`${key}\` make the database name "${main}", which is ${mainBytes} bytes; Postgres allows ${IDENTIFIER_MAX}. Shorten \`name\` or the service key.`);
47
+ }
48
+ const own = suffix !== undefined && service.worktree !== "shared";
49
+ const database = own ? `${main}_${suffix.replaceAll("-", "_")}` : main;
50
+ const bytes = Buffer.byteLength(database);
51
+ if (bytes > IDENTIFIER_MAX) {
52
+ throw new BasepadError(`service \`${key}\` makes a worktree's database name "${database}", which is ${bytes} bytes; Postgres allows ${IDENTIFIER_MAX}.`);
53
+ }
54
+ // Checked when used, not here: a label is the command's input, and a database that fits
55
+ // stays usable even when it leaves no room for these.
56
+ const named = (name, what, room) => {
57
+ const bytes = Buffer.byteLength(name);
58
+ if (bytes > IDENTIFIER_MAX) {
59
+ throw new BasepadError(`${what} of \`${key}\` makes the database name "${name}", which is ${bytes} bytes; Postgres allows ${IDENTIFIER_MAX}${room}`);
60
+ }
61
+ return name;
62
+ };
63
+ return {
64
+ database,
65
+ main,
66
+ data: join(dir, "data"),
67
+ snapshots: `${database}@`,
68
+ snapshot: (label) => {
69
+ const room = IDENTIFIER_MAX - Buffer.byteLength(`${database}@`);
70
+ return named(`${database}@${label}`, `snapshot \`${label}\``, `, which leaves ${room > 0 ? `${room} character${room === 1 ? "" : "s"}` : "no room"} for a label here.`);
71
+ },
72
+ staging: () => named(`${database}~new`, "A replacement", ". The `db` commands make a database there before it replaces another; shorten `name` or the service key."),
73
+ };
74
+ },
75
+ async prepare(service, place) {
76
+ const pg = await installPostgres(service.version, place.machine.home);
77
+ const user = await serviceUser();
78
+ const { data } = place;
79
+ await mkdir(place.dir, { recursive: true });
80
+ await user.grant({ traverse: [pg.dir, place.dir], owned: [place.dir] });
81
+ const initialised = await readFile(join(data, "PG_VERSION"), "utf8").catch(() => undefined);
82
+ if (initialised === undefined) {
83
+ // A half-made folder from an initdb that failed; initdb refuses one that isn't empty.
84
+ await rm(data, { recursive: true, force: true });
85
+ const argv = user.wrap([
86
+ pg.bin("initdb"),
87
+ "-D",
88
+ data,
89
+ "-U",
90
+ USER,
91
+ "--auth=trust",
92
+ // Pinned, or initdb takes the shell's locale and clusters differ between machines.
93
+ "--encoding=UTF8",
94
+ "--locale-provider=builtin",
95
+ "--locale=C.UTF-8",
96
+ ]);
97
+ await must(argv[0], argv.slice(1));
98
+ }
99
+ else if (initialised.trim() !== String(service.version)) {
100
+ throw new BasepadError(`\`${place.key}\` asks for Postgres ${service.version}, but its data in ${data} is Postgres ${initialised.trim()}. Dump it with Postgres ${initialised.trim()}, remove that folder, then run \`basepad up\` again.`);
101
+ }
102
+ const port = (await serverPort(data)) ?? (await freePort(place.port));
103
+ const server = user.wrap([
104
+ pg.bin("postgres"),
105
+ "-D",
106
+ data,
107
+ "-p",
108
+ String(port),
109
+ "-c",
110
+ `listen_addresses=${HOST}`,
111
+ // No unix socket: its path limit and permissions only get in the way; clients use TCP.
112
+ "-c",
113
+ "unix_socket_directories=",
114
+ ]);
115
+ return {
116
+ run: `exec ${server.map(shellQuote).join(" ")}`,
117
+ dir: place.dir,
118
+ // Fast shutdown: SIGTERM's smart shutdown would wait for every app's connections to close.
119
+ stopSignal: "SIGINT",
120
+ listens: port,
121
+ };
122
+ },
123
+ async provision(service, place, { claim, stopped, print }) {
124
+ const pg = await installPostgres(service.version, place.machine.home);
125
+ const { data } = place;
126
+ const port = await waitFor(async () => {
127
+ const port = await serverPort(data);
128
+ if (port === undefined)
129
+ throw new Error("It has not started.");
130
+ await query(pg, port, "select 1");
131
+ return port;
132
+ }, `Postgres for \`${place.key}\` didn't answer within ${READY_MS / 1000} s.`, READY_MS, stopped);
133
+ const sql = (text) => query(pg, port, text);
134
+ const db = place.database;
135
+ const outputs = {
136
+ url: `postgresql://${USER}:${PASSWORD}@${HOST}:${port}/${db}`,
137
+ host: HOST,
138
+ port,
139
+ user: USER,
140
+ password: PASSWORD,
141
+ database: db,
142
+ };
143
+ // A shared worktree database is main's, which main provisions and stamps.
144
+ if (place.suffix !== undefined && service.worktree === "shared")
145
+ return outputs;
146
+ const open = await sql(`select datallowconn from pg_database where datname = ${literal(db)}`);
147
+ const exists = open !== "";
148
+ // Left closed by a copy that was interrupted.
149
+ if (open === "f")
150
+ await sql(`alter database ${identifier(db)} allow_connections true`);
151
+ if (!exists && place.suffix !== undefined && service.worktree === "copy") {
152
+ const start = performance.now();
153
+ const route = await copyDatabase(pg, port, place.main, db, place.owner);
154
+ const seconds = ((performance.now() - start) / 1000).toFixed(1);
155
+ print(`Copied main's \`${place.key}\` by ${route} in ${seconds} s.`);
156
+ }
157
+ else if (!exists) {
158
+ await sql(`create database ${identifier(db)}`);
159
+ }
160
+ // Creating and stamping are not atomic: an unstamped database is stamped by whoever comes next.
161
+ const stamp = () => sql(`select coalesce(shobj_description(oid, 'pg_database'), '') from pg_database where datname = ${literal(db)}`);
162
+ const owner = await stamp();
163
+ if (owner !== "" && owner !== place.owner && !claim) {
164
+ throw new BasepadError(`The database \`${db}\` belongs to the checkout at ${owner}, which has the same name ("${place.name}"). Two checkouts would share its data, so basepad stops here.\nIf that checkout moved here, or you want this one to take over, run \`basepad up --claim\`.`);
165
+ }
166
+ if (owner !== place.owner) {
167
+ // A comment on a database that has gone is only a warning, so the result is read back.
168
+ await sql(`comment on database ${identifier(db)} is ${literal(place.owner)}`);
169
+ if ((await stamp()) !== place.owner) {
170
+ throw new BasepadError(`Could not mark \`${db}\` as belonging to ${place.owner}.`);
171
+ }
172
+ }
173
+ return outputs;
174
+ },
175
+ worktreeData: {
176
+ async list(service, place) {
177
+ const port = await serverPort(place.data);
178
+ if (port === undefined)
179
+ return [];
180
+ const pg = await installPostgres(service.version, place.machine.home);
181
+ const rows = await query(pg, port, `select datname || '|' || coalesce(shobj_description(oid, 'pg_database'), '') from pg_database where datname like ${literal(`${likeEscaped(place.main)}\\_%`)}`);
182
+ const all = rows
183
+ .split("\n")
184
+ .filter(Boolean)
185
+ .map((row) => {
186
+ const at = row.indexOf("|");
187
+ return { name: row.slice(0, at), owner: row.slice(at + 1) };
188
+ })
189
+ .filter((data) => data.name !== place.main);
190
+ if (place.suffix === undefined)
191
+ return all;
192
+ // By name only while unstamped: one stamped elsewhere is another checkout's worktree's.
193
+ return all.filter((data) => data.owner === place.owner || (data.owner === "" && data.name === place.database));
194
+ },
195
+ async drop(service, place, names) {
196
+ if (names.includes(place.main)) {
197
+ throw new BasepadError(`\`${place.main}\` is main's database, which basepad never drops.`);
198
+ }
199
+ const port = await serverPort(place.data);
200
+ if (port === undefined || names.length === 0)
201
+ return;
202
+ const pg = await installPostgres(service.version, place.machine.home);
203
+ for (const name of names) {
204
+ // A worktree's app may still hold it open; its sessions end with it.
205
+ await query(pg, port, `drop database if exists ${identifier(name)} with (force)`);
206
+ }
207
+ },
208
+ },
209
+ async address(_service, place) {
210
+ const port = (await serverPort(place.data)) ?? place.port;
211
+ return `postgresql://${USER}@${HOST}:${port}/${place.database}`;
212
+ },
213
+ database: {
214
+ async save(service, place, label) {
215
+ const name = place.snapshot(label);
216
+ const { pg, port, sql } = await own(service, place);
217
+ // A label names one snapshot: one there is replaced, and kept should the copy fail.
218
+ const swap = {
219
+ target: name,
220
+ staging: place.staging(),
221
+ owner: place.owner,
222
+ failed: `Could not save snapshot \`${label}\`; any snapshot \`${label}\` there was is kept.`,
223
+ };
224
+ await replace(sql, swap, (staging, owner) => copyDatabase(pg, port, place.database, staging, owner));
225
+ },
226
+ async load(service, place, label) {
227
+ const name = place.snapshot(label);
228
+ const { pg, port, sql } = await own(service, place);
229
+ const found = await snapshotsOf(sql, place);
230
+ const snapshot = found.find((snapshot) => snapshot.label === label);
231
+ if (snapshot === undefined)
232
+ throw new BasepadError(noSnapshot(place, label, found));
233
+ if (snapshot.invalid) {
234
+ throw new BasepadError(`Snapshot \`${label}\` of \`${place.key}\` is invalid, as an interrupted copy or drop leaves a database. \`${place.database}\` is as it was; remove the snapshot with \`basepad db rm ${label}\`.`);
235
+ }
236
+ const swap = {
237
+ target: place.database,
238
+ staging: place.staging(),
239
+ owner: place.owner,
240
+ failed: `Could not load snapshot \`${label}\`; \`${place.database}\` is as it was.`,
241
+ };
242
+ await replace(sql, swap, (staging, owner) => copyDatabase(pg, port, name, staging, owner));
243
+ },
244
+ async snapshots(service, place) {
245
+ const { sql } = await own(service, place);
246
+ return snapshotsOf(sql, place);
247
+ },
248
+ async remove(service, place, label) {
249
+ const name = place.snapshot(label);
250
+ const { sql } = await own(service, place);
251
+ const found = await snapshotsOf(sql, place);
252
+ if (!found.some((snapshot) => snapshot.label === label)) {
253
+ throw new BasepadError(noSnapshot(place, label, found));
254
+ }
255
+ await sql(`drop database ${identifier(name)} with (force)`);
256
+ },
257
+ async import(service, place, source, confirm) {
258
+ const { pg, port, sql } = await own(service, place);
259
+ const from = await sourceOf(source);
260
+ // Named before asking, so a name past the limit is refused first.
261
+ const swap = {
262
+ target: place.database,
263
+ staging: place.staging(),
264
+ owner: place.owner,
265
+ failed: `Could not import ${from.description}; \`${place.database}\` is as it was.`,
266
+ };
267
+ await confirm(place.database);
268
+ await replace(sql, swap, async (staging, owner) => {
269
+ await sql(`create database ${identifier(staging)}`);
270
+ await stamp(sql, staging, owner);
271
+ await restore(pg, port, staging, from);
272
+ // A source with no statement in it (a BOM, comments, an empty dump) runs cleanly, and
273
+ // would swap in an empty database.
274
+ if (!(await createdAnything(pg, port, staging))) {
275
+ throw new BasepadError("It created nothing in the database.");
276
+ }
277
+ });
278
+ return from.description;
279
+ },
280
+ async export(service, place, file) {
281
+ const { pg, port } = await own(service, place);
282
+ const plain = file !== undefined && /\.sql$/i.test(file);
283
+ const args = [
284
+ ...connection(port, place.database),
285
+ ...(plain ? ["--format=plain", "--no-owner", "--no-privileges"] : ["--format=custom"]),
286
+ ];
287
+ if (file === undefined) {
288
+ await toStdout(pg.bin("pg_dump"), args);
289
+ }
290
+ else {
291
+ // Written beside and renamed, so a failed export never leaves half a dump at `file`.
292
+ const partial = `${file}.partial`;
293
+ try {
294
+ await must(pg.bin("pg_dump"), [...args, `--file=${partial}`], { env: clientEnv() });
295
+ }
296
+ catch (error) {
297
+ await rm(partial, { force: true });
298
+ throw error;
299
+ }
300
+ await rename(partial, file);
301
+ }
302
+ return plain ? "plain SQL" : "a custom-format dump";
303
+ },
304
+ },
305
+ };
306
+ /**
307
+ * What `source` is: a `postgres://` URL, else a file, a custom-format dump when it starts with
308
+ * `PGDMP`, else plain SQL. A URL's password is kept apart, out of argv and messages.
309
+ */
310
+ async function sourceOf(source) {
311
+ if (/^postgres(ql)?:\/\//i.test(source)) {
312
+ let url;
313
+ try {
314
+ url = new URL(source);
315
+ }
316
+ catch {
317
+ throw new BasepadError(`\`${source}\` is not a URL basepad can read.`);
318
+ }
319
+ const password = url.password === "" ? undefined : decodeURIComponent(url.password);
320
+ url.password = "";
321
+ return {
322
+ format: "url",
323
+ url: url.href,
324
+ password,
325
+ description: `a copy of ${url.host}${url.pathname}`,
326
+ };
327
+ }
328
+ const file = await open(source).catch((error) => {
329
+ if (error.code === "ENOENT") {
330
+ throw new BasepadError(`There is no file ${source}. Import a dump file or a postgres:// URL.`);
331
+ }
332
+ throw error;
333
+ });
334
+ try {
335
+ if ((await file.stat()).isDirectory()) {
336
+ throw new BasepadError(`${source} is a folder. Import a dump file or a postgres:// URL.`);
337
+ }
338
+ const { buffer, bytesRead } = await file.read(Buffer.alloc(5), 0, 5, 0);
339
+ const start = buffer.subarray(0, bytesRead);
340
+ if (start.toString("latin1") === "PGDMP") {
341
+ return { format: "custom", file: source, description: `the custom-format dump ${source}` };
342
+ }
343
+ if (start[0] === 0x1f && start[1] === 0x8b) {
344
+ throw new BasepadError(`${source} is gzipped. Unzip it, then import that.`);
345
+ }
346
+ return { format: "plain", file: source, description: `the SQL file ${source}` };
347
+ }
348
+ finally {
349
+ await file.close();
350
+ }
351
+ }
352
+ /**
353
+ * Whether database `name` holds anything a fresh one lacks: a relation, function, type or schema
354
+ * outside the system's, or an extension. An untouched `public` schema counts as nothing.
355
+ */
356
+ async function createdAnything(pg, port, name) {
357
+ const user = (namespace) => `${namespace} not in (select oid from pg_namespace where nspname in ('pg_catalog', 'information_schema', 'pg_toast') or nspname like 'pg\\_%temp\\_%')`;
358
+ const anything = [
359
+ `select 1 from pg_class where ${user("relnamespace")}`,
360
+ `select 1 from pg_proc where ${user("pronamespace")}`,
361
+ `select 1 from pg_type where ${user("typnamespace")}`,
362
+ `select 1 from pg_namespace where ${user("oid")} and nspname <> 'public'`,
363
+ "select 1 from pg_extension where extname <> 'plpgsql'",
364
+ ];
365
+ const out = await must(pg.bin("psql"), [
366
+ ...connection(port, name),
367
+ "-tAX",
368
+ "-v",
369
+ "ON_ERROR_STOP=1",
370
+ "-c",
371
+ `select exists (${anything.join(" union all ")})`,
372
+ ], { env: clientEnv() });
373
+ return out.trim() === "t";
374
+ }
375
+ /** Loads `from` into the empty database `to`, stopping at the first error. */
376
+ async function restore(pg, port, to, from) {
377
+ const into = connection(port, to);
378
+ // Owners and grants name roles this server lacks; everything here belongs to its superuser.
379
+ const pgRestore = [pg.bin("pg_restore"), "--no-owner", "--no-privileges", "--exit-on-error"];
380
+ if (from.format === "url") {
381
+ const env = { ...clientEnv(), ...(from.password ? { PGPASSWORD: from.password } : {}) };
382
+ // Not `must`: its message repeats the command, and the URL is the user's to show.
383
+ const out = await exec("bash", [
384
+ "-c",
385
+ `set -o pipefail; ${shellQuote(pg.bin("pg_dump"))} --format=custom --dbname=${shellQuote(from.url)} | ${[...pgRestore, ...into].map(shellQuote).join(" ")}`,
386
+ ], { env });
387
+ if (out.code !== 0)
388
+ throw new BasepadError((out.stderr || out.stdout).trim());
389
+ }
390
+ else if (from.format === "plain") {
391
+ try {
392
+ await must(pg.bin("psql"), [...into, "-X", "-q", "-v", "ON_ERROR_STOP=1", "-f", from.file], {
393
+ env: clientEnv(),
394
+ });
395
+ }
396
+ catch (error) {
397
+ if (!(error instanceof BasepadError))
398
+ throw error;
399
+ throw new BasepadError(`${error.message}\nA plain SQL file must load as is: one with other roles' owners or grants needs \`pg_dump --no-owner --no-privileges\`, or use a custom-format dump.`);
400
+ }
401
+ }
402
+ else {
403
+ await must(pgRestore[0], [...pgRestore.slice(1), ...into, from.file], {
404
+ env: clientEnv(),
405
+ });
406
+ }
407
+ }
408
+ /** Client arguments for database `database` on the server at `port`. */
409
+ function connection(port, database) {
410
+ return ["-h", HOST, "-p", String(port), "-U", USER, "-d", database];
411
+ }
412
+ /** Runs a program with its standard output as basepad's own, for a dump piped elsewhere. */
413
+ function toStdout(file, args) {
414
+ return new Promise((resolve, reject) => {
415
+ const child = spawn(file, args, { env: clientEnv(), stdio: ["ignore", "inherit", "pipe"] });
416
+ let stderr = "";
417
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
418
+ stderr += chunk;
419
+ });
420
+ child.on("error", reject);
421
+ child.on("close", (code) => code === 0
422
+ ? resolve()
423
+ : reject(new BasepadError(`\`${file}\` failed (exit ${code}):\n${stderr.trim()}`)));
424
+ });
425
+ }
426
+ /**
427
+ * The running server's SQL, for a service whose database is the environment's own. A worktree's
428
+ * `shared` service's is main's, which the worktree's commands never touch.
429
+ */
430
+ async function own(service, place) {
431
+ if (place.suffix !== undefined && service.worktree === "shared") {
432
+ throw new BasepadError(`\`${place.key}\` is shared with main in worktrees (\`worktree: "shared"\`), so its database is main's. Run this in main to act on it.`);
433
+ }
434
+ const port = await serverPort(place.data);
435
+ if (port === undefined)
436
+ throw new BasepadError(`Postgres for \`${place.key}\` is not running.`);
437
+ const pg = await installPostgres(service.version, place.machine.home);
438
+ return { pg, port, sql: (text) => query(pg, port, text) };
439
+ }
440
+ /** The environment's snapshots, found by name. */
441
+ async function snapshotsOf(sql, place) {
442
+ const prefix = place.snapshots;
443
+ const rows = await sql(
444
+ // An invalid database (`datconnlimit` -2) is what an interrupted copy or drop leaves.
445
+ `select datname || '|' || pg_database_size(oid) || '|' || (datconnlimit = -2) from pg_database where datname like ${literal(`${likeEscaped(prefix)}%`)} order by datname`);
446
+ return rows
447
+ .split("\n")
448
+ .filter(Boolean)
449
+ .map((row) => {
450
+ const [invalid, bytes, ...name] = row.split("|").reverse();
451
+ return {
452
+ label: name.reverse().join("|").slice(prefix.length),
453
+ database: place.database,
454
+ bytes: Number(bytes),
455
+ ...(invalid === "true" ? { invalid: true } : {}),
456
+ };
457
+ });
458
+ }
459
+ function noSnapshot(place, label, found) {
460
+ const labels = found.map((snapshot) => `\`${snapshot.label}\``).join(", ");
461
+ return `\`${place.key}\` has no snapshot \`${label}\`. ${found.length === 0 ? "It has none; make one with `basepad db save`." : `Its snapshots: ${labels}.`}`;
462
+ }
463
+ /**
464
+ * Replaces database `target` with one `fill` makes at `staging`, beside it: the one way the `db`
465
+ * commands replace a database. `fill` makes `staging` and stamps it with `owner` as soon as it
466
+ * exists, so one an interrupted run leaves is found as that checkout's, and goes with its worktree;
467
+ * once `fill` has finished, `replace` stamps it again whatever `fill` did. Only then is `target`
468
+ * dropped, its sessions ended (`with (force)` keeps new ones out until it is gone), and the new one
469
+ * renamed to it, open, its stamp with it. A `fill` that fails leaves `target` as it was, and
470
+ * nothing beside it, and throws `failed` with its reason. Clients connect again by themselves, to
471
+ * the new one.
472
+ */
473
+ async function replace(sql, { target, staging, owner, failed, }, fill) {
474
+ const dropStaging = () => sql(`drop database if exists ${identifier(staging)} with (force)`);
475
+ // Left by a replacement that was interrupted.
476
+ await dropStaging();
477
+ try {
478
+ await fill(staging, owner);
479
+ }
480
+ catch (error) {
481
+ // Its cause is what the developer needs; one left is dropped by the next replacement.
482
+ await dropStaging().catch(() => { });
483
+ if (!(error instanceof BasepadError))
484
+ throw error;
485
+ throw new BasepadError(`${failed}\n${error.message}`);
486
+ }
487
+ await stamp(sql, staging, owner);
488
+ await sql(`drop database if exists ${identifier(target)} with (force)`);
489
+ await sql(`alter database ${identifier(staging)} rename to ${identifier(target)}`);
490
+ }
491
+ /** Marks database `name` as belonging to the checkout at `owner`, as `provision` does. */
492
+ async function stamp(sql, name, owner) {
493
+ await sql(`comment on database ${identifier(name)} is ${literal(owner)}`);
494
+ }
495
+ /**
496
+ * Installs Postgres through mise and places pgvector in it. mise's Postgres is conda-forge's, which
497
+ * lacks pgvector, and Postgres 17 only finds extensions inside its own install, so pgvector's files
498
+ * are copied in. A marker in the install says they are there; a reinstall drops it, and they are
499
+ * copied again.
500
+ */
501
+ async function installPostgres(version, home) {
502
+ const pg = await install("postgres", String(version), home);
503
+ const pgvector = PGVECTOR_VERSIONS[version];
504
+ const marker = join(pg.dir, `.basepad-pgvector-${pgvector}`);
505
+ if (existsSync(marker))
506
+ return pg;
507
+ const from = (await install("conda:pgvector", pgvector, home)).dir;
508
+ // mise doesn't expose pg_config among the install's programs, so it is found in the install.
509
+ const pgConfig = join(pg.dir, "bin", "pg_config");
510
+ const config = (flag) => must(pgConfig, [flag]).then((out) => out.trim());
511
+ const libDir = await config("--pkglibdir");
512
+ const extensionDir = join(await config("--sharedir"), "extension");
513
+ const copies = [];
514
+ for (const file of await readdir(join(from, "lib"))) {
515
+ if (/^vector\.(so|dylib)$/.test(file))
516
+ copies.push([join(from, "lib", file), libDir]);
517
+ }
518
+ for (const file of await readdir(join(from, "share/extension"))) {
519
+ if (/^vector[.-]/.test(file))
520
+ copies.push([join(from, "share/extension", file), extensionDir]);
521
+ }
522
+ if (!copies.some(([file]) => file.endsWith(".control"))) {
523
+ throw new BasepadError(`mise's conda:pgvector@${pgvector} in ${from} has no vector.control.`);
524
+ }
525
+ for (const [file, dir] of copies) {
526
+ // By temp file and rename, so a server loading the library never sees half of it.
527
+ const target = join(dir, file.slice(file.lastIndexOf("/") + 1));
528
+ const temp = `${target}.${process.pid}.tmp`;
529
+ await copyFile(file, temp);
530
+ await rename(temp, target);
531
+ }
532
+ await writeFile(marker, "");
533
+ return pg;
534
+ }
535
+ /**
536
+ * Makes `to` a copy of `from`, rows, schema and extensions, while `from`'s clients stay connected,
537
+ * and says how. `to` is stamped as belonging to the checkout at `owner` as soon as it exists, so a
538
+ * copy an interrupted run leaves is found as that checkout's. The one way basepad copies a database.
539
+ *
540
+ * A template copy needs `from` to have no other session, and only `allow_connections` keeps out a
541
+ * superuser, so `from` is closed, its sessions ended, and opened again after: a pooled client
542
+ * reconnects by itself. `provision` opens a database an interrupted copy left closed. The copy is a
543
+ * file clone where the server offers one (Postgres 18; copy-on-write on APFS, btrfs, XFS), else a
544
+ * plain template copy; Postgres never falls back by itself, so basepad does. Dump and restore,
545
+ * with `from` open again, only when a template copy fails.
546
+ */
547
+ async function copyDatabase(pg, port, from, to, owner) {
548
+ const sql = (...statements) => query(pg, port, statements);
549
+ const offered = await sql("select coalesce(enumvals::text, '') from pg_settings where name = 'file_copy_method'");
550
+ // File by file, not the default WAL_LOG: faster on a large database, and a clone needs it.
551
+ const create = `create database ${identifier(to)} template ${identifier(from)} strategy file_copy`;
552
+ const routes = [["template copy", [create]]];
553
+ // One session: the setting is the session's own.
554
+ if (offered.includes("clone")) {
555
+ routes.unshift(["file clone", ["set file_copy_method = clone", create]]);
556
+ }
557
+ const failures = [];
558
+ await sql(`alter database ${identifier(from)} allow_connections false`);
559
+ try {
560
+ await sql(`select pg_terminate_backend(pid, 5000) from pg_stat_activity where datname = ${literal(from)} and pid <> pg_backend_pid()`);
561
+ for (const [route, statements] of routes) {
562
+ try {
563
+ await sql(...statements);
564
+ }
565
+ catch (error) {
566
+ failures.push(`${route}: ${error.message.trim()}`);
567
+ await sql(`drop database if exists ${identifier(to)}`);
568
+ continue;
569
+ }
570
+ await stamp(sql, to, owner);
571
+ return route;
572
+ }
573
+ }
574
+ finally {
575
+ await sql(`alter database ${identifier(from)} allow_connections true`);
576
+ }
577
+ await sql(`create database ${identifier(to)}`);
578
+ await stamp(sql, to, owner);
579
+ const conn = (db) => ["-h", HOST, "-p", String(port), "-U", USER, "-d", db].map(shellQuote).join(" ");
580
+ try {
581
+ await must("bash", [
582
+ "-c",
583
+ `set -o pipefail; ${shellQuote(pg.bin("pg_dump"))} -Fc ${conn(from)} | ${shellQuote(pg.bin("pg_restore"))} --no-owner --exit-on-error ${conn(to)}`,
584
+ ], { env: clientEnv() });
585
+ }
586
+ catch (error) {
587
+ await sql(`drop database if exists ${identifier(to)}`);
588
+ failures.push(`dump and restore: ${error.message.trim()}`);
589
+ throw new BasepadError(`Could not copy \`${from}\` to \`${to}\`.\n${failures.join("\n")}`);
590
+ }
591
+ return "dump and restore";
592
+ }
593
+ /** The port of the server running on `data`, from Postgres's own pid file. */
594
+ function serverPort(data) {
595
+ return runningPort(join(data, "postmaster.pid"), 3);
596
+ }
597
+ /**
598
+ * Runs SQL in the maintenance database as the superuser, each statement on its own in one session;
599
+ * returns psql's unaligned output.
600
+ */
601
+ async function query(pg, port, sql) {
602
+ const out = await must(pg.bin("psql"), [
603
+ "-h",
604
+ HOST,
605
+ "-p",
606
+ String(port),
607
+ "-U",
608
+ USER,
609
+ "-d",
610
+ "postgres",
611
+ "-tAX",
612
+ "-v",
613
+ "ON_ERROR_STOP=1",
614
+ ...[sql].flat().flatMap((statement) => ["-c", statement]),
615
+ ], { env: clientEnv() });
616
+ return out.trim();
617
+ }
618
+ /** `text` for a `like` pattern, its wildcards matched as themselves (backslash escapes). */
619
+ function likeEscaped(text) {
620
+ return text.replace(/[\\%_]/g, (char) => `\\${char}`);
621
+ }
622
+ /** The environment for Postgres's client programs, free of the developer's own `PG*` settings. */
623
+ function clientEnv() {
624
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PG")));
625
+ return { ...env, PGCONNECT_TIMEOUT: "2" };
626
+ }
627
+ function literal(text) {
628
+ return `'${text.replaceAll("'", "''")}'`;
629
+ }
630
+ function identifier(text) {
631
+ return `"${text.replaceAll('"', '""')}"`;
632
+ }