@hedeqiang/dsh-nl2sql 0.14.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.2] - 2026-11-13
9
+
10
+ ### Fixed
11
+ - A column named just `token` was always masked, but in crypto / payment databases it is the currency (USDT, BTC…). It is now masked only in credential tables (sessions, oauth, api keys…) or when its comment says it is a credential; credential names such as `access_token`, `api_token` and `token_hash` are still masked. Mask any other column explicitly with the data source's access policy.
12
+ - `tel`, `mail` and `mobile` no longer match inside other words (`whitelist_status`, `hotel`, `telegram_id`, `mailbox`).
13
+ - Cached schema analyses are re-classified with the new rules on load (no re-analysis needed), and cached query results masked under the old rules are not reused.
14
+
15
+ ## [0.14.1] - 2026-11-13
16
+
17
+ ### Fixed
18
+ - Charts, the ER graph and forecast charts were blurry on Retina / HiDPI screens: ECharts runs in a sandboxed chunk without access to the real `window`, so it rendered at 1×. Charts now render at the screen's device pixel ratio (re-rendering when it changes, e.g. moving to another monitor or zooming), and PNG export uses at least 2×.
19
+
8
20
  ## [0.14.0] - 2026-11-13
9
21
 
10
22
  ### Added
@@ -139,7 +151,9 @@ These features are tested with local databases and fake IM / Lark servers; real
139
151
 
140
152
  - Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
141
153
 
