@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/src/pglite.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Single responsibility: the embedded development database — Postgres compiled to WASM, running
|
|
2
|
+
// inside this process, so `x dev` needs no Docker, no DATABASE_URL and no container to wait for.
|
|
3
|
+
// The module is resolved at first query and never at import: it is an OPTIONAL peer, and an image
|
|
4
|
+
// that only ever talks to a managed Postgres must not carry 26 MB of WASM it will never load.
|
|
5
|
+
|
|
6
|
+
import type { DbConnection, ReservableClient } from './client';
|
|
7
|
+
import { DbError, dbUnavailable } from './errors';
|
|
8
|
+
import { createTurnQueue } from './pglite-turns';
|
|
9
|
+
import type { SqlFragment } from './sql';
|
|
10
|
+
import { currentTx } from './transaction';
|
|
11
|
+
|
|
12
|
+
/** What PGlite answers with. `rows` is empty for a write, which is why the count is separate. */
|
|
13
|
+
export interface PgliteResult {
|
|
14
|
+
readonly rows: readonly unknown[];
|
|
15
|
+
/** Postgres' command-tag count — the only truthful answer for INSERT/UPDATE/DELETE. */
|
|
16
|
+
readonly affectedRows?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The slice of PGlite we need. Declared structurally — this package has no dependencies. */
|
|
20
|
+
export interface PgliteDriver {
|
|
21
|
+
query(text: string, values?: readonly unknown[]): Promise<PgliteResult>;
|
|
22
|
+
exec?(text: string): Promise<unknown>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The one export taken off `@electric-sql/pglite`. */
|
|
27
|
+
export interface PgliteModule {
|
|
28
|
+
readonly PGlite: new (dataDir?: string) => PgliteDriver;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the module namespace. Unknown, not typed, because it is validated before use. */
|
|
32
|
+
export type PgliteLoader = () => Promise<unknown>;
|
|
33
|
+
|
|
34
|
+
export interface PgliteOptions {
|
|
35
|
+
/** `memory://` (default) or a directory. Branches are a directory per branch. */
|
|
36
|
+
readonly dataDir?: string | undefined;
|
|
37
|
+
/** Inject a driver — tests do this so no test needs the WASM build. */
|
|
38
|
+
readonly driver?: PgliteDriver | undefined;
|
|
39
|
+
/** Swap the module loader. Tests use it; nothing in the framework does. */
|
|
40
|
+
readonly load?: PgliteLoader | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const PGLITE_FIX =
|
|
44
|
+
'bun add @electric-sql/pglite, or set DATABASE_URL to a Postgres server and re-run';
|
|
45
|
+
|
|
46
|
+
/** Postgres with no filesystem behind it: the default, and what a test wants. */
|
|
47
|
+
export const PGLITE_MEMORY = 'memory://';
|
|
48
|
+
|
|
49
|
+
const PGLITE_URL = 'pglite://';
|
|
50
|
+
|
|
51
|
+
const PGLITE_PACKAGE = '@electric-sql/pglite';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The specifier is held in a variable on purpose: a literal would make every consumer's `tsc`
|
|
55
|
+
* resolve an optional peer that is legitimately absent, and every bundler inline it.
|
|
56
|
+
*/
|
|
57
|
+
const importPglite: PgliteLoader = () => import(PGLITE_PACKAGE);
|
|
58
|
+
|
|
59
|
+
const missing = (cause: string, sourceError?: unknown): DbError =>
|
|
60
|
+
new DbError({ code: 'X_DB_UNAVAILABLE', cause, fix: PGLITE_FIX, sourceError });
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `pglite://<dir>` and `pglite://memory/<name>` — the URLs `x dev` and the test template already
|
|
64
|
+
* print — read back as the dataDir the driver takes. One parser, so no caller invents a second.
|
|
65
|
+
*/
|
|
66
|
+
export function pgliteDataDir(url: string): string {
|
|
67
|
+
if (!url.startsWith(PGLITE_URL)) return url;
|
|
68
|
+
const rest = url.slice(PGLITE_URL.length);
|
|
69
|
+
return rest === '' || rest === 'memory' || rest.startsWith('memory/') ? PGLITE_MEMORY : rest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pgliteConstructor(loaded: unknown): PgliteModule['PGlite'] {
|
|
73
|
+
const exported = (loaded as { readonly PGlite?: unknown } | null | undefined)?.PGlite;
|
|
74
|
+
if (typeof exported !== 'function') {
|
|
75
|
+
throw missing(`${PGLITE_PACKAGE} resolved but exports no PGlite constructor`);
|
|
76
|
+
}
|
|
77
|
+
return exported as PgliteModule['PGlite'];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Boots one embedded Postgres. Costs seconds — `createPgliteClient` calls it exactly once. */
|
|
81
|
+
export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<PgliteDriver> {
|
|
82
|
+
if (options.driver !== undefined) return options.driver;
|
|
83
|
+
const dataDir = options.dataDir ?? PGLITE_MEMORY;
|
|
84
|
+
let loaded: unknown;
|
|
85
|
+
try {
|
|
86
|
+
loaded = await (options.load ?? importPglite)();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw missing(`${PGLITE_PACKAGE} is not installed, so there is no embedded database`, error);
|
|
89
|
+
}
|
|
90
|
+
const PGlite = pgliteConstructor(loaded);
|
|
91
|
+
try {
|
|
92
|
+
return new PGlite(dataDir);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw missing(`PGlite could not open its data directory (dataDir=${dataDir})`, error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reservable, and that is the whole point of the binding: `withTransaction` and `readOnlyQuery`
|
|
100
|
+
* both pin a connection before they `BEGIN`, and a client that cannot be pinned silently gets a
|
|
101
|
+
* shared one — which on a single-session database is every concurrent transaction at once.
|
|
102
|
+
*/
|
|
103
|
+
export interface PgliteClient extends ReservableClient {
|
|
104
|
+
/** Pay the boot up front. `x dev` calls it so the first request is not the slow one. */
|
|
105
|
+
ping(): Promise<void>;
|
|
106
|
+
close(): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Lazily boots: constructing a client opens nothing, exactly like `createPostgresClient`. */
|
|
110
|
+
export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
111
|
+
// One in-flight boot, shared. PGlite takes seconds to start, so two concurrent first queries
|
|
112
|
+
// would otherwise build two instances over the same data directory and orphan one of them.
|
|
113
|
+
let booting: Promise<PgliteDriver> | undefined;
|
|
114
|
+
const turns = createTurnQueue();
|
|
115
|
+
|
|
116
|
+
function connect(): Promise<PgliteDriver> {
|
|
117
|
+
booting ??= loadPgliteDriver(options).catch((error: unknown) => {
|
|
118
|
+
// A failed boot must not be cached: the fix is `bun add`, and then this has to work.
|
|
119
|
+
booting = undefined;
|
|
120
|
+
throw error;
|
|
121
|
+
});
|
|
122
|
+
return booting;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function statement(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
|
|
126
|
+
try {
|
|
127
|
+
return await driver.query(fragment.text, fragment.values);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function run(fragment: SqlFragment): Promise<PgliteResult> {
|
|
134
|
+
const driver = await connect();
|
|
135
|
+
// A statement issued inside an open transaction is already inside it — there is one
|
|
136
|
+
// connection and that transaction is holding the turn, so waiting for a turn we are already
|
|
137
|
+
// inside of would hang. `handle.enqueue(input, { outbox: false })` within `withTransaction`
|
|
138
|
+
// is the shape that reaches this line; on a pooled server it would get its own connection,
|
|
139
|
+
// and here it joins the caller's transaction because a second connection does not exist.
|
|
140
|
+
if (currentTx() !== undefined) return statement(driver, fragment);
|
|
141
|
+
return turns.run(() => statement(driver, fragment));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// PGlite counts MODIFIED rows, so a SELECT that returned rows still reports `affectedRows: 0` —
|
|
145
|
+
// `??` would answer 0 for every read and disagree with `PostgresClient.execute`. A write that
|
|
146
|
+
// modified nothing returned no rows either, so falling back to the row count stays 0 there.
|
|
147
|
+
const rowsOf = (result: PgliteResult): number =>
|
|
148
|
+
result.affectedRows !== undefined && result.affectedRows > 0
|
|
149
|
+
? result.affectedRows
|
|
150
|
+
: result.rows.length;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
|
|
154
|
+
return (await run(fragment)).rows as readonly T[];
|
|
155
|
+
},
|
|
156
|
+
async one<T>(fragment: SqlFragment): Promise<T | null> {
|
|
157
|
+
const { rows } = await run(fragment);
|
|
158
|
+
return (rows[0] as T | undefined) ?? null;
|
|
159
|
+
},
|
|
160
|
+
async execute(fragment: SqlFragment): Promise<number> {
|
|
161
|
+
return rowsOf(await run(fragment));
|
|
162
|
+
},
|
|
163
|
+
async reserve(): Promise<DbConnection> {
|
|
164
|
+
const driver = await connect();
|
|
165
|
+
// Held until `release()`, so every statement between `BEGIN` and `COMMIT` is this caller's
|
|
166
|
+
// and no other unit of work can interleave one of its own.
|
|
167
|
+
const turn = await turns.take();
|
|
168
|
+
let held = true;
|
|
169
|
+
// Direct only while the turn is held — re-queueing behind ourselves would deadlock. Once
|
|
170
|
+
// released the handle has no claim on the connection, and a leaked `tx` writing straight to
|
|
171
|
+
// it would land inside whatever transaction holds it now, with no error to read; so a late
|
|
172
|
+
// statement queues like any other caller and waits for its own turn.
|
|
173
|
+
const on = (fragment: SqlFragment): Promise<PgliteResult> =>
|
|
174
|
+
held ? statement(driver, fragment) : turns.run(() => statement(driver, fragment));
|
|
175
|
+
return {
|
|
176
|
+
query: async <T>(fragment: SqlFragment) => (await on(fragment)).rows as readonly T[],
|
|
177
|
+
one: async <T>(fragment: SqlFragment) =>
|
|
178
|
+
((await on(fragment)).rows[0] as T | undefined) ?? null,
|
|
179
|
+
execute: async (fragment: SqlFragment) => rowsOf(await on(fragment)),
|
|
180
|
+
release: () => {
|
|
181
|
+
held = false;
|
|
182
|
+
turn();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
async ping(): Promise<void> {
|
|
187
|
+
await connect();
|
|
188
|
+
},
|
|
189
|
+
async close(): Promise<void> {
|
|
190
|
+
const pending = booting;
|
|
191
|
+
booting = undefined;
|
|
192
|
+
// A boot that never finished has nothing to close, and re-throwing its failure here would
|
|
193
|
+
// mask whatever the process was actually shutting down for.
|
|
194
|
+
await pending?.then(
|
|
195
|
+
(driver) => driver.close(),
|
|
196
|
+
() => undefined,
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Single responsibility: LAYER 2 of `db.query`'s defence-in-depth — an isolated `BEGIN READ
|
|
2
|
+
// ONLY` transaction with a statement timeout for any SQL an LLM is about to run. Postgres
|
|
3
|
+
// enforcing read-only beats a regex, and the timeout bounds a runaway scan the agent never
|
|
4
|
+
// meant to ask for.
|
|
5
|
+
|
|
6
|
+
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
7
|
+
import { stripSqlNoise } from './readonly';
|
|
8
|
+
import { identifier, raw, sql } from './sql';
|
|
9
|
+
|
|
10
|
+
/** Default per-statement ceiling for an agent-authored read. */
|
|
11
|
+
export const READONLY_TIMEOUT_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Leaders Postgres accepts in `DECLARE ... CURSOR FOR`. `EXPLAIN` and `SHOW` are commands, not
|
|
15
|
+
* queries, so they have no cursor form and stay on the direct path.
|
|
16
|
+
*/
|
|
17
|
+
const CURSORABLE_LEADERS = new Set(['select', 'with', 'table', 'values']);
|
|
18
|
+
|
|
19
|
+
/** Fixed: the cursor lives inside one pinned transaction that always rolls back. */
|
|
20
|
+
const CURSOR_NAME = 'ultimate_read_cursor';
|
|
21
|
+
|
|
22
|
+
export interface ReadOnlyQueryOptions {
|
|
23
|
+
/** Override the ambient pool. */
|
|
24
|
+
readonly client?: DbClient | undefined;
|
|
25
|
+
/** Role to assume for the statement, from `ensureReadOnlyRole`. `null` = none available. */
|
|
26
|
+
readonly role?: string | null | undefined;
|
|
27
|
+
/** 0 disables. Default READONLY_TIMEOUT_MS. */
|
|
28
|
+
readonly timeoutMs?: number | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Ask the server for at most this many rows, through a cursor. A caller that slices the
|
|
31
|
+
* answer afterwards has already paid for every row the driver produced — `select * from
|
|
32
|
+
* events` materialises the whole table in this process before any ceiling gets to drop it,
|
|
33
|
+
* and a statement timeout does not stop a fast scan that simply returns a lot. Omit for the
|
|
34
|
+
* whole result set. Ignored for `EXPLAIN`/`SHOW`, which have no cursor form.
|
|
35
|
+
*/
|
|
36
|
+
readonly maxRows?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReadOnlyQueryResult<T> {
|
|
40
|
+
readonly rows: readonly T[];
|
|
41
|
+
/** The defences that actually engaged, in the order they ran. Reported, never assumed. */
|
|
42
|
+
readonly guards: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A whole positive row count, or `undefined` when the caller wants everything. NaN and a
|
|
47
|
+
* fractional or negative ask are the same mistake — treat them as "no cursor" rather than
|
|
48
|
+
* emitting `FETCH FORWARD NaN`, which Postgres would reject as a syntax error.
|
|
49
|
+
*/
|
|
50
|
+
function fetchCount(maxRows: number | undefined): number | undefined {
|
|
51
|
+
if (maxRows === undefined || !Number.isFinite(maxRows)) return undefined;
|
|
52
|
+
const whole = Math.trunc(maxRows);
|
|
53
|
+
return whole > 0 ? whole : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when Postgres will accept `statement` as a cursor's query. Reads the leading keyword off
|
|
58
|
+
* the *stripped* form so a leading comment or a keyword inside a literal cannot decide it.
|
|
59
|
+
*/
|
|
60
|
+
function cursorable(statement: string): boolean {
|
|
61
|
+
const leader = /^\s*([a-z]+)/i.exec(stripSqlNoise(statement))?.[1]?.toLowerCase();
|
|
62
|
+
return leader !== undefined && CURSORABLE_LEADERS.has(leader);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs `statement` inside its own `BEGIN READ ONLY` transaction, on one pinned connection, and
|
|
67
|
+
* always exits via `ROLLBACK` — a read-only transaction has nothing to commit, and rollback is
|
|
68
|
+
* the one exit that cannot be wrong. The statement has already been parsed and accepted
|
|
69
|
+
* upstream, so this does not re-scan it for mutating keywords: a second gate is a second place
|
|
70
|
+
* to keep right.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately does not use `withTransaction`: it nests into an ambient transaction with a
|
|
73
|
+
* `SAVEPOINT`, and a savepoint inside a read-write transaction is not read-only. Deliberately
|
|
74
|
+
* does not wrap the connection in `readOnly()` either: that guard's regex would refuse our own
|
|
75
|
+
* `SET LOCAL` statements, and `BEGIN READ ONLY` is a stronger, Postgres-enforced backstop.
|
|
76
|
+
*/
|
|
77
|
+
export async function readOnlyQuery<T>(
|
|
78
|
+
statement: string,
|
|
79
|
+
options: ReadOnlyQueryOptions = {},
|
|
80
|
+
): Promise<ReadOnlyQueryResult<T>> {
|
|
81
|
+
const client = options.client ?? baseClient();
|
|
82
|
+
// A pooled BEGIN that lands on a different physical connection than the query that follows is
|
|
83
|
+
// not a transaction at all, so a reservable client must pin one connection for the sequence.
|
|
84
|
+
const reserved: DbConnection | undefined = isReservable(client)
|
|
85
|
+
? await client.reserve()
|
|
86
|
+
: undefined;
|
|
87
|
+
const connection: DbClient = reserved ?? client;
|
|
88
|
+
const guards: string[] = [];
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await connection.execute(raw('BEGIN READ ONLY'));
|
|
92
|
+
guards.push('txn:read-only');
|
|
93
|
+
|
|
94
|
+
// Clamped and truncated to an integer: the result is a JS number the caller never touches
|
|
95
|
+
// as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n`
|
|
96
|
+
// parameters, which is why this can't go through `sql` the normal, parameterised way.
|
|
97
|
+
// NaN normalises to the default first: only an explicit 0 disables the timeout, and
|
|
98
|
+
// `Math.min(3_600_000, NaN)` is NaN, which would fail `ms > 0` and silently skip the layer.
|
|
99
|
+
const asked = options.timeoutMs ?? READONLY_TIMEOUT_MS;
|
|
100
|
+
const requested = Number.isNaN(asked) ? READONLY_TIMEOUT_MS : asked;
|
|
101
|
+
const ms = Math.max(0, Math.min(3_600_000, Math.trunc(requested)));
|
|
102
|
+
if (ms > 0) {
|
|
103
|
+
// `LOCAL`, so the setting dies with the transaction — one agent read must not re-time
|
|
104
|
+
// every request the pool serves afterwards. Caveat: embedded PGlite applies the setting
|
|
105
|
+
// but is single-threaded WASM, so it cannot interrupt a running scan; on a real server
|
|
106
|
+
// the timeout fires. Layers 1 and 3 do not depend on it.
|
|
107
|
+
await connection.execute(raw(`SET LOCAL statement_timeout = ${ms}`));
|
|
108
|
+
guards.push(`timeout:${ms}ms`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Timeout first, then role: a role restricted to SELECT must not be the one asked to change
|
|
112
|
+
// the session's own statement_timeout.
|
|
113
|
+
if (typeof options.role === 'string' && options.role.length > 0) {
|
|
114
|
+
await connection.execute(sql`SET LOCAL ROLE ${identifier(options.role)}`);
|
|
115
|
+
guards.push(`role:${options.role}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rows = await readRows<T>(connection, statement, fetchCount(options.maxRows), guards);
|
|
119
|
+
await connection.execute(raw('ROLLBACK'));
|
|
120
|
+
return { rows, guards };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Best-effort: the caller needs the original error, never the rollback's.
|
|
123
|
+
await connection.execute(raw('ROLLBACK')).catch(() => undefined);
|
|
124
|
+
throw error;
|
|
125
|
+
} finally {
|
|
126
|
+
reserved?.release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run the caller's statement and return its rows, bounded at the server when it can be.
|
|
132
|
+
*
|
|
133
|
+
* `DECLARE ... CURSOR` costs one extra round trip and buys the only bound that exists before the
|
|
134
|
+
* rows are in this process: without it `maxRows` describes what the caller *keeps*, not what the
|
|
135
|
+
* driver *allocates*. `NO SCROLL` because nothing here reads backwards, and the cursor needs no
|
|
136
|
+
* closing — it dies with the transaction that always rolls back.
|
|
137
|
+
*
|
|
138
|
+
* The statement is spliced in, not bound: a cursor's query is syntax, and there is no `$n` that
|
|
139
|
+
* could carry it. It has already passed the caller's own gate — this function adds no second one.
|
|
140
|
+
*/
|
|
141
|
+
async function readRows<T>(
|
|
142
|
+
connection: DbClient,
|
|
143
|
+
statement: string,
|
|
144
|
+
fetch: number | undefined,
|
|
145
|
+
guards: string[],
|
|
146
|
+
): Promise<readonly T[]> {
|
|
147
|
+
if (fetch === undefined || !cursorable(statement)) return connection.query<T>(raw(statement));
|
|
148
|
+
|
|
149
|
+
// A trailing `;` would close `DECLARE` before its query and turn one statement into two.
|
|
150
|
+
const query = statement.trim().replace(/;\s*$/, '');
|
|
151
|
+
await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${query}`));
|
|
152
|
+
const rows = await connection.query<T>(raw(`FETCH FORWARD ${fetch} FROM ${CURSOR_NAME}`));
|
|
153
|
+
guards.push(`fetch:${fetch} rows`);
|
|
154
|
+
return rows;
|
|
155
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Single responsibility: LAYER 1 of `db.query`'s defence-in-depth — a Postgres role that can
|
|
2
|
+
// only SELECT. A grant set is the layer that survives a bug in every other layer, because
|
|
3
|
+
// Postgres itself refuses the write. `NOLOGIN` means it is reachable only via `SET LOCAL ROLE`
|
|
4
|
+
// inside an already-`READ ONLY` transaction, never by a connection string.
|
|
5
|
+
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { identifier, literal, raw, type SqlFragment, sql } from './sql';
|
|
8
|
+
|
|
9
|
+
/** The role `ensureReadOnlyRole` creates and `readOnlyQuery` assumes by default. */
|
|
10
|
+
export const READONLY_ROLE = 'ultimate_readonly';
|
|
11
|
+
|
|
12
|
+
export interface ReadOnlyRoleOptions {
|
|
13
|
+
readonly role?: string | undefined;
|
|
14
|
+
readonly schema?: string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The roles that CREATE objects in `schema` — in practice whoever runs the migrations.
|
|
17
|
+
* Postgres scopes `ALTER DEFAULT PRIVILEGES` to objects created by the roles it names, so when
|
|
18
|
+
* migrations run as a different user than this DDL, every table created afterwards is
|
|
19
|
+
* unreadable by the read-only role and layer 1 quietly stops covering new tables. Defaults to
|
|
20
|
+
* the connected user; naming another role requires membership in it.
|
|
21
|
+
*/
|
|
22
|
+
readonly creators?: readonly string[] | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `CURRENT_USER` is a keyword, not an identifier — quoting it would name a role actually called
|
|
27
|
+
* "current_user", so it stays bare exactly like `GRANT ... TO CURRENT_USER` below.
|
|
28
|
+
*/
|
|
29
|
+
function creatorRefs(creators: readonly string[] | undefined): readonly SqlFragment[] {
|
|
30
|
+
// Absent *or* empty means "whoever is connected", never "no creators": the second reading would
|
|
31
|
+
// silently drop the layer for every object created after this DDL.
|
|
32
|
+
if (creators === undefined || creators.length === 0) return [raw('CURRENT_USER')];
|
|
33
|
+
return creators.map((creator) => identifier(creator));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Emitted once per creating role, because Postgres applies `ALTER DEFAULT PRIVILEGES` only to
|
|
38
|
+
* objects created by the roles it names. Without the pair its own defaults work against us: a
|
|
39
|
+
* table created after this DDL is invisible to SELECT while a new sequence is readable —
|
|
40
|
+
* backwards for a role that must stay read-only forever, not just at grant time.
|
|
41
|
+
*/
|
|
42
|
+
function defaultPrivileges(
|
|
43
|
+
creator: SqlFragment,
|
|
44
|
+
role: string,
|
|
45
|
+
schema: string,
|
|
46
|
+
): readonly SqlFragment[] {
|
|
47
|
+
return [
|
|
48
|
+
sql`
|
|
49
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE ${creator} IN SCHEMA ${identifier(schema)}
|
|
50
|
+
GRANT SELECT ON TABLES TO ${identifier(role)}
|
|
51
|
+
`,
|
|
52
|
+
sql`
|
|
53
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE ${creator} IN SCHEMA ${identifier(schema)}
|
|
54
|
+
REVOKE ALL ON SEQUENCES FROM ${identifier(role)}
|
|
55
|
+
`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The idempotent DDL that creates and re-grants the role. Safe to run at every boot. */
|
|
60
|
+
export function grantReadOnlySql(options?: ReadOnlyRoleOptions): readonly SqlFragment[] {
|
|
61
|
+
const role = options?.role ?? READONLY_ROLE;
|
|
62
|
+
const schema = options?.schema ?? 'public';
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
// `CREATE ROLE` has no `IF NOT EXISTS`, so the guard is a DO block. The name is compared as
|
|
66
|
+
// a string literal (`literal()`) in the check and quoted as an identifier (`identifier()`)
|
|
67
|
+
// where it names the role — never spliced in as bare text either way.
|
|
68
|
+
sql`
|
|
69
|
+
DO $ultimate$
|
|
70
|
+
BEGIN
|
|
71
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${literal(role)}) THEN
|
|
72
|
+
CREATE ROLE ${identifier(role)} NOLOGIN NOINHERIT;
|
|
73
|
+
END IF;
|
|
74
|
+
END $ultimate$
|
|
75
|
+
`,
|
|
76
|
+
// NOLOGIN roles are only usable by their members, so the connected user needs membership
|
|
77
|
+
// before it can ever `SET LOCAL ROLE` into this one.
|
|
78
|
+
sql`GRANT ${identifier(role)} TO CURRENT_USER`,
|
|
79
|
+
sql`GRANT USAGE ON SCHEMA ${identifier(schema)} TO ${identifier(role)}`,
|
|
80
|
+
sql`GRANT SELECT ON ALL TABLES IN SCHEMA ${identifier(schema)} TO ${identifier(role)}`,
|
|
81
|
+
// Sequences expose nextval/currval, which leaks row counts and (with USAGE) lets a "reader"
|
|
82
|
+
// advance state other transactions depend on — read-only excludes them outright.
|
|
83
|
+
sql`REVOKE ALL ON ALL SEQUENCES IN SCHEMA ${identifier(schema)} FROM ${identifier(role)}`,
|
|
84
|
+
// Two per creating role: what the grants above cover is the schema as it is *now*, and
|
|
85
|
+
// `ALTER DEFAULT PRIVILEGES` is the only thing that covers what lands in it next.
|
|
86
|
+
...creatorRefs(options?.creators).flatMap((creator) =>
|
|
87
|
+
defaultPrivileges(creator, role, schema),
|
|
88
|
+
),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create/refresh the role and make it assumable by the current user.
|
|
94
|
+
*
|
|
95
|
+
* Returns the role name when the role exists and may be assumed, `null` when the connection is
|
|
96
|
+
* not allowed to create or grant roles (a managed Postgres where the app user is not a role
|
|
97
|
+
* admin). Never throws: the other three layers still hold without this one, and the caller
|
|
98
|
+
* reports the missing layer instead of losing all four to an exception.
|
|
99
|
+
*/
|
|
100
|
+
export async function ensureReadOnlyRole(
|
|
101
|
+
client: DbClient,
|
|
102
|
+
options?: ReadOnlyRoleOptions,
|
|
103
|
+
): Promise<string | null> {
|
|
104
|
+
const role = options?.role ?? READONLY_ROLE;
|
|
105
|
+
try {
|
|
106
|
+
for (const statement of grantReadOnlySql(options)) {
|
|
107
|
+
await client.execute(statement);
|
|
108
|
+
}
|
|
109
|
+
return role;
|
|
110
|
+
} catch {
|
|
111
|
+
// Swallowed deliberately: a managed Postgres often refuses CREATE ROLE / GRANT to the app
|
|
112
|
+
// user. The other three layers (read-only transaction, statement timeout, pre-parse scan)
|
|
113
|
+
// still hold — the caller reports this layer as degraded rather than the boot crashing.
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/readonly.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Single responsibility: a `DbClient` that cannot mutate — for any caller that cannot open its
|
|
2
|
+
// own transaction. An LLM with a Postgres connection and no gate is an outage waiting to be
|
|
3
|
+
// prompted into existence. (MCP's `db.query` reaches past this for the stronger `readOnlyQuery`:
|
|
4
|
+
// a SELECT-only role inside `BEGIN READ ONLY`, where Postgres refuses the write, not a regex.)
|
|
5
|
+
// Detection strips comments and string literals first, because
|
|
6
|
+
// `/* x */ update ...` and `WITH t AS (INSERT ...) SELECT` are exactly how a naive check is beaten.
|
|
7
|
+
|
|
8
|
+
import type { DbClient } from './client';
|
|
9
|
+
import { readonlyViolation } from './errors';
|
|
10
|
+
import { raw, type SqlFragment } from './sql';
|
|
11
|
+
|
|
12
|
+
const MUTATING = [
|
|
13
|
+
'insert',
|
|
14
|
+
'update',
|
|
15
|
+
'delete',
|
|
16
|
+
'truncate',
|
|
17
|
+
'drop',
|
|
18
|
+
'alter',
|
|
19
|
+
'create',
|
|
20
|
+
'grant',
|
|
21
|
+
'revoke',
|
|
22
|
+
'copy',
|
|
23
|
+
'set',
|
|
24
|
+
'call',
|
|
25
|
+
'do',
|
|
26
|
+
'refresh',
|
|
27
|
+
'vacuum',
|
|
28
|
+
'reindex',
|
|
29
|
+
'cluster',
|
|
30
|
+
'lock',
|
|
31
|
+
'merge',
|
|
32
|
+
'analyze',
|
|
33
|
+
'prepare',
|
|
34
|
+
'execute',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
const MUTATING_PATTERN = new RegExp(`\\b(${MUTATING.join('|')})\\b`, 'i');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Blank out anything a keyword could legitimately hide inside: line comments, block comments,
|
|
41
|
+
* single-quoted literals, dollar-quoted bodies and quoted identifiers. Blanking (rather than
|
|
42
|
+
* deleting) keeps offsets stable so the reported statement still reads correctly.
|
|
43
|
+
*/
|
|
44
|
+
export function stripSqlNoise(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1?\$/g, ' ')
|
|
47
|
+
.replace(/--[^\n]*/g, ' ')
|
|
48
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
49
|
+
.replace(/'(?:[^']|'')*'/g, " '' ")
|
|
50
|
+
.replace(/"(?:[^"]|"")*"/g, ' "" ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MutationVerdict {
|
|
54
|
+
readonly mutating: boolean;
|
|
55
|
+
readonly keyword: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whole-text scan, not a leading-keyword check: multi-statement strings and CTEs that end in a
|
|
60
|
+
* writing branch must both be caught, and a false positive here is far cheaper than a false
|
|
61
|
+
* negative. `updated_at` and `offset` do not match — `\b` requires a non-word boundary.
|
|
62
|
+
*/
|
|
63
|
+
export function inspectStatement(text: string): MutationVerdict {
|
|
64
|
+
const match = MUTATING_PATTERN.exec(stripSqlNoise(text));
|
|
65
|
+
if (match === null) return { mutating: false, keyword: null };
|
|
66
|
+
return { mutating: true, keyword: match[1] ?? match[0] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertReadOnly(fragment: SqlFragment): void {
|
|
70
|
+
const verdict = inspectStatement(fragment.text);
|
|
71
|
+
if (!verdict.mutating) return;
|
|
72
|
+
throw readonlyViolation(fragment.text.trim().slice(0, 160), verdict.keyword ?? 'mutating');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ReadOnlyOptions {
|
|
76
|
+
/** Also ask Postgres to enforce it. Off only for clients that cannot run `SET TRANSACTION`. */
|
|
77
|
+
readonly seal?: boolean | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Belt and braces: the regex is the gate, `SET TRANSACTION READ ONLY` is the backstop for
|
|
82
|
+
* anything the regex was too clever to catch. Sealing is best-effort — outside a transaction
|
|
83
|
+
* block Postgres only warns, and a driver that rejects it must not break every read.
|
|
84
|
+
*/
|
|
85
|
+
export function readOnly(client: DbClient, options: ReadOnlyOptions = {}): DbClient {
|
|
86
|
+
let sealed = options.seal === false;
|
|
87
|
+
|
|
88
|
+
async function seal(): Promise<void> {
|
|
89
|
+
if (sealed) return;
|
|
90
|
+
sealed = true;
|
|
91
|
+
await client.execute(raw('SET TRANSACTION READ ONLY')).catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
|
|
96
|
+
assertReadOnly(fragment);
|
|
97
|
+
await seal();
|
|
98
|
+
return client.query<T>(fragment);
|
|
99
|
+
},
|
|
100
|
+
async one<T>(fragment: SqlFragment): Promise<T | null> {
|
|
101
|
+
assertReadOnly(fragment);
|
|
102
|
+
await seal();
|
|
103
|
+
return client.one<T>(fragment);
|
|
104
|
+
},
|
|
105
|
+
async execute(fragment: SqlFragment): Promise<number> {
|
|
106
|
+
assertReadOnly(fragment);
|
|
107
|
+
await seal();
|
|
108
|
+
return client.execute(fragment);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|