@astralarium/repo-postgres 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -2
  3. package/container/init/.containerignore +2 -0
  4. package/container/init/Containerfile +26 -0
  5. package/container/init/entrypoint.d/init-db.sh +20 -0
  6. package/container/snapshot/run-snapshot.sh +23 -0
  7. package/container/util/.containerignore +2 -0
  8. package/container/util/Containerfile +7 -0
  9. package/container/util/entrypoint.d/dump-db.sh +19 -0
  10. package/container/util/entrypoint.d/upgrade-db.sh +103 -0
  11. package/dist/bin/stage-schema.d.ts +2 -0
  12. package/dist/bin/stage-schema.js +20 -0
  13. package/dist/src/provision/connection.d.ts +11 -0
  14. package/dist/src/provision/connection.js +33 -0
  15. package/dist/src/provision/container.d.ts +87 -0
  16. package/dist/src/provision/container.js +244 -0
  17. package/dist/src/provision/data.d.ts +8 -0
  18. package/dist/src/provision/data.js +47 -0
  19. package/dist/src/provision/disposable.d.ts +49 -0
  20. package/dist/src/provision/disposable.js +188 -0
  21. package/dist/src/provision/image.d.ts +63 -0
  22. package/dist/src/provision/image.js +159 -0
  23. package/dist/src/provision/index.d.ts +17 -0
  24. package/dist/src/provision/index.js +205 -0
  25. package/dist/src/provision/options.d.ts +23 -0
  26. package/dist/src/provision/options.js +1 -0
  27. package/dist/src/provision/schema.d.ts +11 -0
  28. package/dist/src/provision/schema.js +134 -0
  29. package/dist/src/provision/test-database.d.ts +29 -0
  30. package/dist/src/provision/test-database.js +130 -0
  31. package/dist/src/queue.d.ts +86 -0
  32. package/dist/src/queue.js +201 -0
  33. package/dist/src/schema/index.d.ts +3 -0
  34. package/dist/src/schema/index.js +3 -0
  35. package/dist/src/schema/module.d.ts +59 -0
  36. package/dist/src/schema/module.js +122 -0
  37. package/dist/src/schema/modules.d.ts +24 -0
  38. package/dist/src/schema/modules.js +71 -0
  39. package/dist/src/sql.d.ts +83 -0
  40. package/dist/src/sql.js +146 -0
  41. package/dist/testkit/index.d.ts +14 -0
  42. package/dist/testkit/index.js +32 -0
  43. package/dist/testkit/locks.d.ts +27 -0
  44. package/dist/testkit/locks.js +57 -0
  45. package/dist/testkit/schema.d.ts +14 -0
  46. package/dist/testkit/schema.js +89 -0
  47. package/package.json +63 -3
  48. package/scripts/build-images.sh +52 -0
  49. package/scripts/dump-db.sh +32 -0
  50. package/sql/app/schema.sql +8 -0
  51. package/sql/bootstrap.sql +27 -0
  52. package/sql/database-grants.sql +3 -0
  53. package/sql/drop-schemas.sql +11 -0
  54. package/sql/extensions/cron/schema.sql +23 -0
  55. package/sql/extensions/partman/before-reset.sql +38 -0
  56. package/sql/extensions/partman/schema.sql +8 -0
  57. package/sql/extensions/trgm/schema.sql +1 -0
  58. package/sql/reset.sql +22 -0
  59. package/sql/roles.sql +10 -0
  60. package/sql/slug/schema.sql +25 -0
  61. package/sql/taskclaim/jobs/schema.sql +19 -0
  62. package/sql/taskclaim/schema.sql +41 -0
  63. package/sql/taskclaim/scrub.sql +3 -0
