@nifrajs/mcp-db 5.5.0 → 5.6.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/src/index.ts CHANGED
@@ -31,14 +31,9 @@ import {
31
31
  fitToBytes,
32
32
  gateSqliteStatement,
33
33
  type SqliteDatabaseLike,
34
- unexposedPlanRelation,
35
34
  unexposedProgramRelation,
36
35
  } from "./internal/shared.ts"
37
- import {
38
- openQuerySession,
39
- type QuerySession,
40
- QueryTimeoutError,
41
- } from "./internal/sqlite-session.ts"
36
+ import { openQuerySession, QueryTimeoutError } from "./internal/sqlite-session.ts"
42
37
 
43
38
  export type { SqliteDatabaseLike } from "./internal/shared.ts"
44
39
 
@@ -65,7 +60,8 @@ export interface RunQueryOptions {
65
60
  */
66
61
  readonly maxResultBytes?: number
67
62
  /**
68
- * Maximum wall-clock time for planning, execution, and optional counting (default 5 seconds).
63
+ * Maximum wall-clock time for planning, execution, and optional counting (default 5 seconds). It
64
+ * also bounds `list_tables`' row counts, at the same 5-second default when `runQuery` is omitted.
69
65
  * The deadline always bounds the RESPONSE. Whether it also stops the WORK depends on where the
70
66
  * query runs: a file-backed database runs it in a reusable read-only worker that is terminated
71
67
  * on expiry, while an in-memory database (or a shim with no `filename`) runs it on this thread,
@@ -94,7 +90,7 @@ export interface ServeDatabaseAsMcpOptions {
94
90
  /**
95
91
  * Set `PRAGMA query_only = ON` on the connection at construction (default true). Disable ONLY
96
92
  * when the same connection must also serve writes elsewhere in the app - with it disabled,
97
- * `run_query` still gates on SELECT/WITH + plan verification, but the engine-level guarantee is
93
+ * `run_query` still gates on SELECT/WITH + compiled-statement verification, but the engine-level guarantee is
98
94
  * yours to provide (e.g. open a second, read-only connection for the MCP mount - preferred).
99
95
  */
100
96
  readonly enforceQueryOnly?: boolean
@@ -113,6 +109,10 @@ export class McpDbConfigError extends Error {
113
109
  }
114
110
  }
115
111
 
112
+ // A count reads the whole table and the schema tools answer anyone, so one count per table serves
113
+ // every listing for this long instead of each call scanning again.
114
+ const ROW_COUNT_TTL_MS = 10_000
115
+
116
116
  /** A safe SQL identifier - quoting is not enough for PRAGMA args, so reject instead. */
117
117
  const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
118
118
 
