@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
package/sqlite-driver.js
ADDED
|
@@ -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
|
+
}
|