@yixinshuke/node-dashboard 0.0.0-stage → 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/package.json +29 -6
- package/src/assets/index.html +309 -0
- package/src/assets/js/agg.js +99 -0
- package/src/assets/js/common.js +594 -0
- package/src/assets/js/instances.js +152 -0
- package/src/assets/js/push.js +178 -0
- package/src/config.ts +27 -0
- package/src/dashboard.ts +171 -0
- package/src/http-logger.ts +65 -0
- package/src/index.ts +10 -0
- package/src/panel-token-inject.ts +53 -0
- package/src/qpm-hist.ts +208 -0
- package/src/shardorm-logger.ts +107 -0
- package/src/sql-trace.ts +291 -0
- package/README.md +0 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给 apirouter 调试面板注入「令牌自动保存」。
|
|
3
|
+
*
|
|
4
|
+
* 背景:运行时安装的 @yixinshuke/node-apirouter 较旧,其面板 renderResp 只在
|
|
5
|
+
* `cmd` 以 "/login" 结尾时才把 accessToken/refreshToken 写进面板 Access/Refresh 输入框;
|
|
6
|
+
* 本项目登录 cmd 是中文 "auth/登录",故初次登录后令牌不会自动落进调试面板(需手动粘贴)。
|
|
7
|
+
* 新版 apirouter 已改成「成功 + 响应含令牌字段即保存」,这里在服务端补上等价行为,
|
|
8
|
+
* 让 GET /api 调试面板返回的 HTML 自动具备该能力,与框架新版本对齐。
|
|
9
|
+
*
|
|
10
|
+
* 实现:注册一个 app 级 onSend 钩子,仅对调试面板的 text/html 响应注入一段脚本,
|
|
11
|
+
* 覆写 renderResp —— 任意「成功 + data 含令牌字段」的响应都自动填入输入框。
|
|
12
|
+
* 仅针对面板 HTML,不影响 JSON/业务接口(那些 content-type 不是 text/html)。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 必须为 async 并返回 payload:Fastify 的 onSend 钩子若用 3 参形式,
|
|
15
|
+
* 会被当作「返回 Promise 的钩子」,返回值即新 payload;若写成同步函数直接
|
|
16
|
+
* return,Fastify 会一直等 done 回调 → 所有响应挂死直到超时。
|
|
17
|
+
*/
|
|
18
|
+
export function registerPanelTokenInject(app: any): void {
|
|
19
|
+
if (!app || typeof app.addHook !== "function") return;
|
|
20
|
+
|
|
21
|
+
app.addHook(
|
|
22
|
+
"onSend",
|
|
23
|
+
async (_request: any, reply: any, payload: any): Promise<any> => {
|
|
24
|
+
const ct: string = (reply.getHeader && reply.getHeader("content-type")) || "";
|
|
25
|
+
if (!ct.includes("text/html") || typeof payload !== "string") return payload;
|
|
26
|
+
const url: string = String(_request?.url ?? "").split("?")[0];
|
|
27
|
+
// 仅匹配面板渲染端点:/api 与 /api/:module(带 / 不带尾斜杠)
|
|
28
|
+
if (!/^\/api(\/[^/]+)?\/?$/.test(url)) return payload;
|
|
29
|
+
|
|
30
|
+
const inject = `<script>
|
|
31
|
+
(function(){
|
|
32
|
+
if(typeof renderResp !== "function") return;
|
|
33
|
+
var _orig = renderResp;
|
|
34
|
+
renderResp = function(data, t0){
|
|
35
|
+
_orig(data, t0);
|
|
36
|
+
try{
|
|
37
|
+
if(data && data.result === "成功" && data.data){
|
|
38
|
+
if(data.data.accessToken) setAccessToken(String(data.data.accessToken));
|
|
39
|
+
if(data.data.refreshToken) setRefreshToken(String(data.data.refreshToken));
|
|
40
|
+
syncTokenInputs();
|
|
41
|
+
}
|
|
42
|
+
}catch(e){ /* 面板脚本未定义相关函数时静默忽略 */ }
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
</script>`;
|
|
46
|
+
|
|
47
|
+
if (payload.includes("</body>")) {
|
|
48
|
+
return payload.replace("</body>", inject + "\n</body>");
|
|
49
|
+
}
|
|
50
|
+
return payload + inject;
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
}
|
package/src/qpm-hist.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { dashboardConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QPM 分钟历史持久化(宿主层)。
|
|
6
|
+
*
|
|
7
|
+
* 背景:apirouter(QosStats)与 http-client(Metrics)的统计都在进程内存,重启即丢;
|
|
8
|
+
* 只有 shardorm 走库内 RedisMetricsBackend 已持久化。这里在宿主层把 apirouter / http-client
|
|
9
|
+
* 的逐分钟计数与窗口累计(total/ok/fail/avgMs/maxMs/minMs)落到 Redis。
|
|
10
|
+
*
|
|
11
|
+
* 设计要点:
|
|
12
|
+
* - 实例标识用 ip:port(稳定):重启后 pid 会变,但端口不变,历史键才能接上。
|
|
13
|
+
* - 键:qpmhist:{env}:{ip}:{port}:{svc},hash field = `{分钟序号}:{c|ok|f|ls|lmx|lmn}`,TTL 25h。
|
|
14
|
+
* (c=请求数 ok=成功 f=失败 ls=耗时和 lmx=耗时峰值 lmn=耗时最低;avgMs=ls/c)
|
|
15
|
+
* - 通道维度:svc = `apirouter-api` / `apirouter-open` / `apirouter-callback`(按 apirouter 命名空间拆分)。
|
|
16
|
+
* - cmd 维度:qpmhist:{env}:{ip}:{port}:{svc}:cmd:{cmd},同样 6 个 field;供「cmd 钻取」时序。
|
|
17
|
+
* - apirouter:QosStats.onFlush 窗口增量 HINCRBY(事件驱动,空闲零回调)。
|
|
18
|
+
* - http-client:无钩子,宿主每 2s 观测当前分钟桶,跨分钟时把上一分钟终值 HSET 覆盖。
|
|
19
|
+
* - 读时聚合:histGetAgg / histGetCmd 返回最近 24h 窗口累计 + 逐分钟序列(供折线)。
|
|
20
|
+
* - 【铁律】监控非关键路径:所有 Redis 异常一律吞掉,绝不影响业务。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TTL_MS = 25 * 60 * 60 * 1000; // 保留 24h 数据 + 1h 余量;写入持续续期
|
|
24
|
+
|
|
25
|
+
/** 直方图桶上界(ms),须与 node-apirouter 的 LAT_BOUNDS 逐字节一致;否则百分位估算错位。 */
|
|
26
|
+
const DEFAULT_LAT_BOUNDS = [1, 2, 3, 5, 8, 12, 20, 30, 50, 80, 120, 200, 300, 500, 1000, 2000, 5000, 10000];
|
|
27
|
+
|
|
28
|
+
let redis: any = null;
|
|
29
|
+
let inst = "unknown";
|
|
30
|
+
|
|
31
|
+
/** 启动时调用一次:注入 Redis 客户端与稳定实例标识。无 Redis 则全部降级为 no-op。 */
|
|
32
|
+
export function initQpmHist(client: any, port: number): void {
|
|
33
|
+
redis = client ?? null;
|
|
34
|
+
const ip = Object.values(os.networkInterfaces())
|
|
35
|
+
.flat()
|
|
36
|
+
.find((i: any) => i && i.family === "IPv4" && !i.internal)?.address ?? "127.0.0.1";
|
|
37
|
+
inst = `${ip}:${port}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const keyOf = (svc: string) => `qpmhist:${dashboardConfig.env}:${inst}:${svc}`;
|
|
41
|
+
const cmdKeyOf = (svc: string, cmd: string) => `qpmhist:${dashboardConfig.env}:${inst}:${svc}:cmd:${cmd}`;
|
|
42
|
+
const minuteOf = (t = Date.now()) => Math.floor(t / 60000);
|
|
43
|
+
|
|
44
|
+
export interface MinuteStat {
|
|
45
|
+
total: number;
|
|
46
|
+
ok: number;
|
|
47
|
+
fail: number;
|
|
48
|
+
durMs: number;
|
|
49
|
+
maxMs: number;
|
|
50
|
+
minMs: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 单条调用增量写入(apirouter onFlush 用):窗口增量 HINCRBY 到其所在分钟;同时落 cmd 维度。 */
|
|
54
|
+
export function histIncrDelta(svc: string, d: { total?: number; ok?: number; fail?: number; costMs?: number; maxMs?: number; minMs?: number; hist?: number[]; byCmd?: Array<{ cmd: string; total?: number; ok?: number; fail?: number; costMs?: number; maxMs?: number; minMs?: number; hist?: number[] }> }): void {
|
|
55
|
+
if (!redis) return;
|
|
56
|
+
const c = Number(d.total ?? 0);
|
|
57
|
+
if (!c) return;
|
|
58
|
+
const p = String(minuteOf());
|
|
59
|
+
const svcKey = keyOf(svc);
|
|
60
|
+
|
|
61
|
+
const svcOps: Promise<any>[] = [
|
|
62
|
+
redis.hincrby(svcKey, `${p}:c`, c),
|
|
63
|
+
redis.hincrby(svcKey, `${p}:ok`, Number(d.ok ?? 0)),
|
|
64
|
+
redis.hincrby(svcKey, `${p}:f`, Number(d.fail ?? 0)),
|
|
65
|
+
redis.hincrby(svcKey, `${p}:ls`, Math.round(Number(d.costMs ?? 0))),
|
|
66
|
+
redis.hincrby(svcKey, `${p}:lmx`, Number(d.maxMs ?? 0)),
|
|
67
|
+
redis.hincrby(svcKey, `${p}:lmn`, Number(d.minMs ?? 0)),
|
|
68
|
+
redis.pexpire(svcKey, TTL_MS),
|
|
69
|
+
];
|
|
70
|
+
// 耗时直方图(桶定义见 DEFAULT_LAT_BOUNDS):HINCRBY 累加窗口增量,多实例/多次 flush 合并
|
|
71
|
+
const bn = Math.min((d.hist?.length) ?? 0, DEFAULT_LAT_BOUNDS.length + 1);
|
|
72
|
+
for (let k = 0; k < bn; k++) svcOps.push(redis.hincrby(svcKey, `${p}:b${k}`, d.hist?.[k] ?? 0));
|
|
73
|
+
svcOps.push(redis.hsetnx(svcKey, "bounds", JSON.stringify(DEFAULT_LAT_BOUNDS)));
|
|
74
|
+
void Promise.all(svcOps).catch(() => {});
|
|
75
|
+
|
|
76
|
+
// cmd 维度
|
|
77
|
+
for (const cmd of d.byCmd ?? []) {
|
|
78
|
+
const cc = Number(cmd.total ?? 0);
|
|
79
|
+
if (!cc) continue;
|
|
80
|
+
const ck = cmdKeyOf(svc, cmd.cmd);
|
|
81
|
+
const ops: Promise<any>[] = [
|
|
82
|
+
redis.hincrby(ck, `${p}:c`, cc),
|
|
83
|
+
redis.hincrby(ck, `${p}:ok`, Number(cmd.ok ?? 0)),
|
|
84
|
+
redis.hincrby(ck, `${p}:f`, Number(cmd.fail ?? 0)),
|
|
85
|
+
redis.hincrby(ck, `${p}:ls`, Math.round(Number(cmd.costMs ?? 0))),
|
|
86
|
+
redis.hincrby(ck, `${p}:lmx`, Number(cmd.maxMs ?? 0)),
|
|
87
|
+
redis.hincrby(ck, `${p}:lmn`, Number(cmd.minMs ?? 0)),
|
|
88
|
+
redis.pexpire(ck, TTL_MS),
|
|
89
|
+
];
|
|
90
|
+
const cbn = Math.min((cmd.hist?.length) ?? 0, DEFAULT_LAT_BOUNDS.length + 1);
|
|
91
|
+
for (let k = 0; k < cbn; k++) ops.push(redis.hincrby(ck, `${p}:b${k}`, cmd.hist?.[k] ?? 0));
|
|
92
|
+
ops.push(redis.hsetnx(ck, "bounds", JSON.stringify(DEFAULT_LAT_BOUNDS)));
|
|
93
|
+
void Promise.all(ops).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 兼容旧调用:仅写 svc 级(不落 cmd 维度) */
|
|
98
|
+
export function histIncrQos(svc: string, d: any): void {
|
|
99
|
+
histIncrDelta(svc, d ?? {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 分钟终值覆盖写(http tick 结算用)。 */
|
|
103
|
+
export function histSetMinute(svc: string, minute: number, b: MinuteStat): void {
|
|
104
|
+
if (!redis) return;
|
|
105
|
+
try {
|
|
106
|
+
const k = keyOf(svc);
|
|
107
|
+
const p = String(minute);
|
|
108
|
+
void Promise.all([
|
|
109
|
+
redis.hset(
|
|
110
|
+
k,
|
|
111
|
+
`${p}:c`, String(b.total),
|
|
112
|
+
`${p}:ok`, String(b.ok),
|
|
113
|
+
`${p}:f`, String(b.fail),
|
|
114
|
+
`${p}:ls`, String(Math.round(b.durMs)),
|
|
115
|
+
`${p}:lmx`, String(b.maxMs),
|
|
116
|
+
`${p}:lmn`, String(b.minMs),
|
|
117
|
+
),
|
|
118
|
+
redis.pexpire(k, TTL_MS),
|
|
119
|
+
]).catch(() => {});
|
|
120
|
+
} catch {
|
|
121
|
+
/* ignore */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface HistAgg {
|
|
126
|
+
total: number;
|
|
127
|
+
ok: number;
|
|
128
|
+
fail: number;
|
|
129
|
+
avgMs: number;
|
|
130
|
+
maxMs: number;
|
|
131
|
+
minMs: number;
|
|
132
|
+
perMinute: Array<{ t: number; count: number; fail: number; avgMs: number; maxMs: number; minMs: number; hist: number[] }>;
|
|
133
|
+
/** 直方图桶上界(ms),与 perMinute[].hist 配套 */
|
|
134
|
+
bounds: number[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 把 Redis hash(含直方图桶 b0..bN 与 bounds)解析为聚合结果 */
|
|
138
|
+
function parseHistAgg(raw: any): HistAgg | null {
|
|
139
|
+
if (!raw || !Object.keys(raw).length) return null;
|
|
140
|
+
const boundsRaw = raw["bounds"] ? safeParseBounds(raw["bounds"]) : null;
|
|
141
|
+
const bounds = Array.isArray(boundsRaw) && boundsRaw.length ? boundsRaw : DEFAULT_LAT_BOUNDS;
|
|
142
|
+
const bn = bounds.length + 1;
|
|
143
|
+
const byMinute = new Map<number, { c: number; ok: number; f: number; ls: number; lmx: number; lmn: number; hist: number[] }>();
|
|
144
|
+
for (const [field, v] of Object.entries(raw)) {
|
|
145
|
+
if (field === "bounds") continue;
|
|
146
|
+
const i = field.indexOf(":");
|
|
147
|
+
if (i <= 0) continue;
|
|
148
|
+
const m = Number(field.slice(0, i));
|
|
149
|
+
if (!Number.isFinite(m)) continue;
|
|
150
|
+
const b = byMinute.get(m) ?? { c: 0, ok: 0, f: 0, ls: 0, lmx: 0, lmn: 0, hist: new Array(bn).fill(0) };
|
|
151
|
+
const n = Number(v) || 0;
|
|
152
|
+
const sub = field.slice(i + 1);
|
|
153
|
+
if (sub === "c") b.c += n;
|
|
154
|
+
else if (sub === "ok") b.ok += n;
|
|
155
|
+
else if (sub === "f") b.f += n;
|
|
156
|
+
else if (sub === "ls") b.ls += n;
|
|
157
|
+
else if (sub === "lmx") b.lmx += n;
|
|
158
|
+
else if (sub === "lmn") b.lmn += n;
|
|
159
|
+
else if (sub[0] === "b") { const k = Number(sub.slice(1)); if (Number.isFinite(k) && k < bn) b.hist[k] += n; }
|
|
160
|
+
byMinute.set(m, b);
|
|
161
|
+
}
|
|
162
|
+
let total = 0, ok = 0, fail = 0, ls = 0, lmx = 0, lmn = 0;
|
|
163
|
+
const perMinute: Array<{ t: number; count: number; fail: number; avgMs: number; maxMs: number; minMs: number; hist: number[] }> = [];
|
|
164
|
+
for (const [m, b] of [...byMinute.entries()].sort((a, z) => a[0] - z[0])) {
|
|
165
|
+
total += b.c; ok += b.ok; fail += b.f; ls += b.ls; if (b.lmx > lmx) lmx = b.lmx; if (b.lmn && (!lmn || b.lmn < lmn)) lmn = b.lmn;
|
|
166
|
+
perMinute.push({ t: m * 60000, count: b.c, fail: b.f, avgMs: b.c ? Math.round(b.ls / b.c) : 0, maxMs: b.lmx, minMs: b.c ? b.lmn : 0, hist: b.hist });
|
|
167
|
+
}
|
|
168
|
+
return { total, ok, fail, avgMs: total ? Math.round(ls / total) : 0, maxMs: lmx, minMs: total ? lmn : 0, perMinute, bounds };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function safeParseBounds(s: any): number[] | null {
|
|
172
|
+
try { const a = JSON.parse(s); return Array.isArray(a) ? a.map(Number) : null; } catch { return null; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 读本实例历史并聚合为最近 24 小时窗口累计(无 Redis 返回 null,调用方回退内存值)。 */
|
|
176
|
+
export async function histGetAgg(svc: string): Promise<HistAgg | null> {
|
|
177
|
+
if (!redis) return null;
|
|
178
|
+
try {
|
|
179
|
+
const raw = await redis.hgetall(keyOf(svc));
|
|
180
|
+
return parseHistAgg(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 读某 cmd 的逐分钟时序(钻取用)。 */
|
|
187
|
+
export async function histGetCmd(svc: string, cmd: string): Promise<HistAgg | null> {
|
|
188
|
+
if (!redis) return null;
|
|
189
|
+
try {
|
|
190
|
+
const raw = await redis.hgetall(cmdKeyOf(svc, cmd));
|
|
191
|
+
return parseHistAgg(raw);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 内存实时桶 + Redis 历史合并(折线用):内存优先(实时权威),Redis 只补内存缺失的分钟,不双计。 */
|
|
198
|
+
export function mergeMemHist(
|
|
199
|
+
mem: Array<{ t: number; count: number }>,
|
|
200
|
+
hist: Array<{ t: number; count: number }>,
|
|
201
|
+
): Array<{ t: number; count: number }> {
|
|
202
|
+
const out = new Map<number, number>();
|
|
203
|
+
for (const b of hist) out.set(b.t, b.count);
|
|
204
|
+
for (const b of mem) out.set(b.t, b.count);
|
|
205
|
+
return [...out.entries()]
|
|
206
|
+
.map(([t, count]) => ({ t, count }))
|
|
207
|
+
.sort((a, b) => a.t - b.t);
|
|
208
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { GlobalLogger } from "@yixinshuke/node-log";
|
|
2
|
+
import type { Logger, LogCategory, LogLevel } from "@yixinshuke/node-shardorm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* shardorm → node-log 日志桥接。
|
|
6
|
+
*
|
|
7
|
+
* 把 shardorm 的 `Logger` 端口(debug/info/warn/error(category, msg, meta?))转投到 node-log,
|
|
8
|
+
* 按 docs/日志路径说明.md §2.2 的类别约定落到 `shardorm/` 之下的对应子目录:
|
|
9
|
+
*
|
|
10
|
+
* dml → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/sql/<bizKey>.log
|
|
11
|
+
* slow → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/msql/<bizKey>.log
|
|
12
|
+
* ddl / sync → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/sync/<bizKey>.log
|
|
13
|
+
* startup → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/startup/<bizKey>.log
|
|
14
|
+
* routing/connection/cache/tx/ → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/{cat}/<bizKey>.log
|
|
15
|
+
* saga/tcc/guard
|
|
16
|
+
* monitor/encrypto/system → www/logs/{yyyy}/{yyyyMM}/{yyyyMMdd}/shardorm/system/<bizKey>.log
|
|
17
|
+
*
|
|
18
|
+
* 设计要点(端口原则,shardorm 自身零开关):
|
|
19
|
+
* - shardorm 只管「产生语义化日志事件」;是否落盘/落哪/落多少由本桥接控制(见 CATEGORY_DIR / CATEGORY_ENABLED / DROP_DEBUG);
|
|
20
|
+
* - 每条日志以 `category` 末段(sql/msql/sync/startup/routing/…)作 bizKey,故同一类别当天写进同一文件;
|
|
21
|
+
* - meta(如 sql / db / shard / durationMs / error)经 `extra` 透传进 JSONL;
|
|
22
|
+
* - 观测是非关键路径:桥接本身抛错一律吞掉,**绝不阻断 shardorm 主链路**(与 node-log 铁律一致)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 消费端类别开关:置 false 的类别(key 用 LogCategory 值)桥接直接丢弃,不写盘。
|
|
26
|
+
* shardorm 仍照常 emit,开关完全在消费端(符合「依赖注入 / 端口」原则,shardorm 零开关)。
|
|
27
|
+
* 默认全开;取消注释即可临时关闭某类别(如热路径的 cache)。 */
|
|
28
|
+
const CATEGORY_ENABLED: Partial<Record<LogCategory, boolean>> = {
|
|
29
|
+
// cache: false,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** 这些类别的 debug 级日志量极大(tcc 步骤流、cache 命中明细),桥接层丢弃 debug 以控体积 */
|
|
33
|
+
const DROP_DEBUG: ReadonlySet<LogCategory> = new Set<LogCategory>(["tcc", "cache"]);
|
|
34
|
+
|
|
35
|
+
/** shardorm LogCategory → node-log 类别路径(见 docs/日志路径说明.md §2.2)。
|
|
36
|
+
* 全部落在 `shardorm/` 之下;monitor/encrypto 合并进 system 兜底。 */
|
|
37
|
+
const CATEGORY_DIR: Record<LogCategory, string> = {
|
|
38
|
+
dml: "shardorm/sql",
|
|
39
|
+
slow: "shardorm/msql",
|
|
40
|
+
ddl: "shardorm/sync",
|
|
41
|
+
sync: "shardorm/sync",
|
|
42
|
+
startup: "shardorm/startup",
|
|
43
|
+
routing: "shardorm/routing",
|
|
44
|
+
connection: "shardorm/connection",
|
|
45
|
+
cache: "shardorm/cache",
|
|
46
|
+
tx: "shardorm/tx",
|
|
47
|
+
saga: "shardorm/saga",
|
|
48
|
+
tcc: "shardorm/tcc",
|
|
49
|
+
monitor: "shardorm/system",
|
|
50
|
+
encrypto: "shardorm/system",
|
|
51
|
+
guard: "shardorm/guard",
|
|
52
|
+
system: "shardorm/system",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function dirOf(category: LogCategory): string {
|
|
56
|
+
return CATEGORY_DIR[category] ?? "shardorm/system";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 构造一个实现 shardorm `Logger` 接口的桥接器(注入 ShardingDBOptions.logger 即可) */
|
|
60
|
+
export function createShardormLogger(): Logger {
|
|
61
|
+
const logger = GlobalLogger.getInstance();
|
|
62
|
+
|
|
63
|
+
const emit = (
|
|
64
|
+
level: LogLevel,
|
|
65
|
+
category: LogCategory,
|
|
66
|
+
msg: string,
|
|
67
|
+
meta?: Record<string, unknown>,
|
|
68
|
+
): void => {
|
|
69
|
+
// 消费端开关:关闭的类别直接丢弃(shardorm 已照常 emit,仅桥接不写盘)
|
|
70
|
+
if (CATEGORY_ENABLED[category] === false) return;
|
|
71
|
+
// 控体积:tcc/cache 丢弃 debug 级(量大)
|
|
72
|
+
if (level === "debug" && DROP_DEBUG.has(category)) return;
|
|
73
|
+
try {
|
|
74
|
+
const dir = dirOf(category);
|
|
75
|
+
const fileBase = dir.split("/").pop()!; // sql / msql / shardorm
|
|
76
|
+
const fn = (
|
|
77
|
+
{
|
|
78
|
+
debug: logger.debug.bind(logger),
|
|
79
|
+
info: logger.info.bind(logger),
|
|
80
|
+
warn: logger.warn.bind(logger),
|
|
81
|
+
error: logger.error.bind(logger),
|
|
82
|
+
} as const
|
|
83
|
+
)[level];
|
|
84
|
+
fn(
|
|
85
|
+
{
|
|
86
|
+
// 走 node-log 的 category 路径模式:{年}/{月}/{日}/{category}/{bizKey}.log
|
|
87
|
+
category: dir,
|
|
88
|
+
merchantId: 0,
|
|
89
|
+
storeId: 0,
|
|
90
|
+
system: "shardorm",
|
|
91
|
+
bizKey: fileBase,
|
|
92
|
+
extra: meta,
|
|
93
|
+
},
|
|
94
|
+
msg,
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
/* 观测非关键路径:吞错,绝不阻断 shardorm 主链路 */
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
debug: (c, m, meta) => emit("debug", c, m, meta),
|
|
103
|
+
info: (c, m, meta) => emit("info", c, m, meta),
|
|
104
|
+
warn: (c, m, meta) => emit("warn", c, m, meta),
|
|
105
|
+
error: (c, m, meta) => emit("error", c, m, meta),
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/sql-trace.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 请求级「shardorm 执行记录」桶 —— 响应体 `data.sql` 的唯一数据来源。
|
|
5
|
+
*
|
|
6
|
+
* 三条写入路径,形状统一、互不重叠:
|
|
7
|
+
* 1. **成功**:service 调完 shardorm 后,把返回对象的 **SQL 元信息**放进桶(用 `traceExec` 包装一行即可)。
|
|
8
|
+
* shardorm 的既有约定:读返回 rows/row、写返回 affected,且都带 `sql:{logicalSql,statements}`;
|
|
9
|
+
* 其中 rows/row 是业务数据(响应 data 里已有一份),**不进桶**,否则响应体翻倍。
|
|
10
|
+
* 2. **失败**:SQL 执行抛错时 shardorm 不返回对象(异常里也没有 sql),故由构造项 `onError`
|
|
11
|
+
* 钩子补录一条 `ok:false` 记录(含**真实下发**的物理 SQL、物理库/表、根因)。
|
|
12
|
+
* 3. 【兜底·推荐】**shardorm 构造项 `onSql`**:每条物理 SQL 执行后即时入桶(`src:"hook"`),
|
|
13
|
+
* 覆盖全部执行(读/写/DDL),无需业务侧逐处包 traceExec —— 只因历史上有模块漏包,
|
|
14
|
+
* 那些接口的 data.sql 会莫名为空。与 1 重叠时由 pushSql 合并(保留钩子那条,见 pushSql 注释)。
|
|
15
|
+
*
|
|
16
|
+
* 出口:Fastify `preSerialization` 把桶挂到 `body.data.sql`(恒定数组;无 SQL 时 `[]`)。
|
|
17
|
+
* 红线:观测是非关键路径 —— 任何异常一律吞掉,绝不阻断业务。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 桶元素:一次 shardorm 调用的执行记录 */
|
|
21
|
+
export interface SqlExecRecord {
|
|
22
|
+
/** 本次请求内的执行序号(从 1 开始,按实际调用顺序) */
|
|
23
|
+
seq: number;
|
|
24
|
+
/** 操作类型:select / selectOne / insert / update / delete / upsert / exec(失败记录为 query/write/error) */
|
|
25
|
+
op: string;
|
|
26
|
+
/** 逻辑表名 */
|
|
27
|
+
table?: string;
|
|
28
|
+
/** service 侧测得的耗时(毫秒,含 MySQL 往返) */
|
|
29
|
+
ms?: number;
|
|
30
|
+
/** 失败记录固定 false;成功记录不带该字段 */
|
|
31
|
+
ok?: boolean;
|
|
32
|
+
/** 失败原因(ok:false 时)= shardorm 的 `err.message`:面向业务,**不含分片细节** */
|
|
33
|
+
error?: string;
|
|
34
|
+
/**
|
|
35
|
+
* 排障信息(ok:false 时)= shardorm 的 `err.shardMsg`:失败/成功分片清单 + 物理 SQL。
|
|
36
|
+
* 由 traceExec 的 catch 从抛出的 ShardormError 回填(onError 钩子按分片回调,那时还没有整体 shardMsg)。
|
|
37
|
+
*/
|
|
38
|
+
shardMsg?: string;
|
|
39
|
+
/** 其余字段 = shardorm 返回对象的 SQL 元信息(sql / affected / routes …;rows 等行数据已剔除) */
|
|
40
|
+
[k: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 单请求最多保留的记录数(超出丢弃,防止响应体被刷爆) */
|
|
44
|
+
const MAX_ITEMS = 200;
|
|
45
|
+
|
|
46
|
+
const als = new AsyncLocalStorage<SqlExecRecord[]>();
|
|
47
|
+
|
|
48
|
+
/** 请求入口调用:为当前异步上下文绑定一个全新的收集桶 */
|
|
49
|
+
export function beginSqlTrace(): SqlExecRecord[] {
|
|
50
|
+
const bucket: SqlExecRecord[] = [];
|
|
51
|
+
als.enterWith(bucket);
|
|
52
|
+
return bucket;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 读取当前请求的桶(不在请求上下文中时返回 undefined) */
|
|
56
|
+
export function currentSqlTrace(): SqlExecRecord[] | undefined {
|
|
57
|
+
return als.getStore();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 出口格式化:恒定返回数组(无执行 → `[]`) */
|
|
61
|
+
export function toSqlTrace(bucket?: SqlExecRecord[] | null): SqlExecRecord[] {
|
|
62
|
+
return bucket ?? [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 成功路径:把一次 shardorm 调用的返回对象记入桶(**剔除行数据**)。
|
|
67
|
+
*
|
|
68
|
+
* shardorm 的返回对象里,`rows`/`row`/`result` 就是查询结果本身(业务 `data` 里已经有一份),
|
|
69
|
+
* 若原样展开进桶,同一份数据会在响应里出现两次、列表接口的响应体直接翻倍。
|
|
70
|
+
* 故这里只保留 SQL 元信息(sql / affected / routes / value …),数据只存在业务侧那一份。
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* 取一条记录所代表的**物理语句指纹**集合:`物理库 | 物理表 | SQL 文本` 三元组。
|
|
74
|
+
* 必须带库与表,只看 SQL 文本会误杀——月汇总这类「按年分库不分表」的表,
|
|
75
|
+
* 2025/2026/2027 三个库执行的是**同一条 SQL 文本**(表名不带年后缀),只有 db 不同。
|
|
76
|
+
* 两种记录形状都要认:
|
|
77
|
+
* - onSql 钩子记录:`sql` 是物理 SQL 字符串,库/表在 `db` / `shard`;
|
|
78
|
+
* - 执行结果记录:`sql` 是 `{ logicalSql, statements:[{db, physicalTable, sql}] }`。
|
|
79
|
+
*/
|
|
80
|
+
function sqlKeysOf(rec: Record<string, unknown>): string[] {
|
|
81
|
+
const r = rec ?? {};
|
|
82
|
+
const s = r.sql;
|
|
83
|
+
if (typeof s === "string") return [`${r.db ?? ""}|${r.shard ?? ""}|${s}`];
|
|
84
|
+
const st = (s as { statements?: unknown } | undefined)?.statements;
|
|
85
|
+
if (Array.isArray(st)) {
|
|
86
|
+
return st.map((x) => {
|
|
87
|
+
const o = (x ?? {}) as { db?: unknown; physicalTable?: unknown; sql?: unknown };
|
|
88
|
+
return `${o.db ?? ""}|${o.physicalTable ?? ""}|${o.sql ?? ""}`;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function pushSql(res: unknown, meta: { op: string; table?: string; ms?: number }): void {
|
|
95
|
+
try {
|
|
96
|
+
const bucket = als.getStore();
|
|
97
|
+
if (!bucket || bucket.length >= MAX_ITEMS) return;
|
|
98
|
+
// 三种「数据本体」形状都要摘,否则各自重复一份:
|
|
99
|
+
// rows → 列表查询 select(SelectResult.rows)
|
|
100
|
+
// row → 单条查询 first/selectOne(SelectOneResult.row)
|
|
101
|
+
// result → 原生 exec(ExecResult.result,可能是整页数据)
|
|
102
|
+
// 保留:sql(logicalSql + 各分片 statements)/ routes / affected / value(聚合标量)等元信息
|
|
103
|
+
const { rows: _rows, row: _row, result: _result, ...rest } = (res ?? {}) as Record<string, unknown>;
|
|
104
|
+
// 【去重】挂了 shardorm `onSql` 钩子的服务:钩子在每条物理 SQL 执行后即时入桶(src:"hook"),
|
|
105
|
+
// 业务侧 traceExec 随后还会推一条同语句的执行结果记录 —— 同一条语句会出现两条。
|
|
106
|
+
// 这里命中「同一条物理语句的钩子记录」就**只做合并**不再新增:把业务侧更精确的 op(insert/update…)
|
|
107
|
+
// 与逻辑表名补过去,耗时保留钩子那条(它是 SQL 真实往返耗时)。
|
|
108
|
+
// 两个护栏:① 钩子记录本身(src:"hook")永不参与去重,否则跨分片执行的同文本语句会被互相吞掉;
|
|
109
|
+
// ② 指纹含物理库 + 物理表 + SQL 文本,跨年库的同文本语句互不干扰。
|
|
110
|
+
// 未挂钩子的服务桶里没有 hook 记录,本分支永不命中,行为与既往完全一致。
|
|
111
|
+
const keys = sqlKeysOf(rest);
|
|
112
|
+
if (keys.length > 0 && (rest as { src?: unknown }).src !== "hook") {
|
|
113
|
+
const hit = bucket.find(
|
|
114
|
+
(r) =>
|
|
115
|
+
(r as { src?: unknown } | undefined)?.src === "hook" &&
|
|
116
|
+
sqlKeysOf(r as Record<string, unknown>).some((k) => keys.includes(k)),
|
|
117
|
+
) as SqlExecRecord | undefined;
|
|
118
|
+
if (hit) {
|
|
119
|
+
if (hit.op === "query" || hit.op === "write") hit.op = meta.op;
|
|
120
|
+
if (hit.table === undefined && meta.table !== undefined) hit.table = meta.table;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
bucket.push({ seq: bucket.length + 1, op: meta.op, table: meta.table, ms: meta.ms, ...rest });
|
|
125
|
+
} catch {
|
|
126
|
+
/* 观测非关键路径:吞错 */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 包装一次 shardorm 调用(service 层统一入口):
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* const res = await traceExec(q.insert(), { op: "insert", table: "b_商品" });
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* 成功 → 返回对象记入桶并原样返回;抛错 → 原错照抛(SQL 由 onError 钩子补录、shardMsg 由 catch 回填),
|
|
138
|
+
* 业务语义不变。
|
|
139
|
+
*/
|
|
140
|
+
export async function traceExec<P extends Promise<any>>(
|
|
141
|
+
exec: P,
|
|
142
|
+
meta: { op: string; table?: string },
|
|
143
|
+
): Promise<Awaited<P>> {
|
|
144
|
+
const t0 = Date.now();
|
|
145
|
+
// 本次调用开始前的桶长度:失败时只在 [before, end) 区间回填,绝不误伤上一次调用的记录
|
|
146
|
+
const before = als.getStore()?.length ?? 0;
|
|
147
|
+
try {
|
|
148
|
+
const res = await exec;
|
|
149
|
+
pushSql(res, { op: meta.op, table: meta.table, ms: Date.now() - t0 });
|
|
150
|
+
return res as Awaited<P>;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
attachShardMsg(err, before);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 失败路径(traceExec 的 catch):把抛出的 ShardormError 自带的 `shardMsg` 补进本次调用的失败记录。
|
|
159
|
+
*
|
|
160
|
+
* 为什么必须在这里补:shardorm 的 `onError` 钩子是**按失败分片**回调的,那一刻只有分片级原始错误
|
|
161
|
+
* (如 `Duplicate entry ...`),还没有「失败分片清单 + 成功分片清单 + 物理 SQL」这个整体 shardMsg
|
|
162
|
+
* —— 它是在失败分片聚合完成后随异常抛出的。所以只读异常,不依赖钩子。
|
|
163
|
+
*
|
|
164
|
+
* @param before 本次调用开始前的桶长度,只在该区间内回填
|
|
165
|
+
*/
|
|
166
|
+
function attachShardMsg(err: unknown, before: number): void {
|
|
167
|
+
try {
|
|
168
|
+
const bucket = als.getStore();
|
|
169
|
+
if (!bucket) return;
|
|
170
|
+
const msg = (err as { shardMsg?: unknown } | null)?.shardMsg;
|
|
171
|
+
if (typeof msg !== "string" || !msg) return;
|
|
172
|
+
// 优先回填本次调用新产生的失败记录(不新增条目,避免同一错误出现两条)
|
|
173
|
+
for (let i = bucket.length - 1; i >= before; i--) {
|
|
174
|
+
const rec = bucket[i];
|
|
175
|
+
if (rec?.ok === false) {
|
|
176
|
+
if (rec.shardMsg === undefined) rec.shardMsg = msg;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 本次调用没有产出失败语句(如护栏类错误不触发 onError)→ 补一条最小记录,保证 shardMsg 不丢
|
|
181
|
+
if (bucket.length >= MAX_ITEMS) return;
|
|
182
|
+
bucket.push({
|
|
183
|
+
seq: bucket.length + 1,
|
|
184
|
+
op: "error",
|
|
185
|
+
ok: false,
|
|
186
|
+
error: (err as Error)?.message,
|
|
187
|
+
shardMsg: msg,
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
/* 观测非关键路径:吞错 */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 链式 builder 的「执行型」方法:调用即打库(中间方法只改条件、返回 this) */
|
|
195
|
+
const TERMINAL_METHODS = new Set(["select", "selectOne", "first", "agg", "insert", "update", "delete", "upsert", "exec"]);
|
|
196
|
+
/** 不执行的纯计算方法(离线生成 SQL),直接透传 */
|
|
197
|
+
const PASSTHROUGH_METHODS = new Set(["toSql"]);
|
|
198
|
+
|
|
199
|
+
/** 包一个链式 builder:终结方法执行后把返回对象记入桶;中间方法保持代理链 */
|
|
200
|
+
function tracedBuilder(builder: any, table: string): any {
|
|
201
|
+
return new Proxy(builder, {
|
|
202
|
+
get(t: any, prop: string | symbol, recv) {
|
|
203
|
+
const v = Reflect.get(t, prop, recv);
|
|
204
|
+
if (typeof v !== "function") return v;
|
|
205
|
+
if (typeof prop !== "string" || PASSTHROUGH_METHODS.has(prop)) return v.bind(t);
|
|
206
|
+
if (TERMINAL_METHODS.has(prop)) {
|
|
207
|
+
// first 归一为 selectOne,与 service 里手写的 op 命名保持一致
|
|
208
|
+
const op = prop === "first" ? "selectOne" : prop;
|
|
209
|
+
return (...args: any[]) => traceExec(v.apply(t, args), { op, table });
|
|
210
|
+
}
|
|
211
|
+
return (...args: any[]) => {
|
|
212
|
+
const out = v.apply(t, args);
|
|
213
|
+
if (out === t) return recv; // 链式方法返回 this → 复用当前代理
|
|
214
|
+
return out !== null && typeof out === "object" ? tracedBuilder(out, table) : out;
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 给 shardorm 门面套一层代理:该入口下**所有**链式执行(读/写/exec,含 `tx()` 回调内、
|
|
222
|
+
* 以及复用同一 db 对象的内部函数)执行成功后自动把返回对象记入本次请求的桶。
|
|
223
|
+
*
|
|
224
|
+
* 用法(service 层一处即可):
|
|
225
|
+
* ```ts
|
|
226
|
+
* const { base, order } = dbReady(); // 内部即 tracedDb(getBaseDb())
|
|
227
|
+
* await order.tx("sys_审批单", shard, async (db) => { await db.from(...).insert(); }); // 回调内同样被记录
|
|
228
|
+
* ```
|
|
229
|
+
*
|
|
230
|
+
* 是非关键路径:仅在一次调用**成功返回**后补记录;抛错不拦截(由 onError 钩子补录失败语句)。
|
|
231
|
+
*/
|
|
232
|
+
export function tracedDb<T extends object>(db: T): T {
|
|
233
|
+
return new Proxy(db, {
|
|
234
|
+
get(t: any, prop: string | symbol, recv) {
|
|
235
|
+
const v = Reflect.get(t, prop, recv);
|
|
236
|
+
if (typeof v !== "function") return v;
|
|
237
|
+
if (typeof prop !== "string") return v.bind(t);
|
|
238
|
+
if (prop === "from") return (name: string) => tracedBuilder(v.call(t, name), String(name));
|
|
239
|
+
if (prop === "tx") {
|
|
240
|
+
// tx(table, shard, fn):把回调收到的 db 也包一层,保证事务内语句同样进桶
|
|
241
|
+
return (...args: any[]) => {
|
|
242
|
+
const i = args.findIndex((a: unknown) => typeof a === "function");
|
|
243
|
+
if (i >= 0) {
|
|
244
|
+
const fn = args[i] as (inner: any, ...rest: any[]) => unknown;
|
|
245
|
+
args[i] = (inner: any, ...rest: any[]) => fn(tracedDb(inner), ...rest);
|
|
246
|
+
}
|
|
247
|
+
return v.apply(t, args);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (TERMINAL_METHODS.has(prop)) {
|
|
251
|
+
// 门面级终结方法(如 db.exec(sql, params))
|
|
252
|
+
return (...args: any[]) => traceExec(v.apply(t, args), { op: prop });
|
|
253
|
+
}
|
|
254
|
+
return v.bind(t);
|
|
255
|
+
},
|
|
256
|
+
}) as T;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 失败路径(shardorm 构造项 `onError` 的钩子实现):补录一条失败记录。
|
|
261
|
+
*
|
|
262
|
+
* 事件里的 `sql` 是**真实下发**的物理 SQL(已改写为物理表名),并带物理库/表与失败根因;
|
|
263
|
+
* 不是离线预生成结果,故外键第二段、对齐式深分页等「执行期组装」的语句也不会缺。
|
|
264
|
+
*/
|
|
265
|
+
export function pushFailedSql(info: unknown): void {
|
|
266
|
+
try {
|
|
267
|
+
const bucket = als.getStore();
|
|
268
|
+
if (!bucket || bucket.length >= MAX_ITEMS) return;
|
|
269
|
+
const e = info as Record<string, any>;
|
|
270
|
+
if (!e?.sql) return; // 无 SQL 的错误事件(如结果行数护栏)不产出语句记录
|
|
271
|
+
const context = e?.context === undefined || e?.context === null ? "" : String(e.context);
|
|
272
|
+
const sql = String(e.sql);
|
|
273
|
+
// 事件里的 error 是「分片级原始错误」,正常不带 shardMsg(整体 shardMsg 在抛出的异常上,
|
|
274
|
+
// 由 traceExec 的 catch 回填);这里只做防御性透传,shardorm 若给事件补上就原样带出。
|
|
275
|
+
const evMsg = (e?.error as { shardMsg?: unknown } | undefined)?.shardMsg;
|
|
276
|
+
bucket.push({
|
|
277
|
+
seq: bucket.length + 1,
|
|
278
|
+
op: context.includes("write") ? "write" : context.includes("query") ? "query" : "error",
|
|
279
|
+
table: e?.shard,
|
|
280
|
+
ok: false,
|
|
281
|
+
error: e?.error?.message ?? (e?.error === undefined || e?.error === null ? undefined : String(e.error)),
|
|
282
|
+
...(typeof evMsg === "string" && evMsg ? { shardMsg: evMsg } : {}),
|
|
283
|
+
db: e?.db,
|
|
284
|
+
context: context || undefined,
|
|
285
|
+
// 失败路径没有逻辑 SQL 预览,logicalSql 置空以保持与成功记录同构
|
|
286
|
+
sql: { logicalSql: "", statements: [{ db: e?.db, physicalTable: e?.shard, sql }] },
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
/* 观测非关键路径:吞错 */
|
|
290
|
+
}
|
|
291
|
+
}
|
package/README.md
DELETED