@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 +34 -4
- package/dist/index.d.ts +28 -2
- package/dist/index.js +43 -5
- package/package.json +5 -5
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
|
|
6
|
-
5 per pod)
|
|
7
|
-
connection, so a rotated password does not
|
|
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
|
|
8
|
-
* the realtime LISTEN connection. The migrate
|
|
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
|
|
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": "
|
|
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": "^
|
|
42
|
-
"@viktorbarzin/runtime": "^
|
|
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": "^
|
|
47
|
-
"@viktorbarzin/runtime": "^
|
|
46
|
+
"@viktorbarzin/log": "^1.0.0",
|
|
47
|
+
"@viktorbarzin/runtime": "^1.0.0",
|
|
48
48
|
"drizzle-orm": "^0.45.4"
|
|
49
49
|
}
|
|
50
50
|
}
|