@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1

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,677 @@
1
+ /**
2
+ * Local "file" data source backed by SQLite (node:sqlite, built into the runtime).
3
+ *
4
+ * Read side — SqliteDriver mirrors the drivers.js interface:
5
+ * test() → { version, user, database, tableCount, readOnlyGrant, grants, warnings }
6
+ * introspect(timeoutMs) → { version, tables, columns, foreignKeys, indexes }
7
+ * query(sql, { timeoutMs, maxRows }) → { columns: [{ name, kind, table?, column?, index }], rows }
8
+ * explain(sql, { timeoutMs }) → { plan, scans: [{ table, rows }] }
9
+ * sampleColumn(table, column) → [{ value, count }]
10
+ * previewSql(table, limit), ident(name), close(), dialect = 'sqlite', useHint = false
11
+ *
12
+ * Every read statement runs on a separate connection opened with
13
+ * readOnly: true, defensive mode, ATTACH disabled (limits.attach = 0),
14
+ * extension loading off, and an authorizer that only admits SELECT / READ /
15
+ * non-dangerous FUNCTION / RECURSIVE and a whitelist of introspection PRAGMAs.
16
+ *
17
+ * Timeouts: node:sqlite is synchronous and exposes no progress handler or
18
+ * sqlite3_interrupt(); a worker thread cannot be terminated while SQLite is
19
+ * inside one long step either. Reads therefore run in one long-lived child
20
+ * process per driver (process.execPath with ELECTRON_RUN_AS_NODE=1, IPC). The
21
+ * host event loop never blocks; when timeoutMs (+250ms grace) elapses the
22
+ * caller gets query/timeout and the child is SIGKILLed — a hard stop — and the
23
+ * next call forks a fresh one (~100ms). Row loops also check the deadline every
24
+ * 256 rows and stop at maxRows + 1.
25
+ *
26
+ * Write side — importTable / dropTable / listTables / fileStats open a
27
+ * short-lived read-write connection per call.
28
+ */
29
+ import { DatabaseSync } from 'node:sqlite'
30
+ import { fork } from 'node:child_process'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { mkdirSync, existsSync, statSync } from 'node:fs'
33
+ import { dirname, basename } from 'node:path'
34
+
35
+ export const META_TABLE = '__nl2sql_tables'
36
+
37
+ export class SqliteError extends Error {
38
+ constructor(code, message) {
39
+ super(message)
40
+ this.code = code
41
+ }
42
+ }
43
+
44
+ // drivers.js may import this module (createDriver), so we must not import it
45
+ // back. The host can inject DriverError so describeError() keeps the error code:
46
+ // import { useErrorClass } from './sqlite-driver.js'; useErrorClass(DriverError)
47
+ let ErrorClass = SqliteError
48
+ export function useErrorClass(C) {
49
+ if (typeof C === 'function') ErrorClass = C
50
+ }
51
+ const fail = (code, message) => new ErrorClass(code, message)
52
+ const isOurs = e => e instanceof SqliteError || e instanceof ErrorClass
53
+
54
+ const SQLITE_VERSION = (() => {
55
+ try {
56
+ const db = new DatabaseSync(':memory:')
57
+ const v = db.prepare('SELECT sqlite_version() AS v').get().v
58
+ db.close()
59
+ return String(v)
60
+ } catch {
61
+ return ''
62
+ }
63
+ })()
64
+
65
+ function versionNumber(v) {
66
+ const m = String(v).match(/(\d+)\.(\d+)(?:\.(\d+))?/)
67
+ return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3] ?? 0) : 0
68
+ }
69
+
70
+ const MODERN_JOINS = versionNumber(SQLITE_VERSION) >= 33900
71
+
72
+ /** Dialect notes for the model (append to DIALECT_HINTS.sqlite). */
73
+ export const sqliteDialectHints = [
74
+ `SQLite ${SQLITE_VERSION}: quote identifiers with double quotes (always for Chinese or mixed-case names, e.g. "订单"."金额").`,
75
+ 'Dates are stored as TEXT \'YYYY-MM-DD\' / \'YYYY-MM-DD HH:MM:SS\': use date(col), strftime(\'%Y-%m\', col) for months, strftime(\'%Y\', col), strftime(\'%Y-%W\', col) for weeks, strftime(\'%w\', col) weekday (0=Sunday), date(\'now\', \'-30 days\'), julianday(a) - julianday(b) for day differences, unixepoch(col) for epoch seconds; there is no DATE_FORMAT, date_trunc, EXTRACT, NOW() or INTERVAL.',
76
+ `CTEs, window functions, FILTER (WHERE ...), IIF(), CASE, group_concat()/string_agg(), JSON ->/->> ${MODERN_JOINS ? 'and RIGHT / FULL OUTER JOIN are supported' : 'are supported; RIGHT and FULL OUTER JOIN are NOT supported (rewrite as LEFT JOIN)'}.`,
77
+ 'Integer division truncates (use col * 1.0 / x); || concatenates strings; LIKE is case-insensitive for ASCII (no ILIKE); ROUND(x, 2) for decimals; no schemas, the whole file is one database.',
78
+ ].join(' ')
79
+
80
+ // ------------------------------------------------------------------ helpers
81
+
82
+ const A = {
83
+ PRAGMA: 19, READ: 20, SELECT: 21, FUNCTION: 31, RECURSIVE: 33,
84
+ }
85
+
86
+ const FORBIDDEN_FUNCTIONS = new Set(['load_extension', 'readfile', 'writefile', 'edit', 'fts3_tokenizer', 'fts5', 'zipfile', 'sqlar_compress', 'sqlar_uncompress', 'lsmode', 'sqlite_offset'])
87
+ // PRAGMAs whose argument is a table/index name and which never write.
88
+ const PRAGMA_WITH_ARG = new Set(['table_info', 'table_xinfo', 'index_list', 'index_info', 'index_xinfo', 'foreign_key_list', 'table_list'])
89
+ // PRAGMAs readable without argument; any assignment (arg present) is refused.
90
+ const PRAGMA_READ = new Set(['user_version', 'schema_version', 'application_id', 'encoding', 'page_size', 'page_count', 'freelist_count', 'database_list', 'compile_options', 'function_list', 'collation_list', 'table_list', 'foreign_keys', 'journal_mode'])
91
+
92
+ const ACTION_NAMES = {
93
+ 1: 'CREATE INDEX', 2: 'CREATE TABLE', 3: 'CREATE TEMP INDEX', 4: 'CREATE TEMP TABLE', 5: 'CREATE TEMP TRIGGER', 6: 'CREATE TEMP VIEW',
94
+ 7: 'CREATE TRIGGER', 8: 'CREATE VIEW', 9: 'DELETE', 10: 'DROP INDEX', 11: 'DROP TABLE', 12: 'DROP TEMP INDEX', 13: 'DROP TEMP TABLE',
95
+ 14: 'DROP TEMP TRIGGER', 15: 'DROP TEMP VIEW', 16: 'DROP TRIGGER', 17: 'DROP VIEW', 18: 'INSERT', 19: 'PRAGMA', 22: 'TRANSACTION',
96
+ 23: 'UPDATE', 24: 'ATTACH', 25: 'DETACH', 26: 'ALTER TABLE', 27: 'REINDEX', 28: 'ANALYZE', 29: 'CREATE VIRTUAL TABLE', 30: 'DROP VIRTUAL TABLE', 31: 'FUNCTION', 32: 'SAVEPOINT',
97
+ }
98
+
99
+ export function ident(name) {
100
+ return `"${String(name).replace(/"/g, '""')}"`
101
+ }
102
+
103
+ /** Convert one SQLite value to a JSON-friendly cell. */
104
+ export function toCell(value) {
105
+ if (value === null || value === undefined) return null
106
+ if (typeof value === 'bigint') return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString()
107
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
108
+ if (value instanceof Uint8Array) return `<binary ${value.length} bytes>`
109
+ return value
110
+ }
111
+
112
+ /** Map a declared SQLite type to the service's dataType vocabulary. */
113
+ function mapType(declared) {
114
+ const t = String(declared ?? '').toUpperCase()
115
+ if (!t) return { dataType: 'varchar', affinity: 'none' }
116
+ if (/BOOL/.test(t)) return { dataType: 'tinyint', affinity: 'number', columnType: 'tinyint(1)' }
117
+ if (/^BIGINT|INT8/.test(t)) return { dataType: 'bigint', affinity: 'number' }
118
+ if (/INT/.test(t)) return { dataType: 'int', affinity: 'number' }
119
+ if (/DATETIME|TIMESTAMP/.test(t)) return { dataType: 'datetime', affinity: 'time' }
120
+ if (/^DATE/.test(t)) return { dataType: 'date', affinity: 'time' }
121
+ if (/^TIME/.test(t)) return { dataType: 'time', affinity: 'time' }
122
+ if (/CHAR|CLOB|TEXT/.test(t)) return { dataType: 'varchar', affinity: 'text' }
123
+ if (/BLOB/.test(t)) return { dataType: 'blob', affinity: 'text' }
124
+ if (/REAL|FLOA|DOUB/.test(t)) return { dataType: 'double', affinity: 'number' }
125
+ if (/NUM|DEC/.test(t)) return { dataType: 'decimal', affinity: 'number' }
126
+ return { dataType: t.toLowerCase(), affinity: 'number' } // SQLite: unknown types get NUMERIC affinity
127
+ }
128
+
129
+ /** Text left after the first statement, minus whitespace and comments. */
130
+ function trailingCode(rest) {
131
+ return rest.replace(/--[^\n]*/g, ' ').replace(/\/\*[\s\S]*?(\*\/|$)/g, ' ').replace(/[\s;]+/g, '')
132
+ }
133
+
134
+ function translate(error, denied) {
135
+ if (isOurs(error)) return error
136
+ const msg = String(error?.message ?? error)
137
+ if (/not authorized|too many attached/i.test(msg)) return fail('sqlite/denied', `只读数据源不允许执行该语句${denied ? `(${denied})` : ''}:仅支持 SELECT 查询`)
138
+ if (/attempt to write a readonly database/i.test(msg)) return fail('sqlite/denied', '只读数据源不允许写入')
139
+ if (/unable to open database file/i.test(msg)) return fail('sqlite/missing', '数据文件不存在或无法打开,请先上传 CSV/Excel 文件')
140
+ const e = fail(`sqlite/${error?.errstr ? error.errstr.replace(/\s+/g, '-') : 'error'}`, `SQLite:${msg}`)
141
+ return e
142
+ }
143
+
144
+ // ------------------------------------------------------------- read session
145
+
146
+ /** Read-only connection with an authorizer; used in the child process or in-process. */
147
+ class ReadSession {
148
+ constructor(file) {
149
+ this.file = file
150
+ this.denied = null
151
+ this.db = new DatabaseSync(file, { readOnly: true, allowExtension: false, timeout: 5000, defensive: true, limits: { attach: 0, length: 64 * 1024 * 1024 } })
152
+ try { this.db.enableDefensive?.(true) } catch {}
153
+ this.db.setAuthorizer((action, a1, a2) => {
154
+ if (action === A.SELECT || action === A.READ || action === A.RECURSIVE) return 0
155
+ if (action === A.FUNCTION) {
156
+ if (FORBIDDEN_FUNCTIONS.has(String(a2 ?? '').toLowerCase())) { this.denied = `函数 ${a2}`; return 1 }
157
+ return 0
158
+ }
159
+ if (action === A.PRAGMA) {
160
+ const name = String(a1 ?? '').toLowerCase()
161
+ if (PRAGMA_WITH_ARG.has(name) || (PRAGMA_READ.has(name) && (a2 === null || a2 === undefined))) return 0
162
+ this.denied = `PRAGMA ${name}${a2 != null ? `=${a2}` : ''}`
163
+ return 1
164
+ }
165
+ this.denied = ACTION_NAMES[action] ?? `action ${action}`
166
+ return 1
167
+ })
168
+ }
169
+
170
+ prepare(sql) {
171
+ // Stepping any statement re-checks the schema cookie, so a table replaced by
172
+ // an import on another connection is seen before columns() is read.
173
+ this.ping ??= this.db.prepare('SELECT 1 FROM sqlite_master LIMIT 1')
174
+ try { this.ping.get() } catch {}
175
+ this.denied = null
176
+ let stmt
177
+ try {
178
+ stmt = this.db.prepare(sql)
179
+ } catch (error) {
180
+ throw translate(error, this.denied)
181
+ }
182
+ const src = stmt.sourceSQL
183
+ if (typeof src === 'string' && sql.startsWith(src) && trailingCode(sql.slice(src.length))) throw fail('sqlite/multi', '一次只能执行一条语句')
184
+ stmt.setReadBigInts(true)
185
+ return stmt
186
+ }
187
+
188
+ objects(sql, ...params) {
189
+ return this.prepare(sql).all(...params).map(r => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, toCell(v)])))
190
+ }
191
+
192
+ query(sql, { maxRows = 1000, deadline = Infinity } = {}) {
193
+ const stmt = this.prepare(sql)
194
+ const meta = stmt.columns()
195
+ stmt.setReturnArrays(true)
196
+ const rows = []
197
+ try {
198
+ for (const raw of stmt.iterate()) {
199
+ rows.push(raw.map(toCell))
200
+ if (rows.length > maxRows) break
201
+ if ((rows.length & 255) === 0 && Date.now() > deadline) throw fail('query/timeout', '查询超时')
202
+ }
203
+ } catch (error) {
204
+ throw translate(error, this.denied)
205
+ }
206
+ const columns = meta.map((c, i) => {
207
+ const affinity = c.type ? mapType(c.type).affinity : 'none'
208
+ let kind
209
+ if (affinity === 'time') kind = 'time'
210
+ else if (affinity === 'text') kind = 'text'
211
+ else if (affinity === 'number') kind = 'number'
212
+ else {
213
+ const values = rows.map(r => r[i]).filter(v => v !== null)
214
+ kind = values.length && values.every(v => typeof v === 'number') ? 'number' : 'auto'
215
+ }
216
+ if (kind === 'number' && /(^id$|_id$)/i.test(c.name)) kind = 'text'
217
+ return { name: c.name, kind, ...(c.table ? { table: c.table, column: c.column } : {}), index: i }
218
+ })
219
+ return { columns, rows }
220
+ }
221
+
222
+ rowCount(table) {
223
+ return Number(this.objects(`SELECT COUNT(*) AS n FROM ${ident(table)}`)[0].n)
224
+ }
225
+
226
+ userTables() {
227
+ return this.objects(`SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' AND name <> ? ORDER BY name`, META_TABLE)
228
+ }
229
+
230
+ meta() {
231
+ const has = this.objects(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?`, META_TABLE).length > 0
232
+ return has ? new Map(this.objects(`SELECT name, source, rows, created_at, updated_at FROM ${ident(META_TABLE)}`).map(r => [r.name, r])) : new Map()
233
+ }
234
+
235
+ info() {
236
+ return { version: SQLITE_VERSION, tableCount: this.userTables().length }
237
+ }
238
+
239
+ introspect({ deadline = Infinity } = {}) {
240
+ const meta = this.meta()
241
+ const list = this.userTables()
242
+ const tables = []
243
+ const columns = []
244
+ const foreignKeys = []
245
+ const indexes = []
246
+ for (const t of list) {
247
+ if (Date.now() > deadline) throw fail('query/timeout', '读取表结构超时')
248
+ const m = meta.get(t.name)
249
+ const comment = m ? [m.source ? `来源:${m.source}` : '', m.updated_at ? `导入于 ${new Date(Number(m.updated_at)).toLocaleString('zh-CN', { hour12: false })}` : ''].filter(Boolean).join(',') : ''
250
+ tables.push({ name: t.name, type: t.type === 'view' ? 'VIEW' : 'TABLE', rows: t.type === 'view' ? null : this.rowCount(t.name), comment })
251
+ const cols = this.objects('SELECT cid, name, type, "notnull" AS nn, pk FROM pragma_table_info(?)', t.name)
252
+ const idx = t.type === 'view' ? [] : this.objects('SELECT name, "unique" AS u, origin FROM pragma_index_list(?) ORDER BY seq DESC', t.name)
253
+ const idxCols = idx.map(i => ({ ...i, cols: this.objects('SELECT name FROM pragma_index_info(?) ORDER BY seqno', i.name).map(r => r.name) }))
254
+ const pkCols = cols.filter(c => Number(c.pk) > 0).sort((a, b) => a.pk - b.pk).map(c => c.name)
255
+ for (const i of idxCols) {
256
+ const name = i.origin === 'pk' ? 'PRIMARY' : i.name
257
+ for (const c of i.cols) if (c !== null) indexes.push({ table: t.name, name, unique: Number(i.u) === 1, column: c })
258
+ }
259
+ if (pkCols.length && !idxCols.some(i => i.origin === 'pk')) for (const c of pkCols) indexes.push({ table: t.name, name: 'PRIMARY', unique: true, column: c })
260
+ for (const c of cols) {
261
+ const mt = mapType(c.type)
262
+ const isPk = Number(c.pk) > 0
263
+ const key = isPk ? 'PRI'
264
+ : idxCols.some(i => Number(i.u) === 1 && i.cols.length === 1 && i.cols[0] === c.name) ? 'UNI'
265
+ : idxCols.some(i => i.cols[0] === c.name) ? 'MUL' : ''
266
+ columns.push({ table: t.name, name: c.name, dataType: mt.dataType, columnType: mt.columnType ?? (String(c.type || '').toLowerCase() || 'any'), nullable: !Number(c.nn) && !isPk, key, comment: '' })
267
+ }
268
+ if (t.type !== 'view') {
269
+ for (const fk of this.objects('SELECT "table" AS ref, "from" AS col, "to" AS refCol FROM pragma_foreign_key_list(?) ORDER BY id, seq', t.name)) {
270
+ let refCol = fk.refCol
271
+ if (refCol === null) refCol = this.objects('SELECT name FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk', fk.ref)[0]?.name ?? 'rowid'
272
+ foreignKeys.push({ from: { table: t.name, column: fk.col }, to: { table: fk.ref, column: refCol } })
273
+ }
274
+ }
275
+ }
276
+ return { version: SQLITE_VERSION, tables, columns, foreignKeys, indexes }
277
+ }
278
+
279
+ explain(sql) {
280
+ const plan = this.objects(`EXPLAIN QUERY PLAN ${sql}`).map(r => ({ id: r.id, parent: r.parent, detail: r.detail }))
281
+ const tables = this.userTables().filter(t => t.type === 'table').map(t => t.name)
282
+ // Aliases: FROM/JOIN <name> [AS] <alias>
283
+ const alias = new Map()
284
+ const identRe = '("(?:[^"]|"")+"|`[^`]+`|\\[[^\\]]+\\]|[\\p{L}\\p{N}_$]+)'
285
+ const unq = s => (/^["`[]/.test(s) ? s.slice(1, -1).replace(/""/g, '"') : s)
286
+ const kw = /^(where|on|using|join|left|right|full|inner|cross|natural|outer|group|order|limit|having|union|except|intersect|window|as|set)$/i
287
+ for (const m of sql.matchAll(new RegExp(`\\b(?:FROM|JOIN)\\s+${identRe}(?:\\s+(?:AS\\s+)?${identRe})?`, 'giu'))) {
288
+ const name = unq(m[1])
289
+ const a = m[2] ? unq(m[2]) : null
290
+ if (a && !kw.test(a)) alias.set(a, name)
291
+ }
292
+ const candidates = [...new Set([...tables, ...alias.keys()])].sort((a, b) => b.length - a.length)
293
+ const scans = []
294
+ const counted = new Map()
295
+ for (const p of plan) {
296
+ const d = String(p.detail ?? '')
297
+ if (!d.startsWith('SCAN ') || /\bUSING\b.*\bINDEX\b/.test(d)) continue
298
+ let rest = d.slice(5)
299
+ if (rest.startsWith('TABLE ')) rest = rest.slice(6) // pre-3.36 wording
300
+ const hit = candidates.find(c => rest === c || rest.startsWith(`${c} `))
301
+ if (!hit) continue
302
+ const table = alias.get(hit) ?? hit
303
+ const real = tables.find(t => t.toLowerCase() === table.toLowerCase())
304
+ if (!real) continue
305
+ if (!counted.has(real)) counted.set(real, this.rowCount(real))
306
+ scans.push({ table: real, rows: counted.get(real) })
307
+ }
308
+ return { plan, scans }
309
+ }
310
+
311
+ sample(table, column) {
312
+ return this.objects(`SELECT ${ident(column)} AS v, COUNT(*) AS n FROM ${ident(table)} GROUP BY 1 ORDER BY 2 DESC LIMIT 13`).map(r => ({ value: r.v, count: Number(r.n) }))
313
+ }
314
+
315
+ run(op, args) {
316
+ switch (op) {
317
+ case 'query': return this.query(args.sql, args)
318
+ case 'explain': return this.explain(args.sql)
319
+ case 'sample': return this.sample(args.table, args.column)
320
+ case 'introspect': return this.introspect(args)
321
+ case 'info': return this.info()
322
+ default: throw new Error(`unknown op ${op}`)
323
+ }
324
+ }
325
+
326
+ close() {
327
+ try { this.db.close() } catch {}
328
+ }
329
+ }
330
+
331
+ // ------------------------------------------------------------- child process
332
+
333
+ // Reads run in a child process (same runtime binary, ELECTRON_RUN_AS_NODE=1) so
334
+ // a runaway statement can be SIGKILLed — node:sqlite has no interrupt API and
335
+ // a worker thread cannot be stopped while SQLite is inside one long step.
336
+ if (process.env.NL2SQL_SQLITE_CHILD === '1' && typeof process.send === 'function') {
337
+ let session = null
338
+ process.on('message', ({ id, op, args }) => {
339
+ try {
340
+ session ??= new ReadSession(process.env.NL2SQL_SQLITE_FILE)
341
+ process.send({ id, ok: true, result: session.run(op, args) })
342
+ } catch (error) {
343
+ const e = translate(error)
344
+ process.send({ id, ok: false, code: e.code, message: e.message })
345
+ }
346
+ })
347
+ process.on('disconnect', () => process.exit(0))
348
+ }
349
+
350
+ // ------------------------------------------------------------------- driver
351
+
352
+ export class SqliteDriver {
353
+ /**
354
+ * @param {{ file: string, worker?: boolean }} conf - absolute path of the .sqlite file;
355
+ * worker:false runs reads in-process (blocks the event loop, timeouts only
356
+ * checked between rows; for tests or hosts that cannot fork).
357
+ */
358
+ constructor(conf) {
359
+ this.conf = conf
360
+ this.file = conf.file
361
+ this.dialect = 'sqlite'
362
+ this.version = SQLITE_VERSION
363
+ this.useWorker = conf.worker !== false
364
+ this.child = null
365
+ this.pending = new Map()
366
+ this.seq = 0
367
+ this.local = null
368
+ }
369
+
370
+ get useHint() { return false }
371
+
372
+ ident(name) { return ident(name) }
373
+
374
+ previewSql(table, limit) {
375
+ return `SELECT * FROM ${ident(table)} LIMIT ${Math.max(0, Math.floor(Number(limit) || 0))}`
376
+ }
377
+
378
+ ensureFile() {
379
+ if (!this.file || !existsSync(this.file)) throw fail('sqlite/missing', '数据文件不存在,请先上传 CSV/Excel 文件')
380
+ }
381
+
382
+ spawn() {
383
+ const child = fork(fileURLToPath(import.meta.url), [], {
384
+ execPath: process.execPath,
385
+ execArgv: [],
386
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NL2SQL_SQLITE_CHILD: '1', NL2SQL_SQLITE_FILE: this.file },
387
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
388
+ serialization: 'advanced',
389
+ })
390
+ child.unref()
391
+ child.channel?.unref?.()
392
+ child.on('message', (m) => {
393
+ const p = this.pending.get(m?.id)
394
+ if (!p) return
395
+ this.pending.delete(m.id)
396
+ clearTimeout(p.timer)
397
+ if (this.pending.size === 0) child.channel?.unref?.()
398
+ if (m.ok) p.resolve(m.result)
399
+ else p.reject(fail(m.code ?? 'sqlite/error', m.message))
400
+ })
401
+ const dead = (error) => {
402
+ if (this.child === child) this.child = null
403
+ for (const [id, p] of this.pending) {
404
+ if (p.child !== child) continue
405
+ this.pending.delete(id)
406
+ clearTimeout(p.timer)
407
+ p.reject(fail('sqlite/worker', `SQLite 查询进程异常退出${error?.message ? `:${error.message}` : ''}`))
408
+ }
409
+ }
410
+ child.on('error', dead)
411
+ child.on('exit', () => dead())
412
+ return child
413
+ }
414
+
415
+ async call(op, args, timeoutMs) {
416
+ this.ensureFile()
417
+ const deadline = Date.now() + timeoutMs
418
+ if (!this.useWorker) {
419
+ this.local ??= new ReadSession(this.file)
420
+ try {
421
+ return this.local.run(op, { ...args, deadline })
422
+ } catch (error) {
423
+ throw translate(error)
424
+ }
425
+ }
426
+ if (!this.child) {
427
+ try {
428
+ this.child = this.spawn()
429
+ } catch {
430
+ this.useWorker = false // cannot fork in this host: run in-process (no hard timeout)
431
+ return this.call(op, args, timeoutMs)
432
+ }
433
+ }
434
+ const child = this.child
435
+ const id = ++this.seq
436
+ return new Promise((resolve, reject) => {
437
+ const timer = setTimeout(() => {
438
+ if (!this.pending.has(id)) return
439
+ this.pending.delete(id)
440
+ // node:sqlite cannot interrupt a statement; kill the process that runs it.
441
+ if (this.child === child) this.child = null
442
+ for (const [pid, p] of this.pending) if (p.child === child) { this.pending.delete(pid); clearTimeout(p.timer); p.reject(fail('query/timeout', `查询超时(>${timeoutMs}ms)`)) }
443
+ child.kill('SIGKILL')
444
+ reject(fail('query/timeout', `查询超时(>${timeoutMs}ms)`))
445
+ }, timeoutMs + 250)
446
+ this.pending.set(id, { resolve, reject, timer, child })
447
+ child.channel?.ref?.()
448
+ try {
449
+ child.send({ id, op, args: { ...args, deadline } })
450
+ } catch (error) {
451
+ this.pending.delete(id)
452
+ clearTimeout(timer)
453
+ reject(fail('sqlite/worker', `SQLite 查询进程不可用:${error.message}`))
454
+ }
455
+ })
456
+ }
457
+
458
+ async test() {
459
+ const info = await this.call('info', {}, 10000)
460
+ const bytes = statSync(this.file).size
461
+ return {
462
+ version: `SQLite ${info.version}`, user: 'local', database: basename(this.file), tableCount: info.tableCount, readOnlyGrant: true,
463
+ grants: ['read-only connection (readOnly, defensive, ATTACH disabled)', 'authorizer: SELECT / read PRAGMA only', 'extension loading disabled', `file size: ${bytes} bytes`],
464
+ warnings: [],
465
+ }
466
+ }
467
+
468
+ async introspect(timeoutMs = 30000) {
469
+ const raw = await this.call('introspect', {}, timeoutMs)
470
+ this.version = raw.version
471
+ return raw
472
+ }
473
+
474
+ async query(sql, { timeoutMs = 30000, maxRows = 1000 } = {}) {
475
+ return this.call('query', { sql: String(sql), maxRows }, timeoutMs)
476
+ }
477
+
478
+ async explain(sql, { timeoutMs = 10000 } = {}) {
479
+ return this.call('explain', { sql: String(sql) }, timeoutMs)
480
+ }
481
+
482
+ async sampleColumn(table, column) {
483
+ return this.call('sample', { table, column }, 5000)
484
+ }
485
+
486
+ async close() {
487
+ const child = this.child
488
+ this.child = null
489
+ for (const [id, p] of this.pending) { this.pending.delete(id); clearTimeout(p.timer); p.reject(fail('sqlite/closed', '数据源已关闭')) }
490
+ if (child && child.exitCode === null) {
491
+ const exited = new Promise(r => child.once('exit', r))
492
+ child.ref() // keep the loop alive until the exit is observed
493
+ child.kill('SIGKILL')
494
+ let timer
495
+ await Promise.race([exited, new Promise(r => { timer = setTimeout(r, 2000) })])
496
+ clearTimeout(timer)
497
+ child.unref()
498
+ }
499
+ this.local?.close()
500
+ this.local = null
501
+ }
502
+ }
503
+
504
+ // --------------------------------------------------------------- write side
505
+
506
+ const COLUMN_TYPES = new Set(['INTEGER', 'REAL', 'TEXT', 'DATE', 'DATETIME'])
507
+
508
+ /**
509
+ * Normalize a user-supplied table name: keep letters (incl. Chinese), digits and
510
+ * underscores; others become "_". Prefix "t_" when it starts with a digit.
511
+ */
512
+ export function sanitizeTableName(input) {
513
+ let name = String(input ?? '').normalize('NFC').trim().replace(/[^\p{L}\p{Nd}_]/gu, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')
514
+ if (!name) throw fail('sqlite/invalid-name', '表名不能为空')
515
+ if (/^\p{Nd}/u.test(name)) name = `t_${name}`
516
+ name = [...name].slice(0, 64).join('')
517
+ if (/^sqlite_/i.test(name) || name.toLowerCase() === META_TABLE) throw fail('sqlite/invalid-name', `表名不能以 sqlite_ 开头或使用保留名:${name}`)
518
+ return name
519
+ }
520
+
521
+ function openWrite(file, { create = false } = {}) {
522
+ if (!file || typeof file !== 'string') throw fail('sqlite/invalid', '缺少数据文件路径')
523
+ if (!create && !existsSync(file)) throw fail('sqlite/missing', '数据文件不存在')
524
+ if (create) mkdirSync(dirname(file), { recursive: true })
525
+ const db = new DatabaseSync(file, { allowExtension: false, timeout: 5000, defensive: true, limits: { attach: 0 } })
526
+ db.exec(`CREATE TABLE IF NOT EXISTS ${ident(META_TABLE)} (name TEXT PRIMARY KEY, source TEXT, rows INTEGER, created_at INTEGER, updated_at INTEGER)`)
527
+ return db
528
+ }
529
+
530
+ function findTable(db, name) {
531
+ return db.prepare(`SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND lower(name) = lower(?)`).get(name)
532
+ }
533
+
534
+ function pad2(n) { return String(n).padStart(2, '0') }
535
+
536
+ function toValue(v, type) {
537
+ if (v === undefined || v === null) return null
538
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null
539
+ if (typeof v === 'bigint') return v
540
+ if (typeof v === 'boolean') return v ? 1 : 0
541
+ if (v instanceof Date) {
542
+ if (Number.isNaN(v.getTime())) return null
543
+ const d = `${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())}`
544
+ return type === 'DATE' ? d : `${d} ${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}`
545
+ }
546
+ if (v instanceof Uint8Array) return v
547
+ return String(v)
548
+ }
549
+
550
+ /**
551
+ * Import already-coerced rows into a table.
552
+ * @param {{ file: string, table: string, columns: { name: string, type: 'INTEGER'|'REAL'|'TEXT'|'DATE'|'DATETIME' }[], rows: any[][], mode?: 'create'|'replace'|'append', source?: string }} input
553
+ * @returns {{ table: string, rows: number, columns: { name: string, type: string }[] }}
554
+ */
555
+ export function importTable({ file, table, columns, rows, mode = 'create', source = '' }) {
556
+ if (!['create', 'replace', 'append'].includes(mode)) throw fail('sqlite/invalid', `未知导入模式:${mode}`)
557
+ const name = sanitizeTableName(table)
558
+ if (!Array.isArray(columns) || columns.length === 0) throw fail('sqlite/invalid', '至少需要一列')
559
+ if (columns.length > 2000) throw fail('sqlite/invalid', '列数过多(最多 2000 列)')
560
+ const seen = new Set()
561
+ const cols = columns.map((c, i) => {
562
+ const cname = String(c?.name ?? '').trim()
563
+ if (!cname) throw fail('sqlite/invalid', `第 ${i + 1} 列缺少列名`)
564
+ if (seen.has(cname.toLowerCase())) throw fail('sqlite/invalid', `列名重复:${cname}`)
565
+ seen.add(cname.toLowerCase())
566
+ const type = String(c.type ?? 'TEXT').toUpperCase()
567
+ if (!COLUMN_TYPES.has(type)) throw fail('sqlite/invalid', `不支持的列类型:${c.type}`)
568
+ return { name: cname, type }
569
+ })
570
+ const list = Array.isArray(rows) ? rows : []
571
+ const db = openWrite(file, { create: true })
572
+ let inserted = 0
573
+ try {
574
+ db.exec('BEGIN IMMEDIATE')
575
+ try {
576
+ const existing = findTable(db, name)
577
+ let target = cols
578
+ let createdAt = Date.now()
579
+ if (existing && existing.type === 'view') throw fail('sqlite/exists', `同名视图已存在:${existing.name}`)
580
+ if (mode === 'create' && existing) throw fail('sqlite/exists', `表已存在:${existing.name}`)
581
+ if (mode === 'append') {
582
+ if (!existing) throw fail('sqlite/not-found', `表不存在:${name}`)
583
+ const have = db.prepare('SELECT name, type FROM pragma_table_info(?) ORDER BY cid').all(existing.name)
584
+ const same = have.length === cols.length && have.every((h, i) => h.name.toLowerCase() === cols[i].name.toLowerCase())
585
+ if (!same) throw fail('sqlite/columns-mismatch', `追加的数据列与现有表不一致:现有 [${have.map(h => h.name).join(', ')}],导入 [${cols.map(c => c.name).join(', ')}]`)
586
+ target = have.map(h => ({ name: h.name, type: String(h.type || 'TEXT').toUpperCase() }))
587
+ createdAt = Number(db.prepare(`SELECT created_at FROM ${ident(META_TABLE)} WHERE name = ?`).get(existing.name)?.created_at ?? createdAt)
588
+ }
589
+ const tname = existing && mode === 'append' ? existing.name : name
590
+ if (mode === 'replace' && existing) {
591
+ db.exec(`DROP TABLE ${ident(existing.name)}`)
592
+ db.prepare(`DELETE FROM ${ident(META_TABLE)} WHERE name = ?`).run(existing.name)
593
+ }
594
+ if (mode !== 'append') db.exec(`CREATE TABLE ${ident(tname)} (${target.map(c => `${ident(c.name)} ${c.type}`).join(', ')})`)
595
+ const n = target.length
596
+ const batch = Math.max(1, Math.min(500, Math.floor(32766 / n)))
597
+ const sqlFor = k => `INSERT INTO ${ident(tname)} (${target.map(c => ident(c.name)).join(', ')}) VALUES ${Array.from({ length: k }, () => `(${Array(n).fill('?').join(',')})`).join(',')}`
598
+ const full = db.prepare(sqlFor(batch))
599
+ const flat = (slice) => {
600
+ const out = []
601
+ for (const r of slice) {
602
+ const row = Array.isArray(r) ? r : []
603
+ for (let i = 0; i < n; i++) out.push(toValue(row[i], target[i].type))
604
+ }
605
+ return out
606
+ }
607
+ let i = 0
608
+ for (; i + batch <= list.length; i += batch) { full.run(...flat(list.slice(i, i + batch))); inserted += batch }
609
+ if (i < list.length) {
610
+ const rest = list.slice(i)
611
+ db.prepare(sqlFor(rest.length)).run(...flat(rest))
612
+ inserted += rest.length
613
+ }
614
+ const total = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${ident(tname)}`).get().n)
615
+ const now = Date.now()
616
+ db.prepare(`INSERT INTO ${ident(META_TABLE)} (name, source, rows, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
617
+ ON CONFLICT(name) DO UPDATE SET source = excluded.source, rows = excluded.rows, updated_at = excluded.updated_at`).run(tname, String(source ?? ''), total, createdAt, now)
618
+ db.exec('COMMIT')
619
+ return { table: tname, rows: inserted, columns: target }
620
+ } catch (error) {
621
+ try { db.exec('ROLLBACK') } catch {}
622
+ throw translate(error)
623
+ }
624
+ } finally {
625
+ db.close()
626
+ }
627
+ }
628
+
629
+ /** Drop a user table and its metadata row. */
630
+ export function dropTable({ file, table }) {
631
+ if (/^sqlite_/i.test(String(table)) || String(table).toLowerCase() === META_TABLE) throw fail('sqlite/invalid-name', `不能删除系统表:${table}`)
632
+ const db = openWrite(file)
633
+ try {
634
+ const existing = findTable(db, String(table))
635
+ if (!existing) throw fail('sqlite/not-found', `表不存在:${table}`)
636
+ db.exec('BEGIN IMMEDIATE')
637
+ try {
638
+ db.exec(`DROP ${existing.type === 'view' ? 'VIEW' : 'TABLE'} ${ident(existing.name)}`)
639
+ db.prepare(`DELETE FROM ${ident(META_TABLE)} WHERE name = ?`).run(existing.name)
640
+ db.exec('COMMIT')
641
+ } catch (error) {
642
+ try { db.exec('ROLLBACK') } catch {}
643
+ throw translate(error)
644
+ }
645
+ return { table: existing.name, dropped: true }
646
+ } finally {
647
+ db.close()
648
+ }
649
+ }
650
+
651
+ /** List imported tables with live row counts and upload metadata. */
652
+ export function listTables({ file }) {
653
+ if (!file || !existsSync(file)) return []
654
+ const s = new ReadSession(file)
655
+ try {
656
+ const meta = s.meta()
657
+ return s.userTables().filter(t => t.type === 'table').map((t) => {
658
+ const m = meta.get(t.name)
659
+ return { name: t.name, rows: s.rowCount(t.name), source: m?.source ?? null, createdAt: m?.created_at ?? null, updatedAt: m?.updated_at ?? null }
660
+ })
661
+ } finally {
662
+ s.close()
663
+ }
664
+ }
665
+
666
+ /** Size on disk (including WAL/journal side files) and user table count. */
667
+ export function fileStats({ file }) {
668
+ if (!file || !existsSync(file)) return { bytes: 0, tables: 0 }
669
+ let bytes = 0
670
+ for (const f of [file, `${file}-wal`, `${file}-journal`]) { try { bytes += statSync(f).size } catch {} }
671
+ const s = new ReadSession(file)
672
+ try {
673
+ return { bytes, tables: s.userTables().filter(t => t.type === 'table').length }
674
+ } finally {
675
+ s.close()
676
+ }
677
+ }