@ultimat3/db 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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +43 -0
- package/src/branch.ts +136 -0
- package/src/client.ts +237 -0
- package/src/drift.ts +148 -0
- package/src/errors.ts +150 -0
- package/src/fake.ts +79 -0
- package/src/generate.ts +310 -0
- package/src/index.ts +129 -0
- package/src/introspect.ts +187 -0
- package/src/migrate.ts +228 -0
- package/src/pglite-branch.ts +84 -0
- package/src/pglite-turns.ts +51 -0
- package/src/pglite.ts +200 -0
- package/src/readonly-query.ts +155 -0
- package/src/readonly-role.ts +116 -0
- package/src/readonly.ts +111 -0
- package/src/sql.ts +152 -0
- package/src/transaction.ts +124 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @ultimat3/db 🐘
|
|
2
|
+
|
|
3
|
+
Postgres access for Ultimate: parameterised SQL, transactions, migrations, drift detection,
|
|
4
|
+
branch databases, and a read-only client for anything an LLM drives.
|
|
5
|
+
|
|
6
|
+
Tier 1. Imports `@ultimat3/core` only. No runtime dependencies; `@electric-sql/pglite` is an
|
|
7
|
+
**optional peer**, loaded at first query and only by the embedded driver. **No ORM backs any of
|
|
8
|
+
this** — `@ultimat3/entity`'s hand-written `postgresDriver()` compiles every statement out of the
|
|
9
|
+
`sql` / `identifier` / `join` fragments below; this package declares the narrow structural types it
|
|
10
|
+
consumes (`DbClient`, `SqlFragment`, `EntityDescriptionLike`) so the SQL stays readable and the
|
|
11
|
+
boundary stays thin.
|
|
12
|
+
|
|
13
|
+
## Public API
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { db, sql, raw, withTransaction, currentTx, readOnly, setDbClient } from '@ultimat3/db';
|
|
17
|
+
|
|
18
|
+
const rows = await db().query<Post>(sql`select * from posts where org_id = ${orgId}`);
|
|
19
|
+
|
|
20
|
+
await withTransaction(async (tx) => {
|
|
21
|
+
await tx.execute(sql`update posts set likes = likes + 1 where id = ${id}`);
|
|
22
|
+
tx.onRollback(() => cache.restore(id)); // fires in reverse order on rollback
|
|
23
|
+
}, { isolation: 'serializable', readOnly: false });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Export | |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
29
|
+
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
30
|
+
| `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam |
|
|
31
|
+
| `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
|
|
32
|
+
| `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report |
|
|
33
|
+
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL |
|
|
34
|
+
| `introspect()` | live schema → `SchemaDescription` |
|
|
35
|
+
| `createBranch()` / `dropBranch()` / `reapBranches()` | copy-on-write branch databases |
|
|
36
|
+
| `createPgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
|
|
37
|
+
| `readOnly()` | mutation-rejecting wrapper |
|
|
38
|
+
| `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
|
|
39
|
+
| `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
|
|
40
|
+
| `createRecordingClient()` | in-memory `DbClient` that records SQL, for tests |
|
|
41
|
+
|
|
42
|
+
## `sql` is parameters-only
|
|
43
|
+
|
|
44
|
+
String interpolation is how every SQL injection ships, and an agent writing SQL cannot be
|
|
45
|
+
trusted to remember the difference between a value and a fragment. So:
|
|
46
|
+
|
|
47
|
+
- scalars (`string`, `number`, `boolean`, `bigint`, `Date`, `Uint8Array`, arrays, `null`) become
|
|
48
|
+
`$1..$n` and never touch `.text`;
|
|
49
|
+
- a nested fragment is spliced and its parameters are renumbered;
|
|
50
|
+
- **anything else throws `X_SQL_UNSAFE`** — including an object shaped like a `SqlFragment` that
|
|
51
|
+
`sql`/`raw` did not produce;
|
|
52
|
+
- `raw(trusted)` is the one audited escape hatch, `identifier(name)` the safe way to interpolate
|
|
53
|
+
a table or column, `literal(text)` for utility statements that reject bound parameters.
|
|
54
|
+
|
|
55
|
+
## Read-only access for anything an LLM drives
|
|
56
|
+
|
|
57
|
+
`As of 2026-07`: a bug in one defence must not become a write, so `db.query` on the MCP dev
|
|
58
|
+
server stacks independent layers rather than trusting a single gate. This package owns the two
|
|
59
|
+
layers that are Postgres facts rather than MCP facts — the tool-boundary layers (pre-parse scan,
|
|
60
|
+
policy) live above it, and `@ultimat3/mcp` never imports this package directly.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
|
|
64
|
+
|
|
65
|
+
const role = await ensureReadOnlyRole(db()); // layer 1, once at boot
|
|
66
|
+
const { rows, guards } = await readOnlyQuery(statement, { role }); // layer 2, per statement
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Layer | Export | |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 1 | `ensureReadOnlyRole()` | idempotent DDL (`grantReadOnlySql()`) for `READONLY_ROLE`, a `NOLOGIN` role granted `SELECT` on every table and nothing else. Returns `null` instead of throwing when the connection cannot create or grant roles — a managed Postgres where the app user isn't a role admin |
|
|
72
|
+
| 2 | `readOnlyQuery()` | runs one statement inside `BEGIN READ ONLY`, assumes `role` via `SET LOCAL ROLE` when given one, bounds it with `SET LOCAL statement_timeout` (`READONLY_TIMEOUT_MS` default, `timeoutMs: 0` to disable), and always exits via `ROLLBACK` |
|
|
73
|
+
|
|
74
|
+
`readOnlyQuery()` reports which guards actually engaged (`result.guards`, e.g. `['txn:read-only',
|
|
75
|
+
'timeout:5000ms', 'role:ultimate_readonly']`) instead of assuming every layer held — a silently
|
|
76
|
+
degraded layer is a caller's problem to surface, never this package's to hide.
|
|
77
|
+
|
|
78
|
+
Only layer 1 degrades. `readOnlyQuery()` **throws** — a pool that cannot reserve a connection
|
|
79
|
+
(`X_DB_UNAVAILABLE`), a refused `SET LOCAL ROLE`, a failed transaction command, or the
|
|
80
|
+
statement's own error. Every caller handles that failure; nothing here swallows it.
|
|
81
|
+
|
|
82
|
+
`ALTER DEFAULT PRIVILEGES` is scoped to whoever creates an object, so pass
|
|
83
|
+
`creators: ['migrator']` when migrations run as a different DB user than the one that ran
|
|
84
|
+
`ensureReadOnlyRole()` — otherwise a table created later is not selectable by the role, and
|
|
85
|
+
layer 1 covers only what existed at grant time.
|
|
86
|
+
|
|
87
|
+
## The drift contract
|
|
88
|
+
|
|
89
|
+
`x db drift` compares `introspect()` against the snapshot the newest applied migration carries.
|
|
90
|
+
Rendered output is pinned byte-for-byte:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
X_DB_DRIFT: schema differs from migrations
|
|
94
|
+
cause: table "posts" has column "publish_at" not present in any migration
|
|
95
|
+
fix: x db gen "add publish_at"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Difference | cause | fix |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` |
|
|
101
|
+
| migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
|
|
102
|
+
| live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` |
|
|
103
|
+
| migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
|
|
104
|
+
|
|
105
|
+
`checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x verify` fails on it.
|
|
106
|
+
|
|
107
|
+
## The embedded database
|
|
108
|
+
|
|
109
|
+
No `DATABASE_URL` means no Docker: `createPgliteClient()` runs Postgres as WASM inside this
|
|
110
|
+
process. The module is resolved on the first statement, never at import, so an image that only
|
|
111
|
+
ever talks to a managed Postgres never loads it.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const dev = createPgliteClient({ dataDir: pgliteDataDir(services.db.url) }); // or memory://
|
|
115
|
+
await dev.ping(); // pay the ~3s boot before serving
|
|
116
|
+
setDbClient(dev);
|
|
117
|
+
|
|
118
|
+
const branch = await branchPglite('feature_x', { from: '.x/pgdata' });
|
|
119
|
+
setDbClient(createPgliteClient({ dataDir: branch.dataDir }));
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
PGlite has no `CREATE DATABASE ... TEMPLATE`, so `branchPglite()` copies the data directory —
|
|
123
|
+
close the source first, and expect `X_NOT_IMPLEMENTED` on `memory://`, which has no directory to
|
|
124
|
+
copy. Branch names go through the same `assertBranchName()` as the Postgres path: here the name
|
|
125
|
+
lands in a filesystem path, so an unvalidated one is traversal rather than a typo.
|
|
126
|
+
|
|
127
|
+
### One session, so callers take turns
|
|
128
|
+
|
|
129
|
+
Embedded Postgres is a single session, not a pool. `createPgliteClient()` is therefore
|
|
130
|
+
`ReservableClient`: `withTransaction()` and `readOnlyQuery()` pin it, and every other statement
|
|
131
|
+
waits for its turn. Without that pin two concurrent units of work each run `BEGIN` on the same
|
|
132
|
+
connection — the second `COMMIT` commits the first's rows and the first `ROLLBACK` finds no
|
|
133
|
+
transaction left to undo.
|
|
134
|
+
|
|
135
|
+
| On a pooled server | Embedded, on one session |
|
|
136
|
+
|---|---|
|
|
137
|
+
| concurrent transactions run side by side | they run consecutively; throughput is one at a time |
|
|
138
|
+
| a statement outside a transaction gets its own connection | it waits for the open transaction to finish |
|
|
139
|
+
| `enqueue(input, { outbox: false })` inside a transaction survives its rollback | it joins that transaction, and rolls back with it |
|
|
140
|
+
|
|
141
|
+
The last row is the one divergence a second connection would remove and PGlite cannot: a statement
|
|
142
|
+
issued *inside* an open transaction's scope runs immediately rather than waiting for a turn that
|
|
143
|
+
scope is already holding, because waiting would be a deadlock with no error to explain it.
|
|
144
|
+
|
|
145
|
+
## Pool sizing by role
|
|
146
|
+
|
|
147
|
+
`ROLE` picks the profile — a `worker` draining a queue must not size like a `web` process.
|
|
148
|
+
|
|
149
|
+
| Role | max | statement timeout | idle timeout |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `web` | 20 | 10s | 30s |
|
|
152
|
+
| `sync` | 10 | 10s | 60s |
|
|
153
|
+
| `worker` | 8 | 120s | 30s |
|
|
154
|
+
| `scheduler` | 2 | 15s | 60s |
|
|
155
|
+
| `migrate` | 1 | none | 10s |
|
|
156
|
+
| `replicator` | 4 | none | 60s |
|
|
157
|
+
|
|
158
|
+
The timeout is pinned per connection via libpq `options=-c statement_timeout=`. `Bun.SQL` is
|
|
159
|
+
reached lazily, so importing this package never opens a socket.
|
|
160
|
+
|
|
161
|
+
## Migrations
|
|
162
|
+
|
|
163
|
+
`migrate()` takes an advisory lock (`pg_advisory_lock(4919202607)`), ensures `x_migrations`
|
|
164
|
+
(`id, name, checksum, applied_at, app_version, duration_ms`), audits, then applies each pending
|
|
165
|
+
migration inside its own transaction. It refuses **before applying anything** when:
|
|
166
|
+
|
|
167
|
+
- the ledger records a migration this build does not ship **and** its `app_version` differs from
|
|
168
|
+
the running one — another version owns the database; or
|
|
169
|
+
- an applied migration's `up` SQL no longer matches its recorded checksum.
|
|
170
|
+
|
|
171
|
+
Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion }`.
|
|
172
|
+
|
|
173
|
+
## Error codes
|
|
174
|
+
|
|
175
|
+
| Code | Meaning |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `X_DB_UNAVAILABLE` | no reachable database; `fix:` names `DATABASE_URL` |
|
|
178
|
+
| `X_DB_DRIFT` | live schema differs from migrations |
|
|
179
|
+
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch |
|
|
180
|
+
| `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
|
|
181
|
+
| `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
|
|
182
|
+
| `X_BRANCH_EXISTS` | branch database already exists (or is the connected one) |
|
|
183
|
+
| `X_READONLY_VIOLATION` | a mutating statement reached a `readOnly()` client |
|
|
184
|
+
| `X_NOT_IMPLEMENTED` | branching an in-memory PGlite — a copy needs a directory |
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
x db migrate --json
|
|
188
|
+
x db drift --json
|
|
189
|
+
x db gen "add publish_at"
|
|
190
|
+
x db branch create feature_x
|
|
191
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/db",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/db"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/core": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@electric-sql/pglite": ">=0.5.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@electric-sql/pglite": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/branch.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Single responsibility: copy-on-write branch databases. `CREATE DATABASE ... TEMPLATE` gives an
|
|
2
|
+
// agent (or a preview environment) a private copy in seconds, so destructive work — a migration
|
|
3
|
+
// it is unsure about, a data backfill, a DROP — never happens against the shared database.
|
|
4
|
+
// Branches are cheap and forgettable, so `reapBranches()` is part of the design, not an add-on.
|
|
5
|
+
|
|
6
|
+
import { baseClient, type DbClient } from './client';
|
|
7
|
+
import { branchExists, branchNameInvalid, DbError } from './errors';
|
|
8
|
+
import { identifier, literal, sql } from './sql';
|
|
9
|
+
|
|
10
|
+
const BRANCH_NAME = /^[a-z0-9_-]+$/;
|
|
11
|
+
|
|
12
|
+
/** Written as a database comment at creation time — `pg_database` has no created_at. */
|
|
13
|
+
const BRANCH_MARKER = 'ultimate:branch:';
|
|
14
|
+
|
|
15
|
+
export interface BranchInfo {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly createdAt: string | null;
|
|
18
|
+
readonly sizeBytes: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertBranchName(branch: string): string {
|
|
22
|
+
if (!BRANCH_NAME.test(branch)) throw branchNameInvalid(branch);
|
|
23
|
+
return branch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BranchOptions {
|
|
27
|
+
readonly client?: DbClient | undefined;
|
|
28
|
+
/** The database to copy. Defaults to the current one. */
|
|
29
|
+
readonly base?: string | undefined;
|
|
30
|
+
readonly now?: Date | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function exists(client: DbClient, branch: string): Promise<boolean> {
|
|
34
|
+
const row = await client.one<{ ok: number }>(
|
|
35
|
+
sql`select 1 as ok from pg_database where datname = ${branch}`,
|
|
36
|
+
);
|
|
37
|
+
return row !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `CREATE DATABASE` cannot run inside a transaction and needs no other session connected to the
|
|
42
|
+
* template, so this deliberately runs statement-by-statement on the ambient client.
|
|
43
|
+
*/
|
|
44
|
+
export async function createBranch(
|
|
45
|
+
branch: string,
|
|
46
|
+
options: BranchOptions = {},
|
|
47
|
+
): Promise<BranchInfo> {
|
|
48
|
+
const client = options.client ?? baseClient();
|
|
49
|
+
assertBranchName(branch);
|
|
50
|
+
if (await exists(client, branch)) throw branchExists(branch);
|
|
51
|
+
|
|
52
|
+
const base = options.base ?? (await currentDatabase(client));
|
|
53
|
+
await client.execute(sql`create database ${identifier(branch)} template ${identifier(base)}`);
|
|
54
|
+
const createdAt = (options.now ?? new Date()).toISOString();
|
|
55
|
+
await client.execute(
|
|
56
|
+
sql`comment on database ${identifier(branch)} is ${literal(`${BRANCH_MARKER}${createdAt}`)}`,
|
|
57
|
+
);
|
|
58
|
+
return { name: branch, createdAt, sizeBytes: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function currentDatabase(client: DbClient = baseClient()): Promise<string> {
|
|
62
|
+
const row = await client.one<{ name: string }>(sql`select current_database() as name`);
|
|
63
|
+
return row?.name ?? 'postgres';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface BranchRow {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly comment: string | null;
|
|
69
|
+
readonly size_bytes: string | number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function listBranches(options: BranchOptions = {}): Promise<readonly BranchInfo[]> {
|
|
73
|
+
const client = options.client ?? baseClient();
|
|
74
|
+
const rows = await client.query<BranchRow>(sql`
|
|
75
|
+
select
|
|
76
|
+
d.datname as name,
|
|
77
|
+
shobj_description(d.oid, 'pg_database') as comment,
|
|
78
|
+
pg_database_size(d.datname) as size_bytes
|
|
79
|
+
from pg_database d
|
|
80
|
+
where not d.datistemplate
|
|
81
|
+
order by d.datname
|
|
82
|
+
`);
|
|
83
|
+
return rows
|
|
84
|
+
.filter((row) => row.comment?.startsWith(BRANCH_MARKER) === true)
|
|
85
|
+
.map((row) => ({
|
|
86
|
+
name: row.name,
|
|
87
|
+
createdAt: row.comment?.slice(BRANCH_MARKER.length) ?? null,
|
|
88
|
+
sizeBytes: Number(row.size_bytes),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DropBranchOptions extends BranchOptions {
|
|
93
|
+
/** Disconnect other sessions first. Without it Postgres refuses while anyone is connected. */
|
|
94
|
+
readonly force?: boolean | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function dropBranch(
|
|
98
|
+
branch: string,
|
|
99
|
+
options: DropBranchOptions = {},
|
|
100
|
+
): Promise<boolean> {
|
|
101
|
+
const client = options.client ?? baseClient();
|
|
102
|
+
assertBranchName(branch);
|
|
103
|
+
if (branch === (await currentDatabase(client))) {
|
|
104
|
+
throw new DbError({
|
|
105
|
+
code: 'X_BRANCH_EXISTS',
|
|
106
|
+
cause: `"${branch}" is the database this session is connected to, so it cannot be dropped`,
|
|
107
|
+
fix: 'connect to another database (DATABASE_URL=.../postgres) and drop it from there',
|
|
108
|
+
meta: { branch },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (options.force === true) {
|
|
112
|
+
await client.execute(sql`
|
|
113
|
+
select pg_terminate_backend(pid) from pg_stat_activity where datname = ${branch}
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
const affected = await client.execute(sql`drop database if exists ${identifier(branch)}`);
|
|
117
|
+
return affected >= 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ReapOptions extends DropBranchOptions {
|
|
121
|
+
readonly maxAgeMs: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Preview environments leak branches; this is what the nightly `reapBranches` task calls. */
|
|
125
|
+
export async function reapBranches(options: ReapOptions): Promise<readonly string[]> {
|
|
126
|
+
const cutoff = (options.now ?? new Date()).getTime() - options.maxAgeMs;
|
|
127
|
+
const branches = await listBranches(options);
|
|
128
|
+
const dropped: string[] = [];
|
|
129
|
+
for (const branch of branches) {
|
|
130
|
+
if (branch.createdAt === null) continue;
|
|
131
|
+
if (new Date(branch.createdAt).getTime() > cutoff) continue;
|
|
132
|
+
await dropBranch(branch.name, options);
|
|
133
|
+
dropped.push(branch.name);
|
|
134
|
+
}
|
|
135
|
+
return dropped;
|
|
136
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Single responsibility: the Postgres connection and the ambient `db()` handle. Pool size and
|
|
2
|
+
// statement timeout are chosen by runtime ROLE — a `worker` draining a queue must not size its
|
|
3
|
+
// pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module
|
|
4
|
+
// never opens a socket (the CLI imports it to print help).
|
|
5
|
+
|
|
6
|
+
import { type Role, resolveRole } from '@ultimat3/core';
|
|
7
|
+
import { dbUnavailable } from './errors';
|
|
8
|
+
import { type SqlFragment, sql } from './sql';
|
|
9
|
+
import { currentTx } from './transaction';
|
|
10
|
+
|
|
11
|
+
export interface DbClient {
|
|
12
|
+
query<T>(fragment: SqlFragment): Promise<readonly T[]>;
|
|
13
|
+
one<T>(fragment: SqlFragment): Promise<T | null>;
|
|
14
|
+
/** Rows affected. */
|
|
15
|
+
execute(fragment: SqlFragment): Promise<number>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A connection pinned out of the pool. `withTransaction` needs one so BEGIN/COMMIT agree. */
|
|
19
|
+
export interface DbConnection extends DbClient {
|
|
20
|
+
release(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReservableClient extends DbClient {
|
|
24
|
+
reserve(): Promise<DbConnection>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isReservable(client: DbClient): client is ReservableClient {
|
|
28
|
+
return typeof (client as Partial<ReservableClient>).reserve === 'function';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PoolProfile {
|
|
32
|
+
readonly max: number;
|
|
33
|
+
/** 0 disables the timeout — only `migrate`, which is allowed to take as long as it takes. */
|
|
34
|
+
readonly statementTimeoutMs: number;
|
|
35
|
+
readonly idleTimeoutMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
|
|
39
|
+
export const POOL_PROFILES: Readonly<Record<Role, PoolProfile>> = Object.freeze({
|
|
40
|
+
web: { max: 20, statementTimeoutMs: 10_000, idleTimeoutMs: 30_000 },
|
|
41
|
+
sync: { max: 10, statementTimeoutMs: 10_000, idleTimeoutMs: 60_000 },
|
|
42
|
+
worker: { max: 8, statementTimeoutMs: 120_000, idleTimeoutMs: 30_000 },
|
|
43
|
+
scheduler: { max: 2, statementTimeoutMs: 15_000, idleTimeoutMs: 60_000 },
|
|
44
|
+
migrate: { max: 1, statementTimeoutMs: 0, idleTimeoutMs: 10_000 },
|
|
45
|
+
replicator: { max: 4, statementTimeoutMs: 0, idleTimeoutMs: 60_000 },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function poolProfileFor(role: Role = resolveRole()): PoolProfile {
|
|
49
|
+
return POOL_PROFILES[role];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
53
|
+
interface BunSqlReserved {
|
|
54
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
55
|
+
release(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
|
|
59
|
+
interface BunSqlDriver {
|
|
60
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
61
|
+
reserve(): Promise<BunSqlReserved>;
|
|
62
|
+
close(options?: { readonly timeout?: number }): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type BunSqlFactory = new (url: string, options?: Readonly<Record<string, unknown>>) => BunSqlDriver;
|
|
66
|
+
|
|
67
|
+
function bunSqlFactory(): BunSqlFactory {
|
|
68
|
+
const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } };
|
|
69
|
+
const factory = host.Bun?.SQL;
|
|
70
|
+
if (typeof factory !== 'function') {
|
|
71
|
+
throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3');
|
|
72
|
+
}
|
|
73
|
+
return factory as BunSqlFactory;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PostgresClientOptions {
|
|
77
|
+
readonly url?: string | undefined;
|
|
78
|
+
readonly role?: Role | undefined;
|
|
79
|
+
readonly profile?: Partial<PoolProfile> | undefined;
|
|
80
|
+
readonly applicationName?: string | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): string {
|
|
84
|
+
const raw = options.url ?? process.env['DATABASE_URL'];
|
|
85
|
+
if (raw === undefined || raw === '') {
|
|
86
|
+
throw dbUnavailable('DATABASE_URL is not set, so there is no database to connect to');
|
|
87
|
+
}
|
|
88
|
+
let url: URL;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(raw);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
|
|
93
|
+
}
|
|
94
|
+
// libpq `options` is the portable way to pin a statement timeout for every pooled connection.
|
|
95
|
+
if (profile.statementTimeoutMs > 0) {
|
|
96
|
+
url.searchParams.set('options', `-c statement_timeout=${profile.statementTimeoutMs}`);
|
|
97
|
+
}
|
|
98
|
+
url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rowsOf<T>(result: unknown): readonly T[] {
|
|
103
|
+
return Array.isArray(result) ? (result as readonly T[]) : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function affectedBy(result: unknown): number {
|
|
107
|
+
if (!Array.isArray(result)) return 0;
|
|
108
|
+
const count = (result as { count?: unknown }).count;
|
|
109
|
+
return typeof count === 'number' ? count : result.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface PostgresClient extends ReservableClient {
|
|
113
|
+
readonly profile: PoolProfile;
|
|
114
|
+
ping(): Promise<void>;
|
|
115
|
+
close(): Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Lazily connects: the pool opens on the first statement, never at import. */
|
|
119
|
+
export function createPostgresClient(options: PostgresClientOptions = {}): PostgresClient {
|
|
120
|
+
const role = options.role ?? resolveRole();
|
|
121
|
+
const profile: PoolProfile = { ...poolProfileFor(role), ...(options.profile ?? {}) };
|
|
122
|
+
let driver: BunSqlDriver | undefined;
|
|
123
|
+
|
|
124
|
+
function connect(): BunSqlDriver {
|
|
125
|
+
if (driver !== undefined) return driver;
|
|
126
|
+
const url = connectionUrl(options, profile);
|
|
127
|
+
const Factory = bunSqlFactory();
|
|
128
|
+
driver = new Factory(url, { max: profile.max, idleTimeout: profile.idleTimeoutMs / 1000 });
|
|
129
|
+
return driver;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runOn(
|
|
133
|
+
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
134
|
+
fragment: SqlFragment,
|
|
135
|
+
): Promise<unknown> {
|
|
136
|
+
try {
|
|
137
|
+
return await driver.unsafe(fragment.text, fragment.values);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function run(fragment: SqlFragment): Promise<unknown> {
|
|
144
|
+
return runOn(connect(), fragment);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const client: PostgresClient = {
|
|
148
|
+
profile,
|
|
149
|
+
async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
|
|
150
|
+
return rowsOf<T>(await run(fragment));
|
|
151
|
+
},
|
|
152
|
+
async one<T>(fragment: SqlFragment): Promise<T | null> {
|
|
153
|
+
const rows = rowsOf<T>(await run(fragment));
|
|
154
|
+
return rows[0] ?? null;
|
|
155
|
+
},
|
|
156
|
+
async execute(fragment: SqlFragment): Promise<number> {
|
|
157
|
+
return affectedBy(await run(fragment));
|
|
158
|
+
},
|
|
159
|
+
async reserve(): Promise<DbConnection> {
|
|
160
|
+
// A real pin, not a seam: `Bun.SQL` refuses a bare BEGIN on a pooled handle
|
|
161
|
+
// (`ERR_POSTGRES_UNSAFE_TRANSACTION`), and a BEGIN that landed on a different connection
|
|
162
|
+
// than the statement after it would not be a transaction at all — which is exactly what
|
|
163
|
+
// `withTransaction` and `readOnlyQuery` depend on being true.
|
|
164
|
+
const pool = connect();
|
|
165
|
+
let reserved: BunSqlReserved;
|
|
166
|
+
try {
|
|
167
|
+
reserved = await pool.reserve();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Acquiring the pin is the one step that runs outside `runOn`, so an exhausted or
|
|
170
|
+
// unreachable pool would escape as an untyped driver error — and `readOnlyQuery` reaches
|
|
171
|
+
// this line before its first statement, which is how MCP ends up returning something
|
|
172
|
+
// other than X_DB_UNAVAILABLE.
|
|
173
|
+
throw dbUnavailable('could not reserve a connection from the pool', error);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
query: async <T>(fragment: SqlFragment) => rowsOf<T>(await runOn(reserved, fragment)),
|
|
177
|
+
one: async <T>(fragment: SqlFragment) =>
|
|
178
|
+
rowsOf<T>(await runOn(reserved, fragment))[0] ?? null,
|
|
179
|
+
execute: async (fragment: SqlFragment) => affectedBy(await runOn(reserved, fragment)),
|
|
180
|
+
release: () => {
|
|
181
|
+
reserved.release();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
async ping(): Promise<void> {
|
|
186
|
+
await client.query(sql`select 1`);
|
|
187
|
+
},
|
|
188
|
+
async close(): Promise<void> {
|
|
189
|
+
await driver?.close();
|
|
190
|
+
driver = undefined;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
return client;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let ambient: DbClient | undefined;
|
|
197
|
+
|
|
198
|
+
/** Test/dev seam. `setDbClient(undefined)` restores lazy construction from `DATABASE_URL`. */
|
|
199
|
+
export function setDbClient(client: DbClient | undefined): void {
|
|
200
|
+
ambient = client;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The pool, ignoring any open transaction. `withTransaction` must not re-enter `db()`. */
|
|
204
|
+
export function baseClient(): DbClient {
|
|
205
|
+
if (ambient === undefined) ambient = createPostgresClient();
|
|
206
|
+
return ambient;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The ambient client. Inside `withTransaction` this is the transaction, so a repository
|
|
211
|
+
* written against `db()` joins the caller's transaction without knowing it exists.
|
|
212
|
+
*/
|
|
213
|
+
export function db(): DbClient {
|
|
214
|
+
return currentTx() ?? baseClient();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Named `Db*` because `@ultimat3/core` already exports a `HealthReport` for the lifecycle. */
|
|
218
|
+
export interface DbHealthReport {
|
|
219
|
+
readonly ok: boolean;
|
|
220
|
+
readonly latencyMs: number;
|
|
221
|
+
readonly error?: string | undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Backs `/readyz` for every role. Never throws — the probe wants a report, not an exception. */
|
|
225
|
+
export async function checkDb(client: DbClient = baseClient()): Promise<DbHealthReport> {
|
|
226
|
+
const started = performance.now();
|
|
227
|
+
try {
|
|
228
|
+
await client.query(sql`select 1`);
|
|
229
|
+
return { ok: true, latencyMs: Math.round(performance.now() - started) };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
latencyMs: Math.round(performance.now() - started),
|
|
234
|
+
error: error instanceof Error ? error.message : String(error),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|