@nifrajs/mcp-db 3.5.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/README.md +83 -0
- package/dist/engine.d.ts +71 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +494 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +8 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -610
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +113 -0
- package/dist/internal/pg-exec.d.ts.map +1 -0
- package/dist/internal/pg-exec.js +286 -0
- package/dist/internal/pg-exec.js.map +1 -0
- package/dist/internal/pg-lexer.d.ts +52 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -0
- package/dist/internal/pg-lexer.js +273 -0
- package/dist/internal/pg-lexer.js.map +1 -0
- package/dist/internal/shared.d.ts +161 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +538 -0
- package/dist/internal/shared.js.map +1 -0
- package/dist/internal/sqlite-session.d.ts +21 -0
- package/dist/internal/sqlite-session.d.ts.map +1 -0
- package/dist/internal/sqlite-session.js +254 -0
- package/dist/internal/sqlite-session.js.map +1 -0
- package/dist/postgres.d.ts +148 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +580 -0
- package/dist/postgres.js.map +1 -0
- package/package.json +15 -2
- package/src/engine.ts +664 -0
- package/src/index.ts +43 -640
- package/src/internal/pg-exec.ts +386 -0
- package/src/internal/pg-lexer.ts +279 -0
- package/src/internal/shared.ts +705 -0
- package/src/internal/sqlite-session.ts +273 -0
- package/src/postgres.ts +777 -0
|
@@ -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
|
+
}
|