@@ -0,0 +1,244 @@
1
+ /** Postgres containers: runtime detection, inspection, startup, readiness. */
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { output, READY_TIMEOUT_MS, succeeds, waitFor, } from "@astralarium/repo-dev";
5
+ import { ExpectedError } from "@astralarium/repo-telemetry";
6
+ /** Image label carrying the `schemaHash()` it was built from. */
7
+ export const SCHEMA_LABEL = "db.schema";
8
+ /** Container and volume label naming the checkout that owns it. */
9
+ export const OWNER_LABEL = "dev.owner";
10
+ /** Container label carrying the sha256 of its Postgres server arguments. */
11
+ const ARGS_LABEL = "db.args";
12
+ /** File the init script writes the image's schema hash to, inside the data volume. */
13
+ const SCHEMA_STAMP = "/var/lib/postgresql/db-schema";
14
+ /** `CONTAINER_RUNTIME`, else the first of podman, docker that answers `info`. */
15
+ export function detectRuntime() {
16
+ const override = process.env.CONTAINER_RUNTIME;
17
+ if (override)
18
+ return override;
19
+ for (const bin of ["podman", "docker"]) {
20
+ if (succeeds(bin, ["info"]))
21
+ return bin;
22
+ }
23
+ throw new ExpectedError("neither podman nor docker is running");
24
+ }
25
+ /** Schema hash the container's data was initialised from; null while
26
+ * init has yet to complete. */
27
+ function schemaStamp(runtime, container) {
28
+ return output(runtime, ["exec", container, "cat", SCHEMA_STAMP]);
29
+ }
30
+ /** The container's data was initialised from a schema other than its image's. */
31
+ export class StaleSchemaError extends Error {
32
+ constructor(runtime, container, stamp) {
33
+ super(stamp === null
34
+ ? `${container} has no schema stamp: its first boot is incomplete (see ${runtime} logs ${container}).`
35
+ : `${container} data was initialised from another schema.`);
36
+ }
37
+ }
38
+ /** Host port published for Postgres; the container must be running. */
39
+ function publishedPort(runtime, container) {
40
+ const port = Number(execFileSync(runtime, [
41
+ "container",
42
+ "inspect",
43
+ "-f",
44
+ '{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}',
45
+ container,
46
+ ], { encoding: "utf8" }).trim());
47
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
48
+ throw new Error(`${container} has no published Postgres port: ${port}`);
49
+ return port;
50
+ }
51
+ /** One `container inspect`; null for an absent `name`. */
52
+ export function inspectContainer(runtime, name) {
53
+ const state = output(runtime, [
54
+ "container",
55
+ "inspect",
56
+ "-f",
57
+ "{{json .}}",
58
+ name,
59
+ ]);
60
+ if (state === null)
61
+ return null;
62
+ const inspected = JSON.parse(state);
63
+ const image = inspected.Image;
64
+ return {
65
+ running: inspected.State.Running,
66
+ image: image.startsWith("sha256:") ? image : `sha256:${image}`,
67
+ schema: inspected.Config.Labels[SCHEMA_LABEL],
68
+ owner: inspected.Config.Labels[OWNER_LABEL],
69
+ args: inspected.Config.Labels[ARGS_LABEL],
70
+ bindings: inspected.HostConfig.PortBindings["5432/tcp"] ?? [],
71
+ dataMount: inspected.Mounts.find((mount) => mount.Destination === "/var/lib/postgresql"),
72
+ };
73
+ }
74
+ /** Persistent data volume of dev instance `name`. */
75
+ export function volumeName(name) {
76
+ return `${name}-data`;
77
+ }
78
+ /** A named volume's `OWNER_LABEL`; null for an absent `name`. */
79
+ function inspectVolume(runtime, name) {
80
+ const labels = output(runtime, [
81
+ "volume",
82
+ "inspect",
83
+ "-f",
84
+ "{{json .Labels}}",
85
+ name,
86
+ ]);
87
+ if (labels === null)
88
+ return null;
89
+ const parsed = JSON.parse(labels || "null");
90
+ return { owner: parsed?.[OWNER_LABEL] };
91
+ }
92
+ /** The existing container and data volume of dev instance `name`. */
93
+ export function holders(runtime, name) {
94
+ const found = [];
95
+ const container = inspectContainer(runtime, name);
96
+ if (container)
97
+ found.push({ kind: "container", name, owner: container.owner });
98
+ const volume = inspectVolume(runtime, volumeName(name));
99
+ if (volume)
100
+ found.push({ kind: "volume", name: volumeName(name), owner: volume.owner });
101
+ return found;
102
+ }
103
+ /** Container `name` and its data volume are each absent or labeled as
104
+ * owned by `owner`, so a volume surviving its container stays with the
105
+ * checkout that made it. */
106
+ export function claimable(runtime, name, owner) {
107
+ return holders(runtime, name).every((holder) => holder.owner === owner);
108
+ }
109
+ /** Creates `volume` labeled for `owner` when absent; one labeled
110
+ * otherwise throws, keeping each checkout on its own data. */
111
+ function ensureVolume(runtime, volume, owner) {
112
+ const current = inspectVolume(runtime, volume);
113
+ if (current === null) {
114
+ execFileSync(runtime, [
115
+ "volume",
116
+ "create",
117
+ ...(owner ? ["--label", `${OWNER_LABEL}=${owner}`] : []),
118
+ volume,
119
+ ], { stdio: ["ignore", "ignore", "inherit"] });
120
+ return;
121
+ }
122
+ if (current.owner !== owner)
123
+ throw new ExpectedError(`data volume ${volume} is ${current.owner === undefined ? "unlabeled" : `labeled for ${current.owner}`}; rerun to allocate another instance, or free the name with: ${runtime} volume rm ${volume}`);
124
+ }
125
+ /** `rm -f`; a missing container is fine, any other failure throws. */
126
+ export function removeContainer(runtime, name) {
127
+ if (inspectContainer(runtime, name) === null)
128
+ return;
129
+ execFileSync(runtime, ["rm", "-f", "-v", name], {
130
+ stdio: ["ignore", "ignore", "inherit"],
131
+ });
132
+ }
133
+ /** `volume rm -f`; a missing volume is fine, any other failure throws. */
134
+ export function removeVolume(runtime, name) {
135
+ if (inspectVolume(runtime, name) === null)
136
+ return;
137
+ execFileSync(runtime, ["volume", "rm", "-f", name], {
138
+ stdio: ["ignore", "ignore", "inherit"],
139
+ });
140
+ }
141
+ /**
142
+ * Reuses a container of exactly `image`, `owner`, server arguments and
143
+ * settings, else recreates it (named volumes survive). Resolves once
144
+ * Postgres accepts connections and the data carries `hash`, with the
145
+ * published host port. `resources` stops a persistent container this call
146
+ * started; one already running is left alone. The caller removes an
147
+ * ephemeral one (`volume` unset).
148
+ */
149
+ export async function startPostgresContainer(runtime, resources, { name, image, hash, port, volume, owner, serverArgs = [], labels = {}, }) {
150
+ const args = [
151
+ ...serverArgs,
152
+ ...(volume
153
+ ? []
154
+ : [
155
+ "-c",
156
+ "fsync=off",
157
+ "-c",
158
+ "synchronous_commit=off",
159
+ "-c",
160
+ "full_page_writes=off",
161
+ ]),
162
+ ];
163
+ const argsHash = createHash("sha256")
164
+ .update(JSON.stringify(args))
165
+ .digest("hex");
166
+ const current = inspectContainer(runtime, name);
167
+ const reuse = current !== null &&
168
+ current.schema === hash &&
169
+ current.image === image &&
170
+ current.owner === owner &&
171
+ current.args === argsHash &&
172
+ current.bindings.length === 1 &&
173
+ current.bindings[0]?.HostIp === "127.0.0.1" &&
174
+ (port === undefined || current.bindings[0].HostPort === String(port)) &&
175
+ (volume
176
+ ? current.dataMount?.Type === "volume" &&
177
+ current.dataMount.Name === volume
178
+ : current.dataMount === undefined);
179
+ if (!reuse && volume)
180
+ ensureVolume(runtime, volume, owner);
181
+ if (current && !reuse)
182
+ removeContainer(runtime, name);
183
+ if (!reuse) {
184
+ execFileSync(runtime, [
185
+ "create",
186
+ "--name",
187
+ name,
188
+ "--env",
189
+ "POSTGRES_HOST_AUTH_METHOD=trust",
190
+ "--env",
191
+ "PGUSER=postgres",
192
+ ...(owner ? ["--label", `${OWNER_LABEL}=${owner}`] : []),
193
+ "--label",
194
+ `${ARGS_LABEL}=${argsHash}`,
195
+ ...Object.entries(labels).flatMap(([key, value]) => [
196
+ "--label",
197
+ `${key}=${value}`,
198
+ ]),
199
+ "--publish",
200
+ `127.0.0.1:${port ?? ""}:5432`,
201
+ ...(volume ? ["--volume", `${volume}:/var/lib/postgresql`] : []),
202
+ image,
203
+ ...args,
204
+ ], { stdio: ["ignore", "ignore", "inherit"] });
205
+ }
206
+ if (!(reuse && current.running)) {
207
+ if (volume)
208
+ resources.own({
209
+ dispose: () => {
210
+ execFileSync(runtime, ["stop", name], { stdio: "ignore" });
211
+ },
212
+ });
213
+ execFileSync(runtime, ["start", name], { stdio: "ignore" });
214
+ }
215
+ try {
216
+ await waitForPostgres(runtime, name, resources.signal);
217
+ }
218
+ catch (error) {
219
+ spawnSync(runtime, ["logs", "--tail", "50", name], { stdio: "inherit" });
220
+ throw error;
221
+ }
222
+ const stamp = schemaStamp(runtime, name);
223
+ if (stamp !== hash)
224
+ throw new StaleSchemaError(runtime, name, stamp);
225
+ return { port: publishedPort(runtime, name) };
226
+ }
227
+ /** Resolves once `pg_isready` succeeds in `container`: init SQL has been applied. */
228
+ function waitForPostgres(runtime, container, signal) {
229
+ return waitFor(() => {
230
+ if (succeeds(runtime, [
231
+ "exec",
232
+ container,
233
+ "pg_isready",
234
+ "-h",
235
+ "127.0.0.1",
236
+ "-U",
237
+ "postgres",
238
+ ]))
239
+ return true;
240
+ if (!inspectContainer(runtime, container)?.running)
241
+ throw new ExpectedError(`${container} exited during startup. Inspect: ${runtime} logs ${container}`);
242
+ return false;
243
+ }, `${container} not ready after ${READY_TIMEOUT_MS}ms. Inspect: ${runtime} logs ${container}`, READY_TIMEOUT_MS, signal);
244
+ }
@@ -0,0 +1,8 @@
1
+ /** `data`'s files in load order: each file as given, each directory's
2
+ * `*.sql` in name order. */
3
+ export declare function dataFiles(data: readonly string[]): string[];
4
+ /** sha256 over the data files' names and contents, in load order; the
5
+ * same across checkouts. */
6
+ export declare function dataDigest(data: readonly string[]): string;
7
+ /** Runs the data files in load order, each as one multi-statement script. */
8
+ export declare function loadData(connectionString: string, data: readonly string[]): Promise<void>;
@@ -0,0 +1,47 @@
1
+ /** The app's data SQL: loaded into `db_template` after the schema, its
2
+ * digest recorded in the test image manifest. */
3
+ import { createHash } from "node:crypto";
4
+ import { readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { basename, join } from "node:path";
6
+ import { ExpectedError } from "@astralarium/repo-telemetry";
7
+ import { withSql } from "../sql.js";
8
+ /** `data`'s files in load order: each file as given, each directory's
9
+ * `*.sql` in name order. */
10
+ export function dataFiles(data) {
11
+ return data.flatMap((path) => {
12
+ let directory;
13
+ try {
14
+ directory = statSync(path).isDirectory();
15
+ }
16
+ catch (cause) {
17
+ throw new ExpectedError(`data path ${path} is missing`, { cause });
18
+ }
19
+ return directory
20
+ ? readdirSync(path)
21
+ .filter((file) => file.endsWith(".sql"))
22
+ .sort()
23
+ .map((file) => join(path, file))
24
+ : [path];
25
+ });
26
+ }
27
+ /** sha256 over the data files' names and contents, in load order; the
28
+ * same across checkouts. */
29
+ export function dataDigest(data) {
30
+ const hash = createHash("sha256");
31
+ for (const file of dataFiles(data)) {
32
+ hash.update(`${basename(file)}\0`);
33
+ hash.update(readFileSync(file));
34
+ hash.update("\0");
35
+ }
36
+ return hash.digest("hex");
37
+ }
38
+ /** Runs the data files in load order, each as one multi-statement script. */
39
+ export async function loadData(connectionString, data) {
40
+ const files = dataFiles(data);
41
+ if (files.length === 0)
42
+ return;
43
+ await withSql(connectionString, async (sql) => {
44
+ for (const file of files)
45
+ await sql.file(file);
46
+ });
47
+ }
@@ -0,0 +1,49 @@
1
+ /** Throws an `ExpectedError` unless `path` is an existing file. */
2
+ export declare function requireFile(path: string): void;
3
+ /** One foreground disposable server. */
4
+ export interface DisposableServer {
5
+ /** Container name. */
6
+ name: string;
7
+ /** Immutable image ID. */
8
+ image: string;
9
+ /** Published loopback port. */
10
+ port: number;
11
+ /** Server arguments appended to the image command. */
12
+ serverArgs: readonly string[];
13
+ /** Full dump the initdb hook loads into `db_template`. */
14
+ snapshot?: {
15
+ dump: string;
16
+ scrub: boolean;
17
+ };
18
+ /** Stops the server: SIGINT, Postgres' fast shutdown. */
19
+ signal: AbortSignal;
20
+ }
21
+ /** `run --rm` in the foreground; logs the connection once Postgres
22
+ * listens on the published port, resolves once the container is gone. */
23
+ export declare function runDisposableServer(runtime: string, { name, image, port, serverArgs, snapshot, signal }: DisposableServer): Promise<void>;
24
+ /** `--mount` arguments placing each migrate file at
25
+ * `/app/migrate.d/<basename>`; each must be an existing `.sql` file with a
26
+ * unique basename. */
27
+ export declare function migrationMounts(files: readonly string[]): string[];
28
+ /** Throws an `ExpectedError` for a `DB_DUMP_EXCLUDE` pattern lacking a
29
+ * schema. */
30
+ export declare function checkDumpExclude(patterns?: string): void;
31
+ /** The upgrade dump's containers. */
32
+ export interface UpgradeDump {
33
+ /** Name of the throwaway instance and its private network. */
34
+ name: string;
35
+ /** Immutable db image ID. */
36
+ image: string;
37
+ /** Util image tag. */
38
+ util: string;
39
+ /** `migrationMounts` of the migrate files. */
40
+ mounts: readonly string[];
41
+ /** Stops the dump; its containers go. */
42
+ signal: AbortSignal;
43
+ }
44
+ /**
45
+ * The util image loads the `PG*` source into a throwaway instance of
46
+ * `image` on a private network and prints that instance's full dump on
47
+ * stdout. Leftovers of an earlier run go first; everything goes on exit.
48
+ */
49
+ export declare function runUpgradeDump(runtime: string, { name, image, util, mounts, signal }: UpgradeDump): Promise<void>;
@@ -0,0 +1,188 @@
1
+ /** Disposable instances of the schema image: foreground servers and the
2
+ * upgrade dump. */
3
+ import { execFileSync, spawn, } from "node:child_process";
4
+ import { once } from "node:events";
5
+ import { chmodSync, copyFileSync, mkdtempSync, rmSync, statSync, } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { basename, extname, join } from "node:path";
8
+ import { succeeds, waitFor } from "@astralarium/repo-dev";
9
+ import { packageRoot } from "@astralarium/repo-package-files";
10
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
11
+ import { adminConnectionString } from "./connection.js";
12
+ /** initdb hook loading `/app/data.sql`, relative to the package root. */
13
+ const SNAPSHOT_HOOK = "container/snapshot/run-snapshot.sh";
14
+ /** Container environment: trust auth, `postgres` as the default client. */
15
+ const TRUST_ENV = [
16
+ "--env",
17
+ "POSTGRES_HOST_AUTH_METHOD=trust",
18
+ "--env",
19
+ "PGUSER=postgres",
20
+ ];
21
+ /** Throws an `ExpectedError` unless `path` is an existing file. */
22
+ export function requireFile(path) {
23
+ let file = false;
24
+ try {
25
+ file = statSync(path).isFile();
26
+ }
27
+ catch {
28
+ // Missing: reported below.
29
+ }
30
+ if (!file)
31
+ throw new ExpectedError(`${path} is not a file`);
32
+ }
33
+ /** Exit code of `child`, or its signal; rejects when it fails to spawn. */
34
+ async function exit(child) {
35
+ const [code, signal] = (await once(child, "exit"));
36
+ return code === null ? String(signal) : String(code);
37
+ }
38
+ /** `run --rm` in the foreground; logs the connection once Postgres
39
+ * listens on the published port, resolves once the container is gone. */
40
+ export async function runDisposableServer(runtime, { name, image, port, serverArgs, snapshot, signal }) {
41
+ // The entrypoint sources non-executable hooks; packed packages drop mode bits.
42
+ const hooks = snapshot ? mkdtempSync(join(tmpdir(), "db-snapshot-")) : "";
43
+ try {
44
+ const snapshotArgs = [];
45
+ if (snapshot) {
46
+ const hook = join(hooks, basename(SNAPSHOT_HOOK));
47
+ copyFileSync(join(packageRoot(import.meta.filename), SNAPSHOT_HOOK), hook);
48
+ chmodSync(hook, 0o755);
49
+ snapshotArgs.push("--env", `SNAPSHOT_SCRUB=${snapshot.scrub ? 1 : 0}`, "--mount", `type=bind,source=${snapshot.dump},target=/app/data.sql,readonly`, "--mount", `type=bind,source=${hook},target=/docker-entrypoint-initdb.d/${basename(hook)},readonly`);
50
+ }
51
+ signal.throwIfAborted();
52
+ const server = spawn(runtime, [
53
+ "run",
54
+ "--rm",
55
+ "--name",
56
+ name,
57
+ ...TRUST_ENV,
58
+ "--publish",
59
+ `127.0.0.1:${port}:5432`,
60
+ ...snapshotArgs,
61
+ image,
62
+ ...serverArgs,
63
+ ], { stdio: ["ignore", "inherit", "inherit"] });
64
+ const exited = exit(server);
65
+ const gone = new AbortController();
66
+ exited.then(() => gone.abort(), () => gone.abort());
67
+ const stop = () => server.kill("SIGINT");
68
+ signal.addEventListener("abort", stop, { once: true });
69
+ try {
70
+ try {
71
+ // initdb, a snapshot load included, listens on the socket alone.
72
+ await waitFor(() => succeeds(runtime, [
73
+ "exec",
74
+ name,
75
+ "pg_isready",
76
+ "-h",
77
+ "127.0.0.1",
78
+ "-U",
79
+ "postgres",
80
+ ]), `${name} never became ready`, Number.POSITIVE_INFINITY, AbortSignal.any([signal, gone.signal]));
81
+ log.info("dev.db_disposable_ready", "{name}: ready on 127.0.0.1:{port}, removed on exit: {url}", { name, port, url: adminConnectionString(port, "db_template") });
82
+ }
83
+ catch (error) {
84
+ if (!signal.aborted && !gone.signal.aborted)
85
+ throw error;
86
+ }
87
+ const status = await exited;
88
+ if (!signal.aborted && status !== "0")
89
+ throw new ExpectedError(`${name} exited with ${status}`);
90
+ }
91
+ finally {
92
+ signal.removeEventListener("abort", stop);
93
+ }
94
+ }
95
+ finally {
96
+ if (hooks)
97
+ rmSync(hooks, { recursive: true, force: true });
98
+ }
99
+ }
100
+ /** `--mount` arguments placing each migrate file at
101
+ * `/app/migrate.d/<basename>`; each must be an existing `.sql` file with a
102
+ * unique basename. */
103
+ export function migrationMounts(files) {
104
+ const targets = new Set();
105
+ return files.flatMap((file) => {
106
+ requireFile(file);
107
+ if (extname(file) !== ".sql")
108
+ throw new ExpectedError(`${file} lacks the .sql extension`);
109
+ const target = `/app/migrate.d/${basename(file)}`;
110
+ if (targets.has(target))
111
+ throw new ExpectedError(`two migrate files share the basename of ${file}`);
112
+ targets.add(target);
113
+ return ["--mount", `type=bind,source=${file},target=${target},readonly`];
114
+ });
115
+ }
116
+ /** Throws an `ExpectedError` for a `DB_DUMP_EXCLUDE` pattern lacking a
117
+ * schema. */
118
+ export function checkDumpExclude(patterns = "") {
119
+ for (const pattern of patterns.split(/\s+/))
120
+ if (pattern !== "" && !pattern.includes("."))
121
+ throw new ExpectedError(`DB_DUMP_EXCLUDE pattern ${pattern} lacks a schema`);
122
+ }
123
+ /**
124
+ * The util image loads the `PG*` source into a throwaway instance of
125
+ * `image` on a private network and prints that instance's full dump on
126
+ * stdout. Leftovers of an earlier run go first; everything goes on exit.
127
+ */
128
+ export async function runUpgradeDump(runtime, { name, image, util, mounts, signal }) {
129
+ const utilName = `${name}-util`;
130
+ const cleanup = () => {
131
+ succeeds(runtime, ["rm", "-f", utilName]);
132
+ succeeds(runtime, ["rm", "-f", name]);
133
+ succeeds(runtime, ["network", "rm", name]);
134
+ };
135
+ cleanup();
136
+ try {
137
+ const quiet = {
138
+ stdio: ["ignore", "ignore", "inherit"],
139
+ };
140
+ execFileSync(runtime, ["network", "create", name], quiet);
141
+ execFileSync(runtime, [
142
+ "run",
143
+ "--rm",
144
+ "--detach",
145
+ "--name",
146
+ name,
147
+ ...TRUST_ENV,
148
+ "--network",
149
+ name,
150
+ "--expose",
151
+ "5432",
152
+ image,
153
+ ], quiet);
154
+ signal.throwIfAborted();
155
+ const dump = spawn(runtime, [
156
+ "run",
157
+ "--rm",
158
+ "--interactive",
159
+ "--name",
160
+ utilName,
161
+ "--env",
162
+ `CONTAINER_PGHOST=${name}`,
163
+ ...["PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE"].flatMap((key) => ["--env", key]),
164
+ "--env",
165
+ "DB_DUMP_EXCLUDE",
166
+ "--network",
167
+ name,
168
+ ...mounts,
169
+ util,
170
+ "/entrypoint.d/upgrade-db.sh",
171
+ ], { stdio: "inherit" });
172
+ const stop = () => dump.kill("SIGINT");
173
+ signal.addEventListener("abort", stop, { once: true });
174
+ try {
175
+ const status = await exit(dump);
176
+ if (signal.aborted)
177
+ throw new ExpectedError(`${utilName} interrupted; the dump is partial`);
178
+ if (status !== "0")
179
+ throw new ExpectedError(`${utilName} exited with ${status}`);
180
+ }
181
+ finally {
182
+ signal.removeEventListener("abort", stop);
183
+ }
184
+ }
185
+ finally {
186
+ cleanup();
187
+ }
188
+ }
@@ -0,0 +1,63 @@
1
+ import type { PostgresOptions } from "./options.ts";
2
+ /**
3
+ * What the package contributes to the image: `name@version` once
4
+ * installed from a registry; while linked from a workspace (files change
5
+ * without a version bump), the Git-visible `container/init` and `sql`
6
+ * sources and the build script.
7
+ */
8
+ export declare function packageFingerprint(dir?: string): string;
9
+ /**
10
+ * sha256 of what the image bakes: the staged schema (`schemaDigest`), the
11
+ * package fingerprint and the modules' extensions.
12
+ */
13
+ export declare function schemaHash(options: PostgresOptions): string;
14
+ /** What `buildImages` builds and how. */
15
+ interface BuildImagesOptions {
16
+ /** Images to build; defaults to `all`. */
17
+ target?: "db" | "util" | "all";
18
+ /** Image base name. */
19
+ name: string;
20
+ /** File receiving the built db image's ID. */
21
+ imageIdFile?: string;
22
+ /** Pulls the base image first. */
23
+ refreshBase?: boolean;
24
+ }
25
+ /**
26
+ * Builds database images with `build-images.sh` over a staged copy of the
27
+ * schema; base-image refresh is explicit.
28
+ */
29
+ export declare function buildImages(options: PostgresOptions, runtime: string, hash: string, { target, name, imageIdFile, refreshBase, }: BuildImagesOptions): void;
30
+ /**
31
+ * Reuses the schema-tagged local image or builds it; returns its immutable
32
+ * ID. A reused image is retagged `<name>:latest`, as a build tags it.
33
+ */
34
+ export declare function ensureImage(options: PostgresOptions, runtime: string, hash: string, name: string): string;
35
+ /** Builds `<name>-util:latest` from the package's util sources
36
+ * (layer-cached) and returns the tag. */
37
+ export declare function ensureUtilImage(runtime: string, name: string): string;
38
+ /** Database image and host runtime captured by the image prerequisite. */
39
+ export interface TestImage {
40
+ /** `PostgresOptions.name` the image was built for. */
41
+ name: string;
42
+ /** Container runtime the image was built with. */
43
+ runtime: string;
44
+ /** Immutable image ID (`sha256:…`). */
45
+ image: string;
46
+ /** `schemaHash` the image was built from. */
47
+ hash: string;
48
+ /** `dataDigest` of `PostgresOptions.data`, which loads after the image's
49
+ * schema. */
50
+ data: string;
51
+ /** Node version and platform captured by the prerequisite. */
52
+ host: string;
53
+ }
54
+ /** Selects the schema image, tagged `name`, and records its immutable ID,
55
+ * runtime and data digest. */
56
+ export declare function prepareTestImage(options: PostgresOptions, name?: string): TestImage;
57
+ /** Publishes the manifest of the image tagged `envDbName` at `file`,
58
+ * replacing it whole. */
59
+ export declare function writeTestImage(options: PostgresOptions, file: URL): void;
60
+ /** Reads a prepared database-image manifest; a malformed one is an
61
+ * `ExpectedError`. */
62
+ export declare function readTestImage(path: string): TestImage;
63
+ export {};