@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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The execution lanes `run_query` uses: a reusable read-only worker for a file-backed database, and an
|
|
3
|
+
* in-process lane for one that cannot be reopened. Not part of the public engine - the CLI's database
|
|
4
|
+
* tool runs each query in its own subprocess instead, which bounds the work by killing the process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SqliteDatabaseLike } from "./shared.ts"
|
|
8
|
+
|
|
9
|
+
export class QueryTimeoutError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("query exceeded the time limit")
|
|
12
|
+
this.name = "QueryTimeoutError"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One execution lane for `run_query`: `run` resolves rows, or rejects once the deadline passes. */
|
|
17
|
+
export interface QuerySession {
|
|
18
|
+
run(sql: string, deadline: number): Promise<unknown[]>
|
|
19
|
+
close(): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The isolated lane. The worker reopens the SAME database FILE read-only and answers one statement
|
|
24
|
+
* at a time; it is spawned once and reused for the life of the server. It deliberately does not
|
|
25
|
+
* take a `serialize()` snapshot - that copies the whole database into memory on EVERY call, which
|
|
26
|
+
* is a larger availability problem than the slow query the deadline exists to bound.
|
|
27
|
+
*/
|
|
28
|
+
const SQLITE_WORKER_SOURCE = `
|
|
29
|
+
const { Database } = require("bun:sqlite")
|
|
30
|
+
let db
|
|
31
|
+
self.onmessage = (event) => {
|
|
32
|
+
const { id, filename, sql, type } = event.data
|
|
33
|
+
if (type === "close") {
|
|
34
|
+
let response
|
|
35
|
+
try {
|
|
36
|
+
// prepare().all() leaves a statement alive until it is finalized or collected. Bun's
|
|
37
|
+
// default close(false) preserves those statements and can keep the database file locked;
|
|
38
|
+
// close(true) finalizes every outstanding statement before the worker exits.
|
|
39
|
+
db?.close(true)
|
|
40
|
+
db = undefined
|
|
41
|
+
response = { id, closed: true, ok: true }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
response = { id, closed: true, ok: false, error: String(error) }
|
|
44
|
+
}
|
|
45
|
+
// A forced parent-side terminate can leave native SQLite handles locked on Windows even after
|
|
46
|
+
// close() returns. Let the worker exit its own event turn so the runtime can finish releasing
|
|
47
|
+
// those handles before the parent considers graceful cleanup complete.
|
|
48
|
+
self.postMessage(response)
|
|
49
|
+
self.close()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
if (db === undefined) {
|
|
54
|
+
db = new Database(filename, { readonly: true })
|
|
55
|
+
db.run("PRAGMA query_only = ON")
|
|
56
|
+
}
|
|
57
|
+
self.postMessage({ id, ok: true, rows: db.prepare(sql).all() })
|
|
58
|
+
} catch (error) {
|
|
59
|
+
self.postMessage({ id, ok: false, error: String(error) })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
function isWorkerAvailable(): boolean {
|
|
65
|
+
return typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Only a file-backed database can be reopened in a worker. `:memory:` and the anonymous temp
|
|
69
|
+
* database live in this process alone, so they take the in-process lane instead. */
|
|
70
|
+
function reopenableFilename(db: SqliteDatabaseLike): string | undefined {
|
|
71
|
+
const filename = db.filename
|
|
72
|
+
if (typeof filename !== "string" || filename === "" || filename === ":memory:") return undefined
|
|
73
|
+
return filename.startsWith("file::memory:") ? undefined : filename
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PendingQuery {
|
|
77
|
+
readonly deadline: number
|
|
78
|
+
resolve(rows: unknown[]): void
|
|
79
|
+
reject(error: Error): void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PendingClose {
|
|
83
|
+
readonly id: number
|
|
84
|
+
resolve(graceful?: boolean): void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const WORKER_CLOSE_TIMEOUT_MS = 1_000
|
|
88
|
+
|
|
89
|
+
function workerSession(filename: string): QuerySession {
|
|
90
|
+
let worker: Worker | undefined
|
|
91
|
+
let sourceUrl: string | undefined
|
|
92
|
+
let nextId = 0
|
|
93
|
+
const pending = new Map<number, PendingQuery>()
|
|
94
|
+
let lane: Promise<unknown> = Promise.resolve()
|
|
95
|
+
let closed = false
|
|
96
|
+
let closePromise: Promise<void> | undefined
|
|
97
|
+
let pendingClose: PendingClose | undefined
|
|
98
|
+
|
|
99
|
+
const detachWorker = (active: Worker | undefined = worker): void => {
|
|
100
|
+
if (active === undefined) return
|
|
101
|
+
if (worker !== active) return
|
|
102
|
+
worker = undefined
|
|
103
|
+
if (sourceUrl !== undefined) URL.revokeObjectURL(sourceUrl)
|
|
104
|
+
sourceUrl = undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const releaseWorker = (active: Worker | undefined = worker): void => {
|
|
108
|
+
if (active === undefined) return
|
|
109
|
+
active.terminate()
|
|
110
|
+
detachWorker(active)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rejectPending = (error: Error): void => {
|
|
114
|
+
const waiters = [...pending.values()]
|
|
115
|
+
pending.clear()
|
|
116
|
+
for (const waiter of waiters) waiter.reject(error)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Drop the worker and fail everything riding on it. The only bound this side can enforce over a
|
|
120
|
+
* statement already running inside native SQLite is to stop owning the connection it runs on. */
|
|
121
|
+
const discard = (error: Error): void => {
|
|
122
|
+
const closing = pendingClose
|
|
123
|
+
pendingClose = undefined
|
|
124
|
+
releaseWorker()
|
|
125
|
+
rejectPending(error)
|
|
126
|
+
closing?.resolve()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ensureWorker = (): Worker => {
|
|
130
|
+
if (worker !== undefined) return worker
|
|
131
|
+
sourceUrl = URL.createObjectURL(new Blob([SQLITE_WORKER_SOURCE], { type: "text/javascript" }))
|
|
132
|
+
const spawned = new Worker(sourceUrl)
|
|
133
|
+
spawned.onmessage = (
|
|
134
|
+
event: MessageEvent<{
|
|
135
|
+
id?: number
|
|
136
|
+
ok?: boolean
|
|
137
|
+
rows?: unknown[]
|
|
138
|
+
error?: string
|
|
139
|
+
closed?: boolean
|
|
140
|
+
}>,
|
|
141
|
+
) => {
|
|
142
|
+
const { id, ok, rows, error } = event.data
|
|
143
|
+
if (typeof id !== "number") return
|
|
144
|
+
if (event.data.closed === true) {
|
|
145
|
+
const closing = pendingClose
|
|
146
|
+
if (closing?.id !== id) return
|
|
147
|
+
pendingClose = undefined
|
|
148
|
+
closing.resolve(event.data.ok === true)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const waiter = pending.get(id)
|
|
152
|
+
if (waiter === undefined) return
|
|
153
|
+
if (Date.now() >= waiter.deadline) {
|
|
154
|
+
discard(new QueryTimeoutError())
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
pending.delete(id)
|
|
158
|
+
if (ok === true) waiter.resolve(rows ?? [])
|
|
159
|
+
else waiter.reject(new Error(error ?? "query failed in worker"))
|
|
160
|
+
}
|
|
161
|
+
spawned.onerror = () => discard(new Error("query failed in worker"))
|
|
162
|
+
// An in-flight query holds its own deadline timer on the loop, so an IDLE worker must not be
|
|
163
|
+
// what keeps the process alive.
|
|
164
|
+
;(spawned as { unref?: () => void }).unref?.()
|
|
165
|
+
worker = spawned
|
|
166
|
+
return spawned
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dispatch = (sql: string, deadline: number): Promise<unknown[]> => {
|
|
170
|
+
if (closed) return Promise.reject(new Error("query session is closed"))
|
|
171
|
+
const active = ensureWorker()
|
|
172
|
+
const remaining = deadline - Date.now()
|
|
173
|
+
if (remaining <= 0) {
|
|
174
|
+
discard(new QueryTimeoutError())
|
|
175
|
+
return Promise.reject(new QueryTimeoutError())
|
|
176
|
+
}
|
|
177
|
+
const id = ++nextId
|
|
178
|
+
return new Promise<unknown[]>((resolve, reject) => {
|
|
179
|
+
const timer = setTimeout(() => discard(new QueryTimeoutError()), remaining)
|
|
180
|
+
pending.set(id, {
|
|
181
|
+
deadline,
|
|
182
|
+
resolve: (rows) => {
|
|
183
|
+
clearTimeout(timer)
|
|
184
|
+
resolve(rows)
|
|
185
|
+
},
|
|
186
|
+
reject: (error) => {
|
|
187
|
+
clearTimeout(timer)
|
|
188
|
+
reject(error)
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
try {
|
|
192
|
+
active.postMessage({ id, filename, sql })
|
|
193
|
+
} catch (error) {
|
|
194
|
+
pending.delete(id)
|
|
195
|
+
clearTimeout(timer)
|
|
196
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
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
|
+
},
|
|
209
|
+
close() {
|
|
210
|
+
if (closePromise !== undefined) return closePromise
|
|
211
|
+
closed = true
|
|
212
|
+
closePromise = (async () => {
|
|
213
|
+
const active = worker
|
|
214
|
+
if (active !== undefined) {
|
|
215
|
+
await new Promise<void>((resolve) => {
|
|
216
|
+
const id = ++nextId
|
|
217
|
+
let settled = false
|
|
218
|
+
const finish = (error?: Error, graceful = false): void => {
|
|
219
|
+
if (settled) return
|
|
220
|
+
settled = true
|
|
221
|
+
clearTimeout(timer)
|
|
222
|
+
if (pendingClose?.id === id) pendingClose = undefined
|
|
223
|
+
if (error !== undefined) rejectPending(error)
|
|
224
|
+
if (graceful) detachWorker(active)
|
|
225
|
+
else releaseWorker(active)
|
|
226
|
+
resolve()
|
|
227
|
+
}
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
finish(new Error("query session is closed"))
|
|
230
|
+
}, WORKER_CLOSE_TIMEOUT_MS)
|
|
231
|
+
pendingClose = { id, resolve: (graceful) => finish(undefined, graceful) }
|
|
232
|
+
try {
|
|
233
|
+
active.postMessage({ id, type: "close" })
|
|
234
|
+
} catch {
|
|
235
|
+
finish(new Error("query session is closed"))
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
await lane
|
|
240
|
+
})()
|
|
241
|
+
return closePromise
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The in-process lane, used when the database cannot be reopened elsewhere. The deadline is checked
|
|
248
|
+
* around the statement and nothing more: `prepare().all()` is synchronous, so it holds the only
|
|
249
|
+
* thread there is and no timer of ours can fire while it runs. That makes this lane BEST EFFORT -
|
|
250
|
+
* it reports an overrun, it does not cut one short.
|
|
251
|
+
*/
|
|
252
|
+
function inProcessSession(db: SqliteDatabaseLike): QuerySession {
|
|
253
|
+
return {
|
|
254
|
+
async run(sql, deadline) {
|
|
255
|
+
if (Date.now() >= deadline) throw new QueryTimeoutError()
|
|
256
|
+
const rows = db.prepare(sql).all()
|
|
257
|
+
if (Date.now() > deadline) throw new QueryTimeoutError()
|
|
258
|
+
return rows
|
|
259
|
+
},
|
|
260
|
+
async close() {},
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Pick the lane once, at construction. A file-backed database is isolated in a reusable read-only
|
|
266
|
+
* worker, which is the only arrangement that can actually stop a running statement. Anything else
|
|
267
|
+
* runs in process - every database keeps working, none is refused.
|
|
268
|
+
*/
|
|
269
|
+
export function openQuerySession(db: SqliteDatabaseLike): QuerySession {
|
|
270
|
+
const filename = reopenableFilename(db)
|
|
271
|
+
if (filename !== undefined && isWorkerAvailable()) return workerSession(filename)
|
|
272
|
+
return inProcessSession(db)
|
|
273
|
+
}
|