@nifrajs/mcp-db 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,13 @@
6
6
 
7
7
  /** The structural slice of `bun:sqlite`'s `Database` this package needs. */
8
8
  export interface SqliteDatabaseLike {
9
- /** Prepare a statement; `all` runs it and returns row objects. */
10
- prepare(sql: string): { all(...params: unknown[]): unknown[] }
9
+ /** Prepare a statement; `all` runs it and returns row objects. `columnNames` and `iterate`, which
10
+ * bun:sqlite has, let `run_query` bound cell bytes and stop reading rows once the budget is spent. */
11
+ prepare(sql: string): {
12
+ all(...params: unknown[]): unknown[]
13
+ readonly columnNames?: readonly string[]
14
+ iterate?(): Iterable<unknown>
15
+ }
11
16
  /** Execute a statement for its side effect (used only for `PRAGMA query_only`). */
12
17
  run(sql: string): unknown
13
18
  /** Optional path this database was opened from; a file-backed one can be reopened read-only in a
@@ -210,7 +215,7 @@ const isKeyword = (token: SqlToken | undefined, word: string): boolean =>
210
215
  token?.bare === true && token.value.toLowerCase() === word
211
216
 
212
217
  /**
213
- * Every relation the statement names after `FROM`/`JOIN`, lowercased, with CTE names excluded.
218
+ * Every relation the statement names after `FROM`/`JOIN`/`IN`, lowercased, with CTE names excluded.
214
219
  * A schema qualifier resolves to its table (`main.habits` -> `habits`); a subquery contributes
215
220
  * nothing of its own because its inner `FROM` is tokenized alongside everything else.
216
221
  */
@@ -228,18 +233,21 @@ function relationNames(sql: string): string[] {
228
233
  const names: string[] = []
229
234
  const relationAt = (
230
235
  index: number,
231
- ): { readonly name: string; readonly next: number } | undefined => {
236
+ ): { readonly name: string; readonly qualified: boolean; readonly next: number } | undefined => {
232
237
  const first = tokens[index]
233
238
  if (first === undefined || first.kind !== "word") return undefined // `FROM (subquery)` - nothing to name here
234
239
  // A qualified reference names the table second; an unqualified one names it first.
235
240
  const qualified = tokens[index + 1]?.value === "." && tokens[index + 2]?.kind === "word"
236
241
  const table = qualified ? (tokens[index + 2] as SqlToken).value : first.value
237
- return { name: table.toLowerCase(), next: qualified ? index + 3 : index + 1 }
242
+ return { name: table.toLowerCase(), qualified, next: qualified ? index + 3 : index + 1 }
238
243
  }
239
244
  const addRelation = (index: number): number | undefined => {
240
245
  const relation = relationAt(index)
241
246
  if (relation === undefined) return undefined
242
- if (relation.name !== "" && !ctes.has(relation.name)) names.push(relation.name)
247
+ // A CTE is never schema-qualified: `main.users` is the table even beside a CTE named `users`.
248
+ if (relation.name !== "" && (relation.qualified || !ctes.has(relation.name))) {
249
+ names.push(relation.name)
250
+ }
243
251
  return relation.next
244
252
  }
245
253
  // Comma joins are part of the FROM table-source grammar but do not have a JOIN keyword. Walk the
@@ -260,6 +268,11 @@ function relationNames(sql: string): string[] {
260
268
  "returning",
261
269
  ])
262
270
  for (let i = 0; i < tokens.length; i++) {
271
+ // `x IN users` and `x IN json_each(...)` read a relation without a FROM.
272
+ if (isKeyword(tokens[i], "in")) {
273
+ addRelation(i + 1)
274
+ continue
275
+ }
263
276
  const from = isKeyword(tokens[i], "from")
264
277
  const join = isKeyword(tokens[i], "join")
265
278
  if (!from && !join) continue
@@ -437,7 +450,7 @@ export type SqliteGate =
437
450
 
438
451
  /**
439
452
  * Gate one statement before SQLite sees it: non-empty, a single statement, SELECT/WITH only, and every
440
- * relation it names after `FROM`/`JOIN` exposed. Returns the statement without its terminator, ready
453
+ * relation it names after `FROM`/`JOIN`/`IN` exposed. Returns the statement without its terminator, ready
441
454
  * to wrap. Relation names are checked here as well as in the plan because SQLite's plan names an
442
455
  * alias as the scan target (`users AS habits` plans as `SCAN habits`).
443
456
  */
@@ -504,12 +517,79 @@ export function unexposedPlanRelation(
504
517
  return undefined
505
518
  }
506
519
 
520
+ // The opcodes that open a b-tree cursor.
521
+ const BTREE_OPEN_OPCODES = new Set(["OpenRead", "OpenWrite", "ReopenIdx"])
522
+
523
+ const field = (row: unknown, key: string): unknown =>
524
+ typeof row === "object" && row !== null ? Reflect.get(row, key) : undefined
525
+
526
+ /**
527
+ * The first table a compiled statement opens that `exposed` rejects, or `undefined` when every b-tree
528
+ * it opens belongs to an exposed table (an index counts as its table). `program` is the statement's
529
+ * `EXPLAIN` rows and `catalog` is `SELECT tbl_name, rootpage FROM sqlite_master WHERE rootpage > 0`.
530
+ * The program names what SQLite will read by root page, so no alias, CTE name or quoting changes it.
531
+ * A virtual table or table-valued function (`VOpen`) names no table at all and is never exposed.
532
+ */
533
+ export function unexposedProgramRelation(
534
+ program: readonly unknown[],
535
+ catalog: readonly unknown[],
536
+ exposed: (relation: string) => boolean,
537
+ ): string | undefined {
538
+ const tables = new Map<number, string>([[1, "sqlite_master"]])
539
+ for (const entry of catalog) {
540
+ const table = field(entry, "tbl_name")
541
+ if (typeof table === "string") tables.set(Number(field(entry, "rootpage")), table)
542
+ }
543
+ for (const step of program) {
544
+ const opcode = field(step, "opcode")
545
+ if (opcode === "VOpen") return "<virtual table>"
546
+ if (typeof opcode !== "string" || !BTREE_OPEN_OPCODES.has(opcode)) continue
547
+ // P3 is the schema: 0 is main; temp (1) and attached databases (2+) are not in this catalog.
548
+ const schema = Number(field(step, "p3"))
549
+ if (schema !== 0) return `<schema ${schema}>`
550
+ const page = Number(field(step, "p2"))
551
+ const table = tables.get(page)
552
+ if (table === undefined) return `<root page ${page}>`
553
+ if (!exposed(table.toLowerCase())) return table
554
+ }
555
+ return undefined
556
+ }
557
+
507
558
  /** Wrap a gated query so SQLite materializes at most one row beyond `maxRows`. */
508
559
  export function boundedSqliteQuery(query: string, maxRows: number): string {
509
560
  // nifra-expect sql-dynamic: wraps a query already through gateSqliteStatement in a bounding subselect; it runs on a query-only connection
510
561
  return `SELECT * FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`
511
562
  }
512
563
 
564
+ /** The column {@link cellBoundedSqliteQuery} adds to carry each row's cell bytes. */
565
+ export const ROW_BYTES_COLUMN = "__nifra_row_bytes"
566
+
567
+ /**
568
+ * {@link boundedSqliteQuery}, plus two bounds the driver cannot apply itself: a text or blob cell
569
+ * longer than `maxCellBytes` arrives as `"<n bytes>"` instead of being copied out, and each row
570
+ * carries its cells' byte total in {@link ROW_BYTES_COLUMN} so a reader can stop once the result
571
+ * budget is spent. `columns` are the result column names of the bounded query.
572
+ */
573
+ export function cellBoundedSqliteQuery(
574
+ query: string,
575
+ columns: readonly string[],
576
+ maxRows: number,
577
+ maxCellBytes: number,
578
+ ): string {
579
+ const selected: string[] = []
580
+ const sizes: string[] = []
581
+ for (const name of columns) {
582
+ const column = `"${name.replaceAll('"', '""')}"`
583
+ const bytes = `length(CAST(${column} AS BLOB))`
584
+ selected.push(
585
+ `CASE WHEN typeof(${column}) IN ('text', 'blob') AND ${bytes} > ${maxCellBytes} THEN '<' || ${bytes} || ' bytes>' ELSE ${column} END AS ${column}`,
586
+ )
587
+ sizes.push(`min(coalesce(${bytes}, 0), ${maxCellBytes})`)
588
+ }
589
+ // nifra-expect sql-dynamic: wraps a query already through gateSqliteStatement; column names are quoted identifiers and the bounds are validated integers
590
+ return `SELECT ${selected.join(", ")}, ${sizes.join(" + ")} AS "${ROW_BYTES_COLUMN}" FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`
591
+ }
592
+
513
593
  /** Wrap a gated query to count its rows (re-executes it). */
514
594
  export function countSqliteQuery(query: string): string {
515
595
  // nifra-expect sql-dynamic: wraps a query already through gateSqliteStatement in a counting subselect; it runs on a query-only connection
@@ -4,7 +4,7 @@
4
4
  * tool runs each query in its own subprocess instead, which bounds the work by killing the process.
5
5
  */
6
6
 
7
- import type { SqliteDatabaseLike } from "./shared.ts"
7
+ import { ROW_BYTES_COLUMN, type SqliteDatabaseLike } from "./shared.ts"
8
8
 
9
9
  export class QueryTimeoutError extends Error {
10
10
  constructor() {
@@ -13,9 +13,19 @@ export class QueryTimeoutError extends Error {
13
13
  }
14
14
  }
15
15
 
16
- /** One execution lane for `run_query`: `run` resolves rows, or rejects once the deadline passes. */
16
+ /** Rows read until their bytes passed the budget; `cut` says reading stopped early. */
17
+ export interface BoundedRows {
18
+ readonly rows: unknown[]
19
+ readonly cut: boolean
20
+ }
21
+
22
+ /** One execution lane for `run_query`: each call resolves, or rejects once the deadline passes. */
17
23
  export interface QuerySession {
18
24
  run(sql: string, deadline: number): Promise<unknown[]>
25
+ /** The result column names of `sql`, without running it. Empty when the driver cannot say. */
26
+ columns(sql: string, deadline: number): Promise<readonly string[]>
27
+ /** Rows of a `cellBoundedSqliteQuery`, read one at a time until their bytes pass `maxBytes`. */
28
+ read(sql: string, deadline: number, maxBytes: number): Promise<BoundedRows>
19
29
  close(): Promise<void>
20
30
  }
21
31
 
@@ -29,7 +39,7 @@ const SQLITE_WORKER_SOURCE = `
29
39
  const { Database } = require("bun:sqlite")
30
40
  let db
31
41
  self.onmessage = (event) => {
32
- const { id, filename, sql, type } = event.data
42
+ const { id, filename, sql, type, maxBytes } = event.data
33
43
  if (type === "close") {
34
44
  let response
35
45
  try {
@@ -54,7 +64,30 @@ self.onmessage = (event) => {
54
64
  db = new Database(filename, { readonly: true })
55
65
  db.run("PRAGMA query_only = ON")
56
66
  }
57
- self.postMessage({ id, ok: true, rows: db.prepare(sql).all() })
67
+ const statement = db.prepare(sql)
68
+ if (type === "columns") {
69
+ const rows = statement.columnNames
70
+ statement.finalize()
71
+ self.postMessage({ id, ok: true, rows })
72
+ return
73
+ }
74
+ if (type === "read") {
75
+ const rows = []
76
+ let bytes = 0
77
+ let cut = false
78
+ for (const { ${ROW_BYTES_COLUMN}: size, ...row } of statement.iterate()) {
79
+ rows.push(row)
80
+ bytes += Number(size)
81
+ if (bytes > maxBytes) {
82
+ cut = true
83
+ break
84
+ }
85
+ }
86
+ statement.finalize()
87
+ self.postMessage({ id, ok: true, rows, cut })
88
+ return
89
+ }
90
+ self.postMessage({ id, ok: true, rows: statement.all() })
58
91
  } catch (error) {
59
92
  self.postMessage({ id, ok: false, error: String(error) })
60
93
  }
@@ -75,10 +108,16 @@ function reopenableFilename(db: SqliteDatabaseLike): string | undefined {
75
108
 
76
109
  interface PendingQuery {
77
110
  readonly deadline: number
78
- resolve(rows: unknown[]): void
111
+ resolve(result: BoundedRows): void
79
112
  reject(error: Error): void
80
113
  }
81
114
 
115
+ interface WorkerQuery {
116
+ readonly sql: string
117
+ readonly type?: "columns" | "read"
118
+ readonly maxBytes?: number
119
+ }
120
+
82
121
  interface PendingClose {
83
122
  readonly id: number
84
123
  resolve(graceful?: boolean): void
@@ -135,11 +174,12 @@ function workerSession(filename: string): QuerySession {
135
174
  id?: number
136
175
  ok?: boolean
137
176
  rows?: unknown[]
177
+ cut?: boolean
138
178
  error?: string
139
179
  closed?: boolean
140
180
  }>,
141
181
  ) => {
142
- const { id, ok, rows, error } = event.data
182
+ const { id, ok, rows, cut, error } = event.data
143
183
  if (typeof id !== "number") return
144
184
  if (event.data.closed === true) {
145
185
  const closing = pendingClose
@@ -155,7 +195,7 @@ function workerSession(filename: string): QuerySession {
155
195
  return
156
196
  }
157
197
  pending.delete(id)
158
- if (ok === true) waiter.resolve(rows ?? [])
198
+ if (ok === true) waiter.resolve({ rows: rows ?? [], cut: cut === true })
159
199
  else waiter.reject(new Error(error ?? "query failed in worker"))
160
200
  }
161
201
  spawned.onerror = () => discard(new Error("query failed in worker"))
@@ -166,7 +206,7 @@ function workerSession(filename: string): QuerySession {
166
206
  return spawned
167
207
  }
168
208
 
169
- const dispatch = (sql: string, deadline: number): Promise<unknown[]> => {
209
+ const dispatch = (query: WorkerQuery, deadline: number): Promise<BoundedRows> => {
170
210
  if (closed) return Promise.reject(new Error("query session is closed"))
171
211
  const active = ensureWorker()
172
212
  const remaining = deadline - Date.now()
@@ -175,13 +215,13 @@ function workerSession(filename: string): QuerySession {
175
215
  return Promise.reject(new QueryTimeoutError())
176
216
  }
177
217
  const id = ++nextId
178
- return new Promise<unknown[]>((resolve, reject) => {
218
+ return new Promise<BoundedRows>((resolve, reject) => {
179
219
  const timer = setTimeout(() => discard(new QueryTimeoutError()), remaining)
180
220
  pending.set(id, {
181
221
  deadline,
182
- resolve: (rows) => {
222
+ resolve: (result) => {
183
223
  clearTimeout(timer)
184
- resolve(rows)
224
+ resolve(result)
185
225
  },
186
226
  reject: (error) => {
187
227
  clearTimeout(timer)
@@ -189,7 +229,7 @@ function workerSession(filename: string): QuerySession {
189
229
  },
190
230
  })
191
231
  try {
192
- active.postMessage({ id, filename, sql })
232
+ active.postMessage({ id, filename, ...query })
193
233
  } catch (error) {
194
234
  pending.delete(id)
195
235
  clearTimeout(timer)
@@ -198,14 +238,21 @@ function workerSession(filename: string): QuerySession {
198
238
  })
199
239
  }
200
240
 
241
+ // One worker owns the connection, so overlapping calls queue behind each other rather than
242
+ // interleaving statements on it.
243
+ const queue = (query: WorkerQuery, deadline: number): Promise<BoundedRows> => {
244
+ const result = lane.then(() => dispatch(query, deadline))
245
+ lane = result.catch(() => undefined)
246
+ return result
247
+ }
248
+
201
249
  return {
202
- run(sql, deadline) {
203
- // One worker owns the connection, so overlapping calls queue behind each other rather than
204
- // interleaving statements on it.
205
- const result = lane.then(() => dispatch(sql, deadline))
206
- lane = result.catch(() => undefined)
207
- return result
208
- },
250
+ run: async (sql, deadline) => (await queue({ sql }, deadline)).rows,
251
+ columns: async (sql, deadline) =>
252
+ (await queue({ sql, type: "columns" }, deadline)).rows.filter(
253
+ (name): name is string => typeof name === "string",
254
+ ),
255
+ read: (sql, deadline, maxBytes) => queue({ sql, type: "read", maxBytes }, deadline),
209
256
  close() {
210
257
  if (closePromise !== undefined) return closePromise
211
258
  closed = true
@@ -257,6 +304,29 @@ function inProcessSession(db: SqliteDatabaseLike): QuerySession {
257
304
  if (Date.now() > deadline) throw new QueryTimeoutError()
258
305
  return rows
259
306
  },
307
+ async columns(sql, deadline) {
308
+ if (Date.now() >= deadline) throw new QueryTimeoutError()
309
+ return db.prepare(sql).columnNames ?? []
310
+ },
311
+ async read(sql, deadline, maxBytes) {
312
+ if (Date.now() >= deadline) throw new QueryTimeoutError()
313
+ const statement = db.prepare(sql)
314
+ const rows: unknown[] = []
315
+ let bytes = 0
316
+ let cut = false
317
+ for (const row of statement.iterate?.() ?? statement.all()) {
318
+ if (typeof row !== "object" || row === null) continue
319
+ bytes += Number(Reflect.get(row, ROW_BYTES_COLUMN))
320
+ Reflect.deleteProperty(row, ROW_BYTES_COLUMN)
321
+ rows.push(row)
322
+ if (bytes > maxBytes) {
323
+ cut = true
324
+ break
325
+ }
326
+ }
327
+ if (Date.now() > deadline) throw new QueryTimeoutError()
328
+ return { rows, cut }
329
+ },
260
330
  async close() {},
261
331
  }
262
332
  }
package/src/postgres.ts CHANGED
@@ -494,7 +494,7 @@ export async function explainPostgres(
494
494
  const explained = await gated(client, sql, options, (connection, statement) =>
495
495
  explainThroughServer(connection, statement, {
496
496
  analyze: options.analyze === true,
497
- scope: checkScope(options),
497
+ scope: checkScope(options, options.redaction?.column),
498
498
  }),
499
499
  )
500
500
  if (isDbRefusal(explained)) return explained
@@ -709,10 +709,10 @@ export interface PostgresRoleSql {
709
709
  }
710
710
 
711
711
  /**
712
- * Write the SQL for a login role that can read the allowed schemas and nothing else: `pg_read_all_data`
713
- * on Postgres 14+ when nothing is excluded; otherwise `GRANT SELECT` per schema plus default
714
- * privileges, and a `REVOKE` for each excluded table and its partitions (`pg_read_all_data` would
715
- * override a REVOKE, so it is not used then). Nothing is executed.
712
+ * Write the SQL for a login role that can read the allowed schemas and nothing else: `GRANT SELECT`
713
+ * per schema plus default privileges, and a `REVOKE` for each excluded table and its partitions.
714
+ * `pg_read_all_data` is never used: it reaches every schema, present and future, and overrides a
715
+ * REVOKE. Nothing is executed.
716
716
  */
717
717
  export async function postgresRoleSql(
718
718
  client: PostgresClient,
@@ -756,21 +756,17 @@ export async function postgresRoleSql(
756
756
  `ALTER ROLE ${who} SET default_transaction_read_only = on;`,
757
757
  `GRANT CONNECT ON DATABASE ${quoteIdent(database)} TO ${who};`,
758
758
  ]
759
- if (version >= 140000 && excluded.length === 0) {
760
- statements.push(`GRANT pg_read_all_data TO ${who};`)
761
- } else {
762
- for (const schema of schemas) {
763
- statements.push(
764
- `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${who};`,
765
- `GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${who};`,
766
- `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${who};`,
767
- )
768
- }
769
- for (const row of excluded) {
770
- statements.push(
771
- `REVOKE SELECT ON TABLE ${quoteIdent(String(row.schema))}.${quoteIdent(String(row.name))} FROM ${who};`,
772
- )
773
- }
759
+ for (const schema of schemas) {
760
+ statements.push(
761
+ `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${who};`,
762
+ `GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${who};`,
763
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${who};`,
764
+ )
765
+ }
766
+ for (const row of excluded) {
767
+ statements.push(
768
+ `REVOKE SELECT ON TABLE ${quoteIdent(String(row.schema))}.${quoteIdent(String(row.name))} FROM ${who};`,
769
+ )
774
770
  }
775
771
  return { role, database, serverVersionNum: version, statements }
776
772
  })