@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
@@ -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,15 @@
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
+ /** pg_trgm, for trigram (name) search. */
7
+ export declare const trgm: import("./module.ts").SchemaModule;
8
+ /**
9
+ * pg_cron on persistent instances, running jobs in `db_template`; job
10
+ * `cron-history-ttl` deletes run history past 7 days daily.
11
+ * Disposable instances skip it: its background worker holds
12
+ * `db_template` open, and test databases clone it as a template, which
13
+ * needs it idle.
14
+ */
15
+ export declare const cron: import("./module.ts").SchemaModule;
@@ -0,0 +1,48 @@
1
+ /** The schema modules this package ships: the `app` schema and the
2
+ * extensions apps opt into. */
3
+ import { resolve } from "node:path";
4
+ import { packageRoot } from "@astralarium/repo-package-files";
5
+ import { defineSchemaModule } from "./module.js";
6
+ /** This package's SQL. */
7
+ const SQL_DIR = resolve(packageRoot(import.meta.filename), "sql");
8
+ /** The `app` schema: owned by `app_admin`, used by `app_service`. */
9
+ export const appSchema = defineSchemaModule({
10
+ name: "app",
11
+ root: resolve(SQL_DIR, "app"),
12
+ files: ["schema.sql"],
13
+ });
14
+ /** pg_partman in schema `partman`, granted to `app_admin`; `reset.sql`
15
+ * clears its config for the dropped schemas. */
16
+ export const partman = defineSchemaModule({
17
+ name: "partman",
18
+ root: resolve(SQL_DIR, "extensions/partman"),
19
+ files: ["schema.sql"],
20
+ extensions: ["partman"],
21
+ beforeReset: "before-reset.sql",
22
+ });
23
+ /** pg_trgm, for trigram (name) search. */
24
+ export const trgm = defineSchemaModule({
25
+ name: "trgm",
26
+ root: resolve(SQL_DIR, "extensions/trgm"),
27
+ files: ["schema.sql"],
28
+ });
29
+ /**
30
+ * pg_cron on persistent instances, running jobs in `db_template`; job
31
+ * `cron-history-ttl` deletes run history past 7 days daily.
32
+ * Disposable instances skip it: its background worker holds
33
+ * `db_template` open, and test databases clone it as a template, which
34
+ * needs it idle.
35
+ */
36
+ export const cron = defineSchemaModule({
37
+ name: "cron",
38
+ root: resolve(SQL_DIR, "extensions/cron"),
39
+ files: ["schema.sql"],
40
+ extensions: ["cron"],
41
+ preload: ["pg_cron"],
42
+ serverArgs: [
43
+ "-c",
44
+ "cron.database_name=db_template",
45
+ "-c",
46
+ "cron.use_background_workers=on",
47
+ ],
48
+ });
@@ -0,0 +1,83 @@
1
+ import postgres, { type Fragment, type JSONValue, type SerializableParameter, type Sql, type TransactionSql } from "postgres";
2
+ export { type Fragment, type JSONValue, type Sql, type TransactionSql };
3
+ /** Postgres SQLSTATE codes the apps handle
4
+ * (https://www.postgresql.org/docs/current/errcodes-appendix.html). */
5
+ export declare const SQLSTATE: {
6
+ /** `unique_violation`: a unique constraint refused the row. */
7
+ readonly uniqueViolation: "23505";
8
+ /** `insufficient_privilege`: the role lacks a privilege. */
9
+ readonly insufficientPrivilege: "42501";
10
+ /** `duplicate_database`: `CREATE DATABASE` named an existing database. */
11
+ readonly duplicateDatabase: "42P04";
12
+ };
13
+ /** One of `SQLSTATE`'s codes. */
14
+ export type Sqlstate = (typeof SQLSTATE)[keyof typeof SQLSTATE];
15
+ /** True when `error` is a Postgres error with SQLSTATE `code`; reads the
16
+ * `code` field, so any copy of the Postgres.js package matches. */
17
+ export declare function isSqlstate(error: unknown, code: Sqlstate): boolean;
18
+ /** A pooled client over `connectionString` with `postgres`'s defaults. */
19
+ export declare function sql(connectionString: string): Sql;
20
+ /**
21
+ * A Durable Object's client, kept across its requests: each connection
22
+ * closes after `OBJECT_IDLE_S` idle, so an object past its last query
23
+ * holds none and may hibernate. Hyperdrive owns the real pooling.
24
+ */
25
+ export declare function objectSql(connectionString: string): Sql;
26
+ /**
27
+ * Runs `fn` with a one-shot client, closed once `fn` settles. Workers pins
28
+ * I/O objects to the request that created them, so a Worker's client lives
29
+ * within its request; Hyperdrive owns the real pooling.
30
+ */
31
+ export declare function withSql<T>(connectionString: string, fn: (sql: Sql) => Promise<T>): Promise<T>;
32
+ /** Fragment: `ms` milliseconds as an interval. */
33
+ export declare function msInterval(sql: Sql | TransactionSql, ms: number): postgres.PendingQuery<postgres.Row[]>;
34
+ /** A `WHERE` condition; falsy entries drop out of `and`. */
35
+ export type Condition = Fragment | false | "" | 0 | null | undefined;
36
+ /** Fragment: the truthy `conditions`, each parenthesized, joined by `AND`; `TRUE` when none. */
37
+ export declare function and(sql: Sql | TransactionSql, conditions: readonly Condition[]): postgres.PendingQuery<postgres.Row[]> | postgres.Fragment;
38
+ /**
39
+ * Fragment: `column = value` assignments, comma-separated, for the defined
40
+ * entries of `columns`; `undefined` leaves a column untouched, `null`
41
+ * writes NULL. Renders the same wherever it sits in an `UPDATE … SET`.
42
+ */
43
+ export declare function setDefined(sql: Sql | TransactionSql, columns: Record<string, SerializableParameter | undefined>): postgres.PendingQuery<postgres.Row[]>;
44
+ /** Fragment: the UTC date of `at`. */
45
+ export declare function utcDate(sql: Sql | TransactionSql, at: Date): postgres.PendingQuery<postgres.Row[]>;
46
+ /** A column holding a UUIDv7 id, optionally alias-qualified (`gen.id`):
47
+ * read once per row, where a fragment like `uuidv7()` would generate a
48
+ * new id at each use. */
49
+ export declare class Column {
50
+ readonly name: string;
51
+ constructor(name: string);
52
+ }
53
+ /** `name` as a UUIDv7 id source for `uuidv7Date` and `Uuidv7Key`. */
54
+ export declare function column(name: string): Column;
55
+ /** A UUIDv7 id: a bound value, or a column. */
56
+ export type Uuidv7Id = string | Column;
57
+ /** Fragment: the UTC date of UUIDv7 `id`. */
58
+ export declare function uuidv7Date(sql: Sql | TransactionSql, id: Uuidv7Id): postgres.PendingQuery<postgres.Row[]>;
59
+ /** A UUIDv7 column paired with the date column holding its `uuidv7Date`. */
60
+ export declare class Uuidv7Key {
61
+ /** UUIDv7 column, optionally alias-qualified (`mp.match_id`). */
62
+ readonly id: string;
63
+ /** Date column, optionally alias-qualified (`mp.time_id`). */
64
+ readonly date: string;
65
+ constructor(columns: {
66
+ id: string;
67
+ date: string;
68
+ });
69
+ /** The same columns qualified by table alias `alias`. */
70
+ in(alias: string): Uuidv7Key;
71
+ /** Fragment: the column list `id, date`. */
72
+ columns(sql: Sql | TransactionSql): postgres.PendingQuery<postgres.Row[]>;
73
+ /** Fragment: the value list for `columns`. */
74
+ values(sql: Sql | TransactionSql, id: Uuidv7Id): postgres.PendingQuery<postgres.Row[]>;
75
+ /** Fragment: rows keyed by `id`. */
76
+ eq(sql: Sql | TransactionSql, id: Uuidv7Id): postgres.PendingQuery<postgres.Row[]>;
77
+ /** Fragment: rows keyed before `cursor` in v7 order. */
78
+ before(sql: Sql | TransactionSql, cursor: string): postgres.PendingQuery<postgres.Row[]>;
79
+ /** Fragment: rows keyed after `cursor` in v7 order. */
80
+ after(sql: Sql | TransactionSql, cursor: string): postgres.PendingQuery<postgres.Row[]>;
81
+ /** Fragment: rows whose id timestamp falls in `[since, before)`; the date bounds prune partitions. */
82
+ createdIn(sql: Sql | TransactionSql, since: Date, before: Date): postgres.PendingQuery<postgres.Row[]>;
83
+ }
@@ -0,0 +1,146 @@
1
+ import postgres from "postgres";
2
+ /** Postgres SQLSTATE codes the apps handle
3
+ * (https://www.postgresql.org/docs/current/errcodes-appendix.html). */
4
+ export const SQLSTATE = {
5
+ /** `unique_violation`: a unique constraint refused the row. */
6
+ uniqueViolation: "23505",
7
+ /** `insufficient_privilege`: the role lacks a privilege. */
8
+ insufficientPrivilege: "42501",
9
+ /** `duplicate_database`: `CREATE DATABASE` named an existing database. */
10
+ duplicateDatabase: "42P04",
11
+ };
12
+ /** True when `error` is a Postgres error with SQLSTATE `code`; reads the
13
+ * `code` field, so any copy of the Postgres.js package matches. */
14
+ export function isSqlstate(error, code) {
15
+ return (typeof error === "object" &&
16
+ error !== null &&
17
+ error.code === code);
18
+ }
19
+ /** A pooled client over `connectionString` with `postgres`'s defaults. */
20
+ export function sql(connectionString) {
21
+ return postgres(connectionString);
22
+ }
23
+ /** Seconds an object's idle connection stays open (`objectSql`). */
24
+ const OBJECT_IDLE_S = 10;
25
+ /**
26
+ * A Durable Object's client, kept across its requests: each connection
27
+ * closes after `OBJECT_IDLE_S` idle, so an object past its last query
28
+ * holds none and may hibernate. Hyperdrive owns the real pooling.
29
+ */
30
+ export function objectSql(connectionString) {
31
+ return postgres(connectionString, { idle_timeout: OBJECT_IDLE_S });
32
+ }
33
+ /**
34
+ * Runs `fn` with a one-shot client, closed once `fn` settles. Workers pins
35
+ * I/O objects to the request that created them, so a Worker's client lives
36
+ * within its request; Hyperdrive owns the real pooling.
37
+ */
38
+ export async function withSql(connectionString, fn) {
39
+ const client = postgres(connectionString, { max: 1 });
40
+ try {
41
+ return await fn(client);
42
+ }
43
+ finally {
44
+ await client.end({ timeout: 5 });
45
+ }
46
+ }
47
+ /** Fragment: `ms` milliseconds as an interval. */
48
+ export function msInterval(sql, ms) {
49
+ return sql `(${ms}::float8 * interval '1 millisecond')`;
50
+ }
51
+ /** Fragment: the truthy `conditions`, each parenthesized, joined by `AND`; `TRUE` when none. */
52
+ export function and(sql, conditions) {
53
+ const kept = conditions.filter((c) => !!c);
54
+ if (kept.length === 0)
55
+ return sql `TRUE`;
56
+ return kept
57
+ .slice(1)
58
+ .reduce((joined, c) => sql `${joined} AND (${c})`, sql `(${kept[0]})`);
59
+ }
60
+ /**
61
+ * Fragment: `column = value` assignments, comma-separated, for the defined
62
+ * entries of `columns`; `undefined` leaves a column untouched, `null`
63
+ * writes NULL. Renders the same wherever it sits in an `UPDATE … SET`.
64
+ */
65
+ export function setDefined(sql, columns) {
66
+ const assignments = Object.entries(columns).flatMap(([column, value]) => value === undefined ? [] : [sql `${sql(column)} = ${value}`]);
67
+ if (assignments.length === 0) {
68
+ throw new Error("setDefined: every column is undefined");
69
+ }
70
+ return assignments
71
+ .slice(1)
72
+ .reduce((list, assignment) => sql `${list}, ${assignment}`, assignments[0]);
73
+ }
74
+ /** Fragment: the UTC date of `at`. */
75
+ export function utcDate(sql, at) {
76
+ return sql `(${at} AT TIME ZONE 'UTC')::date`;
77
+ }
78
+ /** A column holding a UUIDv7 id, optionally alias-qualified (`gen.id`):
79
+ * read once per row, where a fragment like `uuidv7()` would generate a
80
+ * new id at each use. */
81
+ export class Column {
82
+ name;
83
+ constructor(name) {
84
+ this.name = name;
85
+ }
86
+ }
87
+ /** `name` as a UUIDv7 id source for `uuidv7Date` and `Uuidv7Key`. */
88
+ export function column(name) {
89
+ return new Column(name);
90
+ }
91
+ /** Fragment: `id` as SQL. */
92
+ function idSql(sql, id) {
93
+ return typeof id === "string" ? sql `${id}` : sql(id.name);
94
+ }
95
+ /** Fragment: the UTC date of UUIDv7 `id`. */
96
+ export function uuidv7Date(sql, id) {
97
+ return sql `(uuid_extract_timestamp((${idSql(sql, id)})::uuid) AT TIME ZONE 'UTC')::date`;
98
+ }
99
+ /** A UUIDv7 column paired with the date column holding its `uuidv7Date`. */
100
+ export class Uuidv7Key {
101
+ /** UUIDv7 column, optionally alias-qualified (`mp.match_id`). */
102
+ id;
103
+ /** Date column, optionally alias-qualified (`mp.time_id`). */
104
+ date;
105
+ constructor(columns) {
106
+ this.id = columns.id;
107
+ this.date = columns.date;
108
+ }
109
+ /** The same columns qualified by table alias `alias`. */
110
+ in(alias) {
111
+ return new Uuidv7Key({
112
+ id: `${alias}.${this.id}`,
113
+ date: `${alias}.${this.date}`,
114
+ });
115
+ }
116
+ /** Fragment: the column list `id, date`. */
117
+ columns(sql) {
118
+ return sql `${sql(this.id)}, ${sql(this.date)}`;
119
+ }
120
+ /** Fragment: the value list for `columns`. */
121
+ values(sql, id) {
122
+ return sql `${idSql(sql, id)}, ${uuidv7Date(sql, id)}`;
123
+ }
124
+ /** Fragment: rows keyed by `id`. */
125
+ eq(sql, id) {
126
+ return sql `${sql(this.id)} = ${idSql(sql, id)}
127
+ AND ${sql(this.date)} = ${uuidv7Date(sql, id)}`;
128
+ }
129
+ /** Fragment: rows keyed before `cursor` in v7 order. */
130
+ before(sql, cursor) {
131
+ return sql `${sql(this.id)} < ${cursor}::uuid
132
+ AND ${sql(this.date)} <= ${uuidv7Date(sql, cursor)}`;
133
+ }
134
+ /** Fragment: rows keyed after `cursor` in v7 order. */
135
+ after(sql, cursor) {
136
+ return sql `${sql(this.id)} > ${cursor}::uuid
137
+ AND ${sql(this.date)} >= ${uuidv7Date(sql, cursor)}`;
138
+ }
139
+ /** Fragment: rows whose id timestamp falls in `[since, before)`; the date bounds prune partitions. */
140
+ createdIn(sql, since, before) {
141
+ return sql `${sql(this.date)} >= ${utcDate(sql, since)}
142
+ AND ${sql(this.date)} <= ${utcDate(sql, before)}
143
+ AND uuid_extract_timestamp(${sql(this.id)}) >= ${since}
144
+ AND uuid_extract_timestamp(${sql(this.id)}) < ${before}`;
145
+ }
146
+ }
@@ -0,0 +1,13 @@
1
+ /** Test support: transactions that roll back, and Vitest hooks for
2
+ * Postgres.js under workerd. */
3
+ import type { Sql, TransactionSql } from "../src/sql.ts";
4
+ /** Runs `fn` in a transaction that always rolls back; resolves to its result. */
5
+ export declare function rolledBack<T>(sql: Sql, fn: (tx: TransactionSql) => Promise<T>): Promise<T>;
6
+ /** Vitest `onUnhandledError` dropping only Postgres.js's close/read race
7
+ * under workerd (https://github.com/porsager/postgres/issues/1196): a
8
+ * `read` on a socket the driver already closed. */
9
+ export declare function ignorePostgresCloseRace(error: {
10
+ name?: string;
11
+ message: string;
12
+ stack?: string;
13
+ }): false | undefined;
@@ -0,0 +1,31 @@
1
+ /** Thrown out of `rolledBack`'s transaction to roll it back. */
2
+ class Rollback extends Error {
3
+ result;
4
+ constructor(result) {
5
+ super("rolledBack: rollback");
6
+ this.result = result;
7
+ }
8
+ }
9
+ /** Runs `fn` in a transaction that always rolls back; resolves to its result. */
10
+ export function rolledBack(sql, fn) {
11
+ return sql
12
+ .begin(async (tx) => {
13
+ throw new Rollback(await fn(tx));
14
+ })
15
+ .catch((error) => {
16
+ if (error instanceof Rollback)
17
+ return error.result;
18
+ throw error;
19
+ });
20
+ }
21
+ /** Vitest `onUnhandledError` dropping only Postgres.js's close/read race
22
+ * under workerd (https://github.com/porsager/postgres/issues/1196): a
23
+ * `read` on a socket the driver already closed. */
24
+ export function ignorePostgresCloseRace(error) {
25
+ const firstFrame = error.stack?.split("\n")[1]?.trim() ?? "";
26
+ if (error.name === "TypeError" &&
27
+ error.message === "This socket has been closed." &&
28
+ /^at read \(.+\/node_modules\/postgres\/cf\/polyfills\.js(?:\?[^:]*)?:\d+:\d+\)$/.test(firstFrame)) {
29
+ return false;
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ import type { TestContainer } from "@astralarium/repo-dev";
2
+ import type { SchemaModule } from "../src/schema/module.ts";
3
+ /** The SQL of `module`'s `kind` file; throws when it declares none. */
4
+ export declare function moduleScript(module: SchemaModule, kind: "scrub" | "beforeReset"): string;
5
+ /**
6
+ * Test: the image's staged `reset.sql` runs unchanged, drops every schema
7
+ * `app_admin` owns and reloads the app's schema; the staged `scrub.sql`
8
+ * then runs on the reloaded schema in one transaction, as a snapshot runs
9
+ * it. Runs on scratch database `app`, a clone of `db_template` granted by
10
+ * the staged `database-grants.sql`, on the instance of
11
+ * `adminConnectionString` (a superuser connection); one call per package,
12
+ * as each package's run owns its instance.
13
+ */
14
+ export declare function testSchemaReset(adminConnectionString: string, container: TestContainer | undefined): void;
@@ -0,0 +1,89 @@
1
+ /** Test support: the image's staged `reset.sql` and `scrub.sql`, run as a
2
+ * deploy and a snapshot run them; a module's `scrub` and `beforeReset`
3
+ * SQL. */
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { ExpectedError } from "@astralarium/repo-telemetry";
7
+ import { expect, it } from "vitest";
8
+ import { withSql } from "../src/sql.js";
9
+ /** Database `reset.sql` connects to. */
10
+ const RESET_DATABASE = "app";
11
+ /** Staged schema directory the image bakes (`container/init`). */
12
+ const STAGED_SCHEMA = "/docker-entrypoint-initdb.d/schema";
13
+ /** The SQL of `module`'s `kind` file; throws when it declares none. */
14
+ export function moduleScript(module, kind) {
15
+ const file = module[kind];
16
+ if (!file)
17
+ throw new Error(`schema module ${module.name} declares no ${kind}`);
18
+ return readFileSync(file, "utf8");
19
+ }
20
+ /** Schemas `app_admin` owns, sorted. */
21
+ async function ownedSchemas(sql) {
22
+ const rows = await sql `
23
+ SELECT nspname FROM pg_namespace
24
+ WHERE nspowner = to_regrole('app_admin')
25
+ ORDER BY nspname
26
+ `;
27
+ return rows.map(({ nspname }) => nspname);
28
+ }
29
+ /** Runs the staged `file` with psql in `container`, connected to
30
+ * `database`, cwd = the staged directory (so `\i` and `\copy` resolve);
31
+ * `args` go before the connection options. */
32
+ function psql(container, database, file, ...args) {
33
+ execFileSync(container.runtime, [
34
+ "exec",
35
+ "-i",
36
+ "-w",
37
+ STAGED_SCHEMA,
38
+ "--env",
39
+ "PGOPTIONS=-c client_min_messages=warning",
40
+ container.name,
41
+ "psql",
42
+ "-X",
43
+ "-q",
44
+ "-v",
45
+ "ON_ERROR_STOP=1",
46
+ ...args,
47
+ "-d",
48
+ database,
49
+ "-f",
50
+ file,
51
+ ], { stdio: ["ignore", "ignore", "inherit"] });
52
+ }
53
+ /**
54
+ * Test: the image's staged `reset.sql` runs unchanged, drops every schema
55
+ * `app_admin` owns and reloads the app's schema; the staged `scrub.sql`
56
+ * then runs on the reloaded schema in one transaction, as a snapshot runs
57
+ * it. Runs on scratch database `app`, a clone of `db_template` granted by
58
+ * the staged `database-grants.sql`, on the instance of
59
+ * `adminConnectionString` (a superuser connection); one call per package,
60
+ * as each package's run owns its instance.
61
+ */
62
+ export function testSchemaReset(adminConnectionString, container) {
63
+ it("a reset drops every schema app_admin owns and reloads the app's schema; the staged scrub.sql runs on it", async () => {
64
+ if (!container)
65
+ throw new ExpectedError("testSchemaReset needs a containerized test instance (dbContainer)");
66
+ const instanceUrl = new URL(adminConnectionString);
67
+ instanceUrl.pathname = "/postgres";
68
+ const appUrl = new URL(adminConnectionString);
69
+ appUrl.pathname = `/${RESET_DATABASE}`;
70
+ await withSql(instanceUrl.href, async (instance) => {
71
+ await instance `CREATE DATABASE ${instance(RESET_DATABASE)} TEMPLATE db_template`;
72
+ try {
73
+ psql(container, RESET_DATABASE, "database-grants.sql");
74
+ await withSql(appUrl.href, async (app) => {
75
+ const before = await ownedSchemas(app);
76
+ expect(before).not.toEqual([]);
77
+ // An extra app_admin schema the drop must clear.
78
+ await app `CREATE SCHEMA reset_probe AUTHORIZATION app_admin`;
79
+ psql(container, "postgres", "reset.sql");
80
+ expect(await ownedSchemas(app)).toEqual(before);
81
+ psql(container, RESET_DATABASE, "scrub.sql", "--single-transaction");
82
+ });
83
+ }
84
+ finally {
85
+ await instance `DROP DATABASE ${instance(RESET_DATABASE)} WITH (FORCE)`;
86
+ }
87
+ });
88
+ });
89
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@astralarium/repo-postgres",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-postgres#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/astralarium/repo.git",
9
+ "directory": "packages/repo-postgres"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "container",
15
+ "scripts",
16
+ "sql"
17
+ ],
18
+ "bin": {
19
+ "postgres-dump-db": "./scripts/dump-db.sh",
20
+ "stage-schema": "./dist/bin/stage-schema.js"
21
+ },
22
+ "exports": {
23
+ "./provision": "./dist/src/provision/index.js",
24
+ "./schema": "./dist/src/schema/index.js",
25
+ "./sql": "./dist/src/sql.js",
26
+ "./testkit": "./dist/testkit/index.js",
27
+ "./testkit/schema": "./dist/testkit/schema.js"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "postgres": "^3.4.9",
34
+ "zod": "^4.6.7",
35
+ "@astralarium/repo-telemetry": "^0.1.0",
36
+ "@astralarium/repo-package-files": "^0.1.0",
37
+ "@astralarium/repo-dev": "^0.1.0",
38
+ "@astralarium/repo-deploy-info": "^0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.2.0",
42
+ "eslint": "^10.2.1",
43
+ "typescript": "^6.0.3",
44
+ "vitest": "^5.0.3",
45
+ "@astralarium/repo-config-eslint": "^0.1.0",
46
+ "@astralarium/repo-config-typescript": "^0.1.0",
47
+ "@astralarium/repo-config-vitest": "^0.1.0"
48
+ },
49
+ "peerDependencies": {
50
+ "vitest": "^5.0.3"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "vitest": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "engines": {
58
+ "node": ">=24.2"
59
+ },
60
+ "scripts": {
61
+ "check-types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
62
+ "lint": "eslint",
63
+ "test": "vitest"
64
+ }
6
65
  }