@astralarium/repo-postgres 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +9 -2
- package/container/init/.containerignore +2 -0
- package/container/init/Containerfile +26 -0
- package/container/init/entrypoint.d/init-db.sh +20 -0
- package/container/snapshot/run-snapshot.sh +23 -0
- package/container/util/.containerignore +2 -0
- package/container/util/Containerfile +7 -0
- package/container/util/entrypoint.d/dump-db.sh +19 -0
- package/container/util/entrypoint.d/upgrade-db.sh +103 -0
- package/dist/bin/stage-schema.d.ts +2 -0
- package/dist/bin/stage-schema.js +20 -0
- package/dist/src/provision/connection.d.ts +11 -0
- package/dist/src/provision/connection.js +33 -0
- package/dist/src/provision/container.d.ts +87 -0
- package/dist/src/provision/container.js +244 -0
- package/dist/src/provision/data.d.ts +8 -0
- package/dist/src/provision/data.js +47 -0
- package/dist/src/provision/disposable.d.ts +49 -0
- package/dist/src/provision/disposable.js +188 -0
- package/dist/src/provision/image.d.ts +63 -0
- package/dist/src/provision/image.js +159 -0
- package/dist/src/provision/index.d.ts +17 -0
- package/dist/src/provision/index.js +205 -0
- package/dist/src/provision/options.d.ts +23 -0
- package/dist/src/provision/options.js +1 -0
- package/dist/src/provision/schema.d.ts +11 -0
- package/dist/src/provision/schema.js +134 -0
- package/dist/src/provision/test-database.d.ts +29 -0
- package/dist/src/provision/test-database.js +130 -0
- package/dist/src/schema/index.d.ts +3 -0
- package/dist/src/schema/index.js +3 -0
- package/dist/src/schema/module.d.ts +59 -0
- package/dist/src/schema/module.js +122 -0
- package/dist/src/schema/modules.d.ts +15 -0
- package/dist/src/schema/modules.js +48 -0
- package/dist/src/sql.d.ts +83 -0
- package/dist/src/sql.js +146 -0
- package/dist/testkit/index.d.ts +13 -0
- package/dist/testkit/index.js +31 -0
- package/dist/testkit/schema.d.ts +14 -0
- package/dist/testkit/schema.js +89 -0
- package/package.json +62 -3
- package/scripts/build-images.sh +52 -0
- package/scripts/dump-db.sh +32 -0
- package/sql/app/schema.sql +8 -0
- package/sql/bootstrap.sql +27 -0
- package/sql/database-grants.sql +3 -0
- package/sql/drop-schemas.sql +11 -0
- package/sql/extensions/cron/schema.sql +23 -0
- package/sql/extensions/partman/before-reset.sql +38 -0
- package/sql/extensions/partman/schema.sql +8 -0
- package/sql/extensions/trgm/schema.sql +1 -0
- package/sql/reset.sql +22 -0
- package/sql/roles.sql +10 -0
|
@@ -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 {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** The app's database image: schema hash, build, selection for test runs. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { envDbName, output, sourceHash } from "@astralarium/repo-dev";
|
|
9
|
+
import { packageRoot, readPackageManifest, } from "@astralarium/repo-package-files";
|
|
10
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { detectRuntime, SCHEMA_LABEL } from "./container.js";
|
|
13
|
+
import { dataDigest } from "./data.js";
|
|
14
|
+
import { moduleExtensions, schemaDigest, stageSchema } from "./schema.js";
|
|
15
|
+
/** `build-images.sh <runtime> <schema-hash> [db|util|all] [db-iidfile]`. */
|
|
16
|
+
const BUILD_IMAGES = "scripts/build-images.sh";
|
|
17
|
+
/** SQL the image bakes beside the schema: roles, database grants,
|
|
18
|
+
* bootstrap, reset, drop. */
|
|
19
|
+
const PACKAGE_SQL = "sql";
|
|
20
|
+
/**
|
|
21
|
+
* What the package contributes to the image: `name@version` once
|
|
22
|
+
* installed from a registry; while linked from a workspace (files change
|
|
23
|
+
* without a version bump), the Git-visible `container/init` and `sql`
|
|
24
|
+
* sources and the build script.
|
|
25
|
+
*/
|
|
26
|
+
export function packageFingerprint(dir = packageRoot(import.meta.filename)) {
|
|
27
|
+
const real = realpathSync(dir);
|
|
28
|
+
if (real.split(sep).includes("node_modules")) {
|
|
29
|
+
const { name, version } = readPackageManifest(join(real, "package.json"));
|
|
30
|
+
return `${name}@${version}`;
|
|
31
|
+
}
|
|
32
|
+
return sourceHash(real, ["container/init", PACKAGE_SQL, BUILD_IMAGES]);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* sha256 of what the image bakes: the staged schema (`schemaDigest`), the
|
|
36
|
+
* package fingerprint and the modules' extensions.
|
|
37
|
+
*/
|
|
38
|
+
export function schemaHash(options) {
|
|
39
|
+
return createHash("sha256")
|
|
40
|
+
.update(schemaDigest(options.modules))
|
|
41
|
+
.update(packageFingerprint())
|
|
42
|
+
.update(extensionList(options))
|
|
43
|
+
.digest("hex");
|
|
44
|
+
}
|
|
45
|
+
/** `DB_EXTENSIONS` for the image build. */
|
|
46
|
+
function extensionList(options) {
|
|
47
|
+
return moduleExtensions(options.modules).join(" ");
|
|
48
|
+
}
|
|
49
|
+
/** Runs `build-images.sh` (through bash: packed scripts lose their mode
|
|
50
|
+
* bits) with the image base name and `env` added. */
|
|
51
|
+
function runBuildImages(args, name, env = {}) {
|
|
52
|
+
execFileSync("bash", [join(packageRoot(import.meta.filename), BUILD_IMAGES), ...args], {
|
|
53
|
+
stdio: "inherit",
|
|
54
|
+
env: { ...process.env, DB_NAME: name, ...env },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds database images with `build-images.sh` over a staged copy of the
|
|
59
|
+
* schema; base-image refresh is explicit.
|
|
60
|
+
*/
|
|
61
|
+
export function buildImages(options, runtime, hash, { target = "all", name, imageIdFile, refreshBase = false, }) {
|
|
62
|
+
const staged = mkdtempSync(join(tmpdir(), "db-schema-"));
|
|
63
|
+
try {
|
|
64
|
+
stageSchema(options.modules, staged);
|
|
65
|
+
runBuildImages([runtime, hash, target, ...(imageIdFile ? [imageIdFile] : [])], name, {
|
|
66
|
+
DB_SCHEMA_DIR: staged,
|
|
67
|
+
DB_EXTENSIONS: extensionList(options),
|
|
68
|
+
DB_REFRESH_BASE: refreshBase ? "true" : "",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(staged, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reuses the schema-tagged local image or builds it; returns its immutable
|
|
77
|
+
* ID. A reused image is retagged `<name>:latest`, as a build tags it.
|
|
78
|
+
*/
|
|
79
|
+
export function ensureImage(options, runtime, hash, name) {
|
|
80
|
+
const cached = output(runtime, [
|
|
81
|
+
"image",
|
|
82
|
+
"inspect",
|
|
83
|
+
"-f",
|
|
84
|
+
`{{.Id}}\t{{index .Config.Labels "${SCHEMA_LABEL}"}}`,
|
|
85
|
+
`${name}:${hash}`,
|
|
86
|
+
]);
|
|
87
|
+
if (cached) {
|
|
88
|
+
const [id, schema] = cached.split("\t");
|
|
89
|
+
if (id && schema === hash) {
|
|
90
|
+
const image = id.startsWith("sha256:") ? id : `sha256:${id}`;
|
|
91
|
+
execFileSync(runtime, ["tag", image, `${name}:latest`]);
|
|
92
|
+
return image;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), "db-image-"));
|
|
96
|
+
try {
|
|
97
|
+
const imageIdFile = join(dir, "id");
|
|
98
|
+
buildImages(options, runtime, hash, { target: "db", name, imageIdFile });
|
|
99
|
+
return readFileSync(imageIdFile, "utf8").trim();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Builds `<name>-util:latest` from the package's util sources
|
|
106
|
+
* (layer-cached) and returns the tag. */
|
|
107
|
+
export function ensureUtilImage(runtime, name) {
|
|
108
|
+
runBuildImages([runtime, "", "util"], name);
|
|
109
|
+
return `${name}-util:latest`;
|
|
110
|
+
}
|
|
111
|
+
/** Selects the schema image, tagged `name`, and records its immutable ID,
|
|
112
|
+
* runtime and data digest. */
|
|
113
|
+
export function prepareTestImage(options, name = options.name) {
|
|
114
|
+
const runtime = detectRuntime();
|
|
115
|
+
const hash = schemaHash(options);
|
|
116
|
+
return {
|
|
117
|
+
name: options.name,
|
|
118
|
+
runtime,
|
|
119
|
+
image: ensureImage(options, runtime, hash, name),
|
|
120
|
+
hash,
|
|
121
|
+
data: dataDigest(options.data ?? []),
|
|
122
|
+
host: `${process.version}/${process.platform}/${process.arch}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Publishes the manifest of the image tagged `envDbName` at `file`,
|
|
126
|
+
* replacing it whole. */
|
|
127
|
+
export function writeTestImage(options, file) {
|
|
128
|
+
const image = prepareTestImage(options, envDbName(options));
|
|
129
|
+
const path = fileURLToPath(file);
|
|
130
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
131
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
132
|
+
writeFileSync(temp, JSON.stringify(image) + "\n");
|
|
133
|
+
renameSync(temp, path);
|
|
134
|
+
}
|
|
135
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
136
|
+
const testImageManifest = z.object({
|
|
137
|
+
name: z.string(),
|
|
138
|
+
runtime: z.string(),
|
|
139
|
+
image: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
140
|
+
hash: z.string().regex(SHA256),
|
|
141
|
+
data: z.string().regex(SHA256),
|
|
142
|
+
host: z.string(),
|
|
143
|
+
});
|
|
144
|
+
/** Reads a prepared database-image manifest; a malformed one is an
|
|
145
|
+
* `ExpectedError`. */
|
|
146
|
+
export function readTestImage(path) {
|
|
147
|
+
const text = readFileSync(path, "utf8");
|
|
148
|
+
let value;
|
|
149
|
+
try {
|
|
150
|
+
value = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
value = undefined;
|
|
154
|
+
}
|
|
155
|
+
const result = testImageManifest.safeParse(value);
|
|
156
|
+
if (!result.success)
|
|
157
|
+
throw new ExpectedError(`${path}: invalid prepared test image; run the app's db:image task to regenerate it`);
|
|
158
|
+
return result.data;
|
|
159
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DatabaseProvider } from "@astralarium/repo-dev";
|
|
2
|
+
import type { PostgresOptions } from "./options.ts";
|
|
3
|
+
import { type PostgresTestDatabase } from "./test-database.ts";
|
|
4
|
+
export { APP_SERVICE_ROLE } from "./connection.ts";
|
|
5
|
+
export { ensureUtilImage } from "./image.ts";
|
|
6
|
+
export type { PostgresOptions } from "./options.ts";
|
|
7
|
+
export type { PostgresTestDatabase } from "./test-database.ts";
|
|
8
|
+
/** The app's Postgres, as `DevConfig.database` and `TestApp.database` take it. */
|
|
9
|
+
export interface PostgresProvider extends DatabaseProvider<PostgresTestDatabase> {
|
|
10
|
+
/**
|
|
11
|
+
* Writes the staged schema directory under `dir`, as the image bakes it
|
|
12
|
+
* (`stageSchema`).
|
|
13
|
+
*/
|
|
14
|
+
stageSchema(dir: string): void;
|
|
15
|
+
}
|
|
16
|
+
/** The app's Postgres provider; the declaration is validated (`ExpectedError`). */
|
|
17
|
+
export declare function postgresDatabase(options: PostgresOptions): PostgresProvider;
|