dsh-db-tool 0.1.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/README.md +66 -0
- package/client/README.md +53 -0
- package/client/client.js +1128 -0
- package/cordis.patch.yml +6 -0
- package/dist/adapters/dmdb/index.js +326 -0
- package/dist/adapters/gaussdb/index.js +32 -0
- package/dist/adapters/index.js +41 -0
- package/dist/adapters/mongodb/index.js +508 -0
- package/dist/adapters/mysql/index.js +222 -0
- package/dist/adapters/oracle/index.js +369 -0
- package/dist/adapters/postgresql/index.js +15 -0
- package/dist/adapters/redis/index.js +492 -0
- package/dist/adapters/sql-shared/common.js +69 -0
- package/dist/adapters/sql-shared/pg-like.js +170 -0
- package/dist/adapters/sqlite/index.js +106 -0
- package/dist/adapters/types.js +1 -0
- package/dist/guard/index.js +221 -0
- package/dist/http/index.js +396 -0
- package/dist/index.js +130 -0
- package/dist/manager.js +469 -0
- package/dist/script/runner.js +147 -0
- package/dist/script/worker.cjs +104 -0
- package/dist/store/audit.js +41 -0
- package/dist/store/connections.js +167 -0
- package/dist/store/grants.js +82 -0
- package/dist/store/index.js +41 -0
- package/dist/store/io.js +66 -0
- package/dist/store/normalize.js +22 -0
- package/dist/store/secrets.js +42 -0
- package/docs/api-contract.md +76 -0
- package/docs/apple-redesign-spec.md +80 -0
- package/docs/dsh-market-submission/mengqi1436__dsh-db-tool.yml +6 -0
- package/docs/install.md +75 -0
- package/docs/review-findings.md +95 -0
- package/docs/skill.md +48 -0
- package/package.json +70 -0
- package/scripts/build-gaussdb.ps1 +72 -0
- package/scripts/build-gaussdb.sh +53 -0
- package/scripts/diag-profile.mjs +21 -0
- package/scripts/verify-host.mjs +53 -0
- package/skills/db-admin/SKILL.md +138 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis 适配器(redis v6,单连接多路复用,官方免池)。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点(依据官方文档调研):
|
|
5
|
+
* - createClient({ url }) + 显式 await connect();socket 5s 连接超时、指数退避重连。
|
|
6
|
+
* - query() 只允许读白名单命令;SCAN 用 scanIterator(禁用 KEYS)。
|
|
7
|
+
* - execute() 只允许写白名单命令,统一经 sendCommand 透传
|
|
8
|
+
* (官方:sendCommand 不解析键位/不加 keyPrefix——本插件不使用 keyPrefix,无影响)。
|
|
9
|
+
* - ro 模式适配器层直接拒绝 execute(服务层另有拦截)。
|
|
10
|
+
* - 只读账号建议由用户在服务端配置 ACL 只读用户(见 README/文档)。
|
|
11
|
+
*/
|
|
12
|
+
import { createClient } from 'redis';
|
|
13
|
+
/** 危险命令:服务层拦截器在用户确认前拒绝执行(KEYS 同时被 query 白名单禁用)。对齐官方 ACL dangerous 类。 */
|
|
14
|
+
export const DANGEROUS_COMMANDS = new Set([
|
|
15
|
+
'FLUSHALL', 'FLUSHDB', 'SWAPDB', 'CONFIG', 'DEBUG', 'SAVE', 'SHUTDOWN',
|
|
16
|
+
'REPLICAOF', 'SLAVEOF', 'MIGRATE', 'RESTORE', 'SORT', 'SORT_RO', 'ACL', 'KEYS',
|
|
17
|
+
'MODULE', 'FUNCTION', 'SCRIPT', 'RESET',
|
|
18
|
+
]);
|
|
19
|
+
/** 读白名单(OBJECT 仅限 ENCODING、MEMORY 仅限 USAGE 子命令) */
|
|
20
|
+
export const READ_COMMANDS = new Set([
|
|
21
|
+
'GET', 'MGET', 'EXISTS', 'TYPE', 'HGET', 'HGETALL', 'HKEYS', 'HVALS', 'HLEN',
|
|
22
|
+
'LRANGE', 'LLEN', 'SMEMBERS', 'SCARD', 'SISMEMBER', 'ZRANGE', 'ZSCORE',
|
|
23
|
+
'ZCARD', 'ZRANK', 'XLEN', 'XRANGE', 'SCAN', 'DBSIZE', 'INFO', 'SELECT',
|
|
24
|
+
'TTL', 'PTTL', 'GETRANGE', 'STRLEN', 'OBJECT', 'MEMORY',
|
|
25
|
+
]);
|
|
26
|
+
/** 写白名单 */
|
|
27
|
+
export const WRITE_COMMANDS = new Set([
|
|
28
|
+
'SET', 'SETEX', 'DEL', 'UNLINK', 'INCR', 'DECR', 'INCRBY', 'HSET', 'HDEL',
|
|
29
|
+
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'SADD', 'SREM', 'ZADD', 'ZREM', 'EXPIRE',
|
|
30
|
+
'PERSIST', 'RENAME', 'APPEND', 'SETRANGE',
|
|
31
|
+
]);
|
|
32
|
+
/** 键类型 → 对应长度命令(describeTable 用) */
|
|
33
|
+
const LENGTH_CMD_BY_TYPE = {
|
|
34
|
+
string: { cmd: 'STRLEN', method: 'strLen' },
|
|
35
|
+
hash: { cmd: 'HLEN', method: 'hLen' },
|
|
36
|
+
list: { cmd: 'LLEN', method: 'lLen' },
|
|
37
|
+
set: { cmd: 'SCARD', method: 'sCard' },
|
|
38
|
+
zset: { cmd: 'ZCARD', method: 'zCard' },
|
|
39
|
+
stream: { cmd: 'XLEN', method: 'xLen' },
|
|
40
|
+
};
|
|
41
|
+
const CELL_TRUNC = 1000;
|
|
42
|
+
const SCAN_COUNT = 100;
|
|
43
|
+
const SCAN_MAX_KEYS = 200;
|
|
44
|
+
const LIST_TABLES_MAX = 500;
|
|
45
|
+
const PREVIEW_MAX = 50;
|
|
46
|
+
/** query 通道集合类读命令(HGETALL/HKEYS/HVALS/SMEMBERS)结果行数上限 */
|
|
47
|
+
const READ_CLAMP = 500;
|
|
48
|
+
function trunc(s, max = CELL_TRUNC) {
|
|
49
|
+
return s.length > max ? `${s.slice(0, max)}…[截断,共${s.length}字符]` : s;
|
|
50
|
+
}
|
|
51
|
+
/** 规范化 Redis 回复为 NormalizedCell:数字原样、字符串截断、Buffer 转 utf8、其余 JSON 序列化 */
|
|
52
|
+
function cell(v) {
|
|
53
|
+
if (v === null || v === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof v === 'number')
|
|
56
|
+
return Number.isFinite(v) ? v : String(v);
|
|
57
|
+
if (typeof v === 'bigint')
|
|
58
|
+
return v.toString();
|
|
59
|
+
if (typeof v === 'string')
|
|
60
|
+
return trunc(v);
|
|
61
|
+
if (typeof v === 'object' && Buffer.isBuffer(v))
|
|
62
|
+
return trunc(v.toString('utf8'));
|
|
63
|
+
try {
|
|
64
|
+
return trunc(JSON.stringify(v) ?? String(v));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return trunc(String(v));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 解析命令输入:JSON 数组('["GET","key with space"]')或空格分隔("GET key")。
|
|
72
|
+
* 带空格的值必须用 JSON 数组形式(空格分隔无法表达)。
|
|
73
|
+
*/
|
|
74
|
+
export function parseRedisCommand(input) {
|
|
75
|
+
const s = input.trim();
|
|
76
|
+
if (s === '')
|
|
77
|
+
throw new Error('Redis 命令不能为空。示例:\'["GET","key"]\' 或 "GET key"');
|
|
78
|
+
if (s.startsWith('[')) {
|
|
79
|
+
let arr;
|
|
80
|
+
try {
|
|
81
|
+
arr = JSON.parse(s);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
throw new Error(`Redis 命令 JSON 解析失败:${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === 'string')) {
|
|
87
|
+
throw new Error('Redis 命令必须为非空字符串数组,如 \'["GET","key"]\'');
|
|
88
|
+
}
|
|
89
|
+
return arr;
|
|
90
|
+
}
|
|
91
|
+
return s.split(/\s+/);
|
|
92
|
+
}
|
|
93
|
+
/** 列出支持的命令(错误提示用) */
|
|
94
|
+
function supportedList() {
|
|
95
|
+
return `读命令: ${[...READ_COMMANDS].sort().join('/')}(OBJECT 仅 ENCODING、MEMORY 仅 USAGE);写命令(execute): ${[...WRITE_COMMANDS].sort().join('/')}`;
|
|
96
|
+
}
|
|
97
|
+
/** 解析 INFO 输出为 [key, value] 行(跳过 # 注释与空行) */
|
|
98
|
+
function parseInfo(raw) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
101
|
+
if (line === '' || line.startsWith('#'))
|
|
102
|
+
continue;
|
|
103
|
+
const idx = line.indexOf(':');
|
|
104
|
+
if (idx <= 0)
|
|
105
|
+
continue;
|
|
106
|
+
const k = line.slice(0, idx);
|
|
107
|
+
const v = line.slice(idx + 1);
|
|
108
|
+
const n = Number(v);
|
|
109
|
+
rows.push([k, v !== '' && Number.isFinite(n) ? n : trunc(v)]);
|
|
110
|
+
}
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
/** 从 fields 拼接连接 URL(url 缺省时) */
|
|
114
|
+
function buildUrl(fields, ssl) {
|
|
115
|
+
const host = typeof fields?.host === 'string' && fields.host !== '' ? fields.host : '127.0.0.1';
|
|
116
|
+
const port = typeof fields?.port === 'number' ? fields.port : 6379;
|
|
117
|
+
const user = typeof fields?.user === 'string' && fields.user !== '' ? encodeURIComponent(fields.user) : '';
|
|
118
|
+
const pass = typeof fields?.password === 'string' ? `:${encodeURIComponent(fields.password)}@` : '';
|
|
119
|
+
const db = typeof fields?.database === 'number' && fields.database !== 0 ? `/${fields.database}` : '';
|
|
120
|
+
const scheme = ssl || connSsl(fields) ? 'rediss' : 'redis';
|
|
121
|
+
return `${scheme}://${user}${pass}${host}:${port}${db}`;
|
|
122
|
+
}
|
|
123
|
+
function connSsl(fields) {
|
|
124
|
+
return fields?.ssl === true || fields?.tls === true;
|
|
125
|
+
}
|
|
126
|
+
export async function createRedisAdapter(conn, opts) {
|
|
127
|
+
const mode = opts?.mode ?? 'rw';
|
|
128
|
+
let client;
|
|
129
|
+
if (opts?.client) {
|
|
130
|
+
client = opts.client;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const url = conn.url ?? buildUrl(conn.fields, conn.ssl);
|
|
134
|
+
client = createClient({
|
|
135
|
+
url,
|
|
136
|
+
socket: {
|
|
137
|
+
connectTimeout: 5000,
|
|
138
|
+
reconnectStrategy: (retries) => Math.min(retries * 50, 2000),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
await client.connect();
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
throw new Error(`Redis 连接失败(${sanitizeUrl(url)}):${e instanceof Error ? e.message : String(e)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function wrap(e) {
|
|
149
|
+
return new Error(`Redis 错误:${e instanceof Error ? e.message : String(e)}`);
|
|
150
|
+
}
|
|
151
|
+
function requireRw(action) {
|
|
152
|
+
if (mode === 'ro') {
|
|
153
|
+
throw new Error(`连接为只读(ro)模式,拒绝执行${action};请使用 rw 连接或只发查询(query)`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** 统一入口:解析并校验命令白名单 */
|
|
157
|
+
function parseAndCheck(input, whitelist, action) {
|
|
158
|
+
const args = parseRedisCommand(input);
|
|
159
|
+
const cmd = (args[0] ?? '').toUpperCase();
|
|
160
|
+
if (DANGEROUS_COMMANDS.has(cmd)) {
|
|
161
|
+
throw new Error(`Redis 命令 ${cmd} 属于危险命令(KEYS/FLUSHALL/CONFIG 等),已被本工具禁用或需服务层确认`);
|
|
162
|
+
}
|
|
163
|
+
if (!whitelist.has(cmd)) {
|
|
164
|
+
throw new Error(`Redis ${action} 不支持命令 ${cmd}。${supportedList()}`);
|
|
165
|
+
}
|
|
166
|
+
if ((cmd === 'OBJECT' && (args[1] ?? '').toUpperCase() !== 'ENCODING') ||
|
|
167
|
+
(cmd === 'MEMORY' && (args[1] ?? '').toUpperCase() !== 'USAGE')) {
|
|
168
|
+
throw new Error(`Redis ${action} 仅支持 OBJECT ENCODING 与 MEMORY USAGE`);
|
|
169
|
+
}
|
|
170
|
+
if ((cmd === 'OBJECT' || cmd === 'MEMORY') && args.length < 3) {
|
|
171
|
+
throw new Error(`Redis ${cmd} ${cmd === 'OBJECT' ? 'ENCODING' : 'USAGE'} 缺少 key 参数`);
|
|
172
|
+
}
|
|
173
|
+
if (args.length < 2 && !['DBSIZE', 'INFO', 'SCAN', 'SELECT'].includes(cmd)) {
|
|
174
|
+
throw new Error(`Redis ${cmd} 缺少参数`);
|
|
175
|
+
}
|
|
176
|
+
return args;
|
|
177
|
+
}
|
|
178
|
+
const adapter = {
|
|
179
|
+
kind: 'redis',
|
|
180
|
+
connId: conn.meta.id,
|
|
181
|
+
async testConnect() {
|
|
182
|
+
try {
|
|
183
|
+
const pong = await client.ping();
|
|
184
|
+
let serverInfo = pong;
|
|
185
|
+
try {
|
|
186
|
+
const info = await client.info('server');
|
|
187
|
+
const m = /redis_version:([^\r\n]+)/.exec(info);
|
|
188
|
+
if (m?.[1])
|
|
189
|
+
serverInfo = `Redis ${m[1].trim()}`;
|
|
190
|
+
}
|
|
191
|
+
catch { /* INFO 失败不影响连通性 */ }
|
|
192
|
+
return { ok: true, serverInfo };
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return { ok: false, error: wrap(e).message };
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async query(sql) {
|
|
199
|
+
try {
|
|
200
|
+
const args = parseAndCheck(sql, READ_COMMANDS, 'query');
|
|
201
|
+
const cmd = args[0].toUpperCase();
|
|
202
|
+
const rest = args.slice(1);
|
|
203
|
+
// SCAN:scanIterator 实现,禁用 KEYS;透传用户 MATCH/COUNT,行数仍 clamp 200
|
|
204
|
+
if (cmd === 'SCAN') {
|
|
205
|
+
let match = '*';
|
|
206
|
+
let count = SCAN_COUNT;
|
|
207
|
+
for (let i = 0; i < rest.length; i++) {
|
|
208
|
+
const a = (rest[i] ?? '').toUpperCase();
|
|
209
|
+
if (a === 'MATCH' && rest[i + 1] !== undefined) {
|
|
210
|
+
match = rest[i + 1];
|
|
211
|
+
i++;
|
|
212
|
+
}
|
|
213
|
+
else if (a === 'COUNT' && rest[i + 1] !== undefined) {
|
|
214
|
+
const c = Number(rest[i + 1]);
|
|
215
|
+
if (Number.isInteger(c) && c > 0)
|
|
216
|
+
count = c;
|
|
217
|
+
i++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const rows = [];
|
|
221
|
+
const keys = [];
|
|
222
|
+
for await (const k of client.scanIterator({ MATCH: match, COUNT: count })) {
|
|
223
|
+
keys.push(...(Array.isArray(k) ? k : [String(k)]));
|
|
224
|
+
if (keys.length >= SCAN_MAX_KEYS)
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (keys.length > 0) {
|
|
228
|
+
const multi = client.multi();
|
|
229
|
+
for (const k of keys)
|
|
230
|
+
multi.type(k);
|
|
231
|
+
const types = (await multi.exec());
|
|
232
|
+
for (let i = 0; i < keys.length; i++) {
|
|
233
|
+
rows.push([keys[i] ?? '', cell(types[i] ?? 'none')]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { columns: ['key', 'type'], rows, rowCount: rows.length, truncated: keys.length >= SCAN_MAX_KEYS || undefined };
|
|
237
|
+
}
|
|
238
|
+
// SELECT:切换逻辑库
|
|
239
|
+
if (cmd === 'SELECT') {
|
|
240
|
+
const db = Number(rest[0]);
|
|
241
|
+
if (!Number.isInteger(db) || db < 0)
|
|
242
|
+
throw new Error('SELECT 需要非负整数库号,如 "SELECT 1"');
|
|
243
|
+
await client.select(db);
|
|
244
|
+
return { columns: ['name', 'value'], rows: [['db', db]], rowCount: 1 };
|
|
245
|
+
}
|
|
246
|
+
// INFO:解析为 key/value 行
|
|
247
|
+
if (cmd === 'INFO') {
|
|
248
|
+
const raw = await client.info(rest[0]);
|
|
249
|
+
const rows = parseInfo(raw).map(([k, v]) => [k, v]);
|
|
250
|
+
return { columns: ['key', 'value'], rows, rowCount: rows.length };
|
|
251
|
+
}
|
|
252
|
+
// DBSIZE
|
|
253
|
+
if (cmd === 'DBSIZE') {
|
|
254
|
+
const n = await client.dbSize();
|
|
255
|
+
return { columns: ['name', 'value'], rows: [['dbsize', n]], rowCount: 1 };
|
|
256
|
+
}
|
|
257
|
+
// MEMORY USAGE / OBJECT ENCODING:sendCommand 透传
|
|
258
|
+
if (cmd === 'MEMORY' || cmd === 'OBJECT') {
|
|
259
|
+
const reply = await client.sendCommand(args);
|
|
260
|
+
const key = rest[1] ?? '';
|
|
261
|
+
return { columns: ['key', 'value'], rows: [[cell(key), cell(reply)]], rowCount: 1 };
|
|
262
|
+
}
|
|
263
|
+
// ---- 单键值类 → ['key','value'] ----
|
|
264
|
+
const key = rest[0] ?? '';
|
|
265
|
+
switch (cmd) {
|
|
266
|
+
case 'GET': {
|
|
267
|
+
const v = await client.get(key);
|
|
268
|
+
return kv(key, v);
|
|
269
|
+
}
|
|
270
|
+
case 'MGET': {
|
|
271
|
+
const vals = await client.mGet(rest);
|
|
272
|
+
const rows = rest.map((k, i) => [cell(k), cell(vals[i])]);
|
|
273
|
+
return { columns: ['key', 'value'], rows, rowCount: rows.length };
|
|
274
|
+
}
|
|
275
|
+
case 'EXISTS': {
|
|
276
|
+
// redis v6:exists 接受 RedisVariadicArgument(string | string[]),直接传数组
|
|
277
|
+
const n = await client.exists(rest);
|
|
278
|
+
return kv(key, n);
|
|
279
|
+
}
|
|
280
|
+
case 'TYPE': {
|
|
281
|
+
const t = await client.type(key);
|
|
282
|
+
return kv(key, t);
|
|
283
|
+
}
|
|
284
|
+
case 'HGET': {
|
|
285
|
+
const v = await client.hGet(key, rest[1] ?? '');
|
|
286
|
+
return kv(rest[1] ?? '', v);
|
|
287
|
+
}
|
|
288
|
+
case 'TTL': return kv(key, await client.ttl(key));
|
|
289
|
+
case 'PTTL': return kv(key, await client.pTTL(key));
|
|
290
|
+
case 'STRLEN': return kv(key, await client.strLen(key));
|
|
291
|
+
case 'GETRANGE': return kv(key, await client.getRange(key, Number(rest[1] ?? 0), Number(rest[2] ?? -1)));
|
|
292
|
+
case 'HLEN': return kv(key, await client.hLen(key));
|
|
293
|
+
case 'LLEN': return kv(key, await client.lLen(key));
|
|
294
|
+
case 'SCARD': return kv(key, await client.sCard(key));
|
|
295
|
+
case 'ZCARD': return kv(key, await client.zCard(key));
|
|
296
|
+
case 'XLEN': return kv(key, await client.xLen(key));
|
|
297
|
+
case 'SISMEMBER': return kv(rest[1] ?? '', await client.sIsMember(key, rest[1] ?? ''));
|
|
298
|
+
case 'ZSCORE': return kv(rest[1] ?? '', await client.zScore(key, rest[1] ?? ''));
|
|
299
|
+
case 'ZRANK': return kv(rest[1] ?? '', await client.zRank(key, rest[1] ?? ''));
|
|
300
|
+
case 'HGETALL': {
|
|
301
|
+
const map = await client.hGetAll(key);
|
|
302
|
+
const all = Object.entries(map).map(([f, v]) => [cell(f), cell(v)]);
|
|
303
|
+
const rows = all.slice(0, READ_CLAMP);
|
|
304
|
+
return { columns: ['field', 'value'], rows, rowCount: rows.length, truncated: all.length > READ_CLAMP || undefined };
|
|
305
|
+
}
|
|
306
|
+
case 'HKEYS': {
|
|
307
|
+
const vals = await client.hKeys(key);
|
|
308
|
+
const all = (vals ?? []).map((f) => [cell(f)]);
|
|
309
|
+
const rows = all.slice(0, READ_CLAMP);
|
|
310
|
+
return { columns: ['field'], rows, rowCount: rows.length, truncated: all.length > READ_CLAMP || undefined };
|
|
311
|
+
}
|
|
312
|
+
case 'HVALS': {
|
|
313
|
+
const vals = await client.hVals(key);
|
|
314
|
+
const all = (vals ?? []).map((v) => [cell(v)]);
|
|
315
|
+
const rows = all.slice(0, READ_CLAMP);
|
|
316
|
+
return { columns: ['value'], rows, rowCount: rows.length, truncated: all.length > READ_CLAMP || undefined };
|
|
317
|
+
}
|
|
318
|
+
case 'LRANGE': {
|
|
319
|
+
const all = await client.lRange(key, Number(rest[1] ?? 0), Number(rest[2] ?? -1));
|
|
320
|
+
const rows = all.slice(0, PREVIEW_MAX).map((v) => [cell(v)]);
|
|
321
|
+
return { columns: ['value'], rows, rowCount: rows.length, truncated: all.length > PREVIEW_MAX || undefined };
|
|
322
|
+
}
|
|
323
|
+
case 'SMEMBERS': {
|
|
324
|
+
const vals = await client.sMembers(key);
|
|
325
|
+
const all = (vals ?? []).map((v) => [cell(v)]);
|
|
326
|
+
const rows = all.slice(0, READ_CLAMP);
|
|
327
|
+
return { columns: ['member'], rows, rowCount: rows.length, truncated: all.length > READ_CLAMP || undefined };
|
|
328
|
+
}
|
|
329
|
+
case 'ZRANGE': {
|
|
330
|
+
const withScores = rest.slice(3).some((a) => a.toUpperCase() === 'WITHSCORES');
|
|
331
|
+
if (withScores) {
|
|
332
|
+
const pairs = await client.zRangeWithScores(key, rest[1] ?? '0', rest[2] ?? '-1');
|
|
333
|
+
const rows = pairs.map((m) => [cell(m.value), cell(m.score)]);
|
|
334
|
+
return { columns: ['member', 'score'], rows, rowCount: rows.length };
|
|
335
|
+
}
|
|
336
|
+
const vals = await client.zRange(key, rest[1] ?? '0', rest[2] ?? '-1');
|
|
337
|
+
const rows = vals.map((v) => [cell(v)]);
|
|
338
|
+
return { columns: ['member'], rows, rowCount: rows.length };
|
|
339
|
+
}
|
|
340
|
+
case 'XRANGE': {
|
|
341
|
+
const countIdx = rest.findIndex((a, i) => i > 0 && a.toUpperCase() === 'COUNT');
|
|
342
|
+
const entries = await client.xRange(key, rest[1] ?? '-', rest[2] ?? '+', countIdx > 0 ? { COUNT: Number(rest[countIdx + 1] ?? 20) } : undefined);
|
|
343
|
+
const rows = [];
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
const fields = Object.entries(entry.message ?? {});
|
|
346
|
+
if (fields.length === 0)
|
|
347
|
+
rows.push([cell(entry.id), null, null]);
|
|
348
|
+
for (const [f, v] of fields)
|
|
349
|
+
rows.push([cell(entry.id), cell(f), cell(v)]);
|
|
350
|
+
}
|
|
351
|
+
return { columns: ['id', 'field', 'value'], rows, rowCount: rows.length };
|
|
352
|
+
}
|
|
353
|
+
default:
|
|
354
|
+
throw new Error(`Redis query 暂未实现命令 ${cmd}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
throw wrap(e);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
async execute(statement) {
|
|
362
|
+
requireRw('写命令(execute)');
|
|
363
|
+
try {
|
|
364
|
+
const args = parseAndCheck(statement, WRITE_COMMANDS, 'execute');
|
|
365
|
+
const cmd = args[0].toUpperCase();
|
|
366
|
+
const reply = await client.sendCommand(args);
|
|
367
|
+
const affected = typeof reply === 'number' ? reply : undefined;
|
|
368
|
+
return {
|
|
369
|
+
affectedRows: affected,
|
|
370
|
+
message: `${cmd} 成功${affected !== undefined ? `(影响 ${affected})` : String(reply) !== 'OK' ? `(${String(reply)})` : ''}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
throw wrap(e);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async listDatabases() {
|
|
378
|
+
try {
|
|
379
|
+
const raw = await client.info('keyspace');
|
|
380
|
+
const dbs = [...raw.matchAll(/^(db\d+):/gm)].map((m) => m[1]).sort();
|
|
381
|
+
return dbs.length > 0 ? dbs : ['db0'];
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return ['db0'];
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
async listTables() {
|
|
388
|
+
try {
|
|
389
|
+
const keys = [];
|
|
390
|
+
for await (const k of client.scanIterator({ MATCH: '*', COUNT: SCAN_COUNT })) {
|
|
391
|
+
keys.push(...(Array.isArray(k) ? k : [String(k)]));
|
|
392
|
+
if (keys.length >= LIST_TABLES_MAX)
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
if (keys.length === 0)
|
|
396
|
+
return [];
|
|
397
|
+
const multi = client.multi();
|
|
398
|
+
for (const k of keys)
|
|
399
|
+
multi.type(k);
|
|
400
|
+
const types = (await multi.exec());
|
|
401
|
+
return keys.map((name, i) => ({ name, type: String(types[i] ?? 'none') }));
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
throw wrap(e);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
async describeTable(key) {
|
|
408
|
+
try {
|
|
409
|
+
const type = await client.type(key);
|
|
410
|
+
const lenCmd = LENGTH_CMD_BY_TYPE[type];
|
|
411
|
+
const encoding = await client.sendCommand(['OBJECT', 'ENCODING', key]);
|
|
412
|
+
const ttl = await client.ttl(key);
|
|
413
|
+
const length = lenCmd ? await client[lenCmd.method](key) : null;
|
|
414
|
+
const cols = [
|
|
415
|
+
{ name: 'type', dataType: type, nullable: false },
|
|
416
|
+
{ name: 'encoding', dataType: 'OBJECT ENCODING', nullable: true, default: encoding === null ? null : String(encoding) },
|
|
417
|
+
{ name: 'ttl', dataType: 'seconds', nullable: true, default: String(ttl), comment: '-1 表示未设置过期' },
|
|
418
|
+
];
|
|
419
|
+
if (lenCmd) {
|
|
420
|
+
cols.push({ name: 'length', dataType: lenCmd.cmd, nullable: true, default: length === null ? null : String(length) });
|
|
421
|
+
}
|
|
422
|
+
return cols;
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
throw wrap(e);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
// KV 型无行偏移语义(契约允许忽略 database/offset),仅按 limit 取样本
|
|
429
|
+
async previewRows(key, limit, _database, _offset) {
|
|
430
|
+
try {
|
|
431
|
+
const n = Math.max(1, Math.min(Math.floor(limit) || PREVIEW_MAX, PREVIEW_MAX));
|
|
432
|
+
const type = await client.type(key);
|
|
433
|
+
switch (type) {
|
|
434
|
+
case 'string': {
|
|
435
|
+
const v = await client.get(key);
|
|
436
|
+
return { columns: ['key', 'value'], rows: [[cell(key), cell(v)]], rowCount: v === null ? 0 : 1 };
|
|
437
|
+
}
|
|
438
|
+
case 'hash': {
|
|
439
|
+
const map = await client.hGetAll(key);
|
|
440
|
+
const rows = Object.entries(map).slice(0, n).map(([f, v]) => [cell(f), cell(v)]);
|
|
441
|
+
return { columns: ['field', 'value'], rows, rowCount: rows.length };
|
|
442
|
+
}
|
|
443
|
+
case 'list': {
|
|
444
|
+
const vals = await client.lRange(key, 0, n - 1);
|
|
445
|
+
return { columns: ['value'], rows: vals.map((v) => [cell(v)]), rowCount: vals.length };
|
|
446
|
+
}
|
|
447
|
+
case 'set': {
|
|
448
|
+
const vals = await client.sMembers(key);
|
|
449
|
+
const rows = (vals ?? []).slice(0, n).map((v) => [cell(v)]);
|
|
450
|
+
return { columns: ['member'], rows, rowCount: rows.length };
|
|
451
|
+
}
|
|
452
|
+
case 'zset': {
|
|
453
|
+
const pairs = await client.zRangeWithScores(key, '0', String(n - 1));
|
|
454
|
+
const rows = pairs.map((m) => [cell(m.value), cell(m.score)]);
|
|
455
|
+
return { columns: ['member', 'score'], rows, rowCount: rows.length };
|
|
456
|
+
}
|
|
457
|
+
case 'stream': {
|
|
458
|
+
const entries = await client.xRange(key, '-', '+', { COUNT: n });
|
|
459
|
+
const rows = [];
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
const fields = Object.entries(entry.message ?? {});
|
|
462
|
+
if (fields.length === 0)
|
|
463
|
+
rows.push([cell(entry.id), null, null]);
|
|
464
|
+
for (const [f, v] of fields)
|
|
465
|
+
rows.push([cell(entry.id), cell(f), cell(v)]);
|
|
466
|
+
}
|
|
467
|
+
return { columns: ['id', 'field', 'value'], rows, rowCount: rows.length };
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
return { columns: ['value'], rows: [], rowCount: 0 };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
throw wrap(e);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
async close() {
|
|
478
|
+
try {
|
|
479
|
+
await client.quit();
|
|
480
|
+
}
|
|
481
|
+
catch { /* 已断开等场景忽略 */ }
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
function kv(key, v) {
|
|
485
|
+
return { columns: ['key', 'value'], rows: [[cell(key), cell(v)]], rowCount: 1 };
|
|
486
|
+
}
|
|
487
|
+
return adapter;
|
|
488
|
+
}
|
|
489
|
+
function sanitizeUrl(url) {
|
|
490
|
+
return url.replace(/:\/\/[^@/]*@/, '://***:***@');
|
|
491
|
+
}
|
|
492
|
+
export const factory = async (conn) => createRedisAdapter(conn);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const MAX_JSON_LEN = 1000;
|
|
2
|
+
/** 标识符基本校验:非空、长度 ≤128、无 NUL/换行等控制字符。
|
|
3
|
+
* 防注入不靠字符白名单(各库引用标识符合法字符极宽:MySQL 反引号内任意、PG/SQLite "..." 任意、
|
|
4
|
+
* Oracle/DM 引用创建的名字可含任意字符——白名单会误杀合法对象名,如 DM ## 开头内部表、
|
|
5
|
+
* MySQL `my-table`/中文表名),而是靠「参数绑定 + quoteIdent 引号转义」。 */
|
|
6
|
+
export function assertIdent(name, label = '标识符') {
|
|
7
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 128 ||
|
|
8
|
+
/[\0\r\n]/.test(name)) {
|
|
9
|
+
throw new Error(`非法${label}: ${JSON.stringify(String(name))}(不允许为空、超 128 字符或含 NUL/换行)`);
|
|
10
|
+
}
|
|
11
|
+
return name;
|
|
12
|
+
}
|
|
13
|
+
/** 校验并用引号包裹标识符,内部引号转义("..." 内 " → "",`...` 内 ` → ``)。
|
|
14
|
+
* 引用标识符内唯一需要转义的就是引号自身,转义后任意名字均安全。 */
|
|
15
|
+
export function quoteIdent(name, quote = '"') {
|
|
16
|
+
return quote + assertIdent(name).replaceAll(quote, quote + quote) + quote;
|
|
17
|
+
}
|
|
18
|
+
/** preview limit 钳制到 [1, max](服务层已限 50,适配器再兜底一次) */
|
|
19
|
+
export function clampLimit(limit, max = 50) {
|
|
20
|
+
const n = Math.floor(Number(limit));
|
|
21
|
+
if (!Number.isFinite(n) || n < 1)
|
|
22
|
+
return 1;
|
|
23
|
+
return n > max ? max : n;
|
|
24
|
+
}
|
|
25
|
+
/** preview offset 钳制到 [0, ∞)(服务层已限 ≥0,适配器再兜底一次;缺省 0) */
|
|
26
|
+
export function clampOffset(offset) {
|
|
27
|
+
const n = Math.floor(Number(offset ?? 0));
|
|
28
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
29
|
+
}
|
|
30
|
+
/** 任意单元格 → NormalizedCell(string | number | null) */
|
|
31
|
+
export function normalizeCell(v) {
|
|
32
|
+
if (v === null || v === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
const t = typeof v;
|
|
35
|
+
if (t === 'string')
|
|
36
|
+
return v;
|
|
37
|
+
if (t === 'number')
|
|
38
|
+
return v;
|
|
39
|
+
if (t === 'boolean')
|
|
40
|
+
return v ? 1 : 0;
|
|
41
|
+
if (t === 'bigint') {
|
|
42
|
+
const b = v;
|
|
43
|
+
return b >= BigInt(Number.MIN_SAFE_INTEGER) && b <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
44
|
+
? Number(b)
|
|
45
|
+
: b.toString();
|
|
46
|
+
}
|
|
47
|
+
if (v instanceof Date)
|
|
48
|
+
return v.toISOString();
|
|
49
|
+
if (v instanceof Uint8Array)
|
|
50
|
+
return `[BLOB ${v.byteLength} bytes]`;
|
|
51
|
+
let s;
|
|
52
|
+
try {
|
|
53
|
+
s = JSON.stringify(v) ?? String(v);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
s = String(v);
|
|
57
|
+
}
|
|
58
|
+
return s.length > MAX_JSON_LEN ? s.slice(0, MAX_JSON_LEN) + '…[已截断]' : s;
|
|
59
|
+
}
|
|
60
|
+
/** 统一错误包装:转成人类可读并保留原始 message */
|
|
61
|
+
export async function humanize(label, fn) {
|
|
62
|
+
try {
|
|
63
|
+
return await fn();
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
67
|
+
throw new Error(`${label}: ${msg}`);
|
|
68
|
+
}
|
|
69
|
+
}
|