@viktorbarzin/pg 0.1.0 → 1.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.
package/README.md CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  Drizzle on Bun's built-in SQL client for an app on a shared Postgres:
4
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;
5
+ - a pool of at most 4 by default (`APP_POOL_MAX`; with the realtime LISTEN
6
+ connection, 5 per pod), or the size you pass, that reads the password from
7
+ a mounted file on every new connection, so a rotated password does not
8
+ break new connections;
9
+ - the per-pod connection budget for a role with a connection limit: half the
10
+ limit (a rollout overlaps two pods), and realtime only when that leaves at
11
+ least 2;
8
12
  - migrations under a session `pg_advisory_lock` (drizzle's migrator takes
9
13
  none), a migrate CLI for an init container, and throwaway test databases.
10
14
 
@@ -23,11 +27,37 @@ await pool.getDb().select().from(items);
23
27
  process.exitCode = await runMigrateCli({ lockKey: migrationLockKey("myapp") });
24
28
  ```
25
29
 
30
+ On a platform that limits the role's connections
31
+ (`CLOUD_DATABASE_CONNECTION_LIMIT`), size the pool from the budget and keep
32
+ realtime off when it says so:
33
+
34
+ <!-- typecheck-preamble
35
+ import { pgTable, text } from "drizzle-orm/pg-core";
36
+ const items = pgTable("items", { title: text("title") });
37
+ const schema = { items };
38
+ -->
39
+ ```ts
40
+ import { connectionBudgetFromEnv, createPool, pgSettingsFromEnv } from "@viktorbarzin/pg";
41
+
42
+ const budget = connectionBudgetFromEnv(); // limit 2: { perPod: 1, poolMax: 1, realtime: false }
43
+ export const pool = createPool({ settings: () => pgSettingsFromEnv(), schema, max: budget.poolMax });
44
+ export const realtimeOn = budget.realtime;
45
+ ```
46
+
47
+ | Limit (Cloud Size) | Per pod | Pool | Realtime LISTEN |
48
+ |---|---|---|---|
49
+ | none | 5 | 4 | 1 |
50
+ | 2 (nano) | 1 | 1 | off |
51
+ | 4 (small) | 2 | 1 | 1 |
52
+ | 8 (medium) | 4 | 3 | 1 |
53
+
26
54
  | Export | |
27
55
  |---|---|
28
56
  | `pgSettingsFromEnv(env?)` | `DATABASE_URL` (required) and `DATABASE_PASSWORD_FILE` |
29
57
  | `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()` |
58
+ | `createPool({ settings, schema, key?, max? })` | lazy, once per process: `getDb()`, `getClient()`, `close()`; `max` (a number or a function, default `APP_POOL_MAX`) is read when the pool opens |
59
+ | `connectionBudget(limit)` | `{ perPod, poolMax, realtime }`: `floor(limit / 2)` per pod, realtime when that is at least 2; `null` gives the default 4 + 1; a limit under 2 throws |
60
+ | `connectionBudgetFromEnv(env?)` | the same for `CLOUD_DATABASE_CONNECTION_LIMIT` (unset: no limit; not a whole number: throws) |
31
61
  | `migrationLockKey(appName)` | the first 8 bytes of sha256(`migrate:<app>`) as a signed int64 |
32
62
  | `runMigrations(client, opts)` | `{ applied }`; the client must have `max: 1`; `beforeMigrate` runs under the lock |
33
63
  | `runMigrateCli({ lockKey, beforeMigrate?, env? })` | reads `DATABASE_URL`, `DATABASE_PASSWORD_FILE`, `MIGRATIONS_DIR` (default `./drizzle`); returns the exit code |
package/dist/index.d.ts CHANGED
@@ -7,8 +7,32 @@ export interface PgSettings {
7
7
  /** DATABASE_PASSWORD_FILE: a mounted file with the rotated password; null uses the URL's password. */
8
8
  passwordFile: string | null;
9
9
  }
10
- /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. */
10
+ /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. The default pool size. */
11
11
  export declare const APP_POOL_MAX = 4;
12
+ /** How many Postgres connections one app pod may hold, and how it spends them. */
13
+ export interface ConnectionBudget {
14
+ /** Every connection the pod may hold: the pool plus the LISTEN connection. */
15
+ perPod: number;
16
+ /** The pool's `max`. */
17
+ poolMax: number;
18
+ /** Whether the pod may hold a realtime LISTEN connection (one of perPod). */
19
+ realtime: boolean;
20
+ }
21
+ /**
22
+ * The per-pod budget for a role whose connection limit is `limit` (null: no
23
+ * limit given, the default pool of APP_POOL_MAX plus one LISTEN connection).
24
+ *
25
+ * A pod gets half the limit, rounded down: during a rollout a surge pod runs
26
+ * beside the old one, and the migrate container connects while the old pod
27
+ * still holds its connections. Realtime takes one of them only when at least
28
+ * two are left; with one, realtime is off and the pool has it.
29
+ */
30
+ export declare function connectionBudget(limit: number | null): ConnectionBudget;
31
+ /**
32
+ * connectionBudget() for the platform contract's
33
+ * CLOUD_DATABASE_CONNECTION_LIMIT (a whole number; unset or empty: no limit).
34
+ */
35
+ export declare function connectionBudgetFromEnv(env?: Record<string, string | undefined>): ConnectionBudget;
12
36
  /**
13
37
  * The platform env contract the Terraform app module sets: DATABASE_URL
14
38
  * (required) and DATABASE_PASSWORD_FILE.
@@ -27,12 +51,14 @@ export interface Pool<TSchema extends Record<string, unknown>> {
27
51
  }
28
52
  /**
29
53
  * The app's pool, once per process (keyed by `key`): lazy, so nothing
30
- * connects until the first getDb() or getClient().
54
+ * connects until the first getDb() or getClient(). `max` (default
55
+ * APP_POOL_MAX) is read when the pool opens.
31
56
  */
32
57
  export declare function createPool<TSchema extends Record<string, unknown>>(opts: {
33
58
  settings: () => PgSettings;
34
59
  schema: TSchema;
35
60
  key?: string;
61
+ max?: number | (() => number);
36
62
  }): Pool<TSchema>;
37
63
  /** A per-app advisory-lock key: the first 8 bytes of sha256(`migrate:<app>`) as a signed int64. */
38
64
  export declare function migrationLockKey(appName: string): bigint;
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * - The password comes from DATABASE_PASSWORD_FILE, read each time a new
6
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.
7
+ * - An app pod keeps at most 5 connections by default: the pool
8
+ * (APP_POOL_MAX = 4) plus the realtime LISTEN connection. The migrate
9
+ * entrypoint uses 1. A platform with a smaller per-role connection limit
10
+ * sizes the pool with `max` and connectionBudget().
9
11
  * - Nothing connects at import time: the first getDb() call opens the pool.
10
12
  * - drizzle-orm's migrator takes no lock of its own, so runMigrations holds a
11
13
  * session-level pg_advisory_lock around it.
@@ -17,8 +19,38 @@ import { processSingleton } from "@viktorbarzin/runtime";
17
19
  import { SQL } from "bun";
18
20
  import { drizzle } from "drizzle-orm/bun-sql";
19
21
  import { migrate } from "drizzle-orm/bun-sql/migrator";
20
- /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. */
22
+ /** ADR budget: 5 connections per pod, one of them the realtime LISTEN. The default pool size. */
21
23
  export const APP_POOL_MAX = 4;
24
+ /**
25
+ * The per-pod budget for a role whose connection limit is `limit` (null: no
26
+ * limit given, the default pool of APP_POOL_MAX plus one LISTEN connection).
27
+ *
28
+ * A pod gets half the limit, rounded down: during a rollout a surge pod runs
29
+ * beside the old one, and the migrate container connects while the old pod
30
+ * still holds its connections. Realtime takes one of them only when at least
31
+ * two are left; with one, realtime is off and the pool has it.
32
+ */
33
+ export function connectionBudget(limit) {
34
+ if (limit === null)
35
+ return { perPod: APP_POOL_MAX + 1, poolMax: APP_POOL_MAX, realtime: true };
36
+ const perPod = Number.isInteger(limit) ? Math.floor(limit / 2) : 0;
37
+ if (perPod < 1)
38
+ throw new Error(`a connection limit of ${limit} leaves no connection per pod (at least 2 needed)`);
39
+ const realtime = perPod >= 2;
40
+ return { perPod, poolMax: realtime ? perPod - 1 : perPod, realtime };
41
+ }
42
+ /**
43
+ * connectionBudget() for the platform contract's
44
+ * CLOUD_DATABASE_CONNECTION_LIMIT (a whole number; unset or empty: no limit).
45
+ */
46
+ export function connectionBudgetFromEnv(env = process.env) {
47
+ const raw = nonEmpty(env.CLOUD_DATABASE_CONNECTION_LIMIT);
48
+ if (raw === null)
49
+ return connectionBudget(null);
50
+ if (!/^[0-9]+$/.test(raw))
51
+ throw new Error(`CLOUD_DATABASE_CONNECTION_LIMIT must be a whole number (got "${raw}")`);
52
+ return connectionBudget(Number(raw));
53
+ }
22
54
  function nonEmpty(value) {
23
55
  return value === undefined || value.trim() === "" ? null : value.trim();
24
56
  }
@@ -48,7 +80,8 @@ export function createSqlClient(settings, opts = {}) {
48
80
  }
49
81
  /**
50
82
  * The app's pool, once per process (keyed by `key`): lazy, so nothing
51
- * connects until the first getDb() or getClient().
83
+ * connects until the first getDb() or getClient(). `max` (default
84
+ * APP_POOL_MAX) is read when the pool opens.
52
85
  */
53
86
  export function createPool(opts) {
54
87
  const slot = processSingleton(opts.key ?? "db.pool", () => ({
@@ -56,7 +89,12 @@ export function createPool(opts) {
56
89
  }));
57
90
  const open = () => {
58
91
  if (slot.shared === null) {
59
- const client = createSqlClient(opts.settings());
92
+ const max = typeof opts.max === "function"
93
+ ? opts.max()
94
+ : (opts.max ?? APP_POOL_MAX);
95
+ if (!Number.isInteger(max) || max < 1)
96
+ throw new Error(`pool max must be a whole number >= 1 (got ${max})`);
97
+ const client = createSqlClient(opts.settings(), { max });
60
98
  slot.shared = { client, db: drizzle({ client, schema: opts.schema }) };
61
99
  }
62
100
  return slot.shared;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viktorbarzin/pg",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,13 +38,13 @@
38
38
  "test": "bun test"
39
39
  },
40
40
  "peerDependencies": {
41
- "@viktorbarzin/log": "^0.1.0",
42
- "@viktorbarzin/runtime": "^0.1.0",
41
+ "@viktorbarzin/log": "^1.0.0",
42
+ "@viktorbarzin/runtime": "^1.0.0",
43
43
  "drizzle-orm": "^0.45.0"
44
44
  },
45
45
  "devDependencies": {
46
- "@viktorbarzin/log": "^0.1.0",
47
- "@viktorbarzin/runtime": "^0.1.0",
46
+ "@viktorbarzin/log": "^1.0.0",
47
+ "@viktorbarzin/runtime": "^1.0.0",
48
48
  "drizzle-orm": "^0.45.4"
49
49
  }
50
50
  }