@nifrajs/mcp-db 3.5.0 → 4.0.2

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,705 @@
1
+ /**
2
+ * What both database engines share and the MCP layer reuses: the refusal codes, the SQLite statement
3
+ * gates, the caps and the JSON-safe result shape. No runtime imports, so `@nifrajs/mcp-db` itself
4
+ * stays free of `node:*` modules; `engine.ts` re-exports all of it.
5
+ */
6
+
7
+ /** The structural slice of `bun:sqlite`'s `Database` this package needs. */
8
+ export interface SqliteDatabaseLike {
9
+ /** Prepare a statement; `all` runs it and returns row objects. */
10
+ prepare(sql: string): { all(...params: unknown[]): unknown[] }
11
+ /** Execute a statement for its side effect (used only for `PRAGMA query_only`). */
12
+ run(sql: string): unknown
13
+ /** Optional path this database was opened from; a file-backed one can be reopened read-only in a
14
+ * worker so `run_query` runs off the serving connection. `:memory:` and the empty anonymous
15
+ * database cannot, and run in process. */
16
+ readonly filename?: string
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------------------------------
20
+ // Refusals
21
+ // ---------------------------------------------------------------------------------------------------
22
+
23
+ /** Every reason a database call is refused or fails, as a stable code. */
24
+ export type DbRefusalCode =
25
+ | "NIFRA_DB_NOT_DECLARED"
26
+ | "NIFRA_DB_CONFIG"
27
+ | "NIFRA_DB_OUTSIDE_ROOT"
28
+ | "NIFRA_DB_REMOTE_HOST"
29
+ | "NIFRA_DB_SUPERUSER"
30
+ | "NIFRA_DB_EXTENSION"
31
+ | "NIFRA_DB_QUERY_OFF"
32
+ | "NIFRA_DB_WRITE_REFUSED"
33
+ | "NIFRA_DB_FUNCTION_REFUSED"
34
+ | "NIFRA_DB_TABLE_EXCLUDED"
35
+ | "NIFRA_DB_COLUMN_REFUSED"
36
+ | "NIFRA_DB_TIMEOUT"
37
+ | "NIFRA_DB_QUERY_FAILED"
38
+ | "NIFRA_DB_DRIVER"
39
+
40
+ /** A refused or failed call: what happened, the fix, and its anchor under `https://nifra.dev/docs/`. */
41
+ export interface DbRefusal {
42
+ readonly code: DbRefusalCode
43
+ readonly message: string
44
+ readonly fix: string
45
+ readonly docsAnchor: string
46
+ }
47
+
48
+ const REFUSAL_FIXES: Readonly<Record<DbRefusalCode, string>> = {
49
+ NIFRA_DB_NOT_DECLARED:
50
+ 'Export devDatabase from nifra.config.ts, e.g. export const devDatabase = { kind: "sqlite", file: "./data/app.db" }. nifra never reads DATABASE_URL on its own.',
51
+ NIFRA_DB_CONFIG:
52
+ "Correct the devDatabase field the message names. Unknown fields are refused, so a misspelled guard cannot silently drop out.",
53
+ NIFRA_DB_OUTSIDE_ROOT:
54
+ "Keep the database file inside the project, or list its path in devDatabase.allowFiles.",
55
+ NIFRA_DB_REMOTE_HOST:
56
+ "Point devDatabase.url at a local database (localhost, 127.0.0.1, ::1, *.localhost or a unix socket), or name the host in devDatabase.allowHosts.",
57
+ NIFRA_DB_SUPERUSER:
58
+ "Connect as a read-only role: run `nifra db role`, apply the SQL it prints, and use that role in devDatabase.url. `nifra db schema` keeps working meanwhile.",
59
+ NIFRA_DB_EXTENSION:
60
+ "Connect as a role that cannot use the extension or language, or name it in devDatabase.allowExtensions to accept that a query can reach outside this database.",
61
+ NIFRA_DB_QUERY_OFF:
62
+ "devDatabase.query is false. Remove it to allow read-only queries, or use `nifra db schema`.",
63
+ NIFRA_DB_WRITE_REFUSED:
64
+ "Send one read-only SELECT (or WITH ... SELECT). Writes, DDL, CALL, DO, COPY and several statements at once are refused.",
65
+ NIFRA_DB_FUNCTION_REFUSED:
66
+ "Remove the named function: it reaches outside a read-only query (server files, other sessions, settings, locks, sequences, notifications or another database).",
67
+ NIFRA_DB_TABLE_EXCLUDED:
68
+ "The query reads a relation the declaration does not expose: a table in devDatabase.exclude, a system catalog, a schema outside the allowed ones, or a name that does not exist. Run `nifra db schema` for what is exposed.",
69
+ NIFRA_DB_COLUMN_REFUSED:
70
+ "The query reads a masked column (a credential name such as password or token, or one in devDatabase.redactColumns) in some form. A masked column cannot be read at all, not even aliased, filtered on or passed to a function: select the other columns by name, or list the column in devDatabase.revealColumns if it holds no secret.",
71
+ NIFRA_DB_TIMEOUT:
72
+ "Narrow the query (a WHERE clause, an index, a LIMIT) or raise devDatabase.timeoutMs.",
73
+ NIFRA_DB_QUERY_FAILED: "The database rejected the statement. Correct the SQL and retry.",
74
+ NIFRA_DB_DRIVER:
75
+ "Check that the database is running and that devDatabase points at it. The message carries the driver's error.",
76
+ }
77
+
78
+ /** Build a {@link DbRefusal} for `code` with its fix and docs anchor. */
79
+ export function dbRefusal(code: DbRefusalCode, message: string): DbRefusal {
80
+ return {
81
+ code,
82
+ message,
83
+ fix: REFUSAL_FIXES[code],
84
+ docsAnchor: `agents#${code
85
+ .toLowerCase()
86
+ .replace(/^nifra_/, "")
87
+ .replaceAll("_", "-")}`,
88
+ }
89
+ }
90
+
91
+ /** True for a value {@link dbRefusal} built. */
92
+ export function isDbRefusal(value: unknown): value is DbRefusal {
93
+ return (
94
+ typeof value === "object" &&
95
+ value !== null &&
96
+ "code" in value &&
97
+ typeof value.code === "string" &&
98
+ Object.hasOwn(REFUSAL_FIXES, value.code) &&
99
+ "message" in value &&
100
+ typeof value.message === "string"
101
+ )
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------------------------------
105
+ // Statement gates
106
+ // ---------------------------------------------------------------------------------------------------
107
+
108
+ const stripSqlNoise = (sql: string): string =>
109
+ sql
110
+ // Strings first so comment markers inside literals don't count.
111
+ .replace(/'(?:[^']|'')*'/g, "''")
112
+ .replace(/"(?:[^"]|"")*"/g, '""')
113
+ .replace(/--[^\n]*/g, " ")
114
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
115
+
116
+ // SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
117
+ // an identifier character; leaving it out would end a word early and split one table name into two.
118
+ const WORD_START = /[A-Za-z_\u0080-\uffff]/
119
+ const WORD_PART = /[A-Za-z0-9_$\u0080-\uffff]/
120
+
121
+ /** One lexical unit of SQL. `bare` distinguishes a keyword-capable word from a quoted identifier, so
122
+ * `FROM "from"` reads the second token as a table name and not as another clause. */
123
+ interface SqlToken {
124
+ readonly kind: "word" | "punct"
125
+ /** Identifier text with quoting removed and escapes collapsed; the raw character for punctuation. */
126
+ readonly value: string
127
+ /** True for an unquoted word - only these can be SQL keywords. */
128
+ readonly bare: boolean
129
+ }
130
+
131
+ /**
132
+ * Tokenize far enough to name every relation. A regex cannot do this job: SQLite needs no separator
133
+ * between a keyword and a quoted identifier (`FROM"users"` is legal), and it accepts four identifier
134
+ * quotings - `"x"`, `[x]`, `` `x` ``, and bare. A `\s+`-anchored pattern silently reads none of
135
+ * those as a table reference, which is exactly how an aliased `FROM"users"AS habits` slipped past
136
+ * the allowlist and returned a non-exposed table's rows.
137
+ *
138
+ * String literals and comments become nothing, so a `FROM` inside attacker-controlled text is inert.
139
+ */
140
+ function tokenizeSql(sql: string): SqlToken[] {
141
+ const tokens: SqlToken[] = []
142
+ let i = 0
143
+ /** Read a quoted identifier, collapsing the doubled-delimiter escape SQLite uses for `"` and `` ` ``. */
144
+ const readQuoted = (close: string, escapable: boolean): void => {
145
+ let value = ""
146
+ i++ // past the opening delimiter
147
+ while (i < sql.length) {
148
+ if (sql[i] === close) {
149
+ if (escapable && sql[i + 1] === close) {
150
+ value += close
151
+ i += 2
152
+ continue
153
+ }
154
+ i++
155
+ tokens.push({ kind: "word", value, bare: false })
156
+ return
157
+ }
158
+ value += sql[i]
159
+ i++
160
+ }
161
+ // Unterminated - emit what we have. The caller's single-statement and plan checks still run.
162
+ tokens.push({ kind: "word", value, bare: false })
163
+ }
164
+ while (i < sql.length) {
165
+ const char = sql[i] as string
166
+ if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
167
+ i++
168
+ } else if (char === "-" && sql[i + 1] === "-") {
169
+ const end = sql.indexOf("\n", i)
170
+ i = end === -1 ? sql.length : end + 1
171
+ } else if (char === "/" && sql[i + 1] === "*") {
172
+ const end = sql.indexOf("*/", i + 2)
173
+ i = end === -1 ? sql.length : end + 2
174
+ } else if (char === "'") {
175
+ // Literal: consumed and dropped, so its contents can never be read as SQL.
176
+ i++
177
+ while (i < sql.length) {
178
+ if (sql[i] === "'") {
179
+ if (sql[i + 1] === "'") {
180
+ i += 2
181
+ continue
182
+ }
183
+ i++
184
+ break
185
+ }
186
+ i++
187
+ }
188
+ } else if (char === '"') {
189
+ readQuoted('"', true)
190
+ } else if (char === "`") {
191
+ readQuoted("`", true)
192
+ } else if (char === "[") {
193
+ readQuoted("]", false) // MSSQL-style bracket quoting: no escape form
194
+ } else if (WORD_START.test(char)) {
195
+ const start = i
196
+ while (i < sql.length && WORD_PART.test(sql[i] as string)) i++
197
+ tokens.push({ kind: "word", value: sql.slice(start, i), bare: true })
198
+ } else if (char >= "0" && char <= "9") {
199
+ while (i < sql.length && /[0-9A-Za-z._]/.test(sql[i] as string)) i++
200
+ } else {
201
+ tokens.push({ kind: "punct", value: char, bare: false })
202
+ i++
203
+ }
204
+ }
205
+ return tokens
206
+ }
207
+
208
+ /** True when `token` is the unquoted keyword `word` (case-insensitive). */
209
+ const isKeyword = (token: SqlToken | undefined, word: string): boolean =>
210
+ token?.bare === true && token.value.toLowerCase() === word
211
+
212
+ /**
213
+ * Every relation the statement names after `FROM`/`JOIN`, lowercased, with CTE names excluded.
214
+ * A schema qualifier resolves to its table (`main.habits` -> `habits`); a subquery contributes
215
+ * nothing of its own because its inner `FROM` is tokenized alongside everything else.
216
+ */
217
+ function relationNames(sql: string): string[] {
218
+ const tokens = tokenizeSql(sql)
219
+ // `name AS (` only ever introduces a CTE - a column alias cannot be followed by a paren - so this
220
+ // needs no `WITH` tracking to be exact.
221
+ const ctes = new Set<string>()
222
+ for (let i = 1; i < tokens.length - 1; i++) {
223
+ const name = tokens[i - 1] as SqlToken
224
+ if (isKeyword(tokens[i], "as") && tokens[i + 1]?.value === "(" && name.kind === "word") {
225
+ ctes.add(name.value.toLowerCase())
226
+ }
227
+ }
228
+ const names: string[] = []
229
+ const relationAt = (
230
+ index: number,
231
+ ): { readonly name: string; readonly next: number } | undefined => {
232
+ const first = tokens[index]
233
+ if (first === undefined || first.kind !== "word") return undefined // `FROM (subquery)` - nothing to name here
234
+ // A qualified reference names the table second; an unqualified one names it first.
235
+ const qualified = tokens[index + 1]?.value === "." && tokens[index + 2]?.kind === "word"
236
+ const table = qualified ? (tokens[index + 2] as SqlToken).value : first.value
237
+ return { name: table.toLowerCase(), next: qualified ? index + 3 : index + 1 }
238
+ }
239
+ const addRelation = (index: number): number | undefined => {
240
+ const relation = relationAt(index)
241
+ if (relation === undefined) return undefined
242
+ if (relation.name !== "" && !ctes.has(relation.name)) names.push(relation.name)
243
+ return relation.next
244
+ }
245
+ // Comma joins are part of the FROM table-source grammar but do not have a JOIN keyword. Walk the
246
+ // source list after every FROM and collect each top-level comma operand. The walk is deliberately
247
+ // conservative: an uncertain token is left for SQLite's plan check, while an apparent relation is
248
+ // always added so an alias cannot hide an unexposed table.
249
+ const clauseBoundary = new Set([
250
+ "where",
251
+ "group",
252
+ "order",
253
+ "having",
254
+ "limit",
255
+ "offset",
256
+ "union",
257
+ "except",
258
+ "intersect",
259
+ "window",
260
+ "returning",
261
+ ])
262
+ for (let i = 0; i < tokens.length; i++) {
263
+ const from = isKeyword(tokens[i], "from")
264
+ const join = isKeyword(tokens[i], "join")
265
+ if (!from && !join) continue
266
+ const next = addRelation(i + 1)
267
+ if (join || next === undefined) continue
268
+ let depth = 0
269
+ for (let j = next; j < tokens.length; j++) {
270
+ const token = tokens[j]
271
+ if (token === undefined) break
272
+ if (token.value === "(") {
273
+ depth++
274
+ continue
275
+ }
276
+ if (token.value === ")") {
277
+ if (depth === 0) break
278
+ depth--
279
+ continue
280
+ }
281
+ if (depth !== 0) continue
282
+ if (token.bare && clauseBoundary.has(token.value.toLowerCase())) break
283
+ if (token.value !== ",") continue
284
+ // A comma at the source-list level is another table reference. If it is a subquery, the nested
285
+ // FROM scan below still finds its base tables; no relation is inferred from the opening paren.
286
+ const afterComma = addRelation(j + 1)
287
+ if (afterComma !== undefined) j = afterComma - 1
288
+ }
289
+ }
290
+ return [...new Set(names)]
291
+ }
292
+
293
+ /** Reject multi-statement input: allow one terminator only when it is the final character. */
294
+ /**
295
+ * Names a statement defines as CTEs, column lists included (`WITH RECURSIVE c(x) AS (...)`), which
296
+ * {@link gateSqliteStatement}'s relation scan does not see past. Only for callers whose own check of
297
+ * the compiled statement is the authority on real tables: a CTE that reads a table still opens it.
298
+ */
299
+ export function sqliteCteNames(sql: string): string[] {
300
+ const tokens = tokenizeSql(stripSqlNoise(sql))
301
+ const names = new Set<string>()
302
+ for (let i = 0; i < tokens.length; i++) {
303
+ const name = tokens[i]
304
+ if (name?.kind !== "word") continue
305
+ let j = i + 1
306
+ if (tokens[j]?.value === "(" && tokens[j]?.kind === "punct") {
307
+ let depth = 0
308
+ for (; j < tokens.length; j++) {
309
+ const value = tokens[j]?.kind === "punct" ? tokens[j]?.value : undefined
310
+ if (value === "(") depth++
311
+ else if (value === ")" && --depth === 0) break
312
+ }
313
+ j++
314
+ }
315
+ if (!isKeyword(tokens[j], "as")) continue
316
+ j++
317
+ if (isKeyword(tokens[j], "not")) j++
318
+ if (isKeyword(tokens[j], "materialized")) j++
319
+ if (tokens[j]?.value === "(" && tokens[j]?.kind === "punct") names.add(name.value.toLowerCase())
320
+ }
321
+ return [...names]
322
+ }
323
+
324
+ const isSingleStatement = (sql: string): boolean => {
325
+ let quote: "'" | '"' | null = null
326
+ let lineComment = false
327
+ let blockComment = false
328
+ for (let i = 0; i < sql.length; i++) {
329
+ const char = sql[i]
330
+ const next = sql[i + 1]
331
+ if (lineComment) {
332
+ if (char === "\n") lineComment = false
333
+ continue
334
+ }
335
+ if (blockComment) {
336
+ if (char === "*" && next === "/") {
337
+ blockComment = false
338
+ i++
339
+ }
340
+ continue
341
+ }
342
+ if (quote !== null) {
343
+ if (char === quote) {
344
+ if (next === quote) i++
345
+ else quote = null
346
+ }
347
+ continue
348
+ }
349
+ if (char === "'" || char === '"') {
350
+ quote = char
351
+ continue
352
+ }
353
+ if (char === "-" && next === "-") {
354
+ lineComment = true
355
+ i++
356
+ continue
357
+ }
358
+ if (char === "/" && next === "*") {
359
+ blockComment = true
360
+ i++
361
+ continue
362
+ }
363
+ if (char === ";") {
364
+ return sql.slice(i + 1).trim() === ""
365
+ }
366
+ }
367
+ return true
368
+ }
369
+
370
+ const isReadStatement = (sql: string): boolean => {
371
+ const head = stripSqlNoise(sql).trim().toLowerCase()
372
+ return head.startsWith("select") || head.startsWith("with")
373
+ }
374
+
375
+ /** Remove one trailing statement terminator (and anything after it) while preserving literals and
376
+ * identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
377
+ * wrapper's closing parenthesis. */
378
+ function executableSql(sql: string): string {
379
+ let quote: "'" | '"' | null = null
380
+ let lineComment = false
381
+ let lineCommentStart = -1
382
+ let blockComment = false
383
+ for (let i = 0; i < sql.length; i++) {
384
+ const char = sql[i]
385
+ const next = sql[i + 1]
386
+ if (lineComment) {
387
+ if (char === "\n") {
388
+ lineComment = false
389
+ lineCommentStart = -1
390
+ }
391
+ continue
392
+ }
393
+ if (blockComment) {
394
+ if (char === "*" && next === "/") {
395
+ blockComment = false
396
+ i++
397
+ }
398
+ continue
399
+ }
400
+ if (quote !== null) {
401
+ if (char === quote) {
402
+ if (next === quote) i++
403
+ else quote = null
404
+ }
405
+ continue
406
+ }
407
+ if (char === "'" || char === '"') {
408
+ quote = char
409
+ continue
410
+ }
411
+ if (char === "-" && next === "-") {
412
+ lineComment = true
413
+ lineCommentStart = i
414
+ i++
415
+ continue
416
+ }
417
+ if (char === "/" && next === "*") {
418
+ blockComment = true
419
+ i++
420
+ continue
421
+ }
422
+ if (char === ";") return sql.slice(0, i).trim()
423
+ }
424
+ if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
425
+ return sql.trim()
426
+ }
427
+
428
+ /** The outcome of {@link gateSqliteStatement}. A refusal's `text` is the message `run_query` returns. */
429
+ export type SqliteGate =
430
+ | { readonly ok: true; readonly query: string }
431
+ | {
432
+ readonly ok: false
433
+ readonly reason: "empty" | "multiple" | "not-read" | "unexposed"
434
+ readonly text: string
435
+ readonly relation?: string
436
+ }
437
+
438
+ /**
439
+ * 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
441
+ * to wrap. Relation names are checked here as well as in the plan because SQLite's plan names an
442
+ * alias as the scan target (`users AS habits` plans as `SCAN habits`).
443
+ */
444
+ export function gateSqliteStatement(
445
+ input: string,
446
+ exposed: (relation: string) => boolean,
447
+ ): SqliteGate {
448
+ const sql = input.trim()
449
+ if (sql === "") return { ok: false, reason: "empty", text: "empty query" }
450
+ if (!isSingleStatement(sql)) {
451
+ return { ok: false, reason: "multiple", text: "only a single statement is allowed" }
452
+ }
453
+ if (!isReadStatement(sql)) {
454
+ return {
455
+ ok: false,
456
+ reason: "not-read",
457
+ text: "only SELECT (or WITH…SELECT) statements are allowed",
458
+ }
459
+ }
460
+ const query = executableSql(sql)
461
+ for (const relation of relationNames(query)) {
462
+ if (!exposed(relation)) {
463
+ return {
464
+ ok: false,
465
+ reason: "unexposed",
466
+ relation,
467
+ text: `query touches ${JSON.stringify(relation)}, which is not exposed`,
468
+ }
469
+ }
470
+ }
471
+ return { ok: true, query }
472
+ }
473
+
474
+ /**
475
+ * The first relation an `EXPLAIN QUERY PLAN` result scans or searches that `exposed` rejects, as the
476
+ * plan spells it, or `undefined` when every scanned relation is exposed.
477
+ */
478
+ export function unexposedPlanRelation(
479
+ planRows: readonly { readonly detail?: unknown }[],
480
+ exposed: (relation: string) => boolean,
481
+ ): string | undefined {
482
+ for (const row of planRows) {
483
+ const detail = typeof row.detail === "string" ? row.detail : ""
484
+ // SQLite also emits non-table nodes such as `SCAN CONSTANT ROW` for a
485
+ // constant-only SELECT. Only treat a scan/search target as a relation
486
+ // when it is not one of those planner pseudo-nodes.
487
+ if (
488
+ /^SCAN\s+CONSTANT\s+ROW$/i.test(detail.trim()) ||
489
+ /^SCAN\s+SUBQUERY\s+\d+$/i.test(detail.trim())
490
+ ) {
491
+ continue
492
+ }
493
+ // A schema-qualified reference plans as `SCAN main.habits`. The table is the last dotted
494
+ // segment; reading the first would reject `main.habits` as a table literally named "main".
495
+ const match =
496
+ /(?:SCAN|SEARCH)\s+(?:TABLE\s+)?(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(
497
+ detail,
498
+ )
499
+ if (match !== null) {
500
+ const relation = match[1]?.toLowerCase() ?? ""
501
+ if (!exposed(relation)) return match[1]
502
+ }
503
+ }
504
+ return undefined
505
+ }
506
+
507
+ /** Wrap a gated query so SQLite materializes at most one row beyond `maxRows`. */
508
+ export function boundedSqliteQuery(query: string, maxRows: number): string {
509
+ // nifra-expect sql-dynamic: wraps a query already through gateSqliteStatement in a bounding subselect; it runs on a query-only connection
510
+ return `SELECT * FROM (${query}) AS "__nifra_result" LIMIT ${maxRows + 1}`
511
+ }
512
+
513
+ /** Wrap a gated query to count its rows (re-executes it). */
514
+ export function countSqliteQuery(query: string): string {
515
+ // nifra-expect sql-dynamic: wraps a query already through gateSqliteStatement in a counting subselect; it runs on a query-only connection
516
+ return `SELECT count(*) AS "__nifra_total" FROM (${query}) AS "__nifra_count"`
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------------------------------
520
+ // Caps and result shape
521
+ // ---------------------------------------------------------------------------------------------------
522
+
523
+ /**
524
+ * Serialize `build(shown)` for the largest `shown <= count` (halving from `count`) whose JSON fits
525
+ * `maxBytes`. `undefined` when even `build(0)` does not fit.
526
+ */
527
+ export function fitToBytes<T>(
528
+ count: number,
529
+ build: (shown: number) => T,
530
+ maxBytes: number,
531
+ ): { readonly value: T; readonly serialized: string; readonly shown: number } | undefined {
532
+ const encoder = new TextEncoder()
533
+ let shown = count
534
+ let value = build(shown)
535
+ let serialized = JSON.stringify(value)
536
+ while (encoder.encode(serialized).byteLength > maxBytes && shown > 0) {
537
+ shown = Math.max(0, Math.floor(shown / 2))
538
+ value = build(shown)
539
+ serialized = JSON.stringify(value)
540
+ }
541
+ if (encoder.encode(serialized).byteLength > maxBytes) return undefined
542
+ return { value, serialized, shown }
543
+ }
544
+
545
+ /** What keeps secrets out of a result: which columns to mask, and how to scrub text. */
546
+ export interface DbRedaction {
547
+ /** True for a column (or a key inside a JSON value) whose values must not leave the process. */
548
+ readonly column?: (name: string) => boolean
549
+ /** Scrub one string value: key formats, tokens, environment values. */
550
+ readonly text?: (value: string) => string
551
+ }
552
+
553
+ /** The value a masked cell carries. */
554
+ export const REDACTED_CELL = "[redacted]"
555
+
556
+ const MAX_CELL_DEPTH = 32
557
+ const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER)
558
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER)
559
+
560
+ /**
561
+ * One database value as JSON: bytes become `<n bytes>`, a bigint outside the safe range a string,
562
+ * a date an ISO string, a non-finite number a string. Strings go through `redaction.text`, and a
563
+ * key inside a JSON value that `redaction.column` matches is masked.
564
+ */
565
+ export function toJsonCell(value: unknown, redaction: DbRedaction = {}, depth = 0): unknown {
566
+ if (value === null || value === undefined) return null
567
+ switch (typeof value) {
568
+ case "string":
569
+ return redaction.text === undefined ? value : redaction.text(value)
570
+ case "number":
571
+ return Number.isFinite(value) ? value : String(value)
572
+ case "bigint":
573
+ return value >= MIN_SAFE && value <= MAX_SAFE ? Number(value) : value.toString()
574
+ case "boolean":
575
+ return value
576
+ case "object":
577
+ break
578
+ default:
579
+ return String(value)
580
+ }
581
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()
582
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
583
+ return `<${value.byteLength} bytes>`
584
+ }
585
+ if (depth >= MAX_CELL_DEPTH) return "[nested]"
586
+ if (Array.isArray(value)) return value.map((item) => toJsonCell(item, redaction, depth + 1))
587
+ // Null prototype: a `__proto__` key in database JSON stays an ordinary key.
588
+ const out: Record<string, unknown> = Object.create(null)
589
+ for (const [key, item] of Object.entries(value)) {
590
+ out[key] =
591
+ item !== null && redaction.column?.(key) === true
592
+ ? REDACTED_CELL
593
+ : toJsonCell(item, redaction, depth + 1)
594
+ }
595
+ return out
596
+ }
597
+
598
+ /** Options for {@link shapeRows}. */
599
+ export interface ShapeRowsOptions {
600
+ readonly maxRows: number
601
+ readonly maxResultBytes: number
602
+ readonly redaction?: DbRedaction
603
+ }
604
+
605
+ /** Rows ready to leave the process: capped, masked and JSON-safe. */
606
+ export interface DbRows {
607
+ readonly columns: readonly string[]
608
+ readonly rows: readonly (readonly unknown[])[]
609
+ readonly rowCount: number
610
+ /** More rows exist than `rows` carries (the row cap or the byte cap cut them). */
611
+ readonly truncated: boolean
612
+ /** Columns whose values were replaced by {@link REDACTED_CELL}. */
613
+ readonly redactedColumns: readonly string[]
614
+ }
615
+
616
+ /**
617
+ * Cap, mask and normalize rows fetched as arrays (at most `maxRows + 1`, so truncation is known
618
+ * without counting). The byte cap halves the row count until the JSON fits.
619
+ */
620
+ export function shapeRows(
621
+ columns: readonly string[],
622
+ rows: readonly (readonly unknown[])[],
623
+ options: ShapeRowsOptions,
624
+ ): DbRows {
625
+ const redaction = options.redaction ?? {}
626
+ const masked = columns.map((column) => redaction.column?.(column) === true)
627
+ const kept = rows.slice(0, options.maxRows)
628
+ const cells = kept.map((row) =>
629
+ row.map((value, index) =>
630
+ masked[index] === true
631
+ ? value === null || value === undefined
632
+ ? null
633
+ : REDACTED_CELL
634
+ : toJsonCell(value, redaction),
635
+ ),
636
+ )
637
+ const fit = fitToBytes(
638
+ cells.length,
639
+ (shown) => ({ columns, rows: cells.slice(0, shown) }),
640
+ options.maxResultBytes,
641
+ )
642
+ const shown = fit?.shown ?? 0
643
+ return {
644
+ columns,
645
+ rows: cells.slice(0, shown),
646
+ rowCount: shown,
647
+ truncated: rows.length > options.maxRows || shown < cells.length,
648
+ redactedColumns: columns.filter((_, index) => masked[index] === true),
649
+ }
650
+ }
651
+
652
+ /** A plan for a statement, as the engine reports it. */
653
+ export interface DbPlan {
654
+ readonly plan: unknown
655
+ /** True when the plan carries actual timings (Postgres `EXPLAIN ANALYZE`). */
656
+ readonly analyzed: boolean
657
+ readonly truncated: boolean
658
+ }
659
+
660
+ /** A column in {@link DbSchemaTable}. */
661
+ export interface DbSchemaColumn {
662
+ readonly name: string
663
+ readonly type: string
664
+ readonly nullable: boolean
665
+ readonly default: string | null
666
+ readonly primaryKey: boolean
667
+ /** Query results mask this column's values. */
668
+ readonly redacted: boolean
669
+ }
670
+
671
+ /** A foreign key in {@link DbSchemaTable}. */
672
+ export interface DbSchemaForeignKey {
673
+ readonly columns: readonly string[]
674
+ readonly references: {
675
+ readonly schema?: string
676
+ readonly table: string
677
+ readonly columns: readonly string[]
678
+ }
679
+ }
680
+
681
+ /** An index in {@link DbSchemaTable}. */
682
+ export interface DbSchemaIndex {
683
+ readonly name: string
684
+ readonly columns: readonly string[]
685
+ readonly unique: boolean
686
+ }
687
+
688
+ /** One exposed table or view. */
689
+ export interface DbSchemaTable {
690
+ readonly schema?: string
691
+ readonly name: string
692
+ readonly kind: "table" | "view" | "materialized view" | "foreign table" | "partitioned table"
693
+ /** Rows, estimated by the engine's statistics; `null` when it keeps none. */
694
+ readonly rowEstimate: number | null
695
+ readonly columns: readonly DbSchemaColumn[]
696
+ readonly foreignKeys: readonly DbSchemaForeignKey[]
697
+ readonly indexes: readonly DbSchemaIndex[]
698
+ }
699
+
700
+ /** The exposed schema, built only from catalog queries the engine writes itself. */
701
+ export interface DbSchemaReport {
702
+ readonly tables: readonly DbSchemaTable[]
703
+ /** Relations left out: named in `exclude`, or (Postgres) inheriting from or reading one. */
704
+ readonly excludedCount: number
705
+ }