@nifrajs/mcp-db 3.3.0 → 4.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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * The server-enforced layers of `@nifrajs/mcp-db/postgres`: the read-only transaction with its
3
+ * timeouts, the extended-protocol statement, the cursor, and the plan-level table scope. Nothing here
4
+ * parses the caller's SQL - the refusals in this file come from the server, and the only text lexed
5
+ * is the expressions of the server's own verbose plan.
6
+ *
7
+ * Bun 1.4.2 sends `sql.unsafe(text)` and `sql.unsafe(text, [])` over the SIMPLE protocol, which runs
8
+ * several `;`-separated statements. Only a tagged-template call (or `unsafe` with a non-empty value
9
+ * list) uses the EXTENDED protocol, where the server refuses a second statement with "cannot insert
10
+ * multiple commands into a prepared statement". So every statement carrying caller text goes through
11
+ * {@link extended}; `unsafe` is used only for fixed text this file writes.
12
+ */
13
+
14
+ import { lexPostgres } from "./pg-lexer.ts"
15
+ import { type DbRefusal, dbRefusal } from "./shared.ts"
16
+
17
+ /** A pending query: awaitable for object rows, or `.values()` for array rows (checked by readers). */
18
+ export interface PgQuery extends PromiseLike<readonly unknown[]> {
19
+ values(): PromiseLike<readonly unknown[]>
20
+ }
21
+
22
+ /** Rows as records: a driver row that is not an object is dropped, never cast. */
23
+ export function records(rows: readonly unknown[]): Record<string, unknown>[] {
24
+ return rows.filter(
25
+ (row): row is Record<string, unknown> =>
26
+ typeof row === "object" && row !== null && !Array.isArray(row),
27
+ )
28
+ }
29
+
30
+ /** One connection reserved from the pool (`Bun.SQL`'s `ReservedSQL`, structurally). */
31
+ export interface PgConnection {
32
+ (strings: TemplateStringsArray, ...values: unknown[]): PgQuery
33
+ unsafe(text: string): PgQuery
34
+ release(): void
35
+ }
36
+
37
+ /** A Postgres client (`Bun.SQL`, structurally): the engine reserves one connection per call. */
38
+ export interface PostgresClient {
39
+ reserve(): Promise<PgConnection>
40
+ close(options?: { readonly timeout?: number }): Promise<void>
41
+ }
42
+
43
+ /** Run caller text as ONE extended-protocol statement with no parameters. */
44
+ export function extended(connection: PgConnection, text: string): PgQuery {
45
+ const strings: TemplateStringsArray = Object.assign([text], { raw: [text] })
46
+ return connection(strings)
47
+ }
48
+
49
+ const WRITE_KEYWORD =
50
+ /at or near "(insert|update|delete|merge|call|do|copy|create|drop|alter|truncate|grant|revoke|lock|vacuum|analyze|cluster|reindex|refresh|comment|security|import|listen|notify|load|discard|reset|set|show|begin|commit|rollback|savepoint|release|prepare|execute|deallocate|declare|fetch|move|close|checkpoint|explain)"/i
51
+
52
+ /** Map a driver error to a refusal by its SQLSTATE; only a genuine query error is a failure. */
53
+ export function pgFailure(error: unknown, context = "query failed"): DbRefusal {
54
+ // Bun.SQL puts the SQLSTATE on `errno`.
55
+ const state =
56
+ typeof error === "object" && error !== null && "errno" in error ? error.errno : undefined
57
+ const message = `${context}: ${error instanceof Error ? error.message : String(error)}`
58
+ if (typeof state !== "string" || !/^[0-9A-Z]{5}$/.test(state)) {
59
+ return dbRefusal("NIFRA_DB_DRIVER", message)
60
+ }
61
+ const text = error instanceof Error ? error.message : ""
62
+ if (state === "25006" || state === "25001") return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
63
+ if (state === "0A000" && /data-modifying/i.test(text)) {
64
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
65
+ }
66
+ if (
67
+ state === "42601" &&
68
+ (/multiple commands/i.test(text) ||
69
+ /INTO is not allowed/i.test(text) ||
70
+ WRITE_KEYWORD.test(text))
71
+ ) {
72
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
73
+ }
74
+ if (state === "57014" || state === "55P03" || state === "25P03") {
75
+ return dbRefusal("NIFRA_DB_TIMEOUT", message)
76
+ }
77
+ if (state === "42501") {
78
+ return dbRefusal(
79
+ /for (table|view|relation|schema|sequence|materialized view|foreign table)\b/i.test(text)
80
+ ? "NIFRA_DB_TABLE_EXCLUDED"
81
+ : "NIFRA_DB_FUNCTION_REFUSED",
82
+ message,
83
+ )
84
+ }
85
+ if (/^(08|28|3D|53|57P|XX)/.test(state)) return dbRefusal("NIFRA_DB_DRIVER", message)
86
+ return dbRefusal("NIFRA_DB_QUERY_FAILED", message)
87
+ }
88
+
89
+ /** Validated milliseconds for a `SET LOCAL` (an integer, so it can be written into the text). */
90
+ function millis(timeoutMs: number): number {
91
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
92
+ throw new RangeError("timeoutMs must be a positive integer")
93
+ }
94
+ return timeoutMs
95
+ }
96
+
97
+ /**
98
+ * Run `work` inside `BEGIN READ ONLY` on one reserved connection, with the statement, lock and
99
+ * idle-in-transaction timeouts set for this transaction only. Always rolls back, so nothing a call
100
+ * does (a `set_config`, a session setting) outlives it.
101
+ */
102
+ export async function inReadOnlyTransaction<T>(
103
+ client: PostgresClient,
104
+ timeoutMs: number,
105
+ work: (connection: PgConnection) => Promise<T | DbRefusal>,
106
+ ): Promise<T | DbRefusal> {
107
+ const ms = millis(timeoutMs)
108
+ let connection: PgConnection
109
+ try {
110
+ connection = await client.reserve()
111
+ } catch (error) {
112
+ return pgFailure(error, "could not connect")
113
+ }
114
+ try {
115
+ // nifra-expect sql-dynamic: fixed statements; the one interpolation is a validated integer of milliseconds
116
+ await connection.unsafe(
117
+ `BEGIN READ ONLY; SET LOCAL statement_timeout = ${ms}; SET LOCAL lock_timeout = ${ms}; SET LOCAL idle_in_transaction_session_timeout = ${ms}`,
118
+ )
119
+ return await work(connection)
120
+ } catch (error) {
121
+ return pgFailure(error)
122
+ } finally {
123
+ try {
124
+ await connection.unsafe("ROLLBACK")
125
+ } catch {
126
+ // A connection the server already closed has nothing to roll back.
127
+ }
128
+ connection.release()
129
+ }
130
+ }
131
+
132
+ /** A relation (or a function scanned as one) the plan reads, as the plan names it. */
133
+ export interface PlanRelation {
134
+ readonly schema: string
135
+ readonly name: string
136
+ }
137
+
138
+ /** A column of a scanned relation that a plan expression names; `*` is a whole-row reference. */
139
+ export interface PlanColumn {
140
+ readonly relation: PlanRelation
141
+ readonly column: string
142
+ }
143
+
144
+ /** What an `EXPLAIN (VERBOSE, FORMAT JSON)` plan reads, InitPlans and SubPlans included. */
145
+ export interface PlanReads {
146
+ readonly relations: readonly PlanRelation[]
147
+ /** Functions in FROM (Function Scan nodes): a system view such as `pg_stat_activity` plans as one. */
148
+ readonly functions: readonly PlanRelation[]
149
+ /**
150
+ * Every relation column an expression of the plan uses: output, filter, join, sort or index
151
+ * condition. A verbose plan qualifies each one with its scan's unique alias, so an alias or an
152
+ * expression in the query still names the column it reads.
153
+ */
154
+ readonly columns: readonly PlanColumn[]
155
+ /**
156
+ * Outputs left out of `columns` because they may be a physical target list: a node under an
157
+ * aggregate, join or result emitting plain columns of one relation. The planner emits every column
158
+ * there whether or not the parent uses it, and the parent's own expressions name what it does use.
159
+ * When the list is not the relation's full column list, it is exactly what was asked for and counts.
160
+ */
161
+ readonly outputs: readonly PlanOutput[]
162
+ }
163
+
164
+ /** Plain columns of one relation that a node outputs, as listed. */
165
+ export interface PlanOutput {
166
+ readonly relation: PlanRelation
167
+ readonly columns: readonly string[]
168
+ }
169
+
170
+ // Nodes that pass their child's rows up unchanged: whether a child's output is used is decided above.
171
+ const PASS_THROUGH_NODES = new Set([
172
+ "Limit",
173
+ "Sort",
174
+ "Incremental Sort",
175
+ "Material",
176
+ "Memoize",
177
+ "Hash",
178
+ "Gather",
179
+ "Gather Merge",
180
+ "Unique",
181
+ "LockRows",
182
+ ])
183
+
184
+ // Nodes whose own expressions a verbose plan prints in terms of the base columns they use.
185
+ const PROJECTING_NODES = new Set([
186
+ "Aggregate",
187
+ "Group",
188
+ "WindowAgg",
189
+ "Nested Loop",
190
+ "Hash Join",
191
+ "Merge Join",
192
+ "Result",
193
+ "ProjectSet",
194
+ ])
195
+
196
+ // Fields that name a node or a relation rather than hold an expression.
197
+ const PLAN_NAME_FIELDS = new Set([
198
+ "Node Type",
199
+ "Relation Name",
200
+ "Schema",
201
+ "Alias",
202
+ "Index Name",
203
+ "CTE Name",
204
+ "Function Name",
205
+ "Subplan Name",
206
+ "Parent Relationship",
207
+ ])
208
+
209
+ /** Every relation and every FROM-clause function a verbose JSON plan scans, and the columns it uses. */
210
+ export function planReads(plan: unknown): PlanReads {
211
+ const relations = new Map<string, PlanRelation>()
212
+ const functions = new Map<string, PlanRelation>()
213
+ const aliases = new Map<string, PlanRelation>()
214
+ const expressions: string[] = []
215
+ // Outputs under a projecting parent, resolved once every alias is known.
216
+ const underProjection: (readonly string[])[] = []
217
+ const visit = (node: unknown, depth: number, projected: boolean): void => {
218
+ if (depth > 256 || typeof node !== "object" || node === null) return
219
+ if (Array.isArray(node)) {
220
+ for (const item of node) visit(item, depth + 1, projected)
221
+ return
222
+ }
223
+ const field = (key: string): unknown => Reflect.get(node, key)
224
+ const declared = field("Schema")
225
+ const schema = typeof declared === "string" ? declared : ""
226
+ for (const [key, found] of [
227
+ ["Relation Name", relations],
228
+ ["Function Name", functions],
229
+ ] as const) {
230
+ const name = field(key)
231
+ if (typeof name === "string") found.set(`${schema}.${name}`, { schema, name })
232
+ }
233
+ const relation = field("Relation Name")
234
+ const alias = field("Alias")
235
+ if (typeof relation === "string") {
236
+ aliases.set(typeof alias === "string" ? alias : relation, { schema, name: relation })
237
+ }
238
+ // A SubPlan's or InitPlan's result reaches its parent as `(SubPlan n)`, not as base columns.
239
+ const role = field("Parent Relationship")
240
+ const own = projected && role !== "SubPlan" && role !== "InitPlan"
241
+ for (const [key, value] of Object.entries(node)) {
242
+ if (PLAN_NAME_FIELDS.has(key)) continue
243
+ if (typeof value === "string") expressions.push(value)
244
+ else if (Array.isArray(value)) {
245
+ const texts = value.filter((item): item is string => typeof item === "string")
246
+ if (key === "Output" && own) underProjection.push(texts)
247
+ else expressions.push(...texts)
248
+ }
249
+ }
250
+ const type = String(field("Node Type"))
251
+ visit(field("Plan"), depth + 1, false)
252
+ visit(
253
+ field("Plans"),
254
+ depth + 1,
255
+ PASS_THROUGH_NODES.has(type) ? own : PROJECTING_NODES.has(type),
256
+ )
257
+ }
258
+ visit(plan, 0, false)
259
+
260
+ const outputs: PlanOutput[] = []
261
+ for (const list of underProjection) {
262
+ // Plain `alias.column` items of one relation (a dropped column shows as a NULL constant).
263
+ let relation: PlanRelation | undefined
264
+ const listed: string[] = []
265
+ for (const text of list) {
266
+ if (text.startsWith("NULL::")) continue
267
+ const tokens = lexPostgres(text).tokens
268
+ const [qualifier, dot, column] = tokens
269
+ const named = qualifier?.kind === "word" ? aliases.get(qualifier.value) : undefined
270
+ if (
271
+ tokens.length !== 3 ||
272
+ named === undefined ||
273
+ dot?.value !== "." ||
274
+ column?.kind !== "word" ||
275
+ (relation !== undefined && relation !== named)
276
+ ) {
277
+ relation = undefined
278
+ break
279
+ }
280
+ relation = named
281
+ listed.push(column.value)
282
+ }
283
+ if (relation !== undefined) outputs.push({ relation, columns: listed })
284
+ else expressions.push(...list)
285
+ }
286
+
287
+ const columns = new Map<string, PlanColumn>()
288
+ for (const text of expressions) {
289
+ const { tokens } = lexPostgres(text)
290
+ for (let i = 0; i + 2 < tokens.length; i++) {
291
+ const [qualifier, dot, column] = [tokens[i], tokens[i + 1], tokens[i + 2]]
292
+ if (qualifier?.kind !== "word" || dot?.kind !== "punct" || dot.value !== ".") continue
293
+ const relation = aliases.get(qualifier.value)
294
+ if (relation === undefined || column === undefined) continue
295
+ if (column.kind === "word" || (column.kind === "punct" && column.value === "*")) {
296
+ columns.set(`${relation.schema}.${relation.name}.${column.value}`, {
297
+ relation,
298
+ column: column.value,
299
+ })
300
+ }
301
+ }
302
+ }
303
+ return {
304
+ relations: [...relations.values()],
305
+ functions: [...functions.values()],
306
+ columns: [...columns.values()],
307
+ outputs,
308
+ }
309
+ }
310
+
311
+ /** The JSON document an `EXPLAIN (FORMAT JSON)` row carries, whatever the driver parsed it into. */
312
+ export function planDocument(rows: readonly unknown[]): unknown {
313
+ const value = records(rows)[0]?.["QUERY PLAN"]
314
+ if (typeof value !== "string") return value
315
+ const parsed: unknown = JSON.parse(value)
316
+ return parsed
317
+ }
318
+
319
+ /** Refuse when anything the plan reads (or a table it inherits from) is out of scope. */
320
+ export type ScopeCheck = (
321
+ connection: PgConnection,
322
+ reads: PlanReads,
323
+ ) => Promise<DbRefusal | undefined>
324
+
325
+ /** Options for {@link fetchThroughCursor}. */
326
+ export interface CursorOptions {
327
+ readonly maxRows: number
328
+ readonly scope: ScopeCheck
329
+ }
330
+
331
+ /**
332
+ * Declare the caller's query as a NO SCROLL cursor (the server accepts only SELECT/VALUES there and
333
+ * refuses a data-modifying WITH), check the plan's relations, then FETCH at most `maxRows + 1` rows.
334
+ *
335
+ * The query is wrapped as a subquery so each row also carries the result's column names: Bun returns
336
+ * array rows without names, and object rows lose a duplicated name. The wrapper's first column is a
337
+ * JSON array of the names, read from the first row.
338
+ */
339
+ export async function fetchThroughCursor(
340
+ connection: PgConnection,
341
+ sql: string,
342
+ options: CursorOptions,
343
+ ): Promise<{ readonly columns: string[]; readonly rows: unknown[][] } | DbRefusal> {
344
+ const wrapped = `SELECT (SELECT json_agg(nifra_k)::text FROM json_object_keys(row_to_json(nifra_q)) AS nifra_k) AS nifra_columns, nifra_q.* FROM (\n${sql}\n) AS nifra_q`
345
+ await extended(connection, `DECLARE nifra_c NO SCROLL CURSOR FOR ${wrapped}`)
346
+ const plan = planDocument(await extended(connection, `EXPLAIN (VERBOSE, FORMAT JSON) ${wrapped}`))
347
+ const refusal = await options.scope(connection, planReads(plan))
348
+ if (refusal !== undefined) return refusal
349
+ const rowCap = options.maxRows + 1
350
+ const fetched =
351
+ // nifra-expect sql-dynamic: a fixed FETCH whose one interpolation is the integer row cap
352
+ (await connection.unsafe(`FETCH FORWARD ${rowCap} FROM nifra_c`).values()).filter(
353
+ (row): row is unknown[] => Array.isArray(row),
354
+ )
355
+ const first = fetched[0]?.[0]
356
+ const names: unknown = typeof first === "string" ? JSON.parse(first) : []
357
+ const columns = Array.isArray(names)
358
+ ? names.filter((name): name is string => typeof name === "string")
359
+ : []
360
+ return { columns, rows: fetched.map((row) => row.slice(1)) }
361
+ }
362
+
363
+ /** Options for {@link explainThroughServer}. */
364
+ export interface ExplainOptions {
365
+ readonly analyze: boolean
366
+ readonly scope: ScopeCheck
367
+ }
368
+
369
+ /**
370
+ * Plan the caller's query. The cursor declaration runs first purely as the statement gate (then is
371
+ * closed), the VERBOSE plan feeds the scope check, and the returned plan is plain `FORMAT JSON` -
372
+ * with `analyze`, executed inside the same read-only transaction and timeout.
373
+ */
374
+ export async function explainThroughServer(
375
+ connection: PgConnection,
376
+ sql: string,
377
+ options: ExplainOptions,
378
+ ): Promise<{ readonly plan: unknown } | DbRefusal> {
379
+ await extended(connection, `DECLARE nifra_c NO SCROLL CURSOR FOR ${sql}`)
380
+ await connection.unsafe("CLOSE nifra_c")
381
+ const verbose = planDocument(await extended(connection, `EXPLAIN (VERBOSE, FORMAT JSON) ${sql}`))
382
+ const refusal = await options.scope(connection, planReads(verbose))
383
+ if (refusal !== undefined) return refusal
384
+ const mode = options.analyze ? "ANALYZE, FORMAT JSON" : "FORMAT JSON"
385
+ return { plan: planDocument(await extended(connection, `EXPLAIN (${mode}) ${sql}`)) }
386
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * A Postgres tokenizer for the parser layer of `@nifrajs/mcp-db/postgres`. It is the SECOND layer: the
3
+ * server already refuses several statements (extended protocol), anything but a query (the cursor),
4
+ * and writes (the read-only transaction). This layer adds what the server allows but a dev agent must
5
+ * not do: call a function that reaches outside the query (server files, locks, other sessions,
6
+ * settings, notifications, another database), or one that runs SQL from a string where the plan check
7
+ * cannot see it (`query_to_xml`).
8
+ *
9
+ * The lexer follows Postgres with `standard_conforming_strings = on`, which the engine sets on every
10
+ * connection so the server reads string boundaries exactly as this lexer does.
11
+ */
12
+
13
+ /** One lexical unit. Strings, comments and dollar-quoted bodies produce nothing. */
14
+ export interface PgToken {
15
+ readonly kind: "word" | "punct" | "number" | "param"
16
+ /** Identifier text (quotes removed, `""` collapsed) or the punctuation character. */
17
+ readonly value: string
18
+ /** An unquoted word - the only kind that can be a keyword. */
19
+ readonly bare: boolean
20
+ /** Where the token starts in the source. */
21
+ readonly offset: number
22
+ }
23
+
24
+ export interface PgLexResult {
25
+ readonly tokens: readonly PgToken[]
26
+ /** A string, quoted identifier, comment or dollar-quoted body never closed. */
27
+ readonly unterminated: boolean
28
+ /** A `U&"..."` identifier: its escapes can spell any name, so it is refused outright. */
29
+ readonly unicodeIdentifier: boolean
30
+ }
31
+
32
+ const WORD_START = /[A-Za-z_\u0080-￿]/
33
+ const WORD_PART = /[A-Za-z0-9_$\u0080-￿]/
34
+ const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-￿][A-Za-z0-9_\u0080-￿]*)?\$/
35
+
36
+ /** Tokenize Postgres SQL far enough to see statements, parentheses and function calls. */
37
+ export function lexPostgres(sql: string): PgLexResult {
38
+ const tokens: PgToken[] = []
39
+ let unterminated = false
40
+ let unicodeIdentifier = false
41
+ let i = 0
42
+ const skipString = (escapes: boolean): void => {
43
+ i++ // past the opening quote
44
+ while (i < sql.length) {
45
+ const char = sql[i]
46
+ if (escapes && char === "\\") {
47
+ i += 2
48
+ continue
49
+ }
50
+ if (char === "'") {
51
+ if (sql[i + 1] === "'") {
52
+ i += 2
53
+ continue
54
+ }
55
+ i++
56
+ return
57
+ }
58
+ i++
59
+ }
60
+ unterminated = true
61
+ }
62
+ while (i < sql.length) {
63
+ const char = sql.charAt(i)
64
+ const next = sql[i + 1]
65
+ if (/\s/.test(char)) {
66
+ i++
67
+ } else if (char === "-" && next === "-") {
68
+ const end = sql.indexOf("\n", i)
69
+ i = end === -1 ? sql.length : end + 1
70
+ } else if (char === "/" && next === "*") {
71
+ // Postgres block comments nest.
72
+ let depth = 1
73
+ i += 2
74
+ while (i < sql.length && depth > 0) {
75
+ if (sql[i] === "/" && sql[i + 1] === "*") {
76
+ depth++
77
+ i += 2
78
+ } else if (sql[i] === "*" && sql[i + 1] === "/") {
79
+ depth--
80
+ i += 2
81
+ } else i++
82
+ }
83
+ if (depth > 0) unterminated = true
84
+ } else if (char === "'") {
85
+ skipString(false)
86
+ } else if ((char === "E" || char === "e") && next === "'") {
87
+ i++
88
+ skipString(true)
89
+ } else if ((char === "U" || char === "u") && next === "&" && sql[i + 2] === "'") {
90
+ i += 2
91
+ skipString(false)
92
+ } else if ((char === "U" || char === "u") && next === "&" && sql[i + 2] === '"') {
93
+ unicodeIdentifier = true
94
+ i += 2
95
+ } else if (
96
+ (char === "B" ||
97
+ char === "b" ||
98
+ char === "X" ||
99
+ char === "x" ||
100
+ char === "N" ||
101
+ char === "n") &&
102
+ next === "'"
103
+ ) {
104
+ i++
105
+ skipString(false)
106
+ } else if (char === '"') {
107
+ const offset = i
108
+ let value = ""
109
+ let closed = false
110
+ i++
111
+ while (i < sql.length) {
112
+ if (sql[i] === '"') {
113
+ if (sql[i + 1] === '"') {
114
+ value += '"'
115
+ i += 2
116
+ continue
117
+ }
118
+ i++
119
+ closed = true
120
+ break
121
+ }
122
+ value += sql[i]
123
+ i++
124
+ }
125
+ if (!closed) unterminated = true
126
+ tokens.push({ kind: "word", value, bare: false, offset })
127
+ } else if (char === "$") {
128
+ const tag = DOLLAR_TAG.exec(sql.slice(i, i + 64))?.[0]
129
+ if (tag !== undefined) {
130
+ const end = sql.indexOf(tag, i + tag.length)
131
+ if (end === -1) {
132
+ unterminated = true
133
+ i = sql.length
134
+ } else i = end + tag.length
135
+ } else if (next !== undefined && next >= "0" && next <= "9") {
136
+ const start = i
137
+ i++
138
+ while (i < sql.length && /[0-9]/.test(sql.charAt(i))) i++
139
+ tokens.push({ kind: "param", value: sql.slice(start, i), bare: false, offset: start })
140
+ } else {
141
+ tokens.push({ kind: "punct", value: char, bare: false, offset: i })
142
+ i++
143
+ }
144
+ } else if (WORD_START.test(char)) {
145
+ const start = i
146
+ while (i < sql.length && WORD_PART.test(sql.charAt(i))) i++
147
+ tokens.push({ kind: "word", value: sql.slice(start, i), bare: true, offset: start })
148
+ } else if (
149
+ (char >= "0" && char <= "9") ||
150
+ (char === "." && next !== undefined && /[0-9]/.test(next))
151
+ ) {
152
+ // Digits only: a letter right after a number starts a new word, so `1pg_sleep(` still shows
153
+ // the call (older servers lex it the same way).
154
+ const start = i
155
+ while (i < sql.length && /[0-9_.]/.test(sql.charAt(i))) i++
156
+ if ((sql[i] === "e" || sql[i] === "E") && /[0-9+-]/.test(sql[i + 1] ?? "")) {
157
+ i += 2
158
+ while (i < sql.length && /[0-9]/.test(sql.charAt(i))) i++
159
+ }
160
+ tokens.push({ kind: "number", value: sql.slice(start, i), bare: false, offset: start })
161
+ } else {
162
+ tokens.push({ kind: "punct", value: char, bare: false, offset: i })
163
+ i++
164
+ }
165
+ }
166
+ return { tokens, unterminated, unicodeIdentifier }
167
+ }
168
+
169
+ /**
170
+ * Function-name prefixes the parser layer refuses. Each reaches outside a read-only query or runs SQL
171
+ * from a string the plan check cannot see; the server lets a read-only transaction call most of them.
172
+ */
173
+ export const DENIED_FUNCTION_PREFIXES: readonly string[] = [
174
+ "dblink",
175
+ "lo_",
176
+ "pg_read_",
177
+ "pg_ls_",
178
+ "pg_stat_file",
179
+ "pg_terminate_backend",
180
+ "pg_cancel_backend",
181
+ "pg_reload_conf",
182
+ "set_config",
183
+ "pg_advisory",
184
+ "pg_try_advisory",
185
+ "pg_notify",
186
+ "query_to_xml",
187
+ "table_to_xml",
188
+ "cursor_to_xml",
189
+ "schema_to_xml",
190
+ "database_to_xml",
191
+ "pg_file_",
192
+ "pg_logical_emit_message",
193
+ "pg_stat_reset",
194
+ "pg_stat_get_",
195
+ "pg_switch_wal",
196
+ "pg_create_restore_point",
197
+ "pg_promote",
198
+ "pg_rotate_logfile",
199
+ "nextval",
200
+ "setval",
201
+ ]
202
+
203
+ const READ_HEADS = new Set(["select", "with", "values", "table"])
204
+ const LOCKING_STRENGTHS = new Set(["update", "share", "no", "key"])
205
+
206
+ /** Why the parser layer refused a statement. */
207
+ export type PgLintRefusal =
208
+ | { readonly kind: "statement"; readonly message: string }
209
+ | { readonly kind: "function"; readonly message: string; readonly name: string }
210
+
211
+ /**
212
+ * Lint one statement: a single read-only query with balanced parentheses, no unicode-escaped
213
+ * identifier and no denied function call. `undefined` when it passes.
214
+ */
215
+ export function lintPostgres(sql: string): PgLintRefusal | undefined {
216
+ const { tokens, unterminated, unicodeIdentifier } = lexPostgres(sql)
217
+ if (unterminated) {
218
+ return { kind: "statement", message: "the statement has an unterminated string or comment" }
219
+ }
220
+ if (unicodeIdentifier) {
221
+ return { kind: "statement", message: 'unicode-escaped identifiers (U&"...") are not accepted' }
222
+ }
223
+ const terminator = tokens.findIndex((token) => token.kind === "punct" && token.value === ";")
224
+ if (terminator !== -1 && tokens.slice(terminator + 1).length > 0) {
225
+ return { kind: "statement", message: "only a single statement is allowed" }
226
+ }
227
+ const body = terminator === -1 ? tokens : tokens.slice(0, terminator)
228
+ const head = body.find((token) => !(token.kind === "punct" && token.value === "("))
229
+ if (head === undefined) return { kind: "statement", message: "empty query" }
230
+ if (!head.bare || !READ_HEADS.has(head.value.toLowerCase())) {
231
+ return {
232
+ kind: "statement",
233
+ message: `only a read-only query (SELECT, WITH, VALUES or TABLE) is allowed, not ${head.value.toUpperCase()}`,
234
+ }
235
+ }
236
+ let depth = 0
237
+ for (const [index, token] of body.entries()) {
238
+ if (token.kind !== "punct" && token.kind !== "word") continue
239
+ const next = body[index + 1]
240
+ if (
241
+ token.bare &&
242
+ token.value.toLowerCase() === "for" &&
243
+ next?.bare === true &&
244
+ LOCKING_STRENGTHS.has(next.value.toLowerCase())
245
+ ) {
246
+ return {
247
+ kind: "statement",
248
+ message: "row-locking clauses (FOR UPDATE, FOR SHARE) are refused: they take locks",
249
+ }
250
+ }
251
+ if (token.value === "(" && token.kind === "punct") {
252
+ depth++
253
+ const callee = body[index - 1]
254
+ if (callee?.kind === "word") {
255
+ const name = callee.value.toLowerCase()
256
+ if (DENIED_FUNCTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
257
+ return {
258
+ kind: "function",
259
+ name,
260
+ message: `${name}() is refused: it reaches outside a read-only query`,
261
+ }
262
+ }
263
+ }
264
+ } else if (token.value === ")" && token.kind === "punct") {
265
+ depth--
266
+ if (depth < 0) {
267
+ return { kind: "statement", message: "the statement closes a parenthesis it never opened" }
268
+ }
269
+ }
270
+ }
271
+ if (depth !== 0) return { kind: "statement", message: "the statement leaves a parenthesis open" }
272
+ return undefined
273
+ }
274
+
275
+ /** The statement without a final `;` (comments after it included), ready to wrap in a subquery. */
276
+ export function withoutTerminator(sql: string): string {
277
+ const last = lexPostgres(sql).tokens.at(-1)
278
+ return last?.kind === "punct" && last.value === ";" ? sql.slice(0, last.offset) : sql
279
+ }