@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/mcp-db",
3
- "version": "3.3.0",
3
+ "version": "4.0.0",
4
4
  "description": "Serve a SQLite database as a fail-closed MCP server - allowlisted schema tools by default, opt-in read-only queries with plan verification.",
5
5
  "keywords": [
6
6
  "bun",
@@ -12,6 +12,9 @@
12
12
  "agent"
13
13
  ],
14
14
  "type": "module",
15
+ "nifra": {
16
+ "environment": "backend"
17
+ },
15
18
  "main": "./dist/index.js",
16
19
  "module": "./dist/index.js",
17
20
  "types": "./dist/index.d.ts",
@@ -20,6 +23,16 @@
20
23
  "bun": "./src/index.ts",
21
24
  "types": "./dist/index.d.ts",
22
25
  "default": "./dist/index.js"
26
+ },
27
+ "./engine": {
28
+ "bun": "./src/engine.ts",
29
+ "types": "./dist/engine.d.ts",
30
+ "default": "./dist/engine.js"
31
+ },
32
+ "./postgres": {
33
+ "bun": "./src/postgres.ts",
34
+ "types": "./dist/postgres.d.ts",
35
+ "default": "./dist/postgres.js"
23
36
  }
24
37
  },
25
38
  "files": [
@@ -35,7 +48,7 @@
35
48
  "access": "public"
36
49
  },
37
50
  "dependencies": {
38
- "@nifrajs/mcp": "3.3.0"
51
+ "@nifrajs/mcp": "4.0.0"
39
52
  },
