@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 +15 -1
- package/lib/client.js +36 -6
- package/package.json +1 -1
- package/service/query.js +3 -3
- package/service/schema.js +20 -2
- package/service/shared.js +15 -6
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.
|
|
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
|
-
|
|
2948
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
|
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
|
|