@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -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/schema/index.d.ts +3 -0
  32. package/dist/src/schema/index.js +3 -0
  33. package/dist/src/schema/module.d.ts +59 -0
  34. package/dist/src/schema/module.js +122 -0
  35. package/dist/src/schema/modules.d.ts +15 -0
  36. package/dist/src/schema/modules.js +48 -0
  37. package/dist/src/sql.d.ts +83 -0
  38. package/dist/src/sql.js +146 -0
  39. package/dist/testkit/index.d.ts +13 -0
  40. package/dist/testkit/index.js +31 -0
  41. package/dist/testkit/schema.d.ts +14 -0
  42. package/dist/testkit/schema.js +89 -0
  43. package/package.json +62 -3
  44. package/scripts/build-images.sh +52 -0
  45. package/scripts/dump-db.sh +32 -0
  46. package/sql/app/schema.sql +8 -0
  47. package/sql/bootstrap.sql +27 -0
  48. package/sql/database-grants.sql +3 -0
  49. package/sql/drop-schemas.sql +11 -0
  50. package/sql/extensions/cron/schema.sql +23 -0
  51. package/sql/extensions/partman/before-reset.sql +38 -0
  52. package/sql/extensions/partman/schema.sql +8 -0
  53. package/sql/extensions/trgm/schema.sql +1 -0
  54. package/sql/reset.sql +22 -0
  55. package/sql/roles.sql +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astralarium
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
- # Temporary Holding Version
1
+ # `@astralarium/repo-postgres`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared Postgres tooling: container images, schema modules, clients.
4
+
5
+ Roles:
6
+
7
+ - `app_admin`: schema owner.
8
+ - `app_service`: app service.
9
+
10
+ Roles are cluster-wide: one app per cluster.
@@ -0,0 +1,2 @@
1
+ *
2
+ !entrypoint.d
@@ -0,0 +1,26 @@
1
+ FROM postgres:18-trixie
2
+
3
+ # Extension names, space-separated: Debian's postgresql-<major>-<name> packages.
4
+ ARG DB_EXTENSIONS=""
5
+ RUN if [ -n "$DB_EXTENSIONS" ]; then \
6
+ packages=""; \
7
+ for name in $DB_EXTENSIONS; do packages="$packages postgresql-${PG_MAJOR}-${name}"; done; \
8
+ apt-get update \
9
+ && apt-get install -y --no-install-recommends $packages \
10
+ && rm -rf /var/lib/apt/lists/*; \
11
+ fi
12
+
13
+ # `schemaHash` of the staged schema, this package and DB_EXTENSIONS.
14
+ # The RUN keys the layer cache on it, so each hash gets its own ENV and LABEL.
15
+ ARG DB_SCHEMA
16
+ RUN echo "$DB_SCHEMA" > /etc/db-schema
17
+ ENV DB_SCHEMA=$DB_SCHEMA
18
+ LABEL db.schema=$DB_SCHEMA
19
+
20
+ WORKDIR /docker-entrypoint-initdb.d
21
+ COPY entrypoint.d/ .
22
+ # Packed packages ship scripts without mode bits.
23
+ RUN chmod 0755 *.sh
24
+
25
+ # The staged schema: schema.sql beside sql/.
26
+ COPY --from=schema . schema
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # First boot: creates `db_template`, the app roles (schema/roles.sql) and
3
+ # its grants (schema/database-grants.sql), loads the staged schema
4
+ # (schema/schema.sql includes sql/ in order) with schema/ as cwd, then
5
+ # stamps the data directory with the image's DB_SCHEMA.
6
+
7
+ set -euo pipefail
8
+
9
+ cd "$( dirname "${BASH_SOURCE[0]}" )"
10
+
11
+ export PGDATABASE="db_template"
12
+
13
+ createdb "${PGDATABASE}"
14
+
15
+ cd schema
16
+ psql -X -v ON_ERROR_STOP=1 --file roles.sql
17
+ psql -X -v ON_ERROR_STOP=1 --file database-grants.sql
18
+ psql -X -v ON_ERROR_STOP=1 --file schema.sql
19
+
20
+ echo "${DB_SCHEMA}" > /var/lib/postgresql/db-schema
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # First boot, after init-db.sh: recreates `db_template` with the staged
3
+ # database-grants.sql, loads the dump at /app/data.sql, runs the staged
4
+ # scrub.sql (each module's `scrub`) unless SNAPSHOT_SCRUB=0, then vacuums.
5
+ # Init runs before the server listens: clients only see the scrubbed data.
6
+
7
+ set -euo pipefail
8
+
9
+ cd /docker-entrypoint-initdb.d/schema
10
+
11
+ export PGDATABASE="db_template"
12
+
13
+ dropdb --force "$PGDATABASE"
14
+ createdb "$PGDATABASE"
15
+ psql -X -v ON_ERROR_STOP=1 --file database-grants.sql
16
+
17
+ psql -X -v ON_ERROR_STOP=1 --file /app/data.sql
18
+
19
+ if [[ ${SNAPSHOT_SCRUB:-1} != 0 ]]; then
20
+ psql -X -v ON_ERROR_STOP=1 --single-transaction --file scrub.sql
21
+ fi
22
+
23
+ vacuumdb --full --analyze --echo
@@ -0,0 +1,2 @@
1
+ *
2
+ !entrypoint.d
@@ -0,0 +1,7 @@
1
+ # Same base as container/init: pg_dump and psql match the server major.
2
+ FROM postgres:18-trixie
3
+
4
+ WORKDIR /entrypoint.d
5
+ COPY entrypoint.d/ .
6
+ # Packed packages ship scripts without mode bits.
7
+ RUN chmod 0755 *.sh
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Data-only dump of the PG* database: the app's rows, excluding extension
3
+ # config tables (the target schema owns its own) and the rows of tables
4
+ # matching DB_DUMP_EXCLUDE (space-separated schema-qualified pg_dump
5
+ # patterns; a partitioned table covers its partitions). The replica role
6
+ # skips triggers and FK checks on load.
7
+
8
+ set -euo pipefail
9
+
10
+ read -ra patterns <<< "${DB_DUMP_EXCLUDE:-}"
11
+ excludes=()
12
+ for pattern in ${patterns[@]+"${patterns[@]}"}; do
13
+ [[ $pattern == *.* ]] || { echo "dump-db: DB_DUMP_EXCLUDE pattern $pattern lacks a schema" >&2; exit 1; }
14
+ excludes+=("--exclude-table-data-and-children=$pattern")
15
+ done
16
+
17
+ echo "SET session_replication_role = 'replica';"
18
+
19
+ pg_dump --data-only --exclude-extension='*' ${excludes[@]+"${excludes[@]}"}
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Loads the PG* source database's data into a db_template clone on the
3
+ # throwaway instance at CONTAINER_PGHOST and prints that clone's full
4
+ # pg_dump. With /app/migrate.d/*.sql, the data comes from a copy of the
5
+ # source those files migrate, in name order. Tables matching
6
+ # DB_DUMP_EXCLUDE keep the clone's seed rows.
7
+
8
+ set -euo pipefail
9
+
10
+ CONTAINER_PGHOST=${CONTAINER_PGHOST:?temporary database host}
11
+
12
+ TEMPLATE_DATABASE=db_template
13
+ UPGRADE_PGDATABASE=db_upgrade
14
+ MIGRATE_PGDATABASE=db_migrate
15
+
16
+ # Name order, independent of the locale.
17
+ LC_COLLATE=C
18
+ shopt -s nullglob
19
+ migratefiles=(/app/migrate.d/*.sql)
20
+
21
+ # Runs a client against the throwaway instance's database <db>.
22
+ on_local() {
23
+ local db=$1
24
+ shift
25
+ PGSSLMODE=prefer \
26
+ PGHOST="$CONTAINER_PGHOST" \
27
+ PGPORT=5432 \
28
+ PGUSER=postgres \
29
+ PGPASSWORD="" \
30
+ PGDATABASE="$db" \
31
+ "$@"
32
+ }
33
+
34
+ # Empties every table of the app_admin-owned schemas in the upgrade db: the
35
+ # template's seed rows give way to the source's data. Tables matching
36
+ # DB_DUMP_EXCLUDE (schema-qualified pg_dump patterns, `*` and `?`
37
+ # wildcards) keep theirs, as the dump skips their rows.
38
+ clear_app_data() {
39
+ on_local "$UPGRADE_PGDATABASE" psql -X -q -v ON_ERROR_STOP=1 \
40
+ -v exclude="${DB_DUMP_EXCLUDE:-}" >&2 <<'SQL'
41
+ SET upgrade.dump_exclude = :'exclude';
42
+ -- One pg_dump pattern part as an anchored regex.
43
+ CREATE FUNCTION pg_temp.pattern_regex(part text) RETURNS text
44
+ LANGUAGE sql IMMUTABLE
45
+ RETURN '^(' || replace(replace(replace(lower(part), '$', '\$'), '?', '.'), '*', '.*') || ')$';
46
+ DO $$
47
+ DECLARE tables text;
48
+ BEGIN
49
+ SELECT string_agg(c.oid::regclass::text, ', ') INTO tables
50
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
51
+ WHERE n.nspowner = 'app_admin'::regrole
52
+ AND c.relkind IN ('r', 'p')
53
+ AND NOT EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid)
54
+ AND NOT EXISTS (
55
+ SELECT 1
56
+ FROM regexp_split_to_table(current_setting('upgrade.dump_exclude'), '\s+') AS pattern
57
+ WHERE pattern <> ''
58
+ AND n.nspname ~ pg_temp.pattern_regex(split_part(pattern, '.', 1))
59
+ AND c.relname ~ pg_temp.pattern_regex(
60
+ substr(pattern, strpos(pattern, '.') + 1))
61
+ );
62
+ IF tables IS NOT NULL THEN
63
+ EXECUTE 'TRUNCATE ' || tables;
64
+ END IF;
65
+ END $$;
66
+ SQL
67
+ }
68
+
69
+ while ! on_local postgres pg_isready >&2; do
70
+ echo "Waiting for local db instance..." >&2
71
+ sleep 2
72
+ done
73
+ echo "Local db connected!" >&2
74
+
75
+ if (( ${#migratefiles[@]} )); then
76
+ echo "Copy remote db into the migration db..." >&2
77
+ on_local postgres createdb "$MIGRATE_PGDATABASE" >&2
78
+ # pg_cron loads only via shared_preload_libraries; the throwaway runs stock Postgres.
79
+ # The copy feeds a data-only dump and migrate files run as `postgres`,
80
+ # so owners and grants stay out.
81
+ pg_dump --no-owner --no-privileges --exclude-extension=pg_cron | on_local "$MIGRATE_PGDATABASE" psql -v ON_ERROR_STOP=1 >&2
82
+
83
+ for migratefile in "${migratefiles[@]}"; do
84
+ echo "Migrate: $(basename "$migratefile")..." >&2
85
+ on_local "$MIGRATE_PGDATABASE" psql -v ON_ERROR_STOP=1 < "$migratefile" >&2
86
+ done
87
+ fi
88
+
89
+ echo "Create upgrade db" >&2
90
+ on_local postgres createdb -T "$TEMPLATE_DATABASE" "$UPGRADE_PGDATABASE" >&2
91
+ clear_app_data
92
+
93
+ echo "Copy data into the upgrade db..." >&2
94
+ if (( ${#migratefiles[@]} )); then
95
+ on_local "$MIGRATE_PGDATABASE" /entrypoint.d/dump-db.sh
96
+ else
97
+ /entrypoint.d/dump-db.sh
98
+ fi | on_local "$UPGRADE_PGDATABASE" psql -v ON_ERROR_STOP=1 >&2
99
+
100
+ echo "Dump upgrade db data" >&2
101
+ echo "SET session_replication_role = 'replica';"
102
+ on_local "$UPGRADE_PGDATABASE" pg_dump
103
+ echo "DONE" >&2
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /** `stage-schema --app <module> <dir>`: stages the app's schema for psql
3
+ * under `<dir>` (`PostgresProvider.stageSchema`). */
4
+ import { appArgs } from "@astralarium/repo-deploy-info/server";
5
+ import { loadDevConfig } from "@astralarium/repo-dev";
6
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
7
+ const USAGE = "Usage: stage-schema --app <module> <dir>";
8
+ try {
9
+ const { app, argv } = appArgs(process.argv.slice(2), USAGE, {
10
+ positionals: 1,
11
+ });
12
+ const { database } = await loadDevConfig(app);
13
+ if (!database || !("stageSchema" in database))
14
+ throw new ExpectedError(`${app}: dev config declares no Postgres database`);
15
+ database.stageSchema(argv[0]);
16
+ }
17
+ catch (exception) {
18
+ log.error("stage_schema.failed", "stage-schema failed", { exception });
19
+ process.exitCode = 1;
20
+ }
@@ -0,0 +1,11 @@
1
+ /** Preferred published port; a taken one moves to the next free port up. */
2
+ export declare const DEFAULT_PORT = 5432;
3
+ /** The app's login role, as `sql/roles.sql` creates it: non-superuser;
4
+ * schema access through grants; `app_admin` inherits it. */
5
+ export declare const APP_SERVICE_ROLE = "app_service";
6
+ /** Local connection as `APP_SERVICE_ROLE`. */
7
+ export declare function appConnectionString(port: number, database: string): string;
8
+ /** Local connection as `postgres`. */
9
+ export declare function adminConnectionString(port: number, database: string): string;
10
+ /** Authenticates through the host port and executes a read-only query. */
11
+ export declare function probePostgres(connectionString: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /** Connections to local instances: trust auth on the published loopback port. */
2
+ import postgres from "postgres";
3
+ /** Preferred published port; a taken one moves to the next free port up. */
4
+ export const DEFAULT_PORT = 5432;
5
+ /**
6
+ * Loopback connection string for a local instance. The containers trust
7
+ * every connection on their published loopback port; the password is a
8
+ * placeholder the Cloudflare tooling insists on.
9
+ */
10
+ function localConnectionString(user, port, database) {
11
+ return `postgres://${user}:trust@127.0.0.1:${port}/${database}`;
12
+ }
13
+ /** The app's login role, as `sql/roles.sql` creates it: non-superuser;
14
+ * schema access through grants; `app_admin` inherits it. */
15
+ export const APP_SERVICE_ROLE = "app_service";
16
+ /** Local connection as `APP_SERVICE_ROLE`. */
17
+ export function appConnectionString(port, database) {
18
+ return localConnectionString(APP_SERVICE_ROLE, port, database);
19
+ }
20
+ /** Local connection as `postgres`. */
21
+ export function adminConnectionString(port, database) {
22
+ return localConnectionString("postgres", port, database);
23
+ }
24
+ /** Authenticates through the host port and executes a read-only query. */
25
+ export async function probePostgres(connectionString) {
26
+ const sql = postgres(connectionString, { max: 1, connect_timeout: 5 });
27
+ try {
28
+ await sql `SELECT 1`;
29
+ }
30
+ finally {
31
+ await sql.end({ timeout: 1 });
32
+ }
33
+ }
@@ -0,0 +1,87 @@
1
+ import { type ResourceScope } from "@astralarium/repo-dev";
2
+ /** Image label carrying the `schemaHash()` it was built from. */
3
+ export declare const SCHEMA_LABEL = "db.schema";
4
+ /** Container and volume label naming the checkout that owns it. */
5
+ export declare const OWNER_LABEL = "dev.owner";
6
+ /** `CONTAINER_RUNTIME`, else the first of podman, docker that answers `info`. */
7
+ export declare function detectRuntime(): string;
8
+ /** The container's data was initialised from a schema other than its image's. */
9
+ export declare class StaleSchemaError extends Error {
10
+ constructor(runtime: string, container: string, stamp: string | null);
11
+ }
12
+ /** Existing container: run state, image ID and labels. */
13
+ interface ContainerState {
14
+ running: boolean;
15
+ /** SHA-256 image ID, including the algorithm prefix. */
16
+ image: string;
17
+ /** `SCHEMA_LABEL` value; undefined on an unlabeled image. */
18
+ schema?: string;
19
+ /** `OWNER_LABEL` value; undefined on an unlabeled container. */
20
+ owner?: string;
21
+ /** `ARGS_LABEL` value; undefined on an unlabeled container. */
22
+ args?: string;
23
+ /** Requested loopback publish specification. */
24
+ bindings: {
25
+ HostIp: string;
26
+ HostPort: string;
27
+ }[];
28
+ /** Mount at the Postgres data directory. */
29
+ dataMount?: {
30
+ Type: string;
31
+ Name?: string;
32
+ Destination: string;
33
+ };
34
+ }
35
+ /** One `container inspect`; null for an absent `name`. */
36
+ export declare function inspectContainer(runtime: string, name: string): ContainerState | null;
37
+ /** Persistent data volume of dev instance `name`. */
38
+ export declare function volumeName(name: string): string;
39
+ /** Something holding dev instance `name`: its container or its data volume. */
40
+ interface Holder {
41
+ kind: "container" | "volume";
42
+ /** Container or volume name. */
43
+ name: string;
44
+ /** `OWNER_LABEL` value; undefined when unlabeled. */
45
+ owner?: string;
46
+ }
47
+ /** The existing container and data volume of dev instance `name`. */
48
+ export declare function holders(runtime: string, name: string): Holder[];
49
+ /** Container `name` and its data volume are each absent or labeled as
50
+ * owned by `owner`, so a volume surviving its container stays with the
51
+ * checkout that made it. */
52
+ export declare function claimable(runtime: string, name: string, owner: string): boolean;
53
+ /** `rm -f`; a missing container is fine, any other failure throws. */
54
+ export declare function removeContainer(runtime: string, name: string): void;
55
+ /** `volume rm -f`; a missing volume is fine, any other failure throws. */
56
+ export declare function removeVolume(runtime: string, name: string): void;
57
+ /** Local Postgres instance. */
58
+ interface PostgresContainerOptions {
59
+ /** Container name. */
60
+ name: string;
61
+ /** Immutable image ID from `ensureImage`. */
62
+ image: string;
63
+ /** Schema hash of the image. */
64
+ hash: string;
65
+ /** Published loopback port; omitted lets the runtime assign one. */
66
+ port?: number;
67
+ /** Persistent data volume, created labeled for `owner`; omitted uses the container's disposable storage. */
68
+ volume?: string;
69
+ /** Owning checkout, stored as `OWNER_LABEL` on the container and the volume. */
70
+ owner?: string;
71
+ /** Postgres server arguments appended to the image command. */
72
+ serverArgs?: readonly string[];
73
+ /** Further container labels, applied at creation. */
74
+ labels?: Readonly<Record<string, string>>;
75
+ }
76
+ /**
77
+ * Reuses a container of exactly `image`, `owner`, server arguments and
78
+ * settings, else recreates it (named volumes survive). Resolves once
79
+ * Postgres accepts connections and the data carries `hash`, with the
80
+ * published host port. `resources` stops a persistent container this call
81
+ * started; one already running is left alone. The caller removes an
82
+ * ephemeral one (`volume` unset).
83
+ */
84
+ export declare function startPostgresContainer(runtime: string, resources: ResourceScope, { name, image, hash, port, volume, owner, serverArgs, labels, }: PostgresContainerOptions): Promise<{
85
+ port: number;
86
+ }>;
87
+ export {};
@@ -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>;