@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.
- package/LICENSE +21 -0
- package/README.md +38 -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/queue.d.ts +86 -0
- package/dist/src/queue.js +201 -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 +24 -0
- package/dist/src/schema/modules.js +71 -0
- package/dist/src/sql.d.ts +83 -0
- package/dist/src/sql.js +146 -0
- package/dist/testkit/index.d.ts +14 -0
- package/dist/testkit/index.js +32 -0
- package/dist/testkit/locks.d.ts +27 -0
- package/dist/testkit/locks.js +57 -0
- package/dist/testkit/schema.d.ts +14 -0
- package/dist/testkit/schema.js +89 -0
- package/package.json +63 -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
- package/sql/slug/schema.sql +25 -0
- package/sql/taskclaim/jobs/schema.sql +19 -0
- package/sql/taskclaim/schema.sql +41 -0
- package/sql/taskclaim/scrub.sql +3 -0
|
@@ -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;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres as a `DatabaseProvider`: the app's schema image, one persistent
|
|
3
|
+
* container per checkout for dev, a disposable one per test run.
|
|
4
|
+
*/
|
|
5
|
+
import { isAbsolute } from "node:path";
|
|
6
|
+
import { keepOrFindPort, RESOURCE_NAME, succeeds, } from "@astralarium/repo-dev";
|
|
7
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { isSchemaModule, loadOrder, stringList, } from "../schema/module.js";
|
|
10
|
+
import { adminConnectionString, APP_SERVICE_ROLE, appConnectionString, DEFAULT_PORT, probePostgres, } from "./connection.js";
|
|
11
|
+
import { claimable, detectRuntime, holders, inspectContainer, OWNER_LABEL, removeContainer, removeVolume, StaleSchemaError, startPostgresContainer, volumeName, } from "./container.js";
|
|
12
|
+
import { loadData } from "./data.js";
|
|
13
|
+
import { checkDumpExclude, migrationMounts, requireFile, runDisposableServer, runUpgradeDump, } from "./disposable.js";
|
|
14
|
+
import { buildImages, ensureImage, ensureUtilImage, schemaHash, writeTestImage, } from "./image.js";
|
|
15
|
+
import { moduleServerArgs, stageSchema } from "./schema.js";
|
|
16
|
+
import { startTestInstance, } from "./test-database.js";
|
|
17
|
+
export { APP_SERVICE_ROLE } from "./connection.js";
|
|
18
|
+
export { ensureUtilImage } from "./image.js";
|
|
19
|
+
/** Container names tried per base: `<base>`, `<base>-2`, … */
|
|
20
|
+
const NAME_ATTEMPTS = 100;
|
|
21
|
+
const postgresOptions = z.object({
|
|
22
|
+
modules: z
|
|
23
|
+
.array(z.custom(isSchemaModule, {
|
|
24
|
+
error: "modules must hold defineSchemaModule results",
|
|
25
|
+
}))
|
|
26
|
+
.min(1, { error: "modules lists no schema module" })
|
|
27
|
+
.readonly()
|
|
28
|
+
.check((ctx) => {
|
|
29
|
+
try {
|
|
30
|
+
loadOrder(ctx.value);
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
ctx.issues.push({
|
|
34
|
+
code: "custom",
|
|
35
|
+
input: ctx.value,
|
|
36
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
name: z.string().regex(RESOURCE_NAME, {
|
|
41
|
+
error: "name must be a container resource name",
|
|
42
|
+
}),
|
|
43
|
+
serverArgs: stringList.optional(),
|
|
44
|
+
ephemeralArgs: stringList.optional(),
|
|
45
|
+
data: z
|
|
46
|
+
.array(z.string().refine(isAbsolute, { error: "data paths must be absolute" }))
|
|
47
|
+
.readonly()
|
|
48
|
+
.optional(),
|
|
49
|
+
});
|
|
50
|
+
class Postgres {
|
|
51
|
+
name;
|
|
52
|
+
#options;
|
|
53
|
+
#runtime;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.#options = options;
|
|
56
|
+
this.name = options.name;
|
|
57
|
+
}
|
|
58
|
+
/** Detected once per process. */
|
|
59
|
+
get runtime() {
|
|
60
|
+
return (this.#runtime ??= detectRuntime());
|
|
61
|
+
}
|
|
62
|
+
async allocate({ base, owner, saved, }) {
|
|
63
|
+
const runtime = this.runtime;
|
|
64
|
+
const names = [
|
|
65
|
+
base,
|
|
66
|
+
...Array.from({ length: NAME_ATTEMPTS - 1 }, (_, i) => `${base}-${i + 2}`),
|
|
67
|
+
];
|
|
68
|
+
const name = [
|
|
69
|
+
...new Set([
|
|
70
|
+
...(saved.name && names.includes(saved.name) ? [saved.name] : []),
|
|
71
|
+
...names,
|
|
72
|
+
]),
|
|
73
|
+
].find((candidate) => claim(runtime, candidate, owner));
|
|
74
|
+
if (!name)
|
|
75
|
+
throw new ExpectedError(`no free container name from ${base} to ${names.at(-1)}`);
|
|
76
|
+
// A claimable container is this checkout's: a running one keeps its port.
|
|
77
|
+
const container = inspectContainer(runtime, name);
|
|
78
|
+
const port = container?.running && container.bindings[0]?.HostPort
|
|
79
|
+
? Number(container.bindings[0].HostPort)
|
|
80
|
+
: await keepOrFindPort(saved.port, DEFAULT_PORT);
|
|
81
|
+
return { name, port };
|
|
82
|
+
}
|
|
83
|
+
async ensure(resources, { allocation: { name, port }, image, owner, program, }) {
|
|
84
|
+
const options = this.#options;
|
|
85
|
+
const runtime = this.runtime;
|
|
86
|
+
const hash = schemaHash(options);
|
|
87
|
+
const resetHint = `${program} reset --db-name ${image} recreates it; this deletes local data.`;
|
|
88
|
+
try {
|
|
89
|
+
await startPostgresContainer(runtime, resources, {
|
|
90
|
+
name,
|
|
91
|
+
image: ensureImage(options, runtime, hash, image),
|
|
92
|
+
hash,
|
|
93
|
+
port,
|
|
94
|
+
volume: volumeName(name),
|
|
95
|
+
owner,
|
|
96
|
+
serverArgs: [
|
|
97
|
+
...moduleServerArgs(options.modules),
|
|
98
|
+
...(options.serverArgs ?? []),
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (cause) {
|
|
103
|
+
if (cause instanceof StaleSchemaError)
|
|
104
|
+
throw new ExpectedError(`${cause.message} ${resetHint}`, { cause });
|
|
105
|
+
throw cause;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await probePostgres(appConnectionString(port, "db_template"));
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
throw new ExpectedError(`${name}: ${APP_SERVICE_ROLE} cannot connect to db_template with the dev credentials. Inspect the database, or ${resetHint}`, { cause });
|
|
112
|
+
}
|
|
113
|
+
await loadData(adminConnectionString(port, "db_template"), options.data ?? []);
|
|
114
|
+
log.info("dev.db_ready", "{name}: ready on :{port}", { name, port });
|
|
115
|
+
}
|
|
116
|
+
reset(name, image) {
|
|
117
|
+
removeContainer(this.runtime, name);
|
|
118
|
+
removeVolume(this.runtime, volumeName(name));
|
|
119
|
+
this.build(image);
|
|
120
|
+
}
|
|
121
|
+
stop(name, owner) {
|
|
122
|
+
let runtime;
|
|
123
|
+
try {
|
|
124
|
+
runtime = this.runtime;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (claimable(runtime, name, owner))
|
|
130
|
+
succeeds(runtime, ["stop", name]);
|
|
131
|
+
}
|
|
132
|
+
build(image) {
|
|
133
|
+
buildImages(this.#options, this.runtime, schemaHash(this.#options), {
|
|
134
|
+
name: image,
|
|
135
|
+
refreshBase: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async runDisposable({ allocation, image, serverArgs, snapshot, signal, }) {
|
|
139
|
+
if (snapshot)
|
|
140
|
+
requireFile(snapshot.dump);
|
|
141
|
+
await runDisposableServer(this.runtime, {
|
|
142
|
+
name: `${allocation.name}-${snapshot ? "snapshot" : "temp"}`,
|
|
143
|
+
image: this.#image(image),
|
|
144
|
+
port: await keepOrFindPort(undefined, DEFAULT_PORT, new Set([allocation.port])),
|
|
145
|
+
serverArgs,
|
|
146
|
+
snapshot,
|
|
147
|
+
signal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async upgradeDump({ allocation, image, migrations, signal, }) {
|
|
151
|
+
const mounts = migrationMounts(migrations);
|
|
152
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars -- no turbo task runs this CLI
|
|
153
|
+
checkDumpExclude(process.env.DB_DUMP_EXCLUDE);
|
|
154
|
+
await runUpgradeDump(this.runtime, {
|
|
155
|
+
name: `${allocation.name}-upgrade`,
|
|
156
|
+
image: this.#image(image),
|
|
157
|
+
util: ensureUtilImage(this.runtime, image),
|
|
158
|
+
mounts,
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** This checkout's schema image under base name `image`, built when
|
|
163
|
+
* missing: its immutable ID. */
|
|
164
|
+
#image(image) {
|
|
165
|
+
return ensureImage(this.#options, this.runtime, schemaHash(this.#options), image);
|
|
166
|
+
}
|
|
167
|
+
connectionString({ port }) {
|
|
168
|
+
return appConnectionString(port, "db_template");
|
|
169
|
+
}
|
|
170
|
+
startTestInstance(resources, name) {
|
|
171
|
+
return startTestInstance(resources, name, this.#options);
|
|
172
|
+
}
|
|
173
|
+
writeTestImage(file) {
|
|
174
|
+
writeTestImage(this.#options, file);
|
|
175
|
+
}
|
|
176
|
+
stageSchema(dir) {
|
|
177
|
+
stageSchema(this.#options.modules, dir);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* `claimable`, naming each holder of `name` lacking `OWNER_LABEL` (a
|
|
182
|
+
* container or data volume) and the command freeing it: that holder keeps
|
|
183
|
+
* the name and its port from every checkout.
|
|
184
|
+
*/
|
|
185
|
+
function claim(runtime, name, owner) {
|
|
186
|
+
const held = holders(runtime, name);
|
|
187
|
+
if (held.every((holder) => holder.owner === owner))
|
|
188
|
+
return true;
|
|
189
|
+
for (const holder of held)
|
|
190
|
+
if (holder.owner === undefined)
|
|
191
|
+
log.warn("dev.db_name_held", "{kind, select, volume {Data volume} other {Container}} {name} carries no {label} label, so this checkout skips the name; free it with: {remove}", {
|
|
192
|
+
kind: holder.kind,
|
|
193
|
+
name: holder.name,
|
|
194
|
+
label: OWNER_LABEL,
|
|
195
|
+
remove: `${runtime} ${holder.kind === "volume" ? "volume rm" : "rm -f"} ${holder.name}`,
|
|
196
|
+
});
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
/** The app's Postgres provider; the declaration is validated (`ExpectedError`). */
|
|
200
|
+
export function postgresDatabase(options) {
|
|
201
|
+
const result = postgresOptions.safeParse(options);
|
|
202
|
+
if (!result.success)
|
|
203
|
+
throw new ExpectedError(`invalid Postgres options:\n${z.prettifyError(result.error)}`);
|
|
204
|
+
return new Postgres(options);
|
|
205
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SchemaModule } from "../schema/module.ts";
|
|
2
|
+
/** What an app declares for its Postgres image and local instances. */
|
|
3
|
+
export interface PostgresOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The app's schema modules; their `requires` load too. Files hash into
|
|
6
|
+
* the image tag; extensions install in the image; `preload` and
|
|
7
|
+
* `serverArgs` reach the dev instance.
|
|
8
|
+
*/
|
|
9
|
+
modules: readonly SchemaModule[];
|
|
10
|
+
/** Image and instance base name; `DB_NAME` and `--db-name` override it. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Postgres server arguments for the dev instance, after the modules'. */
|
|
13
|
+
serverArgs?: readonly string[];
|
|
14
|
+
/** Postgres server arguments for disposable (test) instances; stock Postgres by default. */
|
|
15
|
+
ephemeralArgs?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Data SQL after the schema: absolute `.sql` files, or directories whose
|
|
18
|
+
* `*.sql` run in name order. Loads into `db_template` on every dev start
|
|
19
|
+
* and once per test run, before project databases clone it; idempotent.
|
|
20
|
+
* Its digest joins the test image manifest.
|
|
21
|
+
*/
|
|
22
|
+
data?: readonly string[];
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SchemaModule } from "../schema/module.ts";
|
|
2
|
+
/** sha256 over the staged psql entries, then the module file, asset,
|
|
3
|
+
* `beforeReset` and `scrub` paths and contents, in load order. */
|
|
4
|
+
export declare function schemaDigest(modules: readonly SchemaModule[]): string;
|
|
5
|
+
/** Extensions every module installs, deduplicated in load order. */
|
|
6
|
+
export declare function moduleExtensions(modules: readonly SchemaModule[]): string[];
|
|
7
|
+
/** Persistent-instance server arguments: one `shared_preload_libraries`
|
|
8
|
+
* over every module's `preload`, then each module's `serverArgs`. */
|
|
9
|
+
export declare function moduleServerArgs(modules: readonly SchemaModule[]): string[];
|
|
10
|
+
/** Writes the staged directory under `dir`, creating it. */
|
|
11
|
+
export declare function stageSchema(modules: readonly SchemaModule[], dir: string): void;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's schema modules as psql loads them: a staged directory holding
|
|
3
|
+
* `schema.sql` (one `\i sql/<module>/<file>` per module file, in load
|
|
4
|
+
* order) beside `sql/` (each module's files and assets),
|
|
5
|
+
* `before-reset.sql` and `before-reset/` (each module's `beforeReset`),
|
|
6
|
+
* `scrub.sql` and `scrub/` (each module's `scrub`), and this package's
|
|
7
|
+
* `roles.sql`, `database-grants.sql`, `bootstrap.sql`, `reset.sql` and
|
|
8
|
+
* `drop-schemas.sql`. psql runs every entry with this directory as cwd.
|
|
9
|
+
* The image bakes it and runs `roles.sql`, `database-grants.sql`, then
|
|
10
|
+
* `schema.sql`, at first boot.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { packageRoot } from "@astralarium/repo-package-files";
|
|
16
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
17
|
+
import { loadOrder } from "../schema/module.js";
|
|
18
|
+
/** Entry psql runs from the staged directory. */
|
|
19
|
+
const SCHEMA_ENTRY = "schema.sql";
|
|
20
|
+
/** Directory of the staged module files, beside the entry. */
|
|
21
|
+
const SCHEMA_FILES = "sql";
|
|
22
|
+
/** Entry `reset.sql` runs ahead of its drop. */
|
|
23
|
+
const BEFORE_RESET_ENTRY = "before-reset.sql";
|
|
24
|
+
/** Directory of the staged `beforeReset` files. */
|
|
25
|
+
const BEFORE_RESET_FILES = "before-reset";
|
|
26
|
+
/** Entry a snapshot runs after its dump loads. */
|
|
27
|
+
const SCRUB_ENTRY = "scrub.sql";
|
|
28
|
+
/** Directory of the staged `scrub` files. */
|
|
29
|
+
const SCRUB_FILES = "scrub";
|
|
30
|
+
/** Each module's `files` or `assets` in load order, under `sql/<module>/`. */
|
|
31
|
+
function moduleFiles(order, kind) {
|
|
32
|
+
return order.flatMap((module) => module[kind].map((path) => ({
|
|
33
|
+
staged: `${SCHEMA_FILES}/${module.name}/${basename(path)}`,
|
|
34
|
+
path,
|
|
35
|
+
})));
|
|
36
|
+
}
|
|
37
|
+
/** Each module's `beforeReset` or `scrub` in load order, as
|
|
38
|
+
* `<dir>/<module>.sql`. */
|
|
39
|
+
function moduleScripts(order, kind, dir) {
|
|
40
|
+
return order.flatMap((module) => {
|
|
41
|
+
const path = module[kind];
|
|
42
|
+
return path ? [{ staged: `${dir}/${module.name}.sql`, path }] : [];
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Every staged module file, asset, `beforeReset` and `scrub` file, in load
|
|
46
|
+
* order. */
|
|
47
|
+
function stagedFiles(order) {
|
|
48
|
+
return [
|
|
49
|
+
...moduleFiles(order, "files"),
|
|
50
|
+
...moduleFiles(order, "assets"),
|
|
51
|
+
...moduleScripts(order, "beforeReset", BEFORE_RESET_FILES),
|
|
52
|
+
...moduleScripts(order, "scrub", SCRUB_FILES),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
/** psql entry including `files` in order. */
|
|
56
|
+
function entry(header, files) {
|
|
57
|
+
return [header, ...files.map((file) => `\\i ${file.staged}`), ""].join("\n");
|
|
58
|
+
}
|
|
59
|
+
/** Staged psql entries as `[file name, text]`. */
|
|
60
|
+
function entries(order) {
|
|
61
|
+
return [
|
|
62
|
+
[
|
|
63
|
+
SCHEMA_ENTRY,
|
|
64
|
+
entry("-- Schema load order, staged by @astralarium/repo-postgres from the app's schema modules.\n-- Run connected to the app database, after roles.sql and database-grants.sql.", moduleFiles(order, "files")),
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
BEFORE_RESET_ENTRY,
|
|
68
|
+
entry("-- Each schema module's beforeReset in load order, staged by @astralarium/repo-postgres.\n-- Run by reset.sql as app_admin, inside its transaction.", moduleScripts(order, "beforeReset", BEFORE_RESET_FILES)),
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
SCRUB_ENTRY,
|
|
72
|
+
entry("-- Each schema module's scrub in load order, staged by @astralarium/repo-postgres.\n-- Run by run-snapshot as superuser in db_template, inside one transaction.", moduleScripts(order, "scrub", SCRUB_FILES)),
|
|
73
|
+
],
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/** The file's contents; a missing file names the path and its likely cause. */
|
|
77
|
+
function readSchemaFile({ path }) {
|
|
78
|
+
try {
|
|
79
|
+
return readFileSync(path);
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new ExpectedError(`schema file ${path} is missing: check the module declaring it, or install the package providing it`, { cause });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** sha256 over the staged psql entries, then the module file, asset,
|
|
86
|
+
* `beforeReset` and `scrub` paths and contents, in load order. */
|
|
87
|
+
export function schemaDigest(modules) {
|
|
88
|
+
const order = loadOrder(modules);
|
|
89
|
+
const hash = createHash("sha256");
|
|
90
|
+
for (const [name, text] of entries(order)) {
|
|
91
|
+
hash.update(`${name}\0`);
|
|
92
|
+
hash.update(text);
|
|
93
|
+
hash.update("\0");
|
|
94
|
+
}
|
|
95
|
+
for (const file of stagedFiles(order)) {
|
|
96
|
+
hash.update(`${file.staged}\0`);
|
|
97
|
+
hash.update(readSchemaFile(file));
|
|
98
|
+
hash.update("\0");
|
|
99
|
+
}
|
|
100
|
+
return hash.digest("hex");
|
|
101
|
+
}
|
|
102
|
+
/** Extensions every module installs, deduplicated in load order. */
|
|
103
|
+
export function moduleExtensions(modules) {
|
|
104
|
+
return [
|
|
105
|
+
...new Set(loadOrder(modules).flatMap((module) => module.extensions)),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
/** Persistent-instance server arguments: one `shared_preload_libraries`
|
|
109
|
+
* over every module's `preload`, then each module's `serverArgs`. */
|
|
110
|
+
export function moduleServerArgs(modules) {
|
|
111
|
+
const order = loadOrder(modules);
|
|
112
|
+
const preload = [...new Set(order.flatMap((module) => module.preload))];
|
|
113
|
+
return [
|
|
114
|
+
...(preload.length
|
|
115
|
+
? ["-c", `shared_preload_libraries=${preload.join(",")}`]
|
|
116
|
+
: []),
|
|
117
|
+
...order.flatMap((module) => module.serverArgs),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
/** Writes the staged directory under `dir`, creating it. */
|
|
121
|
+
export function stageSchema(modules, dir) {
|
|
122
|
+
const order = loadOrder(modules);
|
|
123
|
+
for (const file of stagedFiles(order)) {
|
|
124
|
+
const target = join(dir, file.staged);
|
|
125
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
126
|
+
writeFileSync(target, readSchemaFile(file));
|
|
127
|
+
}
|
|
128
|
+
for (const [name, text] of entries(order))
|
|
129
|
+
writeFileSync(join(dir, name), text);
|
|
130
|
+
const scripts = join(packageRoot(import.meta.filename), "sql");
|
|
131
|
+
for (const name of readdirSync(scripts))
|
|
132
|
+
if (name.endsWith(".sql"))
|
|
133
|
+
copyFileSync(join(scripts, name), join(dir, name));
|
|
134
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ResourceScope, type TestContainer, type TestDatabase } from "@astralarium/repo-dev";
|
|
2
|
+
import type { PostgresOptions } from "./options.ts";
|
|
3
|
+
/** Running seeded instance; fixtures may run tools in its container or network namespace. */
|
|
4
|
+
export declare class PostgresTestDatabase implements TestDatabase {
|
|
5
|
+
/** Published loopback port. */
|
|
6
|
+
readonly port: number;
|
|
7
|
+
/** Container running the instance; other containers join its network
|
|
8
|
+
* namespace. */
|
|
9
|
+
readonly container: TestContainer;
|
|
10
|
+
constructor(instance: {
|
|
11
|
+
port: number;
|
|
12
|
+
container: TestContainer;
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* `CREATE DATABASE … TEMPLATE db_template`; returns the `app_service`
|
|
16
|
+
* connection. A project whose name maps to an existing database is an
|
|
17
|
+
* `ExpectedError`.
|
|
18
|
+
*/
|
|
19
|
+
createDatabase(name: string): Promise<string>;
|
|
20
|
+
/** `postgres` connection to the project database. */
|
|
21
|
+
adminConnectionString(name: string): string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sweeps stale test containers (`sweepTestInstances`), then starts
|
|
25
|
+
* `db-test-<name>-<uuid>`, labeled with this process's pid, on an assigned
|
|
26
|
+
* loopback port and loads the app's data into `db_template` (`testImage`).
|
|
27
|
+
* The run's resources remove the container and its data at teardown.
|
|
28
|
+
*/
|
|
29
|
+
export declare function startTestInstance(resources: ResourceScope, name: string, options: PostgresOptions): Promise<PostgresTestDatabase>;
|