@hedeqiang/dsh-nl2sql 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
6
  "exports": {
package/service/alerts.js CHANGED
@@ -6,6 +6,9 @@ import { evaluateAlert } from '../analysis.js'
6
6
  import { admitSql } from '../sql-guard.js'
7
7
  import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
8
8
 
9
+ /** Fields written by checks (not by the edit form). */
10
+ const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
11
+
9
12
  export const methods = {
10
13
  // ---------------------------------------------------------------- alerts
11
14
 
@@ -53,6 +56,10 @@ export const methods = {
53
56
 
54
57
  async saveAlert(input) {
55
58
  const a = await this.normalizeAlert(input)
59
+ // A check may have finished between normalize and now: keep its runtime fields
60
+ // (last check / fire time, state) from the stored object, never the stale copy.
61
+ const current = (this.state.alerts ?? []).find(x => x.id === a.id)
62
+ if (current) for (const k of ALERT_RUNTIME) { if (current[k] === undefined) delete a[k]; else a[k] = current[k] }
56
63
  this.state.alerts = upsert(this.state.alerts ?? [], a)
57
64
  await this.save()
58
65
  this.scheduleAlerts()
@@ -73,20 +80,18 @@ export const methods = {
73
80
 
74
81
  /** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
75
82
  async previewAlert(input) {
76
- const tmpId = `preview-${newId('al')}`
77
- const saved = this.state.alerts
78
- try {
79
- const draft = await this.normalizeAlert({ ...input, id: undefined }, tmpId)
80
- this.state.alerts = [...(saved ?? []), draft]
81
- return await this.checkAlertInner(tmpId, { dryRun: true })
82
- } finally {
83
- this.state.alerts = saved
84
- }
83
+ // The draft is evaluated on its own: state.alerts is never touched, so concurrent
84
+ // saves are kept and the draft is never persisted.
85
+ const draft = await this.normalizeAlert({ ...input, id: undefined }, `preview-${newId('al')}`)
86
+ return this.checkAlertInner(draft.id, { dryRun: true, draft })
85
87
  },
86
88
 
87
- async checkAlertInner(id, { dryRun = false, force = false } = {}) {
88
- const a = (this.state.alerts ?? []).find(x => x.id === id)
89
- if (!a) throw new UserError('nl2sql/invalid', '告警不存在')
89
+ async checkAlertInner(id, { dryRun = false, force = false, draft } = {}) {
90
+ const found = draft ?? (this.state.alerts ?? []).find(x => x.id === id)
91
+ if (!found) throw new UserError('nl2sql/invalid', '告警不存在')
92
+ // Evaluate a snapshot; runtime fields are applied below to the object stored
93
+ // *after* the query (saveAlert may have replaced it meanwhile).
94
+ let a = draft ? structuredClone(draft) : { ...found }
90
95
  const ds = this.requireDatasource(a.datasourceId)
91
96
  let result
92
97
  if (a.source === 'metric') {
@@ -100,6 +105,10 @@ export const methods = {
100
105
  const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
101
106
  if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
102
107
  const ev = evaluateAlert(a.condition, series, { now: Date.now() })
108
+ if (!draft) {
109
+ const current = (this.state.alerts ?? []).find(x => x.id === id)
110
+ if (current) a = current
111
+ }
103
112
  a.lastCheckedAt = Date.now()
104
113
  a.lastValue = ev.value ?? null
105
114
  a.lastResultId = result.resultId
@@ -163,12 +172,20 @@ export const methods = {
163
172
  const last = a.lastCheckedAt ?? 0
164
173
  if (a.every === '15m') return last + 15 * 60000
165
174
  if (a.every === 'hour') return last + 60 * 60000
166
- // daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
175
+ // daily at HH:MM local wall time: the most recent slot not yet covered by lastCheckedAt.
176
+ // DST (same rule as the Harness schedule service): a slot that does not exist
177
+ // that day (spring forward) is skipped; a repeated one (fall back) is the earlier instant.
167
178
  const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
168
- const slot = new Date(now)
169
- slot.setHours(hh, mm, 0, 0)
170
- if (slot.getTime() > now) slot.setDate(slot.getDate() - 1)
171
- return last < slot.getTime() ? slot.getTime() : (() => { const n = new Date(slot); n.setDate(n.getDate() + 1); return n.getTime() })()
179
+ const today = new Date(now)
180
+ const slotOn = (offset) => {
181
+ const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, hh, mm, 0, 0)
182
+ return d.getHours() === hh && d.getMinutes() === mm ? d.getTime() : undefined
183
+ }
184
+ let prev
185
+ for (let k = 0; k >= -3 && prev === undefined; k--) { const s = slotOn(k); if (s !== undefined && s <= now) prev = s }
186
+ if (prev !== undefined && last < prev) return prev
187
+ for (let k = 0; k <= 3; k++) { const s = slotOn(k); if (s !== undefined && s > now && s > last) return s }
188
+ return now + 86400000
172
189
  },
173
190
 
174
191
  async withJobLock(key, fn) {
@@ -192,12 +209,15 @@ export const methods = {
192
209
  this.checkAlert(a.id),
193
210
  new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
194
211
  ])
195
- a.lastError = undefined
212
+ // The stored object may have been replaced by an edit meanwhile.
213
+ const cur = (this.state.alerts ?? []).find(x => x.id === a.id)
214
+ if (cur) cur.lastError = undefined
196
215
  ran.push({ id: a.id, ok: true })
197
216
  } catch (error) {
198
217
  if (error.code === 'nl2sql/busy') return
199
- a.lastError = error.message
200
- a.lastCheckedAt = Date.now()
218
+ const cur = (this.state.alerts ?? []).find(x => x.id === a.id) ?? a
219
+ cur.lastError = error.message
220
+ cur.lastCheckedAt = Date.now()
201
221
  ran.push({ id: a.id, ok: false, error: error.message })
202
222
  }
203
223
  }))
package/service/bots.js CHANGED
@@ -35,6 +35,29 @@ const toast = (type, content) => ({ toast: { type, content } })
35
35
  const label = (platform) => PLATFORM_LABEL[platform] ?? platform ?? 'IM'
36
36
  const SOURCE = (platform) => `${platform ?? 'im'}bot`
37
37
 
38
+ const BARE_COMMANDS = {
39
+ 帮助: 'help', help: 'help', start: 'help',
40
+ 数据源: 'sources', sources: 'sources', datasources: 'sources',
41
+ 自动: 'auto', auto: 'auto',
42
+ 新对话: 'reset', new: 'reset', reset: 'reset',
43
+ }
44
+
45
+ /**
46
+ * A bot command, or null for a question. Argument-less commands must be the whole
47
+ * message (optionally `/`-prefixed, trailing punctuation ignored), so questions like
48
+ * 「自动续费用户有多少」 reach the agent. `切换` / `切换数据源` / `switch` / `use` take an
49
+ * argument after whitespace; alone they ask for usage.
50
+ * → { verb: 'help'|'sources'|'auto'|'reset'|'switch', arg? }
51
+ */
52
+ export function parseBotCommand(text) {
53
+ const s = String(text ?? '').trim().replace(/^\//, '').replace(/[\s??。.!!~~]+$/u, '')
54
+ const bare = BARE_COMMANDS[s.toLowerCase()]
55
+ if (bare) return { verb: bare }
56
+ const m = /^(切换数据源|切换|switch|use)(?:\s+([\s\S]+))?$/i.exec(s)
57
+ if (m) return { verb: 'switch', arg: (m[2] ?? '').trim() }
58
+ return null
59
+ }
60
+
38
61
  function hasAggregate(r, dialect) {
39
62
  const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
40
63
  return Boolean(parsed?.select?.some(i => i.isAggregate))
@@ -286,8 +309,8 @@ export const methods = {
286
309
  if (m.chatType === 'group' && m.chatId) this.noteSeenIdentity({ platform, kind: 'chat', id: String(m.chatId), name: chatName, chatName, channelId: m.channelId, refused: Boolean(resolved.refused) })
287
310
  if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
288
311
  const actor = resolved.actor
289
- // Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
290
- const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|自动|auto|新对话|reset|start)\s*(.*)$/i)
312
+ // Commands: 帮助 / 数据源 / 切换 <名称> / 自动 / 新对话 (and English aliases).
313
+ const cmd = parseBotCommand(text)
291
314
  // Members with different scopes must not share a conversation: per-user sessions
292
315
  // whenever the sender's effective scope or rules differ from the chat's.
293
316
  const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
@@ -301,14 +324,15 @@ export const methods = {
301
324
  const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
302
325
  if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
303
326
  if (cmd) {
304
- const verb = cmd[1].toLowerCase()
305
- if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、自动(按问题自动选择数据源)、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
306
- if (verb === '数据源') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
307
- if (verb === '自动' || verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
308
- if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
309
- const want = cmd[2].trim().toLowerCase()
310
- const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd[2].trim()) ?? (want ? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want) : []).find((_, __, arr) => arr.length === 1)
311
- if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
327
+ const { verb } = cmd
328
+ if (verb === 'help') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令(单独发送):\n· 数据源 / sources — 查看可用数据源\n· 切换 <名称> / switch <name> — 固定使用某个数据源\n· 自动 / auto — 按问题自动选择数据源\n· 新对话 / new — 开始新对话\n· 帮助 / help — 显示本说明\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
329
+ if (verb === 'sources') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
330
+ if (verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
331
+ if (verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
332
+ if (!cmd.arg) { await reply(`用法:切换 <数据源名称>(或 switch <name>)。可用:${allowedDs.map(d => d.name).join('、') || '无'}`); return }
333
+ const want = cmd.arg.toLowerCase()
334
+ const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd.arg) ?? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want).find((_, __, arr) => arr.length === 1)
335
+ if (!target) { await reply(`找不到可用的数据源「${cmd.arg}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
312
336
  conv.datasourceId = target.id
313
337
  conv.datasourcePinned = true
314
338
  if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
@@ -417,7 +441,8 @@ export const methods = {
417
441
  for (const d of list) {
418
442
  let tables = ''
419
443
  try {
420
- const schema = this.schemas.get(d.id) ?? await this.readSchemaFile(d.id)
444
+ // Policy-filtered, like nl2sql_schema: hidden tables never reach the bot prompt.
445
+ const schema = await this.cachedVisibleSchema(d)
421
446
  if (schema?.tables?.length) {
422
447
  const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
423
448
  const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
@@ -475,7 +500,8 @@ export const methods = {
475
500
  }
476
501
  const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
477
502
  const reason = waited?.reason ?? 'idle'
478
- if (reason !== 'idle') { try { controller.cancel({ sessionId: sid }) } catch {} }
503
+ // cancel() is async on DSH controllers: a rejection must not become unhandled.
504
+ if (reason !== 'idle') void Promise.resolve().then(() => controller.cancel({ sessionId: sid })).catch(() => {})
479
505
  const answer = await this.collectTurnByRequest(sid, requestId)
480
506
  return { sessionId: sid, reason, ...answer }
481
507
  },
@@ -19,6 +19,15 @@ import { UserError, newId, pick, requireText, upsert } from './shared.js'
19
19
  const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
20
20
  const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
21
21
 
22
+ /** Truncate to `max` UTF-16 units with an ellipsis, without splitting a surrogate pair. */
23
+ export function clip(s, max) {
24
+ const str = String(s ?? '')
25
+ if (str.length <= max) return str
26
+ let cut = max - 1
27
+ if (/[\uD800-\uDBFF]/.test(str[cut - 1] ?? '')) cut--
28
+ return `${str.slice(0, cut)}…`
29
+ }
30
+
22
31
  /** ChannelError (or legacy ImError) → UserError with the same code and message. */
23
32
  export function channelUserError(error, code) {
24
33
  if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
@@ -172,8 +181,10 @@ export const methods = {
172
181
  c.platform = adapter.id
173
182
  c.description = String(input.description ?? c.description ?? '').slice(0, 300)
174
183
  const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
175
- const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
176
184
  const keys = secretKeys(kind)
185
+ // A type change keeps stored secrets whose key the new kind also has (e.g. webhookUrl, token).
186
+ const stored = existing ? await this.channelSecrets(existing) : {}
187
+ if (typeChanged) for (const k of Object.keys(stored)) if (!keys.includes(k)) delete stored[k]
177
188
  const merged = {}
178
189
  for (const k of keys) {
179
190
  if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
@@ -224,9 +235,19 @@ export const methods = {
224
235
  await this.stopBot(id)
225
236
  this.state.channels = this.state.channels.filter(x => x.id !== id)
226
237
  if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
227
- for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
238
+ // Drop every reference to the channel: alerts, schema notifications, bot conversations, reports.
239
+ for (const a of this.state.alerts ?? []) if (a.channelId === id) a.channelId = null
240
+ if (this.state.settings?.schemaNotifyChannel === id) this.state.settings.schemaNotifyChannel = null
241
+ for (const [k, conv] of Object.entries(this.state.botConversations ?? {})) if (conv?.channelId === id) delete this.state.botConversations[k]
242
+ const reports = (this.state.reports ?? []).filter(r => r.channelId === id)
243
+ for (const r of reports) delete r.channelId
228
244
  try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
229
245
  await this.save()
246
+ // Scheduled report prompts name the channel: rewrite them through the normal report save.
247
+ for (const r of reports) {
248
+ if (!r.scheduleId || !this.ctx.get('schedule')) continue
249
+ try { await this.saveReport({ ...r, channelId: undefined }) } catch (error) { console.warn('nl2sql: cannot update report %s after channel delete: %s', r.id, error.message) }
250
+ }
230
251
  return { deleted: id }
231
252
  },
232
253
 
@@ -311,10 +332,10 @@ export const methods = {
311
332
  const modes = chartModesOf(found.kind)
312
333
  const body = text ? String(text).slice(0, 20000) : ''
313
334
  const message = {
314
- title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
335
+ title: clip(String(title || resolved[0]?.title || '').trim() || '数据问答', 100),
315
336
  text: body,
316
337
  chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
317
- results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
338
+ results: resolved.map(r => ({ ...pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])]), ...(r.title ? { title: clip(String(r.title), 100) } : {}) })),
318
339
  includeSql: Boolean(includeSql),
319
340
  mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
320
341
  ...(template ? { template } : {}),
@@ -328,7 +349,7 @@ export const methods = {
328
349
  if (caps.files && wantAttach) {
329
350
  const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
330
351
  if (idx.length) {
331
- const sheets = idx.map(i => ({ name: (message.results[i].title || `结果${i + 1}`).slice(0, 31), columns: message.results[i].columns, rows: message.results[i].rows }))
352
+ const sheets = idx.map(i => ({ name: message.results[i].title || `结果${i + 1}`, columns: message.results[i].columns, rows: message.results[i].rows }))
332
353
  if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
333
354
  const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
334
355
  const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
@@ -296,8 +296,11 @@ export const methods = {
296
296
  // --------------------------------------------------------- auto overview
297
297
 
298
298
  /** Build overview widgets for one table from its column roles; runs each query and returns results. */
299
- async tableOverview({ datasourceId, table, pin = false }) {
299
+ async tableOverview({ datasourceId, table, pin = false, actor, sessionId, source = 'ui' }) {
300
300
  const ds = this.requireDatasource(datasourceId)
301
+ // Agent / bot callers run as their actor: scope, row-level rules, viewer limits and audit attribution apply.
302
+ actor ??= sessionId ? this.actorFor(sessionId) : undefined
303
+ if (actor) this.assertAccess(actor, ds)
301
304
  const schema = await this.visibleSchema(ds)
302
305
  const t = schema.tables.find(x => x.name === table)
303
306
  if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
@@ -305,7 +308,7 @@ export const methods = {
305
308
  const results = []
306
309
  for (const plan of plans) {
307
310
  try {
308
- const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, title: plan.title, chart: plan.chart, source: 'ui', confirmScan: true })
311
+ const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, sessionId, actor, title: plan.title, chart: plan.chart, source, confirmScan: true })
309
312
  results.push(r)
310
313
  if (pin) await this.pinWidget({ title: plan.title, sql: plan.sql, datasourceId: ds.id, chart: r.chart, width: plan.width })
311
314
  } catch (error) {
@@ -3,7 +3,7 @@
3
3
  * Mixed into the service prototype by index.js; `this` is the service.
4
4
  */
5
5
  import { DIALECTS, createDriver, describeError } from '../drivers.js'
6
- import { normalizePolicy, policyActive } from '../policy.js'
6
+ import { filterSchema, normalizePolicy, policyActive } from '../policy.js'
7
7
  import { sanitizeTableName } from '../sqlite-driver.js'
8
8
  import { join } from 'node:path'
9
9
  import { CREDENTIAL_SCOPE, DEFAULT_SETTINGS, RETENTION_MAX, RETENTION_MIN, SUPPORTED_TYPES, UserError, clampInt, newId, oneOf, pick, requireText } from './shared.js'
@@ -308,14 +308,27 @@ export const methods = {
308
308
  return current
309
309
  },
310
310
 
311
- async bindSession(sessionId, datasourceId) {
311
+ /**
312
+ * The already-analyzed schema (memory or file, no introspection) restricted by the
313
+ * access policy — the same view nl2sql_schema gives the model. Undefined when not analyzed.
314
+ */
315
+ async cachedVisibleSchema(ds) {
316
+ const schema = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
317
+ return schema ? filterSchema(schema, this.policyFor(ds)) : undefined
318
+ },
319
+
320
+ /**
321
+ * Bind a session to a data source. The page RPC also makes it the page's current data source
322
+ * (default); agent tools (including bot sessions) pass setCurrent: false so they never move it.
323
+ */
324
+ async bindSession(sessionId, datasourceId, { setCurrent = true } = {}) {
312
325
  if (!sessionId) throw new UserError('nl2sql/invalid', '缺少会话')
313
326
  if (datasourceId === null) {
314
327
  delete this.state.sessions[sessionId]
315
328
  } else {
316
329
  const ds = this.requireDatasource(datasourceId)
317
330
  this.state.sessions[sessionId] = ds.id
318
- this.state.current = ds.id
331
+ if (setCurrent) this.state.current = ds.id
319
332
  }
320
333
  await this.save()
321
334
  return this.sessionInfo(sessionId)
package/service/query.js CHANGED
@@ -3,10 +3,11 @@
3
3
  * Mixed into the service prototype by index.js; `this` is the service.
4
4
  */
5
5
  import { aliasExpr, buildAttributionSql, buildBreakdownSql, buildDetailSql, buildMetricSql, computeAttribution, isAdditive, parseSelect, quoteName, rankDimensions } from '../analysis.js'
6
- import { applyFilters, normalizeFilters } from '../filters.js'
6
+ import { FilterInjectionError, applyFilters, normalizeFilters } from '../filters.js'
7
7
  import { checkAccess, filterSchema, policyActive, referencedTables, resultColumnPolicy } from '../policy.js'
8
8
  import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
9
9
  import { cacheKey } from '../store.js'
10
+ import { createHash } from 'node:crypto'
10
11
  import { writeXlsx } from '../xlsx.js'
11
12
  import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
12
13
 
@@ -36,12 +37,19 @@ export const methods = {
36
37
  }
37
38
  let appliedFilters = []
38
39
  if (filters?.length) {
39
- const f = applyFilters(admitted.sql, normalizeFilters(filters), { dialect, schema: filterSchema(fullSchema, policy) })
40
+ let f
41
+ try { f = applyFilters(admitted.sql, normalizeFilters(filters), { dialect, schema: filterSchema(fullSchema, policy) }) } catch (error) {
42
+ if (!(error instanceof FilterInjectionError)) throw error
43
+ this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'filter injection refused', actor })
44
+ await this.save()
45
+ throw new UserError('nl2sql/invalid', '无法安全地为这条查询加上筛选条件,已拒绝。请改写查询(检查括号与注释)。')
46
+ }
40
47
  appliedFilters = f.applied
41
48
  if (f.applied.length) admitted = admitSql(f.sql, { dialect })
42
49
  }
43
50
  // Row-level security: every rule must be injected into the outer query; otherwise refuse.
44
51
  const rowRules = this.rowRulesFor(actor, ds)
52
+ const rowRulesSig = rowRules.length ? rowRulesSignature(rowRules) : undefined
45
53
  if (rowRules.length) {
46
54
  const schemaForRules = fullSchema ?? await this.analyzeSchema(ds.id)
47
55
  const refs = referencedTables(admitted.sql, schemaForRules.tables, { dialect })
@@ -55,7 +63,13 @@ export const methods = {
55
63
  await this.save()
56
64
  throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),查询不能包含子查询、CTE 或 UNION,请改写为单层查询。`)
57
65
  }
58
- const f = applyFilters(admitted.sql, needed.map(r => ({ kind: 'in', column: r.column, values: r.values })), { dialect, schema: schemaForRules })
66
+ let f
67
+ try { f = applyFilters(admitted.sql, needed.map(r => ({ kind: 'in', column: r.column, values: r.values })), { dialect, schema: schemaForRules }) } catch (error) {
68
+ if (!(error instanceof FilterInjectionError)) throw error
69
+ this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'row-level policy: injection refused', actor })
70
+ await this.save()
71
+ throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),这条查询无法安全地加上该过滤,已拒绝。`)
72
+ }
59
73
  const missing = needed.filter(r => !f.applied.some(a => a.column.toLowerCase() === r.column.toLowerCase()))
60
74
  // Every referenced table that has the column must be covered.
61
75
  const tablesWithCol = (col) => refs.filter(t => schemaForRules.tables.find(x => x.name === t)?.columns.some(c => c.name.toLowerCase() === col.toLowerCase()))
@@ -71,10 +85,10 @@ export const methods = {
71
85
  }
72
86
  // Query cache: dashboards, reruns and reports reuse a recent identical result.
73
87
  const ttlMs = (settings.cacheTtlSec ?? 0) * 1000
74
- const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules]) : null
88
+ const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
75
89
  if (key) {
76
90
  const hit = this.store.cached(key, ttlMs)
77
- if (hit) {
91
+ if (hit && (hit.rowRulesSig ?? undefined) === rowRulesSig) {
78
92
  const result = { ...hit, resultId: newId('r'), title: title ? String(title).slice(0, 120) : hit.title, chart: normalizeChart(chart, hit.columns) ?? hit.chart, cached: true, cachedAt: hit.createdAt, createdAt: Date.now(), ...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}) }
79
93
  this.remember(result)
80
94
  this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'ok', rows: result.rowCount, elapsedMs: 0, resultId: result.resultId, cached: true, actor })
@@ -134,6 +148,7 @@ export const methods = {
134
148
  executedSql: prepared,
135
149
  ...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}),
136
150
  ...(colPolicy.drop.size ? { hiddenColumns: colPolicy.drop.size } : {}),
151
+ ...(rowRulesSig ? { rowRulesSig } : {}),
137
152
  title: title ? String(title).slice(0, 120) : '',
138
153
  columns,
139
154
  rows: cells,
@@ -211,11 +226,17 @@ export const methods = {
211
226
  return this.lookupResult(resultId) ?? null
212
227
  },
213
228
 
214
- /** A scoped actor may only use results of data sources in scope (and with its own row rules applied). */
229
+ /**
230
+ * A scoped actor may only use results of data sources in scope, and — when it
231
+ * has row rules — only results filtered by exactly its own rules (signature
232
+ * match; results without a signature, e.g. from older versions, are refused).
233
+ */
215
234
  assertResultAccess(r, actor) {
216
235
  if (!actor || actor.kind === 'owner') return
217
- this.assertAccess(actor, this.requireDatasource(r.datasourceId))
218
- if (this.rowRulesFor(actor, this.requireDatasource(r.datasourceId)).length && !r.filters?.some(f => f.rowPolicy)) throw new UserError('nl2sql/row-policy', '该结果未按你的行级权限过滤,不能使用,请重新查询')
236
+ const ds = this.requireDatasource(r.datasourceId)
237
+ this.assertAccess(actor, ds)
238
+ const rules = this.rowRulesFor(actor, ds)
239
+ if (rules.length && (!r.filters?.some(f => f.rowPolicy) || r.rowRulesSig !== rowRulesSignature(rules))) throw new UserError('nl2sql/row-policy', '该结果未按你的行级权限过滤,不能使用,请重新查询')
219
240
  },
220
241
 
221
242
  cacheStats() {
@@ -409,3 +430,10 @@ export const methods = {
409
430
  return { fileName: file, mediaType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base64: buf.toString('base64'), bytes: buf.length, sheets: sheets.length }
410
431
  },
411
432
  }
433
+
434
+ /** Stable signature of a set of row rules (order of rules and of values does not matter). */
435
+ export function rowRulesSignature(rules) {
436
+ const norm = rules.map(r => ({ column: String(r.column).toLowerCase(), values: [...new Set((r.values ?? []).map(String))].sort() }))
437
+ .sort((a, b) => (a.column + '\0' + a.values.join('\0')).localeCompare(b.column + '\0' + b.values.join('\0')))
438
+ return createHash('sha256').update(JSON.stringify(norm)).digest('hex').slice(0, 32)
439
+ }
@@ -202,8 +202,11 @@ export const methods = {
202
202
  fileName = fileName || staged.fileName
203
203
  raw = staged.raw
204
204
  }
205
+ fileName = String(fileName ?? '').trim().slice(0, 255)
206
+ if (!raw && !fileName) throw new UserError('nl2sql/invalid', '缺少文件名')
205
207
  let ds = datasourceId ? this.requireDatasource(datasourceId) : undefined
206
- if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || String(fileName ?? '本地文件').replace(/\.[^.]+$/, '').slice(0, 60), description: `上传的文件:${fileName}` })
208
+ // The description is left empty for the user to fill in (it is shown to the model and in bot catalogs).
209
+ if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || (fileName || 'uploaded-file').replace(/\.[^.]+$/, '').slice(0, 60) })
207
210
  if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '只能导入到「本地文件」类型的数据源')
208
211
  const ds2 = this.requireDatasource(ds.id)
209
212
  if (!raw) {
package/sql-guard.js CHANGED
@@ -34,7 +34,7 @@ const FORBIDDEN = [
34
34
  * Per-dialect lexical rules. Getting these exactly right matters: if the guard
35
35
  * thinks a quote is escaped when the server does not (or vice versa), text the
36
36
  * server executes would be hidden inside a "string".
37
- * mysql: `-- ` needs whitespace after it, `#` comments, backslash escapes in '…' and "…", `…` identifiers
37
+ * mysql: `--` starts a comment only before a char <= 0x20 or end of input, `#` comments, backslash escapes in '…' and "…", `…` identifiers
38
38
  * postgres: any `--` comments, no `#`, backslash escapes only inside E'…', $tag$…$tag$ dollar quoting, "…" identifiers
39
39
  * sqlite: any `--` comments, no `#`, no backslash escapes, "…" / `…` / […] identifiers
40
40
  * elasticsearch: any `--` comments, no `#`, no backslash escapes, "…" identifiers
@@ -52,6 +52,35 @@ export function dialectRules(dialect = 'mysql') {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * MySQL starts a `--` comment only when the next character is a control
57
+ * character or space (code <= 0x20) or the input ends. Unicode spaces such as
58
+ * U+00A0 do NOT start a comment (JS `\s` would wrongly say they do).
59
+ */
60
+ export function dashDashCommentFollower(ch) {
61
+ return ch === undefined || ch.charCodeAt(0) <= 0x20
62
+ }
63
+
64
+ /**
65
+ * Characters refused outside string literals / quoted identifiers / comments:
66
+ * C0 controls other than \t \n \r, DEL, and any non-ASCII whitespace or
67
+ * format character. Servers disagree with each other (and with JS) about these,
68
+ * so they are a lexer-confusion hazard and never needed in real SQL.
69
+ */
70
+ const SUSPICIOUS_CODE_CHAR = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F\u00A0\u1680\u180E\u2000-\u200F\u2028-\u202F\u205F-\u2064\u3000\uFEFF]/
71
+
72
+ /** First suspicious character in the code (non-literal) parts of the SQL, or undefined. */
73
+ export function suspiciousCodeChar(sql, { dialect = 'mysql' } = {}) {
74
+ const { segments } = lexSql(sql, { dialect })
75
+ for (const s of segments) {
76
+ // Comments are checked too: a control character right after `--` is exactly the ambiguity being refused.
77
+ if (s.kind === 'string' || s.kind === 'ident') continue
78
+ const m = SUSPICIOUS_CODE_CHAR.exec(s.text)
79
+ if (m) return m[0]
80
+ }
81
+ return undefined
82
+ }
83
+
55
84
  /**
56
85
  * Tokenize SQL into code / string / identifier / comment segments using the
57
86
  * dialect's lexical rules. Unterminated strings, identifiers and comments are
@@ -72,7 +101,7 @@ export function lexSql(sql, { dialect = 'mysql' } = {}) {
72
101
  const c = sql[i]
73
102
  const n = sql[i + 1]
74
103
  // -- comments
75
- if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || sql[i + 2] === undefined || /\s/.test(sql[i + 2]))) {
104
+ if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || dashDashCommentFollower(sql[i + 2]))) {
76
105
  const start = i
77
106
  while (i < sql.length && sql[i] !== '\n') i++
78
107
  push('comment', start, i)
@@ -167,6 +196,8 @@ export function admitSql(input, { dialect = 'mysql' } = {}) {
167
196
  const { code, executableComment, unterminated } = stripSql(sql, { dialect })
168
197
  if (executableComment) throw new Error('MySQL executable comments (/*! ... */) are not allowed')
169
198
  if (unterminated) throw new Error('unterminated string, identifier or comment')
199
+ const bad = suspiciousCodeChar(sql, { dialect })
200
+ if (bad) throw new Error(`control or non-ASCII whitespace character U+${bad.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')} is only allowed inside string literals or quoted identifiers`)
170
201
  if (code.includes(';')) throw new Error('only one statement may be executed at a time')
171
202
  const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
172
203
  const allowed = ALLOWED_HEADS[dialect] ?? ALLOWED_HEADS.mysql
package/xlsx.js CHANGED
@@ -208,6 +208,16 @@ const STYLES_XML = XML_HEAD +
208
208
 
209
209
  const SHEET_NAME_BAD_RE = /[\[\]:*?/\\]/g;
210
210
 
211
+ /** First `max` UTF-16 units of `s` (Excel's sheet-name limit), never splitting a surrogate pair. */
212
+ function utf16Prefix(s, max) {
213
+ let out = '';
214
+ for (const ch of s) {
215
+ if (out.length + ch.length > max) break;
216
+ out += ch;
217
+ }
218
+ return out;
219
+ }
220
+
211
221
  /** Make Excel-safe, unique (case-insensitive), non-empty sheet names. */
212
222
  function sanitizeSheetNames(names) {
213
223
  const used = new Set(['history']); // "History" is reserved by Excel
@@ -215,11 +225,11 @@ function sanitizeSheetNames(names) {
215
225
  let base = stripIllegal(String(raw ?? '')).replace(SHEET_NAME_BAD_RE, '_').replace(/[\r\n\t]/g, ' ').trim();
216
226
  base = base.replace(/^'+|'+$/g, '').trim();
217
227
  if (!base) base = `Sheet${i + 1}`;
218
- base = Array.from(base).slice(0, 31).join('');
228
+ base = utf16Prefix(base, 31);
219
229
  let name = base;
220
230
  for (let n = 2; used.has(name.toLowerCase()); n++) {
221
231
  const suffix = ` (${n})`;
222
- name = Array.from(base).slice(0, 31 - suffix.length).join('').trimEnd() + suffix;
232
+ name = utf16Prefix(base, 31 - suffix.length).trimEnd() + suffix;
223
233
  }
224
234
  used.add(name.toLowerCase());
225
235
  return name;