40
53
  "repository": {
41
54
  "type": "git",
package/src/engine.ts ADDED
@@ -0,0 +1,664 @@
1
+ /**
2
+ * `@nifrajs/mcp-db/engine` - the read-only SQLite engine behind `@nifrajs/mcp-db`, without the MCP
3
+ * layer, plus the result shape and refusal codes both database engines share.
4
+ *
5
+ * Read-only is enforced by SQLite, not promised by a parser: {@link openReadOnlySqlite} opens the file
6
+ * with the `readonly` flag and sets `PRAGMA query_only = ON`, so the engine rejects every write. The
7
+ * statement gates ({@link gateSqliteStatement}: one statement, SELECT/WITH only, every named relation
8
+ * exposed) and a table check on the compiled statement (every table its bytecode opens must be
9
+ * exposed, so views and aliases cannot hide one) sit in front of it, and the caps
10
+ * ({@link shapeRows}, {@link fitToBytes}) bound what comes back.
11
+ *
12
+ * const db = await openReadOnlySqlite("./data/app.db")
13
+ * const result = querySqlite(db, "SELECT status, count(*) FROM orders GROUP BY 1", {
14
+ * exclude: ["sessions"],
15
+ * maxRows: 100,
16
+ * maxResultBytes: 100 * 1024,
17
+ * })
18
+ * if ("code" in result) console.error(result.code, result.fix)
19
+ */
20
+
21
+ import type { Database } from "bun:sqlite"
22
+ import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs"
23
+ import { isAbsolute, relative, resolve, sep } from "node:path"
24
+ import {
25
+ boundedSqliteQuery,
26
+ type DbPlan,
27
+ type DbRedaction,
28
+ type DbRefusal,
29
+ type DbRows,
30
+ type DbSchemaReport,
31
+ type DbSchemaTable,
32
+ dbRefusal,
33
+ fitToBytes,
34
+ gateSqliteStatement,
35
+ type ShapeRowsOptions,
36
+ shapeRows,
37
+ sqliteCteNames,
38
+ } from "./internal/shared.ts"
39
+
40
+ export {
41
+ boundedSqliteQuery,
42
+ countSqliteQuery,
43
+ type DbPlan,
44
+ type DbRedaction,
45
+ type DbRefusal,
46
+ type DbRefusalCode,
47
+ type DbRows,
48
+ type DbSchemaColumn,
49
+ type DbSchemaForeignKey,
50
+ type DbSchemaIndex,
51
+ type DbSchemaReport,
52
+ type DbSchemaTable,
53
+ dbRefusal,
54
+ fitToBytes,
55
+ gateSqliteStatement,
56
+ isDbRefusal,
57
+ REDACTED_CELL,
58
+ type ShapeRowsOptions,
59
+ type SqliteDatabaseLike,
60
+ type SqliteGate,
61
+ shapeRows,
62
+ toJsonCell,
63
+ unexposedPlanRelation,
64
+ } from "./internal/shared.ts"
65
+
66
+ // ---------------------------------------------------------------------------------------------------
67
+ // SQLite: open, scope, query, explain, schema
68
+ // ---------------------------------------------------------------------------------------------------
69
+
70
+ const isInside = (root: string, target: string): boolean => {
71
+ const rel = relative(root, target)
72
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
73
+ }
74
+
75
+ /**
76
+ * Resolve a database file against the project root. The file must exist and its real path (symlinks
77
+ * followed) must sit inside the root's real path, unless `allowFiles` names it.
78
+ */
79
+ export function resolveSqliteFile(
80
+ root: string,
81
+ file: string,
82
+ allowFiles: readonly string[] = [],
83
+ ): string | DbRefusal {
84
+ const candidate = resolve(root, file)
85
+ if (!existsSync(candidate)) return dbRefusal("NIFRA_DB_DRIVER", `no database file at ${file}`)
86
+ let real: string
87
+ let realRoot: string
88
+ try {
89
+ real = realpathSync(candidate)
90
+ realRoot = realpathSync(root)
91
+ } catch (error) {
92
+ return dbRefusal("NIFRA_DB_DRIVER", `could not resolve ${file}: ${String(error)}`)
93
+ }
94
+ if (isInside(realRoot, real)) return real
95
+ for (const allowed of allowFiles) {
96
+ try {
97
+ if (realpathSync(resolve(root, allowed)) === real) return real
98
+ } catch {
99
+ // An allowFiles entry that does not exist allows nothing.
100
+ }
101
+ }
102
+ return dbRefusal(
103
+ "NIFRA_DB_OUTSIDE_ROOT",
104
+ `${file} resolves to ${real}, outside the project root ${realRoot}`,
105
+ )
106
+ }
107
+
108
+ /** True when the file's header says WAL mode (format versions 2/2 at bytes 18-19). */
109
+ function isWalDatabase(file: string): boolean {
110
+ const header = new Uint8Array(20)
111
+ let fd: number | undefined
112
+ try {
113
+ fd = openSync(file, "r")
114
+ return readSync(fd, header, 0, 20, 0) === 20 && header[18] === 2 && header[19] === 2
115
+ } catch {
116
+ return false
117
+ } finally {
118
+ if (fd !== undefined) closeSync(fd)
119
+ }
120
+ }
121
+
122
+ const SQLITE_BUSY_TIMEOUT_MS = 2_000
123
+
124
+ /**
125
+ * Make `close()` finalize every statement prepared on `db`. Otherwise bun:sqlite keeps a connection with
126
+ * live statements open until they are garbage collected, and the process holds the file until then.
127
+ */
128
+ function finalizeOnClose(db: Database): Database {
129
+ const statements: { finalize(): void }[] = []
130
+ const prepare = db.prepare.bind(db)
131
+ const close = db.close.bind(db)
132
+ Object.assign(db, {
133
+ prepare: (...args: Parameters<Database["prepare"]>) => {
134
+ const statement = prepare(...args)
135
+ statements.push(statement)
136
+ return statement
137
+ },
138
+ close: (throwOnError?: boolean) => {
139
+ for (const statement of statements.splice(0)) statement.finalize()
140
+ close(throwOnError)
141
+ },
142
+ })
143
+ return db
144
+ }
145
+
146
+ /**
147
+ * Open a SQLite file read-only: the `readonly` flag plus `PRAGMA query_only = ON`.
148
+ *
149
+ * Trap: a read-only connection cannot create a WAL database's `-wal` file, so while no writer has the
150
+ * database open (no `-wal` on disk) the first read fails with SQLITE_CANTOPEN. Only in that case the
151
+ * file is reopened read-write with `query_only` on, which creates the WAL files the way any client
152
+ * would and still rejects every write at the engine. With a live writer the `-wal` exists and the
153
+ * read-only open works.
154
+ */
155
+ export async function openReadOnlySqlite(file: string): Promise<Database> {
156
+ const { Database } = await import("bun:sqlite")
157
+ const probe = (db: Database): Database => {
158
+ db.run("PRAGMA query_only = ON")
159
+ // A writer that is mid-recovery or holds the journal lock answers SQLITE_BUSY: wait, not fail.
160
+ db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
161
+ db.prepare("SELECT count(*) FROM sqlite_master").all()
162
+ return db
163
+ }
164
+ // safeIntegers: an INTEGER beyond 2^53 comes back exact (as a bigint) instead of rounded.
165
+ const readonly = finalizeOnClose(new Database(file, { readonly: true, safeIntegers: true }))
166
+ try {
167
+ return probe(readonly)
168
+ } catch (error) {
169
+ readonly.close()
170
+ if (
171
+ sqliteCode(error) !== "SQLITE_CANTOPEN" ||
172
+ !isWalDatabase(file) ||
173
+ existsSync(`${file}-wal`)
174
+ ) {
175
+ throw error
176
+ }
177
+ }
178
+ const fallback = finalizeOnClose(new Database(file, { readwrite: true, safeIntegers: true }))
179
+ try {
180
+ return probe(fallback)
181
+ } catch (error) {
182
+ fallback.close()
183
+ throw error
184
+ }
185
+ }
186
+
187
+ /** The tables and views of a SQLite database, lowercased: all of them, and those not excluded. */
188
+ export function sqliteRelations(
189
+ db: Pick<Database, "prepare">,
190
+ exclude: readonly string[] = [],
191
+ ): { readonly all: ReadonlySet<string>; readonly exposed: ReadonlySet<string> } {
192
+ const excluded = new Set(exclude.map((name) => name.toLowerCase()))
193
+ const rows = db
194
+ .prepare<{ name: string }, []>(
195
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'",
196
+ )
197
+ .all()
198
+ const all = new Set(rows.map((row) => row.name.toLowerCase()))
199
+ return { all, exposed: new Set([...all].filter((name) => !excluded.has(name))) }
200
+ }
201
+
202
+ const SQLITE_WRITE_SYNTAX =
203
+ /near "(insert|update|delete|replace|create|drop|alter|attach|detach|pragma|vacuum|reindex|analyze|begin|commit|rollback|savepoint|release)": syntax error/i
204
+
205
+ /** The `SQLITE_*` code bun:sqlite puts on an error, if any. */
206
+ const sqliteCode = (error: unknown): unknown =>
207
+ typeof error === "object" && error !== null && "code" in error ? error.code : undefined
208
+
209
+ /** Map a SQLite error to a refusal: a write the engine rejected is a refusal, not a failure. */
210
+ function sqliteFailure(error: unknown, context: string): DbRefusal {
211
+ const code = sqliteCode(error)
212
+ const message = `${context}: ${error instanceof Error ? error.message : String(error)}`
213
+ if (typeof code === "string") {
214
+ if (code.startsWith("SQLITE_READONLY") || code === "SQLITE_AUTH") {
215
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
216
+ }
217
+ if (code === "SQLITE_INTERRUPT") return dbRefusal("NIFRA_DB_TIMEOUT", message)
218
+ // SQLite has no data-modifying CTE: `WITH d AS (DELETE ...)` fails to parse at the write.
219
+ if (code === "SQLITE_ERROR" && SQLITE_WRITE_SYNTAX.test(message)) {
220
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
221
+ }
222
+ if (
223
+ code.startsWith("SQLITE_CANTOPEN") ||
224
+ code.startsWith("SQLITE_CORRUPT") ||
225
+ code.startsWith("SQLITE_IOERR") ||
226
+ code === "SQLITE_NOTADB"
227
+ ) {
228
+ return dbRefusal("NIFRA_DB_DRIVER", message)
229
+ }
230
+ }
231
+ return dbRefusal("NIFRA_DB_QUERY_FAILED", message)
232
+ }
233
+
234
+ /** Options for {@link querySqlite} and {@link explainSqlite}. */
235
+ export interface SqliteQueryOptions extends ShapeRowsOptions {
236
+ /** Tables and views the query may not read (case-insensitive). */
237
+ readonly exclude?: readonly string[]
238
+ }
239
+
240
+ type Verified = { readonly ok: true; readonly query: string } | DbRefusal
241
+
242
+ const GATE_MESSAGES = {
243
+ empty: "empty query",
244
+ multiple: "only a single statement is allowed",
245
+ "not-read": "only a read-only query (SELECT, or WITH ... SELECT) is allowed",
246
+ } as const
247
+
248
+ function verifySqlite(
249
+ db: Pick<Database, "prepare">,
250
+ sql: string,
251
+ exclude: readonly string[] | undefined,
252
+ masked?: (column: string) => boolean,
253
+ ): Verified {
254
+ const { all, exposed } = sqliteRelations(db, exclude)
255
+ const unexposed = (relation: string): DbRefusal =>
256
+ dbRefusal(
257
+ "NIFRA_DB_TABLE_EXCLUDED",
258
+ all.has(relation.toLowerCase())
259
+ ? `the query reads ${JSON.stringify(relation)}, which devDatabase.exclude leaves out`
260
+ : `the query reads ${JSON.stringify(relation)}, which is not a table or view of this database`,
261
+ )
262
+ const ctes = new Set(sqliteCteNames(sql))
263
+ const gate = gateSqliteStatement(sql, (relation) => exposed.has(relation) || ctes.has(relation))
264
+ if (!gate.ok) {
265
+ if (gate.reason === "unexposed") return unexposed(gate.relation ?? "")
266
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", GATE_MESSAGES[gate.reason])
267
+ }
268
+ let reads: BytecodeReads
269
+ try {
270
+ reads = bytecodeReads(db, gate.query, masked)
271
+ } catch (error) {
272
+ return sqliteFailure(error, "query failed to plan")
273
+ }
274
+ const relation = reads.tables.find((name) => !exposed.has(name.toLowerCase()))
275
+ if (relation !== undefined) return unexposed(relation)
276
+ if (reads.masked.length > 0) {
277
+ return dbRefusal(
278
+ "NIFRA_DB_COLUMN_REFUSED",
279
+ `the query reads ${reads.masked.join(", ")}, masked as ${reads.masked.length === 1 ? "a credential" : "credentials"}`,
280
+ )
281
+ }
282
+ return { ok: true, query: gate.query }
283
+ }
284
+
285
+ const READ_OPCODES = new Set(["OpenRead", "ReopenIdx", "OpenWrite", "VOpen"])
286
+
287
+ // Each compares a cursor's leading key fields (P4 of them, all when P4 is not a count) with values.
288
+ const KEY_COMPARE_OPCODES = new Set([
289
+ "SeekGE",
290
+ "SeekGT",
291
+ "SeekLE",
292
+ "SeekLT",
293
+ "IdxGE",
294
+ "IdxGT",
295
+ "IdxLE",
296
+ "IdxLT",
297
+ "Found",
298
+ "NotFound",
299
+ "NoConflict",
300
+ "IfNoHope",
301
+ ])
302
+ const ROWID_OPCODES = new Set(["Rowid", "SeekRowid", "NotExists", "IdxRowid"])
303
+
304
+ interface BytecodeReads {
305
+ readonly tables: readonly string[]
306
+ /** Masked columns the statement reads, filters on or compares, as `table.column`. */
307
+ readonly masked: readonly string[]
308
+ }
309
+
310
+ interface ProgramStep {
311
+ readonly opcode: string
312
+ readonly p1: number | bigint
313
+ readonly p2: number | bigint
314
+ readonly p3: number | bigint
315
+ readonly p4: unknown
316
+ }
317
+
318
+ interface CatalogEntry {
319
+ readonly type: string
320
+ readonly name: string
321
+ readonly table: string
322
+ readonly sql: string | null
323
+ }
324
+
325
+ /** What a cursor's record holds: the columns each field position reads, and how many lead as key. */
326
+ interface CursorLayout {
327
+ readonly table: string
328
+ readonly fields: readonly (readonly string[])[]
329
+ readonly keyCount: number
330
+ /** The INTEGER PRIMARY KEY column the rowid is, if the table has one. */
331
+ readonly rowid: string | undefined
332
+ }
333
+
334
+ /**
335
+ * The record layout behind a cursor on `entry`, from SQLite's own catalog. A rowid table stores its
336
+ * non-virtual columns in order, then its virtual ones; a WITHOUT ROWID table and an index store the
337
+ * fields `pragma_index_xinfo` lists. An expression field reads every column its index SQL names.
338
+ */
339
+ function cursorLayout(db: Pick<Database, "prepare">, entry: CatalogEntry): CursorLayout {
340
+ const columns = db
341
+ .prepare<
342
+ { name: string; type: string; pk: number | bigint; hidden: number | bigint },
343
+ [string]
344
+ >("SELECT name, type, pk, hidden FROM pragma_table_xinfo(?) ORDER BY cid")
345
+ .all(entry.table)
346
+ let withoutRowid: boolean
347
+ try {
348
+ withoutRowid =
349
+ Number(
350
+ db
351
+ .prepare<{ wr: number | bigint }, [string]>(
352
+ "SELECT wr FROM pragma_table_list WHERE schema = 'main' AND name = ?",
353
+ )
354
+ .get(entry.table)?.wr ?? 0,
355
+ ) === 1
356
+ } catch {
357
+ // pragma_table_list is SQLite 3.37+; Bun on macOS links the system library, which can be older.
358
+ const created = db
359
+ .prepare<{ sql: string | null }, [string]>(
360
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
361
+ )
362
+ .get(entry.table)?.sql
363
+ withoutRowid = /\)\s*(?:STRICT\s*,\s*)?WITHOUT\s+ROWID\b/i.test(created ?? "")
364
+ }
365
+ const keys = columns.filter((column) => Number(column.pk) > 0)
366
+ const rowid =
367
+ !withoutRowid && keys.length === 1 && keys[0]?.type.toUpperCase() === "INTEGER"
368
+ ? keys[0].name
369
+ : undefined
370
+ let index = entry.type === "index" ? entry.name : undefined
371
+ if (entry.type === "table" && withoutRowid) {
372
+ index = db
373
+ .prepare<{ name: string }, [string]>(
374
+ "SELECT name FROM pragma_index_list(?) WHERE origin = 'pk'",
375
+ )
376
+ .get(entry.table)?.name
377
+ }
378
+ if (index === undefined) {
379
+ const stored = [
380
+ ...columns.filter((column) => Number(column.hidden) !== 2),
381
+ ...columns.filter((column) => Number(column.hidden) === 2),
382
+ ]
383
+ return { table: entry.table, fields: stored.map((column) => [column.name]), keyCount: 0, rowid }
384
+ }
385
+ // A substring match can only over-count: an expression field then reads one column too many.
386
+ const source = (entry.sql ?? "").toLowerCase()
387
+ const named = columns
388
+ .map((column) => column.name)
389
+ .filter((name) => source.includes(name.toLowerCase()))
390
+ const xinfo = db
391
+ .prepare<{ cid: number | bigint; name: string | null; key: number | bigint }, [string]>(
392
+ "SELECT cid, name, key FROM pragma_index_xinfo(?) ORDER BY seqno",
393
+ )
394
+ .all(index)
395
+ return {
396
+ table: entry.table,
397
+ fields: xinfo.map((field) =>
398
+ field.name !== null
399
+ ? [field.name]
400
+ : Number(field.cid) === -1
401
+ ? rowid === undefined
402
+ ? []
403
+ : [rowid]
404
+ : named,
405
+ ),
406
+ keyCount: xinfo.filter((field) => Number(field.key) === 1).length,
407
+ rowid,
408
+ }
409
+ }
410
+
411
+ /**
412
+ * The tables a statement's bytecode opens, mapped from each cursor's root page through
413
+ * `sqlite_master`: a view reads as its base tables, an alias cannot rename a table (the query plan
414
+ * would print `SCAN o` for `orders AS o`), and an index read counts as a read of its table. Root
415
+ * page 1 is `sqlite_master` itself; a cursor on another schema (temp, attached) or a virtual table
416
+ * (`pragma_*`, `dbstat`, `json_each`) is never exposed.
417
+ *
418
+ * With `masked`, also every masked column a cursor on a table or index yields (`Column`), compares
419
+ * (a seek or index range check, so a filter counts) or reads as the rowid. Values the statement
420
+ * derives from those reads live in other cursors and registers, so an alias or an expression
421
+ * cannot hide the column it started from.
422
+ */
423
+ function bytecodeReads(
424
+ db: Pick<Database, "prepare">,
425
+ query: string,
426
+ masked?: (column: string) => boolean,
427
+ ): BytecodeReads {
428
+ const pages = new Map<number, CatalogEntry>([
429
+ [1, { type: "table", name: "sqlite_master", table: "sqlite_master", sql: null }],
430
+ ])
431
+ const catalog = db
432
+ .prepare<CatalogEntry & { rootpage: number | bigint }, []>(
433
+ 'SELECT type, name, tbl_name AS "table", rootpage, sql FROM sqlite_master WHERE rootpage > 0',
434
+ )
435
+ .all()
436
+ for (const entry of catalog) pages.set(Number(entry.rootpage), entry)
437
+ const program = db.prepare<ProgramStep, []>(`EXPLAIN ${query}`).all()
438
+ const reads = new Set<string>()
439
+ const cursors = new Map<number, CatalogEntry>()
440
+ for (const step of program) {
441
+ if (!READ_OPCODES.has(step.opcode)) continue
442
+ const page = Number(step.p2)
443
+ const entry = Number(step.p3) === 0 ? pages.get(page) : undefined
444
+ if (entry !== undefined && step.opcode !== "VOpen") cursors.set(Number(step.p1), entry)
445
+ reads.add(
446
+ step.opcode === "VOpen"
447
+ ? "<virtual table>"
448
+ : Number(step.p3) === 0
449
+ ? (entry?.table ?? `<root page ${page}>`)
450
+ : `<schema ${step.p3}>`,
451
+ )
452
+ }
453
+ if (masked === undefined) return { tables: [...reads], masked: [] }
454
+
455
+ const layouts = new Map<number, CursorLayout>()
456
+ const layoutOf = (cursor: number): CursorLayout | undefined => {
457
+ const entry = cursors.get(cursor)
458
+ if (entry === undefined || entry.name === "sqlite_master") return undefined
459
+ let layout = layouts.get(cursor)
460
+ if (layout === undefined) {
461
+ layout = cursorLayout(db, entry)
462
+ layouts.set(cursor, layout)
463
+ }
464
+ return layout
465
+ }
466
+ const hits = new Set<string>()
467
+ const read = (layout: CursorLayout, names: readonly string[]): void => {
468
+ for (const name of names) if (masked(name)) hits.add(`${layout.table}.${name}`)
469
+ }
470
+ for (const step of program) {
471
+ const layout = layoutOf(Number(step.p1))
472
+ if (layout === undefined) continue
473
+ if (step.opcode === "Column") read(layout, layout.fields[Number(step.p2)] ?? [])
474
+ else if (KEY_COMPARE_OPCODES.has(step.opcode)) {
475
+ const count = Number(step.p4)
476
+ const compared = Number.isInteger(count) && count > 0 ? count : layout.keyCount
477
+ for (const field of layout.fields.slice(0, compared)) read(layout, field)
478
+ } else if (ROWID_OPCODES.has(step.opcode) && layout.rowid !== undefined) {
479
+ read(layout, [layout.rowid])
480
+ }
481
+ }
482
+ return { tables: [...reads], masked: [...hits] }
483
+ }
484
+
485
+ /**
486
+ * Run one read-only query: the statement gates, the check that every table its bytecode opens is
487
+ * exposed (every table and view minus `exclude`), the refusal of any column `redaction.column`
488
+ * masks that the bytecode reads in any form, then at most `maxRows + 1` rows through
489
+ * {@link shapeRows}. Synchronous: run it where the caller can stop the process (the CLI runs each
490
+ * call in its own subprocess, killed at the deadline).
491
+ */
492
+ export function querySqlite(
493
+ db: Pick<Database, "prepare">,
494
+ sql: string,
495
+ options: SqliteQueryOptions,
496
+ ): DbRows | DbRefusal {
497
+ const verified = verifySqlite(db, sql, options.exclude, options.redaction?.column)
498
+ if (!("ok" in verified)) return verified
499
+ try {
500
+ const statement = db.prepare(boundedSqliteQuery(verified.query, options.maxRows))
501
+ const rows = statement.values()
502
+ const columns = [...statement.columnNames]
503
+ statement.finalize()
504
+ return shapeRows(columns, rows, options)
505
+ } catch (error) {
506
+ return sqliteFailure(error, "query failed")
507
+ }
508
+ }
509
+
510
+ /** `EXPLAIN QUERY PLAN` for one statement, after the same gates as {@link querySqlite}. */
511
+ export function explainSqlite(
512
+ db: Pick<Database, "prepare">,
513
+ sql: string,
514
+ options: Pick<SqliteQueryOptions, "exclude" | "maxResultBytes">,
515
+ ): DbPlan | DbRefusal {
516
+ const verified = verifySqlite(db, sql, options.exclude)
517
+ if (!("ok" in verified)) return verified
518
+ try {
519
+ const plan = db
520
+ .prepare<{ id: number | bigint; parent: number | bigint; detail: string }, []>(
521
+ `EXPLAIN QUERY PLAN ${verified.query}`,
522
+ )
523
+ .all()
524
+ const steps = plan.map(({ id, parent, detail }) => ({
525
+ id: Number(id),
526
+ parent: Number(parent),
527
+ detail,
528
+ }))
529
+ const fit = fitToBytes(steps.length, (shown) => steps.slice(0, shown), options.maxResultBytes)
530
+ return {
531
+ plan: fit?.value ?? [],
532
+ analyzed: false,
533
+ truncated: (fit?.shown ?? 0) < steps.length,
534
+ }
535
+ } catch (error) {
536
+ return sqliteFailure(error, "query failed to plan")
537
+ }
538
+ }
539
+
540
+ const quoteSqliteIdentifier = (name: string): string => `"${name.replaceAll('"', '""')}"`
541
+
542
+ interface SqliteColumnRow {
543
+ readonly name: string
544
+ readonly type: string
545
+ readonly required: number | bigint
546
+ readonly dflt: string | null
547
+ readonly pk: number | bigint
548
+ }
549
+
550
+ interface SqliteForeignKeyRow {
551
+ readonly id: number | bigint
552
+ readonly ref: string
553
+ readonly col: string
554
+ readonly refcol: string | null
555
+ }
556
+
557
+ /** Options for {@link readSqliteSchema}. */
558
+ export interface SqliteSchemaOptions {
559
+ readonly exclude?: readonly string[]
560
+ /** Describe only this table or view. */
561
+ readonly table?: string | undefined
562
+ readonly redaction?: DbRedaction
563
+ }
564
+
565
+ /**
566
+ * Describe the exposed tables and views from SQLite's own catalog (`sqlite_master` and the
567
+ * `pragma_*` table functions, each bound by parameter): columns, primary key, foreign keys, indexes,
568
+ * and a row count per table.
569
+ */
570
+ export function readSqliteSchema(
571
+ db: Pick<Database, "prepare">,
572
+ options: SqliteSchemaOptions = {},
573
+ ): DbSchemaReport | DbRefusal {
574
+ const excluded = new Set((options.exclude ?? []).map((name) => name.toLowerCase()))
575
+ const wanted = options.table?.toLowerCase()
576
+ let entries: { name: string; type: "table" | "view" }[]
577
+ try {
578
+ entries = db
579
+ .prepare<{ name: string; type: "table" | "view" }, []>(
580
+ "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
581
+ )
582
+ .all()
583
+ } catch (error) {
584
+ return sqliteFailure(error, "could not read the schema")
585
+ }
586
+ const exposed = entries.filter((entry) => !excluded.has(entry.name.toLowerCase()))
587
+ if (wanted !== undefined && !exposed.some((entry) => entry.name.toLowerCase() === wanted)) {
588
+ return dbRefusal(
589
+ "NIFRA_DB_TABLE_EXCLUDED",
590
+ `${JSON.stringify(options.table)} is not an exposed table or view of this database`,
591
+ )
592
+ }
593
+ const text = (value: string): string => options.redaction?.text?.(value) ?? value
594
+ const tables: DbSchemaTable[] = []
595
+ try {
596
+ for (const entry of exposed) {
597
+ if (wanted !== undefined && entry.name.toLowerCase() !== wanted) continue
598
+ const columns = db
599
+ .prepare<SqliteColumnRow, [string]>(
600
+ 'SELECT name, type, "notnull" AS required, dflt_value AS dflt, pk FROM pragma_table_info(?) ORDER BY cid',
601
+ )
602
+ .all(entry.name)
603
+ const keys = db
604
+ .prepare<SqliteForeignKeyRow, [string]>(
605
+ 'SELECT id, "table" AS ref, "from" AS col, "to" AS refcol FROM pragma_foreign_key_list(?) ORDER BY id, seq',
606
+ )
607
+ .all(entry.name)
608
+ const foreignKeys = new Map<number, { columns: string[]; table: string; refs: string[] }>()
609
+ for (const key of keys) {
610
+ const id = Number(key.id)
611
+ const fk = foreignKeys.get(id) ?? { columns: [], table: key.ref, refs: [] }
612
+ fk.columns.push(key.col)
613
+ if (key.refcol !== null) fk.refs.push(key.refcol)
614
+ foreignKeys.set(id, fk)
615
+ }
616
+ const indexes = db
617
+ .prepare<{ name: string; uniq: number | bigint }, [string]>(
618
+ 'SELECT name, "unique" AS uniq FROM pragma_index_list(?) ORDER BY seq',
619
+ )
620
+ .all(entry.name)
621
+ .map((index) => ({
622
+ name: index.name,
623
+ unique: Number(index.uniq) === 1,
624
+ columns: db
625
+ .prepare<{ name: string | null }, [string]>(
626
+ "SELECT name FROM pragma_index_info(?) ORDER BY seqno",
627
+ )
628
+ .all(index.name)
629
+ .map((column) => column.name ?? "<expression>"),
630
+ }))
631
+ let rowEstimate: number | null = null
632
+ if (entry.type === "table") {
633
+ // nifra-expect sql-dynamic: the table name comes from sqlite_master, quoted as an identifier
634
+ const counted = db
635
+ .prepare<{ n: number | bigint }, []>(
636
+ `SELECT count(*) AS n FROM ${quoteSqliteIdentifier(entry.name)}`,
637
+ )
638
+ .all()
639
+ rowEstimate = counted[0] === undefined ? null : Number(counted[0].n)
640
+ }
641
+ tables.push({
642
+ name: entry.name,
643
+ kind: entry.type,
644
+ rowEstimate,
645
+ columns: columns.map((column) => ({
646
+ name: column.name,
647
+ type: column.type,
648
+ nullable: Number(column.required) === 0 && Number(column.pk) === 0,
649
+ default: column.dflt === null ? null : text(column.dflt),
650
+ primaryKey: Number(column.pk) > 0,
651
+ redacted: options.redaction?.column?.(column.name) === true,
652
+ })),
653
+ foreignKeys: [...foreignKeys.values()].map((fk) => ({
654
+ columns: fk.columns,
655
+ references: { table: fk.table, columns: fk.refs },
656
+ })),
657
+ indexes,
658
+ })
659
+ }
660
+ } catch (error) {
661
+ return sqliteFailure(error, "could not read the schema")
662
+ }
663
+ return { tables, excludedCount: entries.length - exposed.length }
664
+ }