@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,130 @@
1
+ /** Postgres instance per test run; a database per project. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { output, succeeds, } from "@astralarium/repo-dev";
5
+ import { ExpectedError } from "@astralarium/repo-telemetry";
6
+ import { isSqlstate, SQLSTATE, withSql } from "../sql.js";
7
+ import { adminConnectionString, appConnectionString, probePostgres, } from "./connection.js";
8
+ import { removeContainer, startPostgresContainer } from "./container.js";
9
+ import { dataDigest, loadData } from "./data.js";
10
+ import { prepareTestImage, readTestImage, schemaHash, } from "./image.js";
11
+ /** Name prefix of every test instance's container. */
12
+ const TEST_PREFIX = "db-test-";
13
+ /** Test container label carrying the pid of the process that owns it. */
14
+ const TEST_OWNER_LABEL = "db.test-pid";
15
+ /** Running seeded instance; fixtures may run tools in its container or network namespace. */
16
+ export class PostgresTestDatabase {
17
+ /** Published loopback port. */
18
+ port;
19
+ /** Container running the instance; other containers join its network
20
+ * namespace. */
21
+ container;
22
+ constructor(instance) {
23
+ this.port = instance.port;
24
+ this.container = instance.container;
25
+ }
26
+ /**
27
+ * `CREATE DATABASE … TEMPLATE db_template`; returns the `app_service`
28
+ * connection. A project whose name maps to an existing database is an
29
+ * `ExpectedError`.
30
+ */
31
+ async createDatabase(name) {
32
+ const database = testDatabaseName(name);
33
+ try {
34
+ await withSql(adminConnectionString(this.port, "postgres"), (sql) => sql `CREATE DATABASE ${sql(database)} TEMPLATE db_template`);
35
+ }
36
+ catch (cause) {
37
+ if (isSqlstate(cause, SQLSTATE.duplicateDatabase))
38
+ throw new ExpectedError(`Test project ${name} maps to database ${database}, which another project already uses; rename one`, { cause });
39
+ throw cause;
40
+ }
41
+ return appConnectionString(this.port, database);
42
+ }
43
+ /** `postgres` connection to the project database. */
44
+ adminConnectionString(name) {
45
+ return adminConnectionString(this.port, testDatabaseName(name));
46
+ }
47
+ }
48
+ /**
49
+ * The DB_TEST_IMAGE manifest when built for `options.name`, an
50
+ * `ExpectedError` when malformed or built from other schema or data
51
+ * sources; otherwise, the file unset or missing included, the schema
52
+ * image prepared locally.
53
+ */
54
+ function testImage(options) {
55
+ const file = process.env.DB_TEST_IMAGE;
56
+ const supplied = file && existsSync(file) ? readTestImage(file) : undefined;
57
+ if (supplied?.name !== options.name)
58
+ return prepareTestImage(options);
59
+ if (supplied.hash !== schemaHash(options) ||
60
+ supplied.data !== dataDigest(options.data ?? []))
61
+ throw new ExpectedError(`${file} was prepared from other schema or data sources than the current ones; rerun the app's db:image task to regenerate it`);
62
+ return supplied;
63
+ }
64
+ /** Process `pid` has exited. */
65
+ function exited(pid) {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return false;
69
+ }
70
+ catch (error) {
71
+ return error.code === "ESRCH";
72
+ }
73
+ }
74
+ /**
75
+ * Removes test containers left by earlier runs whose `TEST_OWNER_LABEL`
76
+ * process has exited. Best effort; a container another run removes first
77
+ * is fine.
78
+ */
79
+ function sweepTestInstances(runtime) {
80
+ const listed = output(runtime, [
81
+ "ps",
82
+ "-a",
83
+ "--filter",
84
+ `name=^${TEST_PREFIX}`,
85
+ "--format",
86
+ `{{.Names}}\t{{.Label "${TEST_OWNER_LABEL}"}}`,
87
+ ]);
88
+ for (const line of listed?.split("\n") ?? []) {
89
+ const [name, label] = line.split("\t");
90
+ if (!name?.startsWith(TEST_PREFIX))
91
+ continue;
92
+ const pid = Number(label);
93
+ if (Number.isInteger(pid) && pid > 0 && exited(pid))
94
+ succeeds(runtime, ["rm", "-f", "-v", name]);
95
+ }
96
+ }
97
+ /**
98
+ * Sweeps stale test containers (`sweepTestInstances`), then starts
99
+ * `db-test-<name>-<uuid>`, labeled with this process's pid, on an assigned
100
+ * loopback port and loads the app's data into `db_template` (`testImage`).
101
+ * The run's resources remove the container and its data at teardown.
102
+ */
103
+ export async function startTestInstance(resources, name, options) {
104
+ resources.signal.throwIfAborted();
105
+ const { runtime, hash, image } = testImage(options);
106
+ sweepTestInstances(runtime);
107
+ const container = `${TEST_PREFIX}${name}-${randomUUID()}`;
108
+ resources.own({ dispose: () => removeContainer(runtime, container) });
109
+ const { port } = await startPostgresContainer(runtime, resources, {
110
+ name: container,
111
+ hash,
112
+ image,
113
+ serverArgs: options.ephemeralArgs,
114
+ labels: { [TEST_OWNER_LABEL]: String(process.pid) },
115
+ });
116
+ const connectionString = adminConnectionString(port, "db_template");
117
+ await probePostgres(connectionString);
118
+ await loadData(connectionString, options.data ?? []);
119
+ return new PostgresTestDatabase({
120
+ port,
121
+ container: { runtime, name: container },
122
+ });
123
+ }
124
+ /** SQL identifier derived from a nonempty project name. */
125
+ function testDatabaseName(name) {
126
+ const database = name.replace(/[^a-z0-9_]/gi, "_").toLowerCase();
127
+ if (!database || database.length > 63)
128
+ throw new Error("Test project names must contain 1–63 characters");
129
+ return database;
130
+ }
@@ -0,0 +1,86 @@
1
+ import { type Sql, type TransactionSql } from "./sql.ts";
2
+ /** A Cloudflare Queues producer binding, as `enqueueClaimed` sends. */
3
+ export interface Queue<Body> {
4
+ send(body: Body): Promise<unknown>;
5
+ }
6
+ /** A Cloudflare Queues message, as `consumeClaimed` settles it. */
7
+ export interface QueueMessage<Body> {
8
+ readonly body: Body;
9
+ /** Deliveries so far, this one included: 1 on the first. */
10
+ readonly attempts: number;
11
+ ack(): void;
12
+ retry(options?: {
13
+ delaySeconds?: number;
14
+ }): void;
15
+ }
16
+ /** A Cloudflare Queues consumer batch. */
17
+ export interface MessageBatch<Body> {
18
+ /** Queue name. */
19
+ readonly queue: string;
20
+ readonly messages: readonly QueueMessage<Body>[];
21
+ }
22
+ /** A claimed task's queue message body. */
23
+ export interface ClaimedTask<Task> {
24
+ /** Claim key (`claimTask`). */
25
+ key: string;
26
+ /** Claim token (`claimTask`). */
27
+ claim: string;
28
+ /** Claim window (`claimTask`); a held retry's claim ends one window past
29
+ * its due time. */
30
+ windowSeconds: number;
31
+ task: Task;
32
+ }
33
+ /** Claims `key` for `windowSeconds`: a fresh token, or undefined while an
34
+ * unexpired claim holds it. */
35
+ export declare function claimTask(sql: Sql | TransactionSql, key: string, { windowSeconds }: {
36
+ windowSeconds: number;
37
+ }): Promise<string | undefined>;
38
+ /** Holds `key`'s claim by `token` through a retry due in `delaySeconds`,
39
+ * then `windowSeconds` more, reclaiming a freed key; a later claim
40
+ * stays. */
41
+ export declare function holdTask(sql: Sql | TransactionSql, key: string, token: string, { delaySeconds, windowSeconds, }: {
42
+ delaySeconds: number;
43
+ windowSeconds: number;
44
+ }): Promise<void>;
45
+ /** Frees `key`'s claim by `token`; a later claim stays. */
46
+ export declare function releaseTask(sql: Sql | TransactionSql, key: string, token: string): Promise<void>;
47
+ /** Sends `task` to `queue` under a fresh claim of `key` (`claimTask`);
48
+ * resolves to the claim token, undefined while a claim holds the key.
49
+ * A failed send releases the claim and throws the send's error; a failed
50
+ * release logs, its claim held for the window. Takes a client outside any
51
+ * transaction: the claim commits before the send. */
52
+ export declare function enqueueClaimed<Task>(sql: Sql, queue: Queue<ClaimedTask<Task>>, key: string, task: Task, options: {
53
+ windowSeconds: number;
54
+ }): Promise<string | undefined>;
55
+ /** How `consumeClaimed` runs a batch's tasks. */
56
+ export interface ConsumeClaimedOptions<Task> {
57
+ /** The task a message carries; undefined or a throw for one it cannot
58
+ * run. */
59
+ parse: (task: unknown) => Task | undefined;
60
+ /** Runs one task; a throw fails the attempt. Idempotent: a task can run
61
+ * more than once. */
62
+ run: (task: Task) => Promise<void>;
63
+ /** The consumer's `max_retries`: retries after the first delivery, an
64
+ * integer from 0. */
65
+ maxRetries: number;
66
+ /** Milliseconds a `run` throw asks to wait (e.g. `Retry-After`): a finite
67
+ * number from 0, or undefined for none. Another value or a throw counts
68
+ * as none. */
69
+ retryAfterMs?: (exception: unknown) => number | undefined;
70
+ }
71
+ /**
72
+ * Queue consumer of claimed tasks, messages in order. A run acks, keeping
73
+ * its claim until the window ends: a trigger inside the window coalesces.
74
+ * A failure retries after 30 s, doubling each attempt, or a longer
75
+ * `retryAfterMs`, up to 12 hours, holding the claim through the retry
76
+ * (`holdTask`). The last failure logs, releases the claim and acks; so does
77
+ * a task `parse` rejects or throws on. A body lacking a claim logs and acks.
78
+ * A failed claim write logs and the delivery settles as decided: a failed
79
+ * hold retries, a failed release acks, the key claimed for the window. The
80
+ * batch continues. Delivery is at-least-once (a queue redelivery reruns a
81
+ * task): `run` must be idempotent. Takes a client
82
+ * outside any transaction: each claim write commits before its message
83
+ * settles. Rejects with a `RangeError` for a `maxRetries` other than an
84
+ * integer from 0.
85
+ */
86
+ export declare function consumeClaimed<Task>(sql: Sql, batch: MessageBatch<unknown>, options: ConsumeClaimedOptions<Task>): Promise<void>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Claimed tasks on Cloudflare Queues: a task key queues once per claim
3
+ * window, its claim (`taskclaim.claim`) held through retries. Needs the
4
+ * `taskclaim` schema module; `taskclaimJobs` deletes expired claims.
5
+ *
6
+ * `enqueueClaimed` and `consumeClaimed` take a client outside any
7
+ * transaction. `Sql` rejects a `begin` transaction's client; a reserved
8
+ * connection inside a manual `BEGIN` type-checks, so the requirement rests
9
+ * on the caller.
10
+ */
11
+ import { log } from "@astralarium/repo-telemetry";
12
+ import { msInterval } from "./sql.js";
13
+ /** Claims `key` for `windowSeconds`: a fresh token, or undefined while an
14
+ * unexpired claim holds it. */
15
+ export async function claimTask(sql, key, { windowSeconds }) {
16
+ const [claimed] = await sql `
17
+ INSERT INTO taskclaim.claim AS c (key, expires_at, token)
18
+ VALUES (
19
+ ${key},
20
+ CURRENT_TIMESTAMP + ${msInterval(sql, windowSeconds * 1000)},
21
+ gen_random_uuid()
22
+ )
23
+ ON CONFLICT (key) DO UPDATE SET
24
+ expires_at = EXCLUDED.expires_at,
25
+ token = EXCLUDED.token
26
+ WHERE c.expires_at < CURRENT_TIMESTAMP
27
+ RETURNING token
28
+ `;
29
+ return claimed?.token;
30
+ }
31
+ /** Holds `key`'s claim by `token` through a retry due in `delaySeconds`,
32
+ * then `windowSeconds` more, reclaiming a freed key; a later claim
33
+ * stays. */
34
+ export async function holdTask(sql, key, token, { delaySeconds, windowSeconds, }) {
35
+ const held = msInterval(sql, (delaySeconds + windowSeconds) * 1000);
36
+ await sql `
37
+ INSERT INTO taskclaim.claim AS c (key, expires_at, token)
38
+ VALUES (${key}, CURRENT_TIMESTAMP + ${held}, ${token})
39
+ ON CONFLICT (key) DO UPDATE SET
40
+ expires_at = EXCLUDED.expires_at
41
+ WHERE c.token = EXCLUDED.token
42
+ `;
43
+ }
44
+ /** Frees `key`'s claim by `token`; a later claim stays. */
45
+ export async function releaseTask(sql, key, token) {
46
+ await sql `
47
+ DELETE FROM taskclaim.claim
48
+ WHERE key = ${key} AND token = ${token}
49
+ `;
50
+ }
51
+ /** Sends `task` to `queue` under a fresh claim of `key` (`claimTask`);
52
+ * resolves to the claim token, undefined while a claim holds the key.
53
+ * A failed send releases the claim and throws the send's error; a failed
54
+ * release logs, its claim held for the window. Takes a client outside any
55
+ * transaction: the claim commits before the send. */
56
+ export async function enqueueClaimed(sql, queue, key, task, options) {
57
+ const claim = await claimTask(sql, key, options);
58
+ if (!claim)
59
+ return undefined;
60
+ try {
61
+ await queue.send({
62
+ key,
63
+ claim,
64
+ windowSeconds: options.windowSeconds,
65
+ task,
66
+ });
67
+ }
68
+ catch (sendException) {
69
+ try {
70
+ await releaseTask(sql, key, claim);
71
+ }
72
+ catch (exception) {
73
+ log.error("queue.claim_release_failed", "task {key} send failed, then its claim release; key claimed for the window", { key, exception });
74
+ }
75
+ throw sendException;
76
+ }
77
+ return claim;
78
+ }
79
+ /** Longest delay of a queue retry: Cloudflare Queues' 12 hours. */
80
+ const MAX_RETRY_DELAY_SECONDS = 43_200;
81
+ /** Seconds before the retry of a task's `attempts`th failed delivery: 30,
82
+ * doubling each attempt, or a longer `retryAfterMs`; at most
83
+ * `MAX_RETRY_DELAY_SECONDS`. A non-finite or negative `retryAfterMs`
84
+ * counts as none. */
85
+ function retryDelaySeconds(attempts, retryAfterMs) {
86
+ const backoff = 30 * 2 ** (attempts - 1);
87
+ const retryAfter = retryAfterMs !== undefined &&
88
+ Number.isFinite(retryAfterMs) &&
89
+ retryAfterMs >= 0
90
+ ? Math.ceil(retryAfterMs / 1000)
91
+ : 0;
92
+ return Math.min(Math.max(backoff, retryAfter), MAX_RETRY_DELAY_SECONDS);
93
+ }
94
+ /** Milliseconds `retryAfterMs` asks to wait after `exception`; undefined
95
+ * for none or a throw. */
96
+ function requestedWaitMs(retryAfterMs, exception) {
97
+ try {
98
+ return retryAfterMs?.(exception);
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ /** A claim token: a uuid, as `taskclaim.claim.token` stores it. */
105
+ const CLAIM_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
106
+ /** `body` as a claimed task envelope; undefined for another shape. */
107
+ function envelopeOf(body) {
108
+ if (typeof body !== "object" || body === null)
109
+ return undefined;
110
+ const { key, claim, windowSeconds, task } = body;
111
+ return typeof key === "string" &&
112
+ typeof claim === "string" &&
113
+ CLAIM_TOKEN.test(claim) &&
114
+ typeof windowSeconds === "number" &&
115
+ Number.isFinite(windowSeconds) &&
116
+ windowSeconds >= 0
117
+ ? { key, claim, windowSeconds, task }
118
+ : undefined;
119
+ }
120
+ /**
121
+ * Queue consumer of claimed tasks, messages in order. A run acks, keeping
122
+ * its claim until the window ends: a trigger inside the window coalesces.
123
+ * A failure retries after 30 s, doubling each attempt, or a longer
124
+ * `retryAfterMs`, up to 12 hours, holding the claim through the retry
125
+ * (`holdTask`). The last failure logs, releases the claim and acks; so does
126
+ * a task `parse` rejects or throws on. A body lacking a claim logs and acks.
127
+ * A failed claim write logs and the delivery settles as decided: a failed
128
+ * hold retries, a failed release acks, the key claimed for the window. The
129
+ * batch continues. Delivery is at-least-once (a queue redelivery reruns a
130
+ * task): `run` must be idempotent. Takes a client
131
+ * outside any transaction: each claim write commits before its message
132
+ * settles. Rejects with a `RangeError` for a `maxRetries` other than an
133
+ * integer from 0.
134
+ */
135
+ export async function consumeClaimed(sql, batch, options) {
136
+ const { maxRetries } = options;
137
+ if (!(Number.isInteger(maxRetries) && maxRetries >= 0))
138
+ throw new RangeError(`maxRetries must be an integer from 0: ${maxRetries}`);
139
+ const { queue } = batch;
140
+ for (const message of batch.messages) {
141
+ const envelope = envelopeOf(message.body);
142
+ if (!envelope) {
143
+ log.warn("queue.message_malformed", "{queue} message lacks a task claim; acked", { queue });
144
+ message.ack();
145
+ continue;
146
+ }
147
+ const { key, claim, windowSeconds } = envelope;
148
+ const settlement = await attempt(queue, envelope, message, options);
149
+ try {
150
+ if (!settlement.ack) {
151
+ await holdTask(sql, key, claim, {
152
+ delaySeconds: settlement.delaySeconds,
153
+ windowSeconds,
154
+ });
155
+ }
156
+ else if (settlement.release) {
157
+ await releaseTask(sql, key, claim);
158
+ }
159
+ }
160
+ catch (exception) {
161
+ if (settlement.ack)
162
+ log.error("queue.claim_release_failed", "{queue} task {key} claim release failed; acked, key claimed for the window", { queue, key, exception });
163
+ else
164
+ log.error("queue.claim_hold_failed", "{queue} task {key} claim hold failed; retrying", { queue, key, exception });
165
+ }
166
+ if (settlement.ack)
167
+ message.ack();
168
+ else
169
+ message.retry({ delaySeconds: settlement.delaySeconds });
170
+ }
171
+ }
172
+ /** Parses and runs `envelope`'s task, logging an unparseable or finally
173
+ * failed one; resolves to the delivery's settlement. */
174
+ async function attempt(queue, { key, task: body }, { attempts }, { parse, run, maxRetries, retryAfterMs }) {
175
+ let task;
176
+ try {
177
+ task = parse(body);
178
+ }
179
+ catch (exception) {
180
+ log.warn("queue.task_unparseable", "{queue} task {key} unparseable; releasing its claim and acking", { queue, key, exception });
181
+ return { ack: true, release: true };
182
+ }
183
+ if (task === undefined) {
184
+ log.warn("queue.task_unparseable", "{queue} task {key} unparseable; releasing its claim and acking", { queue, key });
185
+ return { ack: true, release: true };
186
+ }
187
+ try {
188
+ await run(task);
189
+ return { ack: true, release: false };
190
+ }
191
+ catch (exception) {
192
+ if (attempts <= maxRetries) {
193
+ return {
194
+ ack: false,
195
+ delaySeconds: retryDelaySeconds(attempts, requestedWaitMs(retryAfterMs, exception)),
196
+ };
197
+ }
198
+ log.error("queue.task_failed", "{queue} task {key} failed after {attempts, plural, one {# attempt} other {# attempts}}", { queue, key, attempts, exception });
199
+ return { ack: true, release: true };
200
+ }
201
+ }
@@ -0,0 +1,3 @@
1
+ /** Schema modules: each package's slice of the app database. */
2
+ export { defineSchemaModule, type SchemaModule, type SchemaModuleDeclaration, } from "./module.ts";
3
+ export { appSchema, cron, partman, slug, taskclaim, taskclaimJobs, trgm, } from "./modules.ts";
@@ -0,0 +1,3 @@
1
+ /** Schema modules: each package's slice of the app database. */
2
+ export { defineSchemaModule, } from "./module.js";
3
+ export { appSchema, cron, partman, slug, taskclaim, taskclaimJobs, trgm, } from "./modules.js";
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ /** What a package declares for its slice of the app database. */
3
+ export interface SchemaModuleDeclaration {
4
+ /** Unique per install; names the module's staged directory. */
5
+ name: string;
6
+ /** Absolute directory `files`, `assets`, `beforeReset` and `scrub`
7
+ * resolve from: an app's SQL directory, or a package's
8
+ * `packageRoot(import.meta.filename)`. */
9
+ root: string;
10
+ /** SQL in load order, relative to `root`; basenames unique across `files`
11
+ * and `assets`. psql runs it connected to the app database, after the
12
+ * roles exist, with the staged directory as cwd: this module's files and
13
+ * assets sit at `sql/<name>/<basename>`. `reset.sql` runs every module's
14
+ * SQL inside one transaction. */
15
+ files: readonly string[];
16
+ /** Files the module's SQL reads via psql (e.g. `\copy` CSVs), relative to
17
+ * `root`, any extension; staged beside `files`. */
18
+ assets?: readonly string[];
19
+ /** Modules loading first; also those the module's queries read at runtime. */
20
+ requires?: readonly SchemaModule[];
21
+ /** Extensions the image installs: Debian `postgresql-<major>-<name>` packages. */
22
+ extensions?: readonly string[];
23
+ /** `shared_preload_libraries` entries for persistent instances. */
24
+ preload?: readonly string[];
25
+ /** Further Postgres server arguments for persistent instances. */
26
+ serverArgs?: readonly string[];
27
+ /** SQL relative to `root`, run as `app_admin` inside `reset.sql`'s
28
+ * transaction before it drops the schemas. */
29
+ beforeReset?: string;
30
+ /** SQL relative to `root`, run by `run-snapshot` as superuser in
31
+ * `db_template` after the dump loads, with the staged directory as cwd;
32
+ * every module's scrub runs inside one transaction. Clears
33
+ * environment-bound state (credentials, deploy state, build-keyed rows). */
34
+ scrub?: string;
35
+ }
36
+ /** A declared module: paths absolute, every list present. */
37
+ export interface SchemaModule {
38
+ readonly name: string;
39
+ readonly root: string;
40
+ readonly files: readonly string[];
41
+ readonly assets: readonly string[];
42
+ readonly requires: readonly SchemaModule[];
43
+ readonly extensions: readonly string[];
44
+ readonly preload: readonly string[];
45
+ readonly serverArgs: readonly string[];
46
+ readonly beforeReset?: string;
47
+ readonly scrub?: string;
48
+ }
49
+ /** Non-empty strings, e.g. extensions or server arguments. */
50
+ export declare const stringList: z.ZodReadonly<z.ZodArray<z.ZodString>>;
51
+ /** A value shaped as `defineSchemaModule` returns. */
52
+ export declare function isSchemaModule(value: unknown): value is SchemaModule;
53
+ /** A schema module; the declaration is validated (`ExpectedError`). */
54
+ export declare function defineSchemaModule(module: SchemaModuleDeclaration): SchemaModule;
55
+ /**
56
+ * `modules` and their `requires` closure in load order. One name on two
57
+ * module objects (two installed copies of a package) is an `ExpectedError`.
58
+ */
59
+ export declare function loadOrder(modules: readonly SchemaModule[]): SchemaModule[];
@@ -0,0 +1,122 @@
1
+ /** Schema modules: a package's slice of the app database, loaded after
2
+ * the modules it requires. psql runs module SQL from the staged schema
3
+ * directory (`@astralarium/repo-postgres/provision` `stageSchema`). */
4
+ import { basename, isAbsolute, resolve } from "node:path";
5
+ import { ExpectedError } from "@astralarium/repo-telemetry";
6
+ import { z } from "zod";
7
+ /** Module and staged directory names. */
8
+ const MODULE_NAME = /^[a-z][a-z0-9_-]*$/;
9
+ const moduleName = z.string().regex(MODULE_NAME, {
10
+ error: "name must be lowercase letters, digits, - and _",
11
+ });
12
+ const absoluteRoot = z
13
+ .string()
14
+ .refine(isAbsolute, { error: "root must be absolute" });
15
+ const sqlPath = z
16
+ .string()
17
+ .refine((path) => !isAbsolute(path) && path.endsWith(".sql"), {
18
+ error: "must be a relative .sql path",
19
+ });
20
+ const relativePath = z
21
+ .string()
22
+ .min(1)
23
+ .refine((path) => !isAbsolute(path), { error: "must be a relative path" });
24
+ const absolutePath = z
25
+ .string()
26
+ .refine(isAbsolute, { error: "must be an absolute path" });
27
+ const absoluteSqlPath = z
28
+ .string()
29
+ .refine((path) => isAbsolute(path) && path.endsWith(".sql"), {
30
+ error: "must be an absolute .sql path",
31
+ });
32
+ /** Non-empty strings, e.g. extensions or server arguments. */
33
+ export const stringList = z.array(z.string().min(1)).readonly();
34
+ /** `SchemaModule`'s shape; any installed copy of this package matches. */
35
+ const schemaModule = z.object({
36
+ name: moduleName,
37
+ root: absoluteRoot,
38
+ files: z.array(absoluteSqlPath).readonly(),
39
+ assets: z.array(absolutePath).readonly(),
40
+ get requires() {
41
+ return z.array(schemaModule).readonly();
42
+ },
43
+ extensions: stringList,
44
+ preload: stringList,
45
+ serverArgs: stringList,
46
+ beforeReset: absoluteSqlPath.optional(),
47
+ scrub: absoluteSqlPath.optional(),
48
+ });
49
+ /** A value shaped as `defineSchemaModule` returns. */
50
+ export function isSchemaModule(value) {
51
+ return schemaModule.safeParse(value).success;
52
+ }
53
+ const declaration = z
54
+ .object({
55
+ name: moduleName,
56
+ root: absoluteRoot,
57
+ files: z.array(sqlPath).readonly(),
58
+ assets: z.array(relativePath).readonly().optional(),
59
+ requires: z
60
+ .array(z.custom(isSchemaModule, {
61
+ error: "requires must hold defineSchemaModule results",
62
+ }))
63
+ .readonly()
64
+ .optional(),
65
+ extensions: stringList.optional(),
66
+ preload: stringList.optional(),
67
+ serverArgs: stringList.optional(),
68
+ beforeReset: sqlPath.optional(),
69
+ scrub: sqlPath.optional(),
70
+ })
71
+ .refine(({ files, assets = [] }) => {
72
+ const paths = [...files, ...assets];
73
+ return new Set(paths.map((path) => basename(path))).size === paths.length;
74
+ }, { error: "files and assets must have unique basenames" });
75
+ /** A schema module; the declaration is validated (`ExpectedError`). */
76
+ export function defineSchemaModule(module) {
77
+ const result = declaration.safeParse(module);
78
+ if (!result.success)
79
+ throw new ExpectedError(`invalid schema module ${module.name}:\n${z.prettifyError(result.error)}`);
80
+ const { root } = module;
81
+ return Object.freeze({
82
+ name: module.name,
83
+ root,
84
+ files: module.files.map((file) => resolve(root, file)),
85
+ assets: (module.assets ?? []).map((asset) => resolve(root, asset)),
86
+ requires: [...(module.requires ?? [])],
87
+ extensions: [...(module.extensions ?? [])],
88
+ preload: [...(module.preload ?? [])],
89
+ serverArgs: [...(module.serverArgs ?? [])],
90
+ ...(module.beforeReset && {
91
+ beforeReset: resolve(root, module.beforeReset),
92
+ }),
93
+ ...(module.scrub && { scrub: resolve(root, module.scrub) }),
94
+ });
95
+ }
96
+ /**
97
+ * `modules` and their `requires` closure in load order. One name on two
98
+ * module objects (two installed copies of a package) is an `ExpectedError`.
99
+ */
100
+ export function loadOrder(modules) {
101
+ const order = [];
102
+ const seen = new Set();
103
+ // Declarations reference already-defined modules: the graph is acyclic.
104
+ const visit = (module) => {
105
+ if (seen.has(module))
106
+ return;
107
+ seen.add(module);
108
+ for (const required of module.requires)
109
+ visit(required);
110
+ order.push(module);
111
+ };
112
+ for (const module of modules)
113
+ visit(module);
114
+ const named = new Map();
115
+ for (const module of order) {
116
+ const held = named.get(module.name);
117
+ if (held)
118
+ throw new ExpectedError(`schema module ${module.name} is declared twice, from ${held.root} and ${module.root}; install one copy of its package`);
119
+ named.set(module.name, module);
120
+ }
121
+ return order;
122
+ }
@@ -0,0 +1,24 @@
1
+ /** The `app` schema: owned by `app_admin`, used by `app_service`. */
2
+ export declare const appSchema: import("./module.ts").SchemaModule;
3
+ /** pg_partman in schema `partman`, granted to `app_admin`; `reset.sql`
4
+ * clears its config for the dropped schemas. */
5
+ export declare const partman: import("./module.ts").SchemaModule;
6
+ /** Schema `slug`: `slug.slug(name)`, a name's URL slug (NFC, lowercase
7
+ * alphanumerics, spaces as `-`), immutable. */
8
+ export declare const slug: import("./module.ts").SchemaModule;
9
+ /** Schema `taskclaim`: table `taskclaim.claim`, one claim per task key,
10
+ * for `@astralarium/repo-postgres/queue`, and `taskclaim.delete_expired()`;
11
+ * a snapshot's scrub empties it. */
12
+ export declare const taskclaim: import("./module.ts").SchemaModule;
13
+ /** pg_trgm, for trigram (name) search. */
14
+ export declare const trgm: import("./module.ts").SchemaModule;
15
+ /**
16
+ * pg_cron on persistent instances, running jobs in `db_template`; job
17
+ * `cron-history-ttl` deletes run history past 7 days daily.
18
+ * Disposable instances skip it: its background worker holds
19
+ * `db_template` open, and test databases clone it as a template, which
20
+ * needs it idle.
21
+ */
22
+ export declare const cron: import("./module.ts").SchemaModule;
23
+ /** pg_cron job `taskclaim-ttl`: `taskclaim.delete_expired()` hourly. */
24
+ export declare const taskclaimJobs: import("./module.ts").SchemaModule;