142
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...HEAD
154
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.2...HEAD
155
+ [0.14.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.1...v0.14.2
156
+ [0.14.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...v0.14.1
143
157
  [0.14.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.1...v0.14.0
144
158
  [0.13.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.0...v0.13.1
145
159
  [0.13.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...v0.13.0
package/lib/client.js CHANGED
@@ -2836,6 +2836,26 @@ window.__ModuleLoader__.load({
2836
2836
  }
2837
2837
  // ----------------------------------------------------------- charts
2838
2838
 
2839
+ // ECharts is loaded in a sandboxed wrapper (lib/client.echarts.js) whose `window` is an empty
2840
+ // object, so it cannot read the screen's devicePixelRatio and would draw at 1x (blurry on
2841
+ // Retina / HiDPI). Every chart passes the real ratio explicitly and re-inits when it changes
2842
+ // (moving between screens, browser zoom).
2843
+ const chartDpr = () => Math.max(1, Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1))
2844
+ function initChart(echarts, el, opts = {}) {
2845
+ return echarts.init(el, null, { renderer: 'canvas', devicePixelRatio: chartDpr(), ...opts })
2846
+ }
2847
+ /** Call `onChange` when the device pixel ratio changes; returns a cleanup. */
2848
+ function watchDpr(onChange) {
2849
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {}
2850
+ let mq
2851
+ const listen = () => {
2852
+ mq = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
2853
+ mq.addEventListener?.('change', handler, { once: true })
2854
+ }
2855
+ const handler = () => { onChange(); listen() }
2856
+ listen()
2857
+ return () => mq?.removeEventListener?.('change', handler)
2858
+ }
2839
2859
  let echartsPromise
2840
2860
  function loadEcharts() {
2841
2861
  echartsPromise ??= require.async('./client.echarts.js')
@@ -2942,17 +2962,27 @@ window.__ModuleLoader__.load({
2942
2962
  useEffect(() => {
2943
2963
  let disposed = false
2944
2964
  let observer
2965
+ let stopDpr
2945
2966
  loadEcharts().then((echarts) => {
2946
2967
  if (disposed || !ref.current) return
2947
- instance.current = echarts.init(ref.current, null, { renderer: 'canvas' })
2948
- instance.current.on('click', (p) => { if (pointRef.current && p.name !== undefined && p.name !== '') pointRef.current(p.name) })
2968
+ const create = () => {
2969
+ const prev = instance.current
2970
+ const option = prev?.getOption?.()
2971
+ prev?.dispose()
2972
+ instance.current = initChart(echarts, ref.current)
2973
+ instance.current.on('click', (p) => { if (pointRef.current && p.name !== undefined && p.name !== '') pointRef.current(p.name) })
2974
+ if (option) instance.current.setOption(option, true)
2975
+ onInstance?.(instance.current)
2976
+ }
2977
+ create()
2978
+ stopDpr = watchDpr(() => { if (!disposed && ref.current) create() })
2949
2979
  observer = new ResizeObserver(() => instance.current?.resize())
2950
2980
  observer.observe(ref.current)
2951
2981
  setReady(true)
2952
- onInstance?.(instance.current)
2953
2982
  }, (e) => setError(e.message))
2954
2983
  return () => {
2955
2984
  disposed = true
2985
+ stopDpr?.()
2956
2986
  observer?.disconnect()
2957
2987
  instance.current?.dispose()
2958
2988
  instance.current = null
@@ -3095,7 +3125,7 @@ window.__ModuleLoader__.load({
3095
3125
  const [flash, setFlash] = useFlash()
3096
3126
  const copySql = () => { navigator.clipboard?.writeText(result.sql); setFlash(t('result.copied')) }
3097
3127
  const exportPng = () => {
3098
- const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
3128
+ const url = chartInstance?.getDataURL({ type: 'png', pixelRatio: Math.max(2, chartDpr()), backgroundColor: cssVar(document.body, '--dsw-alias-bg-base', '#fff') })
3099
3129
  if (url) fetch(url).then(r => r.blob()).then(b => download(`${safeName(result.title)}.png`, b))
3100
3130
  }
3101
3131
  const isChart = chart.type !== 'table' && chart.type !== 'kpi'
@@ -3761,7 +3791,7 @@ window.__ModuleLoader__.load({
3761
3791
  let observer
3762
3792
  loadEcharts().then((echarts) => {
3763
3793
  if (disposed || !ref.current) return
3764
- chart = echarts.init(ref.current, null, { renderer: 'canvas' })
3794
+ chart = initChart(echarts, ref.current)
3765
3795
  observer = new ResizeObserver(() => chart?.resize())
3766
3796
  observer.observe(ref.current)
3767
3797
  const el = ref.current
@@ -7227,7 +7257,7 @@ window.__ModuleLoader__.load({
7227
7257
  let observer
7228
7258
  loadEcharts().then((echarts) => {
7229
7259
  if (disposed || !ref.current) return
7230
- inst.current = echarts.init(ref.current, null, { renderer: 'canvas' })
7260
+ inst.current = initChart(echarts, ref.current)
7231
7261
  observer = new ResizeObserver(() => inst.current?.resize())
7232
7262
  observer.observe(ref.current)
7233
7263
  try { inst.current.setOption(proForecastOption(forecast, ref.current, t), true) } catch (e) { setError(e.message) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.14.0",
3
+ "version": "0.14.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
  "bin": {
package/service/query.js CHANGED
@@ -9,7 +9,7 @@ import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
9
9
  import { cacheKey } from '../store.js'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { writeXlsx } from '../xlsx.js'
12
- import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
12
+ import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, SENSITIVE_RULES_VERSION, isCredentialToken, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
13
13
 
14
14
  export const methods = {
15
15
  // ---------------------------------------------------------------- query
@@ -87,7 +87,7 @@ export const methods = {
87
87
  }
88
88
  // Query cache: dashboards, reruns and reports reuse a recent identical result.
89
89
  const ttlMs = (settings.cacheTtlSec ?? 0) * 1000
90
- 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
90
+ const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, SENSITIVE_RULES_VERSION, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
91
91
  if (key) {
92
92
  const hit = this.store.cached(key, ttlMs)
93
93
  if (hit && (hit.rowRulesSig ?? undefined) === rowRulesSig) {
@@ -142,7 +142,7 @@ export const methods = {
142
142
  if (settings.maskSensitive) {
143
143
  res.columns.forEach((f, i) => {
144
144
  const col = f.table ? schema?.tables.find(t => t.name === f.table)?.columns.find(c => c.name === f.column) : undefined
145
- if (col?.roles.includes('sensitive') || SENSITIVE_NAME.test(f.name)) sensitive.add(i)
145
+ if (col?.roles.includes('sensitive') || SENSITIVE_NAME.test(f.name) || (!col && isCredentialToken(f.name, f.table))) sensitive.add(i)
146
146
  })
147
147
  }
148
148
  for (const row of cells) for (const i of sensitive) row[i] = maskValue(res.columns[i].name, row[i])
package/service/schema.js CHANGED
@@ -7,7 +7,7 @@ import { annotateSchema, columnMasked, filterSchema, normalizePolicy, policyActi
7
7
  import { admitSql } from '../sql-guard.js'
8
8
  import { readFile, writeFile } from 'node:fs/promises'
9
9
  import { join } from 'node:path'
10
- import { DIALECT_HINTS, UserError, classifyColumn, classifyTable, inferRelations, maskValue, newId, requireText, singular, upsert } from './shared.js'
10
+ import { DIALECT_HINTS, SENSITIVE_RULES_VERSION, UserError, classifyColumn, classifyTable, inferRelations, maskValue, newId, requireText, singular, upsert } from './shared.js'
11
11
 
12
12
  /** Character bigrams (whitespace removed, works for Chinese) plus ASCII words of 3+ characters. */
13
13
  export function textGrams(text) {
@@ -18,6 +18,23 @@ export function textGrams(text) {
18
18
  return out
19
19
  }
20
20
 
21
+ /**
22
+ * Re-apply the current sensitive-column rules to a cached schema (rules change between
23
+ * versions, e.g. a bare `token` currency column is no longer sensitive). Only the
24
+ * 'sensitive' role is touched; other roles stay as analysed.
25
+ */
26
+ export function reclassifySensitive(schema) {
27
+ if (!schema?.tables || schema.sensitiveRules === SENSITIVE_RULES_VERSION) return schema
28
+ for (const t of schema.tables) for (const c of t.columns ?? []) {
29
+ const want = classifyColumn(c, t).includes('sensitive')
30
+ const has = (c.roles ?? []).includes('sensitive')
31
+ if (want && !has) c.roles = [...(c.roles ?? []), 'sensitive']
32
+ if (!want && has) c.roles = c.roles.filter(r => r !== 'sensitive')
33
+ }
34
+ schema.sensitiveRules = SENSITIVE_RULES_VERSION
35
+ return schema
36
+ }
37
+
21
38
  export const methods = {
22
39
  // --------------------------------------------------------------- schema
23
40
 
@@ -87,6 +104,7 @@ export const methods = {
87
104
  if (previous && settings.schemaWatch !== false) {
88
105
  try { this.recordSchemaChange(ds, previous, schema) } catch (error) { console.warn('nl2sql: schema diff failed: %s', error.message) }
89
106
  }
107
+ schema.sensitiveRules = SENSITIVE_RULES_VERSION
90
108
  this.schemas.set(ds.id, schema)
91
109
  ds.schemaAnalyzedAt = schema.analyzedAt
92
110
  if (raw.version && !ds.serverVersion) ds.serverVersion = raw.version
@@ -97,7 +115,7 @@ export const methods = {
97
115
 
98
116
  async readSchemaFile(id) {
99
117
  try {
100
- return JSON.parse(await readFile(join(this.dir, `schema-${id}.json`), 'utf8'))
118
+ return reclassifySensitive(JSON.parse(await readFile(join(this.dir, `schema-${id}.json`), 'utf8')))
101
119
  } catch {
102
120
  return undefined
103
121
  }
package/service/shared.js CHANGED
@@ -57,14 +57,24 @@ export function pick(obj, keys) {
57
57
  return out
58
58
  }
59
59
 
60
- // "token" alone is ambiguous (crypto token_code / token_price are business data):
61
- // only credential-like token names count as sensitive.
62
- export const SENSITIVE_NAME = /(phone|mobile|tel|email|mail|id_?card|idno|identity|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|^token$|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
60
+ // "token" alone is ambiguous: in crypto / payment schemas it is the currency (USDT, BTC…), so a
61
+ // bare `token` column is NOT sensitive by name. Credential-like names (access_token, api_token,
62
+ // token_hash, …) are; a bare `token` counts only in credential tables or with a credential comment
63
+ // (see isCredentialToken). Mask anything else explicitly with the access policy's masked columns.
64
+ export const SENSITIVE_NAME = /(phone|(?:^|_)mobile(?:_?(?:no|num|number))?(?:_|$)|(?:^|_)tel(?:ephone)?(?:_?(?:no|num|number))?(?:_|$)|e_?mail|(?:^|_)mail(?:_?addr(?:ess)?)?(?:_|$)|id_?card|idno|identity_?(?:no|num|number|card)|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
65
+ const CREDENTIAL_TABLE = /(session|oauth|auth|login|credential|api_?key|access_?key|personal_?access|user_?token|refresh_?token|jwt|sso)/i
66
+ const CREDENTIAL_COMMENT = /(令牌|凭证|凭据|密钥|访问码|登录态|credential|access token|auth token|bearer|jwt)/i
67
+ /** A bare `token` column that holds a credential (by table name or comment), not a currency. */
68
+ export function isCredentialToken(name, table, comment) {
69
+ return /^token$/i.test(String(name ?? '')) && (CREDENTIAL_TABLE.test(String(table ?? '')) || CREDENTIAL_COMMENT.test(String(comment ?? '')))
70
+ }
71
+ /** Bumped whenever the sensitive-column rules change (part of result cache keys). */
72
+ export const SENSITIVE_RULES_VERSION = 3
63
73
 
64
74
  export function maskValue(column, value) {
65
75
  if (value === null || value === undefined) return value
66
76
  const s = String(value)
67
- if (/(password|passwd|pwd|secret|token|密码)/i.test(column)) return '******'
77
+ if (/(password|passwd|pwd|secret|密码)/i.test(column) || /(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|token_?(?:hash|secret|key)$|^token$/i.test(column)) return '******'
68
78
  if (/(email|mail|邮箱)/i.test(column) && s.includes('@')) {
69
79
  const [u, d] = s.split('@')
70
80
  return `${u.slice(0, 1)}***@${d}`
@@ -91,10 +101,9 @@ export function classifyColumn(col, table) {
91
101
  if (t === 'enum' || t === 'set' || (ENUM_NAME.test(name) && (t === 'tinyint' || t === 'smallint' || t === 'char' || t === 'varchar' || t === 'int'))) roles.push('enum')
92
102
  if (NUMERIC_TYPES.has(t) && !roles.includes('id') && !roles.includes('enum') && !roles.includes('time') && !/^is_|^has_/.test(name)) roles.push('measure')
93
103
  if (TEXT_TYPES.has(t) && GEO_NAME.test(name)) roles.push('geo')
94
- if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '')) roles.push('sensitive')
104
+ if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '') || isCredentialToken(name, typeof table === 'string' ? table : table?.name, col.comment)) roles.push('sensitive')
95
105
  if (/^is_|^has_/.test(name) || col.columnType === 'tinyint(1)' || t === 'bit') roles.push('flag')
96
106
  if (roles.length === 0 && TEXT_TYPES.has(t)) roles.push('text')
97
- void table
98
107
  return roles
99
108
  }
100
109