@viktorbarzin/pg 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @viktorbarzin/pg
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
+ Drizzle on Bun's built-in SQL client for an app on a shared Postgres:
4
+
5
+ - a pool of at most 4 (`APP_POOL_MAX`; with the realtime LISTEN connection,
6
+ 5 per pod) that reads the password from a mounted file on every new
7
+ connection, so a rotated password does not break new connections;
8
+ - migrations under a session `pg_advisory_lock` (drizzle's migrator takes
9
+ none), a migrate CLI for an init container, and throwaway test databases.
10
+
11
+ <!-- typecheck-preamble
12
+ import { pgTable, text } from "drizzle-orm/pg-core";
13
+ const items = pgTable("items", { title: text("title") });
14
+ const schema = { items };
15
+ -->
16
+ ```ts
17
+ import { createPool, migrationLockKey, pgSettingsFromEnv, runMigrateCli } from "@viktorbarzin/pg";
18
+
19
+ export const pool = createPool({ settings: () => pgSettingsFromEnv(), schema });
20
+ await pool.getDb().select().from(items);
21
+
22
+ // entrypoints/migrate.ts, bundled with `bun build --target bun`
23
+ process.exitCode = await runMigrateCli({ lockKey: migrationLockKey("myapp") });
24
+ ```
25
+
26
+ | Export | |
27
+ |---|---|
28
+ | `pgSettingsFromEnv(env?)` | `DATABASE_URL` (required) and `DATABASE_PASSWORD_FILE` |
29
+ | `createSqlClient(settings, { max?, idleTimeout? })` | a Bun `SQL` client; idle connections kept by default |
30
+ | `createPool({ settings, schema, key? })` | lazy, once per process: `getDb()`, `getClient()`, `close()` |
31
+ | `migrationLockKey(appName)` | the first 8 bytes of sha256(`migrate:<app>`) as a signed int64 |
32
+ | `runMigrations(client, opts)` | `{ applied }`; the client must have `max: 1`; `beforeMigrate` runs under the lock |
33
+ | `runMigrateCli({ lockKey, beforeMigrate?, env? })` | reads `DATABASE_URL`, `DATABASE_PASSWORD_FILE`, `MIGRATIONS_DIR` (default `./drizzle`); returns the exit code |
34
+ | `@viktorbarzin/pg/testing`: `createTestDatabase`, `migratedTestDatabase` | a randomly named database on `adminUrl`, dropped by `drop()` |
35
+
36
+ Requires Bun >= 1.4.3. Peers: `drizzle-orm` ^0.45, `@viktorbarzin/log`, `@viktorbarzin/runtime`.
@@ -0,0 +1,69 @@
1
+ import { type Logger } from "@viktorbarzin/log";
2
+ import { SQL } from "bun";
3
+ import { type BunSQLDatabase } from "drizzle-orm/bun-sql";
4
+ export interface PgSettings {
5
+ /** DATABASE_URL, normally without a password, e.g. `postgres://app@pg-host:5432/app`. */
6
+ url: string;
7
+ /** DATABASE_PASSWORD_FILE: a mounted file with the rotated password; null uses the URL's password. */
8
+ passwordFile: string | null;
9
+ }
10
+ /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. */
11
+ export declare const APP_POOL_MAX = 4;
12
+ /**
13
+ * The platform env contract the Terraform app module sets: DATABASE_URL
14
+ * (required) and DATABASE_PASSWORD_FILE.
15
+ */
16
+ export declare function pgSettingsFromEnv(env?: Record<string, string | undefined>): PgSettings;
17
+ /** The mounted password file's contents, without one trailing newline. */
18
+ export declare function readPasswordFile(path: string): Promise<string>;
19
+ export declare function createSqlClient(settings: PgSettings, opts?: {
20
+ max?: number;
21
+ idleTimeout?: number;
22
+ }): SQL;
23
+ export interface Pool<TSchema extends Record<string, unknown>> {
24
+ getDb(): BunSQLDatabase<TSchema>;
25
+ getClient(): SQL;
26
+ close(): Promise<void>;
27
+ }
28
+ /**
29
+ * The app's pool, once per process (keyed by `key`): lazy, so nothing
30
+ * connects until the first getDb() or getClient().
31
+ */
32
+ export declare function createPool<TSchema extends Record<string, unknown>>(opts: {
33
+ settings: () => PgSettings;
34
+ schema: TSchema;
35
+ key?: string;
36
+ }): Pool<TSchema>;
37
+ /** A per-app advisory-lock key: the first 8 bytes of sha256(`migrate:<app>`) as a signed int64. */
38
+ export declare function migrationLockKey(appName: string): bigint;
39
+ export interface MigrateOptions {
40
+ migrationsFolder: string;
41
+ lockKey: bigint;
42
+ migrationsSchema?: string;
43
+ migrationsTable?: string;
44
+ /** Runs under the lock, before the migrator (e.g. marking a pulled baseline as applied). */
45
+ beforeMigrate?: (client: SQL) => Promise<void>;
46
+ logger?: Logger;
47
+ }
48
+ /**
49
+ * Run the migrations in `migrationsFolder` under a session advisory lock.
50
+ * The client must have a pool of exactly one connection, so the lock, the
51
+ * migrator's transaction and the unlock share one session.
52
+ */
53
+ export declare function runMigrations(client: SQL, opts: MigrateOptions): Promise<{
54
+ applied: number;
55
+ }>;
56
+ /**
57
+ * The migrate entrypoint's body, for an init container or Job:
58
+ *
59
+ * process.exitCode = await runMigrateCli({ lockKey })
60
+ *
61
+ * Reads DATABASE_URL, DATABASE_PASSWORD_FILE and MIGRATIONS_DIR (default
62
+ * ./drizzle). Returns the exit code.
63
+ */
64
+ export declare function runMigrateCli(opts: {
65
+ lockKey: bigint;
66
+ beforeMigrate?: MigrateOptions["beforeMigrate"];
67
+ env?: Record<string, string | undefined>;
68
+ logger?: Logger;
69
+ }): Promise<number>;
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Postgres access for an app on the shared CNPG cluster: Drizzle on Bun's
3
+ * built-in SQL client (`drizzle-orm/bun-sql`).
4
+ *
5
+ * - The password comes from DATABASE_PASSWORD_FILE, read each time a new
6
+ * connection opens, so a Vault rotation does not break new connections.
7
+ * - An app pod keeps at most 5 connections: the pool (APP_POOL_MAX = 4) plus
8
+ * the realtime LISTEN connection. The migrate entrypoint uses 1.
9
+ * - Nothing connects at import time: the first getDb() call opens the pool.
10
+ * - drizzle-orm's migrator takes no lock of its own, so runMigrations holds a
11
+ * session-level pg_advisory_lock around it.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { resolve } from "node:path";
15
+ import { getLogger } from "@viktorbarzin/log";
16
+ import { processSingleton } from "@viktorbarzin/runtime";
17
+ import { SQL } from "bun";
18
+ import { drizzle } from "drizzle-orm/bun-sql";
19
+ import { migrate } from "drizzle-orm/bun-sql/migrator";
20
+ /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. */
21
+ export const APP_POOL_MAX = 4;
22
+ function nonEmpty(value) {
23
+ return value === undefined || value.trim() === "" ? null : value.trim();
24
+ }
25
+ /**
26
+ * The platform env contract the Terraform app module sets: DATABASE_URL
27
+ * (required) and DATABASE_PASSWORD_FILE.
28
+ */
29
+ export function pgSettingsFromEnv(env = process.env) {
30
+ const url = nonEmpty(env.DATABASE_URL);
31
+ if (url === null)
32
+ throw new Error("DATABASE_URL is not set");
33
+ return { url, passwordFile: nonEmpty(env.DATABASE_PASSWORD_FILE) };
34
+ }
35
+ /** The mounted password file's contents, without one trailing newline. */
36
+ export async function readPasswordFile(path) {
37
+ const text = await Bun.file(path).text();
38
+ return text.replace(/\r?\n$/, "");
39
+ }
40
+ export function createSqlClient(settings, opts = {}) {
41
+ const { passwordFile } = settings;
42
+ return new SQL({
43
+ url: settings.url,
44
+ max: opts.max ?? APP_POOL_MAX,
45
+ idleTimeout: opts.idleTimeout ?? 0,
46
+ ...(passwordFile ? { password: () => readPasswordFile(passwordFile) } : {}),
47
+ });
48
+ }
49
+ /**
50
+ * The app's pool, once per process (keyed by `key`): lazy, so nothing
51
+ * connects until the first getDb() or getClient().
52
+ */
53
+ export function createPool(opts) {
54
+ const slot = processSingleton(opts.key ?? "db.pool", () => ({
55
+ shared: null,
56
+ }));
57
+ const open = () => {
58
+ if (slot.shared === null) {
59
+ const client = createSqlClient(opts.settings());
60
+ slot.shared = { client, db: drizzle({ client, schema: opts.schema }) };
61
+ }
62
+ return slot.shared;
63
+ };
64
+ return {
65
+ getDb: () => open().db,
66
+ getClient: () => open().client,
67
+ async close() {
68
+ if (slot.shared === null)
69
+ return;
70
+ const { client } = slot.shared;
71
+ slot.shared = null;
72
+ await client.close();
73
+ },
74
+ };
75
+ }
76
+ /** A per-app advisory-lock key: the first 8 bytes of sha256(`migrate:<app>`) as a signed int64. */
77
+ export function migrationLockKey(appName) {
78
+ const digest = createHash("sha256").update(`migrate:${appName}`).digest();
79
+ return BigInt.asIntN(64, digest.readBigUInt64BE(0));
80
+ }
81
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
82
+ async function appliedCount(client, schema, table) {
83
+ const [exists] = await client `
84
+ select to_regclass(${`"${schema}"."${table}"`}) is not null as found`;
85
+ if (!exists.found)
86
+ return 0;
87
+ const [row] = await client.unsafe(`select count(*)::int as n from "${schema}"."${table}"`);
88
+ return row.n;
89
+ }
90
+ /**
91
+ * Run the migrations in `migrationsFolder` under a session advisory lock.
92
+ * The client must have a pool of exactly one connection, so the lock, the
93
+ * migrator's transaction and the unlock share one session.
94
+ */
95
+ export async function runMigrations(client, opts) {
96
+ const log = opts.logger ?? getLogger("pg.migrate");
97
+ const schema = opts.migrationsSchema ?? "drizzle";
98
+ const table = opts.migrationsTable ?? "__drizzle_migrations";
99
+ if (!IDENTIFIER.test(schema) || !IDENTIFIER.test(table))
100
+ throw new Error("migrations schema and table must be plain identifiers");
101
+ log.info("waiting for migration lock", { lock_key: opts.lockKey });
102
+ await client `select pg_advisory_lock(${opts.lockKey})`;
103
+ try {
104
+ if (opts.beforeMigrate)
105
+ await opts.beforeMigrate(client);
106
+ const before = await appliedCount(client, schema, table);
107
+ await migrate(drizzle({ client }), {
108
+ migrationsFolder: opts.migrationsFolder,
109
+ migrationsSchema: schema,
110
+ migrationsTable: table,
111
+ });
112
+ const applied = (await appliedCount(client, schema, table)) - before;
113
+ log.info("migrations complete", { applied });
114
+ return { applied };
115
+ }
116
+ finally {
117
+ await client `select pg_advisory_unlock(${opts.lockKey})`;
118
+ }
119
+ }
120
+ /**
121
+ * The migrate entrypoint's body, for an init container or Job:
122
+ *
123
+ * process.exitCode = await runMigrateCli({ lockKey })
124
+ *
125
+ * Reads DATABASE_URL, DATABASE_PASSWORD_FILE and MIGRATIONS_DIR (default
126
+ * ./drizzle). Returns the exit code.
127
+ */
128
+ export async function runMigrateCli(opts) {
129
+ const env = opts.env ?? process.env;
130
+ const log = opts.logger ?? getLogger("pg.migrate");
131
+ const folder = resolve(nonEmpty(env.MIGRATIONS_DIR) ?? "drizzle");
132
+ let client = null;
133
+ try {
134
+ client = createSqlClient(pgSettingsFromEnv(env), { max: 1 });
135
+ await runMigrations(client, {
136
+ migrationsFolder: folder,
137
+ lockKey: opts.lockKey,
138
+ logger: log,
139
+ ...(opts.beforeMigrate ? { beforeMigrate: opts.beforeMigrate } : {}),
140
+ });
141
+ return 0;
142
+ }
143
+ catch (err) {
144
+ log.exception("migration failed", err, { migrations_dir: folder });
145
+ return 1;
146
+ }
147
+ finally {
148
+ await client?.close().catch(() => { });
149
+ }
150
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Throwaway Postgres databases for tests. Point `adminUrl` (TEST_DATABASE_URL
3
+ * by convention) at a server where that role may create databases; each
4
+ * database gets a random name and is dropped with `drop()`.
5
+ */
6
+ import { SQL } from "bun";
7
+ export interface TestDatabase {
8
+ url: string;
9
+ drop(): Promise<void>;
10
+ }
11
+ export declare function createTestDatabase(opts: {
12
+ adminUrl: string;
13
+ prefix?: string;
14
+ }): Promise<TestDatabase>;
15
+ export interface MigratedTestDatabase extends TestDatabase {
16
+ /** A client on the new database (pool of 2). */
17
+ client: SQL;
18
+ }
19
+ /** A new database with the app's migrations applied. `drop()` closes the client too. */
20
+ export declare function migratedTestDatabase(opts: {
21
+ adminUrl: string;
22
+ migrationsFolder: string;
23
+ lockKey: bigint;
24
+ prefix?: string;
25
+ }): Promise<MigratedTestDatabase>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Throwaway Postgres databases for tests. Point `adminUrl` (TEST_DATABASE_URL
3
+ * by convention) at a server where that role may create databases; each
4
+ * database gets a random name and is dropped with `drop()`.
5
+ */
6
+ import { SQL } from "bun";
7
+ import { runMigrations } from "./index.js";
8
+ const NAME_PREFIX = /^[a-z][a-z0-9_]*$/;
9
+ export async function createTestDatabase(opts) {
10
+ const prefix = opts.prefix ?? "test";
11
+ if (!NAME_PREFIX.test(prefix))
12
+ throw new Error("prefix must be lowercase letters, digits and _");
13
+ const name = `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
14
+ const admin = new SQL(opts.adminUrl);
15
+ try {
16
+ await admin.unsafe(`create database ${name}`);
17
+ }
18
+ finally {
19
+ await admin.close();
20
+ }
21
+ const url = new URL(opts.adminUrl);
22
+ url.pathname = `/${name}`;
23
+ return {
24
+ url: url.toString(),
25
+ async drop() {
26
+ const cleanup = new SQL(opts.adminUrl);
27
+ try {
28
+ await cleanup.unsafe(`drop database if exists ${name} with (force)`);
29
+ }
30
+ finally {
31
+ await cleanup.close();
32
+ }
33
+ },
34
+ };
35
+ }
36
+ /** A new database with the app's migrations applied. `drop()` closes the client too. */
37
+ export async function migratedTestDatabase(opts) {
38
+ const db = await createTestDatabase({
39
+ adminUrl: opts.adminUrl,
40
+ ...(opts.prefix ? { prefix: opts.prefix } : {}),
41
+ });
42
+ const migrator = new SQL({ url: db.url, max: 1 });
43
+ try {
44
+ await runMigrations(migrator, {
45
+ migrationsFolder: opts.migrationsFolder,
46
+ lockKey: opts.lockKey,
47
+ });
48
+ }
49
+ finally {
50
+ await migrator.close();
51
+ }
52
+ const client = new SQL({ url: db.url, max: 2 });
53
+ return {
54
+ url: db.url,
55
+ client,
56
+ async drop() {
57
+ await client.close();
58
+ await db.drop();
59
+ },
60
+ };
61
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
- "name": "@viktorbarzin/pg",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@viktorbarzin/pg",
3
+ "version": "1.0.0",
4
+ "description": "Drizzle on Bun SQL: a small pool that re-reads a rotated password file, migrations under an advisory lock, a migrate CLI and throwaway test databases.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./testing": {
15
+ "types": "./dist/testing.d.ts",
16
+ "default": "./dist/testing.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "bun": ">=1.4.3"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/ViktorBarzin/app-kit.git",
29
+ "directory": "packages/pg"
30
+ },
31
+ "homepage": "https://github.com/ViktorBarzin/app-kit/tree/master/packages/pg#readme",
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -b",
38
+ "test": "bun test"
39
+ },
40
+ "peerDependencies": {
41
+ "@viktorbarzin/log": "^1.0.0",
42
+ "@viktorbarzin/runtime": "^1.0.0",
43
+ "drizzle-orm": "^0.45.0"
44
+ },
45
+ "devDependencies": {
46
+ "@viktorbarzin/log": "^1.0.0",
47
+ "@viktorbarzin/runtime": "^1.0.0",
48
+ "drizzle-orm": "^0.45.4"
49
+ }
50
+ }