@@ -176,21 +176,44 @@ export function serveDatabaseAsMcp(
176
176
  assertResultLimit(queryTimeoutMs, "queryTimeoutMs")
177
177
  assertResultLimit(maxConcurrentQueries, "maxConcurrentQueries")
178
178
  let activeQueries = 0
179
- let session: QuerySession | undefined
179
+ // One lane for the life of the server, picked once here and shared by the row counts and run_query.
180
+ const session = openQuerySession(db)
181
+
182
+ const countTables = async (): Promise<{ table: string; rows: unknown }[]> => {
183
+ const deadline = Date.now() + queryTimeoutMs
184
+ const tables: { table: string; rows: unknown }[] = []
185
+ for (const table of options.tables) {
186
+ let rows: unknown = null
187
+ try {
188
+ const [count] = await session.run(
189
+ // nifra-expect sql-dynamic: table name from the operator-configured allowlist, escaped by sqlIdentifier; SQL cannot bind an identifier as a parameter
190
+ sqlIdentifiers`SELECT count(*) AS n FROM "${table}"`,
191
+ deadline,
192
+ )
193
+ if (typeof count === "object" && count !== null && "n" in count) rows = count.n
194
+ } catch (error) {
195
+ if (!(error instanceof QueryTimeoutError)) throw error
196
+ }
197
+ tables.push({ table, rows })
198
+ }
199
+ return tables
200
+ }
201
+ let listing: { readonly at: number; readonly tables: ReturnType<typeof countTables> } | undefined
180
202
 
181
203
  const listTables = defineMcpTool({
182
204
  name: "list_tables",
183
- description: "List the tables this server exposes, with row counts.",
184
- handler: () => {
185
- const rows = options.tables.map((table) => {
186
- // nifra-expect sql-dynamic: table name from the operator-configured allowlist, escaped by sqlIdentifier; SQL cannot bind an identifier as a parameter
187
- const [count] = db
188
- .prepare(sqlIdentifiers`SELECT count(*) AS n FROM "${table}"`)
189
- .all() as Array<{
190
- n: number
191
- }>
192
- return { table, rows: count?.n ?? 0 }
193
- })
205
+ description:
206
+ "List the tables this server exposes, with row counts (refreshed at most every 10 seconds; null when a count runs past the query time limit).",
207
+ handler: async () => {
208
+ if (listing === undefined || Date.now() - listing.at >= ROW_COUNT_TTL_MS) {
209
+ const tables = countTables()
210
+ const current = { at: Date.now(), tables }
211
+ listing = current
212
+ tables.catch(() => {
213
+ if (listing === current) listing = undefined
214
+ })
215
+ }
216
+ const rows = await listing.tables
194
217
  return { text: JSON.stringify(rows), structuredContent: { tables: rows } }
195
218
  },
196
219
  })
@@ -218,9 +241,6 @@ export function serveDatabaseAsMcp(
218
241
  const tools = [listTables, describeTable]
219
242
 
220
243
  if (options.runQuery !== undefined) {
221
- // One lane for the life of the server: picked once here, so the handler below always has it.
222
- const querySession = openQuerySession(db)
223
- session = querySession
224
244
  const runQuery = defineMcpTool({
225
245
  name: "run_query",
226
246
  description:
@@ -245,35 +265,24 @@ export function serveDatabaseAsMcp(
245
265
  if (!gate.ok) return { isError: true, text: gate.text }
246
266
 
247
267
  const deadline = Date.now() + queryTimeoutMs
248
-
249
- // Verify via the query plan that only allowlisted tables are touched. SQLite names every
250
- // scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
251
268
  const query = gate.query
252
- let planRows: Array<{ detail?: unknown }>
253
- try {
254
- planRows = (await querySession.run(`EXPLAIN QUERY PLAN ${query}`, deadline)) as Array<{
255
- detail?: unknown
256
- }>
257
- } catch (error) {
258
- if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
259
- return { isError: true, text: `query failed to plan: ${String(error)}` }
260
- }
261
269
  let catalog: unknown[]
262
270
  let program: unknown[]
263
271
  try {
264
- catalog = await querySession.run(
272
+ catalog = await session.run(
265
273
  "SELECT tbl_name, rootpage FROM sqlite_master WHERE rootpage > 0",
266
274
  deadline,
267
275
  )
268
- program = await querySession.run(`EXPLAIN ${query}`, deadline)
276
+ program = await session.run(`EXPLAIN ${query}`, deadline)
269
277
  } catch (error) {
270
278
  if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
271
279
  return { isError: true, text: `query failed to plan: ${String(error)}` }
272
280
  }
273
- // The plan names a scan by its alias; the program names the table by its root page.
274
- const unexposed =
275
- unexposedPlanRelation(planRows, (relation) => allowlist.has(relation)) ??
276
- unexposedProgramRelation(program, catalog, (relation) => allowlist.has(relation))
281
+ // The program names each table it opens by root page. EXPLAIN QUERY PLAN cannot stand in:
282
+ // it names a scan by its alias (`FROM habits h` plans as `SCAN h`).
283
+ const unexposed = unexposedProgramRelation(program, catalog, (relation) =>
284
+ allowlist.has(relation),
285
+ )
277
286
  if (unexposed !== undefined) {
278
287
  return {
279
288
  isError: true,
@@ -288,11 +297,11 @@ export function serveDatabaseAsMcp(
288
297
  // cannot make the driver materialize every row. Where the driver names the columns,
289
298
  // oversized cells and rows past the byte budget are never copied out either.
290
299
  const bounded = boundedSqliteQuery(query, maxRows)
291
- const columns = await querySession.columns(bounded, deadline)
300
+ const columns = await session.columns(bounded, deadline)
292
301
  if (columns.length === 0) {
293
- limitedRows = await querySession.run(bounded, deadline)
302
+ limitedRows = await session.run(bounded, deadline)
294
303
  } else {
295
- const read = await querySession.read(
304
+ const read = await session.read(
296
305
  cellBoundedSqliteQuery(query, columns, maxRows, maxResultBytes),
297
306
  deadline,
298
307
  maxResultBytes,
@@ -309,10 +318,9 @@ export function serveDatabaseAsMcp(
309
318
  let total: number | null = limitedRows.length
310
319
  if (wasLimited && options.runQuery?.exactTotal === true) {
311
320
  try {
312
- const countRows = (await querySession.run(
313
- countSqliteQuery(query),
314
- deadline,
315
- )) as Array<{ __nifra_total?: unknown }>
321
+ const countRows = (await session.run(countSqliteQuery(query), deadline)) as Array<{
322
+ __nifra_total?: unknown
323
+ }>
316
324
  const count = countRows[0]?.__nifra_total
317
325
  if (
318
326
  typeof count !== "number" ||
@@ -357,7 +365,7 @@ export function serveDatabaseAsMcp(
357
365
  })
358
366
 
359
367
  const runQueryOptions = options.runQuery
360
- if (session === undefined || runQueryOptions === undefined) return server
368
+ if (runQueryOptions === undefined) return { ...server, close: () => session.close() }
361
369
 
362
370
  // `run_query` is authorized at the TRANSPORT boundary, where the inbound Request's credentials
363
371
  // are visible - the handler itself performs no auth, so every path to it must pass this gate:
@@ -42,8 +42,10 @@ export interface PostgresClient {
42
42
 
43
43
  /** Run caller text as ONE extended-protocol statement with no parameters. */
44
44
  export function extended(connection: PgConnection, text: string): PgQuery {
45
- const strings: TemplateStringsArray = Object.assign([text], { raw: [text] })
46
- return connection(strings)
45
+ const strings = Object.assign([text], { raw: Object.freeze([text]) })
46
+ // Shaped like a real template object: Bun.SQL (1.4.4+) refuses one whose `raw` is enumerable.
47
+ Object.defineProperty(strings, "raw", { enumerable: false })
48
+ return connection(Object.freeze(strings))
47
49
  }
48
50
 
49
51
  const WRITE_KEYWORD =
@@ -98,6 +100,10 @@ function millis(timeoutMs: number): number {
98
100
  * Run `work` inside `BEGIN READ ONLY` on one reserved connection, with the statement, lock and
99
101
  * idle-in-transaction timeouts set for this transaction only. Always rolls back, so nothing a call
100
102
  * does (a `set_config`, a session setting) outlives it.
103
+ *
104
+ * JIT is off too: the catalog reads' recursive subplans cross `jit_above_cost`, and compiling them
105
+ * took past the timeout on a stock Ubuntu server where running them takes under a millisecond.
106
+ * PostgreSQL 10 has no `jit` setting, so the version is checked first.
101
107
  */
102
108
  export async function inReadOnlyTransaction<T>(
103
109
  client: PostgresClient,
@@ -114,7 +120,7 @@ export async function inReadOnlyTransaction<T>(
114
120
  try {
115
121
  // nifra-expect sql-dynamic: fixed statements; the one interpolation is a validated integer of milliseconds
116
122
  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}`,
123
+ `BEGIN READ ONLY; SET LOCAL statement_timeout = ${ms}; SET LOCAL lock_timeout = ${ms}; SET LOCAL idle_in_transaction_session_timeout = ${ms}; SELECT pg_catalog.set_config('jit', 'off', true) WHERE pg_catalog.current_setting('server_version_num')::int >= 110000`,
118
124
  )
119
125
  return await work(connection)
120
126
  } catch (error) {
@@ -184,6 +184,10 @@ export const DENIED_FUNCTION_PREFIXES: readonly string[] = [
184
184
  "pg_try_advisory",
185
185
  "pg_notify",
186
186
  "query_to_xml",
187
+ "ts_stat",
188
+ "ts_rewrite",
189
+ "crosstab",
190
+ "connectby",
187
191
  "table_to_xml",
188
192
  "cursor_to_xml",
189
193
  "schema_to_xml",
@@ -272,6 +276,106 @@ export function lintPostgres(sql: string): PgLintRefusal | undefined {
272
276
  return undefined
273
277
  }
274
278
 
279
+ // Postgres' reserved and type/function-name keywords: none of them can be a bare table name or alias.
280
+ const NOT_A_NAME = new Set(
281
+ `
282
+ all analyse analyze and any array as asc asymmetric both case cast check collate column
283
+ constraint create current_catalog current_date current_role current_time current_timestamp
284
+ current_user default deferrable desc distinct do else end except false fetch for foreign from
285
+ grant group having in initially intersect into lateral leading limit localtime localtimestamp
286
+ not null offset on only or order placing primary references returning select session_user some
287
+ symmetric system_user table then to trailing true union unique user using variadic when where
288
+ window with authorization binary collation concurrently cross current_schema freeze full ilike
289
+ inner is isnull join left like natural notnull outer overlaps right similar tablesample verbose
290
+ `
291
+ .trim()
292
+ .split(/\s+/),
293
+ )
294
+
295
+ // Words that close a FROM list at their own parenthesis depth.
296
+ const FROM_LIST_ENDS = new Set(
297
+ `
298
+ where group having window order limit offset fetch for union intersect except returning select
299
+ `
300
+ .trim()
301
+ .split(/\s+/),
302
+ )
303
+
304
+ const isPunct = (token: PgToken | undefined, value: string): boolean =>
305
+ token?.kind === "punct" && token.value === value
306
+ const isKeyword = (token: PgToken | undefined, word: string): boolean =>
307
+ token?.kind === "word" && token.bare && token.value.toLowerCase() === word
308
+ const isName = (token: PgToken | undefined): token is PgToken =>
309
+ token?.kind === "word" && !(token.bare && NOT_A_NAME.has(token.value.toLowerCase()))
310
+
311
+ /** A table reference starting at `start` that carries a column alias list: `[ONLY] name [*] [AS] alias (`. */
312
+ function aliasListAt(
313
+ tokens: readonly PgToken[],
314
+ start: number,
315
+ ): { readonly table: string; readonly alias: string } | undefined {
316
+ let at = start
317
+ const only = isKeyword(tokens[at], "only")
318
+ if (only) at++
319
+ const parenthesized = only && isPunct(tokens[at], "(")
320
+ if (parenthesized) at++
321
+ let name = tokens[at]
322
+ if (!isName(name)) return undefined
323
+ at++
324
+ for (let part = tokens[at + 1]; isPunct(tokens[at], ".") && isName(part); part = tokens[at + 1]) {
325
+ name = part
326
+ at += 2
327
+ }
328
+ if (parenthesized) {
329
+ if (!isPunct(tokens[at], ")")) return undefined
330
+ at++
331
+ } else if (isPunct(tokens[at], "(")) return undefined // a function in FROM, whose alias list is safe
332
+ if (isPunct(tokens[at], "*")) at++
333
+ if (isKeyword(tokens[at], "as")) at++
334
+ const alias = tokens[at]
335
+ if (!isName(alias) || !isPunct(tokens[at + 1], "(")) return undefined
336
+ return { table: name.value, alias: alias.value }
337
+ }
338
+
339
+ /**
340
+ * Every table the statement gives a column alias list, as in `FROM users AS t(a, b)`. A verbose plan
341
+ * prints such a scan's columns by the alias names (its partitions' scans too), and the list can hand a
342
+ * real column's name to another one, so no check on the plan's column names can see what is read.
343
+ * A function's or a subquery's alias list is not reported: the plan prints the base columns under them.
344
+ */
345
+ export function tableAliasLists(
346
+ sql: string,
347
+ ): readonly { readonly table: string; readonly alias: string }[] {
348
+ const { tokens } = lexPostgres(sql)
349
+ const found: { readonly table: string; readonly alias: string }[] = []
350
+ // Per parenthesis depth: is a FROM list open there.
351
+ const inFromList: boolean[] = [false]
352
+ let itemStart = false
353
+ for (const [index, token] of tokens.entries()) {
354
+ const starts: boolean = itemStart
355
+ itemStart = false
356
+ if (starts) {
357
+ const listed = aliasListAt(tokens, index)
358
+ if (listed !== undefined) found.push(listed)
359
+ }
360
+ if (isPunct(token, "(")) {
361
+ // A parenthesized join holds a FROM list of its own; a subquery's SELECT closes it.
362
+ inFromList.push(starts)
363
+ itemStart = starts
364
+ } else if (isPunct(token, ")")) {
365
+ if (inFromList.length > 1) inFromList.pop()
366
+ } else if (isPunct(token, ",")) {
367
+ itemStart = inFromList.at(-1) === true
368
+ } else if (token.kind === "word" && token.bare) {
369
+ const word = token.value.toLowerCase()
370
+ if (word === "from" || word === "join") {
371
+ inFromList[inFromList.length - 1] = true
372
+ itemStart = true
373
+ } else if (FROM_LIST_ENDS.has(word)) inFromList[inFromList.length - 1] = false
374
+ }
375
+ }
376
+ return found
377
+ }
378
+
275
379
  /** The statement without a final `;` (comments after it included), ready to wrap in a subquery. */
276
380
  export function withoutTerminator(sql: string): string {
277
381
  const last = lexPostgres(sql).tokens.at(-1)
@@ -110,13 +110,83 @@ export function isDbRefusal(value: unknown): value is DbRefusal {
110
110
  // Statement gates
111
111
  // ---------------------------------------------------------------------------------------------------
112
112
 
113
- const stripSqlNoise = (sql: string): string =>
114
- sql
115
- // Strings first so comment markers inside literals don't count.
116
- .replace(/'(?:[^']|'')*'/g, "''")
117
- .replace(/"(?:[^"]|"")*"/g, '""')
118
- .replace(/--[^\n]*/g, " ")
119
- .replace(/\/\*[\s\S]*?\*\//g, " ")
113
+ // SQLite's four quotings, by opening delimiter: a 'literal' and the "x", `x` and [x] identifiers. Only
114
+ // the bracket form has no doubled-delimiter escape.
115
+ const QUOTE_CLOSE = new Map([
116
+ ["'", "'"],
117
+ ['"', '"'],
118
+ ["`", "`"],
119
+ ["[", "]"],
120
+ ])
121
+
122
+ /** The index just past the quoted run opening at `start`, or the text's length when it never closes. */
123
+ function quotedEnd(sql: string, start: number, open: string, close: string): number {
124
+ for (let i = start + 1; i < sql.length; i++) {
125
+ if (sql[i] !== close) continue
126
+ if (open !== "[" && sql[i + 1] === close) i++
127
+ else return i + 1
128
+ }
129
+ return sql.length
130
+ }
131
+
132
+ /** The index just past a comment opening at `start`, or `undefined` when none opens there. An open
133
+ * block comment runs to the end of the text, as SQLite reads it. */
134
+ function commentEnd(sql: string, start: number): number | undefined {
135
+ if (sql[start] === "-" && sql[start + 1] === "-") {
136
+ const end = sql.indexOf("\n", start + 2)
137
+ return end === -1 ? sql.length : end + 1
138
+ }
139
+ if (sql[start] === "/" && sql[start + 1] === "*") {
140
+ const end = sql.indexOf("*/", start + 2)
141
+ return end === -1 ? sql.length : end + 2
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ /** `sql` with each literal and quoted identifier emptied and each comment turned into a space. */
147
+ function stripSqlNoise(sql: string): string {
148
+ let out = ""
149
+ let from = 0
150
+ let i = 0
151
+ while (i < sql.length) {
152
+ const char = sql.charAt(i)
153
+ const comment = commentEnd(sql, i)
154
+ const close = QUOTE_CLOSE.get(char)
155
+ if (comment !== undefined) {
156
+ out += `${sql.slice(from, i)} `
157
+ i = from = comment
158
+ } else if (close !== undefined) {
159
+ out += `${sql.slice(from, i)}${char}${close}`
160
+ i = from = quotedEnd(sql, i, char, close)
161
+ } else {
162
+ i++
163
+ }
164
+ }
165
+ return out + sql.slice(from)
166
+ }
167
+
168
+ /**
169
+ * Where the statement in `sql` ends: `end` is just past its last character of code (a trailing comment
170
+ * is not code, so the bounded wrapper's closing parenthesis can never land in one), and `terminator` is
171
+ * the index of its first `;` outside a literal, quoted identifier or comment, or -1.
172
+ */
173
+ function statementEnd(sql: string): { readonly end: number; readonly terminator: number } {
174
+ let end = 0
175
+ let i = 0
176
+ while (i < sql.length) {
177
+ const char = sql.charAt(i)
178
+ if (char === ";") return { end, terminator: i }
179
+ const comment = commentEnd(sql, i)
180
+ if (comment !== undefined) {
181
+ i = comment
182
+ continue
183
+ }
184
+ const close = QUOTE_CLOSE.get(char)
185
+ i = close === undefined ? i + 1 : quotedEnd(sql, i, char, close)
186
+ if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r" && char !== "\f") end = i
187
+ }
188
+ return { end, terminator: -1 }
189
+ }
120
190
 
121
191
  // SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
122
192
  // an identifier character; leaving it out would end a word early and split one table name into two.
@@ -167,7 +237,7 @@ function tokenizeSql(sql: string): SqlToken[] {
167
237
  tokens.push({ kind: "word", value, bare: false })
168
238
  }
169
239
  while (i < sql.length) {
170
- const char = sql[i] as string
240
+ const char = sql.charAt(i)
171
241
  if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
172
242
  i++
173
243
  } else if (char === "-" && sql[i + 1] === "-") {
@@ -303,7 +373,6 @@ function relationNames(sql: string): string[] {
303
373
  return [...new Set(names)]
304
374
  }
305
375
 
306
- /** Reject multi-statement input: allow one terminator only when it is the final character. */
307
376
  /**
308
377
  * Names a statement defines as CTEs, column lists included (`WITH RECURSIVE c(x) AS (...)`), which
309
378
  * {@link gateSqliteStatement}'s relation scan does not see past. Only for callers whose own check of
@@ -334,50 +403,10 @@ export function sqliteCteNames(sql: string): string[] {
334
403
  return [...names]
335
404
  }
336
405
 
406
+ /** Reject multi-statement input: allow one terminator only when it is the final character. */
337
407
  const isSingleStatement = (sql: string): boolean => {
338
- let quote: "'" | '"' | null = null
339
- let lineComment = false
340
- let blockComment = false
341
- for (let i = 0; i < sql.length; i++) {
342
- const char = sql[i]
343
- const next = sql[i + 1]
344
- if (lineComment) {
345
- if (char === "\n") lineComment = false
346
- continue
347
- }
348
- if (blockComment) {
349
- if (char === "*" && next === "/") {
350
- blockComment = false
351
- i++
352
- }
353
- continue
354
- }
355
- if (quote !== null) {
356
- if (char === quote) {
357
- if (next === quote) i++
358
- else quote = null
359
- }
360
- continue
361
- }
362
- if (char === "'" || char === '"') {
363
- quote = char
364
- continue
365
- }
366
- if (char === "-" && next === "-") {
367
- lineComment = true
368
- i++
369
- continue
370
- }
371
- if (char === "/" && next === "*") {
372
- blockComment = true
373
- i++
374
- continue
375
- }
376
- if (char === ";") {
377
- return sql.slice(i + 1).trim() === ""
378
- }
379
- }
380
- return true
408
+ const { terminator } = statementEnd(sql)
409
+ return terminator === -1 || sql.slice(terminator + 1).trim() === ""
381
410
  }
382
411
 
383
412
  const isReadStatement = (sql: string): boolean => {
@@ -385,58 +414,8 @@ const isReadStatement = (sql: string): boolean => {
385
414
  return head.startsWith("select") || head.startsWith("with")
386
415
  }
387
416
 
388
- /** Remove one trailing statement terminator (and anything after it) while preserving literals and
389
- * identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
390
- * wrapper's closing parenthesis. */
391
- function executableSql(sql: string): string {
392
- let quote: "'" | '"' | null = null
393
- let lineComment = false
394
- let lineCommentStart = -1
395
- let blockComment = false
396
- for (let i = 0; i < sql.length; i++) {
397
- const char = sql[i]
398
- const next = sql[i + 1]
399
- if (lineComment) {
400
- if (char === "\n") {
401
- lineComment = false
402
- lineCommentStart = -1
403
- }
404
- continue
405
- }
406
- if (blockComment) {
407
- if (char === "*" && next === "/") {
408
- blockComment = false
409
- i++
410
- }
411
- continue
412
- }
413
- if (quote !== null) {
414
- if (char === quote) {
415
- if (next === quote) i++
416
- else quote = null
417
- }
418
- continue
419
- }
420
- if (char === "'" || char === '"') {
421
- quote = char
422
- continue
423
- }
424
- if (char === "-" && next === "-") {
425
- lineComment = true
426
- lineCommentStart = i
427
- i++
428
- continue
429
- }
430
- if (char === "/" && next === "*") {
431
- blockComment = true
432
- i++
433
- continue
434
- }
435
- if (char === ";") return sql.slice(0, i).trim()
436
- }
437
- if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
438
- return sql.trim()
439
- }
417
+ /** The statement without its terminator or a trailing comment, literals and identifiers intact. */
418
+ const executableSql = (sql: string): string => sql.slice(0, statementEnd(sql).end)
440
419
 
441
420
  /** The outcome of {@link gateSqliteStatement}. A refusal's `text` is the message `run_query` returns. */
442
421
  export type SqliteGate =
@@ -451,8 +430,8 @@ export type SqliteGate =
451
430
  /**
452
431
  * Gate one statement before SQLite sees it: non-empty, a single statement, SELECT/WITH only, and every
453
432
  * relation it names after `FROM`/`JOIN`/`IN` exposed. Returns the statement without its terminator, ready
454
- * to wrap. Relation names are checked here as well as in the plan because SQLite's plan names an
455
- * alias as the scan target (`users AS habits` plans as `SCAN habits`).
433
+ * to wrap. The compiled program ({@link unexposedProgramRelation}) remains the authority on the
434
+ * tables a statement reads; this check refuses a named relation before SQLite compiles anything.
456
435
  */
457
436
  export function gateSqliteStatement(
458
437
  input: string,
@@ -486,7 +465,9 @@ export function gateSqliteStatement(
486
465
 
487
466
  /**
488
467
  * The first relation an `EXPLAIN QUERY PLAN` result scans or searches that `exposed` rejects, as the
489
- * plan spells it, or `undefined` when every scanned relation is exposed.
468
+ * plan spells it, or `undefined` when every scanned relation is exposed. The plan names a scan by its
469
+ * alias (`FROM habits h` plans as `SCAN h`, `users AS habits` as `SCAN habits`), so it cannot decide
470
+ * on its own which tables a statement reads: the compiled program names them by root page.
490
471
  */
491
472
  export function unexposedPlanRelation(
492
473
  planRows: readonly { readonly detail?: unknown }[],
package/src/postgres.ts CHANGED
@@ -39,7 +39,12 @@ import {
39
39
  type PostgresClient,
40
40
  records,
41
41
  } from "./internal/pg-exec.ts"
42
- import { DENIED_FUNCTION_PREFIXES, lintPostgres, withoutTerminator } from "./internal/pg-lexer.ts"
42
+ import {
43
+ DENIED_FUNCTION_PREFIXES,
44
+ lintPostgres,
45
+ tableAliasLists,
46
+ withoutTerminator,
47
+ } from "./internal/pg-lexer.ts"
43
48
  import {
44
49
  type DbPlan,
45
50
  type DbRedaction,
@@ -342,9 +347,14 @@ function scopeMatcher(scope: PostgresScope): (relation: PlanRelation) => string
342
347
  * The plan-level table check: each relation, and every table it inherits from (partitions too). A
343
348
  * `pg_*` function in FROM reads server state the way a catalog does - a system view such as
344
349
  * `pg_stat_activity` plans as one once the planner drops its catalog joins - so it is out of scope too.
345
- * With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused.
350
+ * With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused,
351
+ * and so is any masked column of a relation `statement` reads when it gives a table a column alias list.
346
352
  */
347
- function checkScope(scope: PostgresScope, masked?: (column: string) => boolean) {
353
+ function checkScope(
354
+ scope: PostgresScope,
355
+ masked: ((column: string) => boolean) | undefined,
356
+ statement: string,
357
+ ) {
348
358
  const outOfScope = scopeMatcher(scope)
349
359
  return async (connection: PgConnection, reads: PlanReads): Promise<DbRefusal | undefined> => {
350
360
  for (const fn of reads.functions) {
@@ -393,6 +403,9 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
393
403
  if (column === "*") wholeRows.push(relation)
394
404
  else if (masked(column)) hits.add(`${relation.name}.${column}`)
395
405
  }
406
+ // The plan names an alias-listed scan's columns by the list, which can reuse a real column's name.
407
+ const renamed = tableAliasLists(statement)
408
+ if (renamed.length > 0) wholeRows.push(...reads.relations)
396
409
  const listed = [...wholeRows, ...reads.outputs.map((output) => output.relation)]
397
410
  if (listed.length > 0) {
398
411
  const live = new Map<string, string[]>()
@@ -423,6 +436,13 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
423
436
  }
424
437
  }
425
438
  if (hits.size === 0) return undefined
439
+ const [aliased] = renamed
440
+ if (aliased !== undefined) {
441
+ return dbRefusal(
442
+ "NIFRA_DB_COLUMN_REFUSED",
443
+ `the query renames the columns of ${aliased.table} with the alias list ${aliased.alias}(...), so the plan cannot show which columns it reads, and it reads a relation holding ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}; alias columns in the select list instead`,
444
+ )
445
+ }
426
446
  return dbRefusal(
427
447
  "NIFRA_DB_COLUMN_REFUSED",
428
448
  `the query reads ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}`,
@@ -465,7 +485,7 @@ export async function queryPostgres(
465
485
  const fetched = await gated(client, sql, options, (connection, statement) =>
466
486
  fetchThroughCursor(connection, statement, {
467
487
  maxRows: options.maxRows,
468
- scope: checkScope(options, options.redaction?.column),
488
+ scope: checkScope(options, options.redaction?.column, statement),
469
489
  }),
470
490
  )
471
491
  if (isDbRefusal(fetched)) return fetched
@@ -494,7 +514,7 @@ export async function explainPostgres(
494
514
  const explained = await gated(client, sql, options, (connection, statement) =>
495
515
  explainThroughServer(connection, statement, {
496
516
  analyze: options.analyze === true,
497
- scope: checkScope(options, options.redaction?.column),
517
+ scope: checkScope(options, options.redaction?.column, statement),
498
518
  }),
499
519
  )
500
520
  if (isDbRefusal(explained)) return explained