dsh-ds-balance 2.2.0 → 2.3.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 CHANGED
@@ -132,6 +132,9 @@ dsh plugin --profile web add "$PWD"
132
132
 
133
133
  与 npm 那条路一样,重启后生效。
134
134
 
135
+ ⚠️ 上面那步 `npm install` 不能省:`link:` 装法下依赖由仓库自己提供(见 [AGENTS.md](AGENTS.md)),
136
+ 漏装则插件显示「未运行」(`ERR_MODULE_NOT_FOUND`)。
137
+
135
138
  ### 发现与安装
136
139
 
137
140
  - **npm**:[`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
package/README_en.md CHANGED
@@ -134,6 +134,10 @@ dsh plugin --profile web add "$PWD"
134
134
 
135
135
  Same as the npm route: it takes effect after a restart.
136
136
 
137
+ ⚠️ The `npm install` above is not optional: under the `link:` form the repository supplies its
138
+ own dependencies (see [AGENTS.md](AGENTS.md)) — skip it and the plugin shows as "not running"
139
+ (`ERR_MODULE_NOT_FOUND`).
140
+
137
141
  ### Discovery
138
142
 
139
143
  - **npm**: [`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
@@ -7,7 +7,7 @@
7
7
  * - 打开失败要**降级**而不是抛出去 —— 未观察的 rejection 曾把宿主整个拖下水。
8
8
  * @module dsh-ds-balance/adapters/domain-core-store
9
9
  */
10
- import type { BalanceSnapshot, BalanceSource } from '../domain/balance.js';
10
+ import { type BalanceSnapshot, type BalanceSource } from '../domain/balance.js';
11
11
  import type { CoreStore, StoreHealth } from '../ports/core-store.js';
12
12
  import type { Logger } from '../ports/logger.js';
13
13
  /** 域里的表名。 */
@@ -41,8 +41,11 @@ export declare const DS_BALANCE_DOMAIN: {
41
41
  /** 表句柄的最小结构化面;测试用替身实现它。 */
42
42
  export interface KvTableLike {
43
43
  get(key: string): StoredSnapshot | undefined;
44
+ /** 一次迭代同时拿到 `[key, record]`;比 `keys()` +逐键 `get()` 少一轮查找。 */
45
+ entries(): IterableIterator<[string, StoredSnapshot]>;
44
46
  keys(): IterableIterator<string>;
45
47
  put(key: string, value: StoredSnapshot): Promise<void>;
48
+ delete(key: string): Promise<boolean>;
46
49
  }
47
50
  /** 域句柄的最小结构化面。 */
48
51
  export interface DomainLike {
@@ -95,8 +98,49 @@ export declare class DomainCoreStore implements CoreStore {
95
98
  *
96
99
  * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
97
100
  * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
101
+ *
102
+ * **走 `entries()` 而不是 `keys()` + 逐键 `get()`**:后者是每条记录两次查找,
103
+ * 记录数涨上去(保留策略落地前实测过千条)时白费一大半;`entries()` 一次迭代给全。
98
104
  */
99
105
  loadLatestSnapshot(accountTag: string): Promise<BalanceSnapshot | null>;
106
+ /**
107
+ * 只保留该账本最近 `keepN` 条,删掉更旧的(跨账本不动)。
108
+ *
109
+ * **批间让出事件循环的理由是摊平写放大,不是防限流**:宿主 json 后端默认 `single`
110
+ * 布局,每次 `delete` 都要原子重写整份单元文件。删几十条若挤在同一个 tick 上,
111
+ * 会连带把同一进程里的取数与轮询一起拖住。所以内部**分批 + 每批之后让出一次** ——
112
+ * 批次大小与间隔属于实现细节,不上端口(领域层只调一次,不需要知道分了几批)。
113
+ *
114
+ * **单次最多删 {@link PRUNE_MAX_PER_CALL} 条**:超出部分留给后续启动或写入路径,
115
+ * 保证任何一次调用的耗时都有确定上界(理由见那个常量)。
116
+ *
117
+ * **零写入的幂等**:没超期时一条都不删、返回 0(超期判定先做完再动手)。
118
+ *
119
+ * @param accountTag - 要修剪的账本。
120
+ * @param keepN - 保留条数(新到旧)。
121
+ * @returns 本次**实际**删掉的条数(可能小于超期总数,因为有单次上限)。
122
+ */
123
+ pruneByTag(accountTag: string, keepN: number): Promise<number>;
124
+ /**
125
+ * 修剪一个账本的内部实现(不重复 `requireTable` 的开表开销)。
126
+ */
127
+ private pruneOwned;
128
+ /**
129
+ * 启动时的一次性清理:把**所有**账本桶各修剪一次。
130
+ *
131
+ * 为什么扫全表而不是只清「当前那个账本」:启动这一刻还不知道会用哪个 `accountTag`
132
+ * (要等 reader 给),而首次升级的迁移诉求是「把所有攒下来的桶都收一遍」——
133
+ * 只清一个桶的话,凭据轮换出来的旧桶永远清不掉,文件就一直大。
134
+ *
135
+ * **失败绝不影响 open**:清理抛错只记一条 warn。这里抛出去会让
136
+ * `ensureOpen` 判成打开失败,整个存储被打成降级 —— 那比慢更糟。
137
+ * 删到一半也是安全的:清理幂等,下次启动继续。
138
+ */
139
+ private pruneAllLedgersOnOpen;
140
+ /** 取出属于该账本的全部记录。 */
141
+ private collectOwned;
142
+ /** 分批删除,每批之后让出一次事件循环。 */
143
+ private deleteInBatches;
100
144
  health(): Promise<StoreHealth>;
101
145
  /** 幂等关闭。**必须挂在 `ctx.effect` 的 disposer 上。** */
102
146
  close(): Promise<void>;
@@ -9,10 +9,35 @@
9
9
  */
10
10
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
11
11
  import { z } from 'zod';
12
+ import { SNAPSHOT_KEEP_N, } from '../domain/balance.js';
12
13
  import { StorageError, describeError } from '../domain/errors.js';
13
14
  import { formatMoney, parseMoney } from '../domain/money.js';
14
15
  /** 域里的表名。 */
15
16
  export const SNAPSHOT_TABLE = 'snapshots';
17
+ /**
18
+ * 修剪时每批删多少条,之后让出一次事件循环。
19
+ *
20
+ * **实现细节,不上端口**:领域层只调一次 `pruneByTag`,不需要知道分了几批。
21
+ * 取值只是「别让一批太大」的粗界,真实瓶颈是单布局下的整份重写次数,
22
+ * 所以调大调小不影响正确性,只影响同进程别的活被挤住多久。
23
+ */
24
+ const PRUNE_BATCH_SIZE = 40;
25
+ /**
26
+ * 单次修剪最多删多少条,**之后剩下的交给后续启动或写入路径**。
27
+ *
28
+ * **为什么需要上界**:`single` 布局下每次 `delete` 都要原子重写整份单元文件,
29
+ * 而「删掉一批」的开销随文件体积线性涨。首次升级时存量可能很大(本机实测 9000 条 /
30
+ * 3.8 MB),若一次性清空就是数千次整份重写,**会把宿主连同 Agent 一起卡死**
31
+ * —— 实测现象是侧栏无限转圈、Agent 停止响应,清空数据后立刻恢复。
32
+ *
33
+ * 这个数是**实现约束**不是领域策略:领域层只说「留几条」,「一次最多删多少」
34
+ * 属于「别把自己卡死」的工程判断,所以不上端口(与批次大小同理)。
35
+ *
36
+ * 取 500 的依据:配合 {@link PRUNE_BATCH_SIZE} 是 12.5 批,批次边界清晰;
37
+ * 收敛靠**多次启动叠加**而不是一次删完 —— 清到 20 条这个终态是幂等的,
38
+ * 每次启动删一批,未删完的下次继续。
39
+ */
40
+ const PRUNE_MAX_PER_CALL = 500;
16
41
  const storedBalanceSchema = z.object({
17
42
  currency: z.string(),
18
43
  total: z.string(),
@@ -127,6 +152,10 @@ export class DomainCoreStore {
127
152
  this.handle = handle;
128
153
  this.table = handle.table(SNAPSHOT_TABLE);
129
154
  this.openError = undefined;
155
+ // 首次升级的一次性清理:必须在**任何读取之前**做,否则首次
156
+ // loadLatestSnapshot 仍要面对存量的大文件。等下一次抓取再清太晚 ——
157
+ // 那正是「保留策略只在稳态有效、首次升级要等两小时」那个盲区。
158
+ await this.pruneAllLedgersOnOpen(SNAPSHOT_KEEP_N);
130
159
  }
131
160
  /** 拿到可用表,或在降级状态下抛出可读错误。 */
132
161
  async requireTable() {
@@ -149,19 +178,128 @@ export class DomainCoreStore {
149
178
  *
150
179
  * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
151
180
  * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
181
+ *
182
+ * **走 `entries()` 而不是 `keys()` + 逐键 `get()`**:后者是每条记录两次查找,
183
+ * 记录数涨上去(保留策略落地前实测过千条)时白费一大半;`entries()` 一次迭代给全。
152
184
  */
153
185
  async loadLatestSnapshot(accountTag) {
154
186
  const table = await this.requireTable();
155
187
  let newest = null;
156
- for (const key of table.keys()) {
157
- const record = table.get(key);
158
- if (record === undefined || record.accountTag !== accountTag)
188
+ for (const [, record] of table.entries()) {
189
+ if (record.accountTag !== accountTag)
159
190
  continue;
160
191
  if (newest === null || record.snapshotId > newest.snapshotId)
161
192
  newest = record;
162
193
  }
163
194
  return newest === null ? null : fromStored(newest);
164
195
  }
196
+ /**
197
+ * 只保留该账本最近 `keepN` 条,删掉更旧的(跨账本不动)。
198
+ *
199
+ * **批间让出事件循环的理由是摊平写放大,不是防限流**:宿主 json 后端默认 `single`
200
+ * 布局,每次 `delete` 都要原子重写整份单元文件。删几十条若挤在同一个 tick 上,
201
+ * 会连带把同一进程里的取数与轮询一起拖住。所以内部**分批 + 每批之后让出一次** ——
202
+ * 批次大小与间隔属于实现细节,不上端口(领域层只调一次,不需要知道分了几批)。
203
+ *
204
+ * **单次最多删 {@link PRUNE_MAX_PER_CALL} 条**:超出部分留给后续启动或写入路径,
205
+ * 保证任何一次调用的耗时都有确定上界(理由见那个常量)。
206
+ *
207
+ * **零写入的幂等**:没超期时一条都不删、返回 0(超期判定先做完再动手)。
208
+ *
209
+ * @param accountTag - 要修剪的账本。
210
+ * @param keepN - 保留条数(新到旧)。
211
+ * @returns 本次**实际**删掉的条数(可能小于超期总数,因为有单次上限)。
212
+ */
213
+ async pruneByTag(accountTag, keepN) {
214
+ const table = await this.requireTable();
215
+ return this.pruneOwned(table, accountTag, keepN);
216
+ }
217
+ /**
218
+ * 修剪一个账本的内部实现(不重复 `requireTable` 的开表开销)。
219
+ */
220
+ async pruneOwned(table, accountTag, keepN) {
221
+ if (keepN < 0)
222
+ return 0;
223
+ const owned = this.collectOwned(table, accountTag);
224
+ if (owned.length <= keepN)
225
+ return 0;
226
+ // 先按 snapshotId 排好序再决定删哪些:留下的必然是「最新的 keepN 条」,
227
+ // 而刚写入的那条 id 单调最大,所以永远在保留集里。
228
+ const ordered = owned
229
+ .slice()
230
+ .sort((a, b) => (a.snapshotId < b.snapshotId ? -1 : a.snapshotId > b.snapshotId ? 1 : 0));
231
+ const doomed = ordered.slice(0, ordered.length - keepN).slice(0, PRUNE_MAX_PER_CALL);
232
+ await this.deleteInBatches(table, doomed);
233
+ return doomed.length;
234
+ }
235
+ /**
236
+ * 启动时的一次性清理:把**所有**账本桶各修剪一次。
237
+ *
238
+ * 为什么扫全表而不是只清「当前那个账本」:启动这一刻还不知道会用哪个 `accountTag`
239
+ * (要等 reader 给),而首次升级的迁移诉求是「把所有攒下来的桶都收一遍」——
240
+ * 只清一个桶的话,凭据轮换出来的旧桶永远清不掉,文件就一直大。
241
+ *
242
+ * **失败绝不影响 open**:清理抛错只记一条 warn。这里抛出去会让
243
+ * `ensureOpen` 判成打开失败,整个存储被打成降级 —— 那比慢更糟。
244
+ * 删到一半也是安全的:清理幂等,下次启动继续。
245
+ */
246
+ async pruneAllLedgersOnOpen(keepN) {
247
+ const table = this.table;
248
+ if (table === null)
249
+ return;
250
+ try {
251
+ const buckets = new Map();
252
+ for (const [, record] of table.entries()) {
253
+ const bucket = buckets.get(record.accountTag);
254
+ if (bucket === undefined)
255
+ buckets.set(record.accountTag, [record]);
256
+ else
257
+ bucket.push(record);
258
+ }
259
+ let removed = 0;
260
+ let remaining = 0;
261
+ for (const accountTag of buckets.keys()) {
262
+ removed += await this.pruneOwned(table, accountTag, keepN);
263
+ }
264
+ for (const bucket of buckets.values())
265
+ remaining += Math.min(bucket.length, keepN);
266
+ if (removed > 0) {
267
+ // 进度日志:删了多少、之后大约还剩多少(每次启动还会再删一批,直到收敛)。
268
+ // 没有它,使用者只会看到「数据怎么还在变多」,无从判断清理是否在推进。
269
+ this.options.logger?.debug('ds-balance: pruned stored snapshots on open', {
270
+ removed,
271
+ remainingApprox: remaining,
272
+ buckets: buckets.size,
273
+ keepN,
274
+ });
275
+ }
276
+ }
277
+ catch (error) {
278
+ // 刻意只记一条 warn:清理是尽力而为,不该把存储打成降级。
279
+ this.options.logger?.warn('ds-balance: startup snapshot prune failed, continuing', {
280
+ error: describeError(error),
281
+ });
282
+ }
283
+ }
284
+ /** 取出属于该账本的全部记录。 */
285
+ collectOwned(table, accountTag) {
286
+ const owned = [];
287
+ for (const [, record] of table.entries()) {
288
+ if (record.accountTag === accountTag)
289
+ owned.push(record);
290
+ }
291
+ return owned;
292
+ }
293
+ /** 分批删除,每批之后让出一次事件循环。 */
294
+ async deleteInBatches(table, doomed) {
295
+ for (let offset = 0; offset < doomed.length; offset += PRUNE_BATCH_SIZE) {
296
+ const batch = doomed.slice(offset, offset + PRUNE_BATCH_SIZE);
297
+ for (const record of batch)
298
+ await table.delete(record.snapshotId);
299
+ // 让出一次事件循环:把同 tick 的连续原子重写摊开,别挤住同进程的别的活。
300
+ await new Promise((resolve) => setImmediate(resolve));
301
+ }
302
+ }
165
303
  async health() {
166
304
  if (this.closed)
167
305
  return { ok: false, detail: 'closed' };
@@ -46,6 +46,26 @@ export interface BalanceSnapshot {
46
46
  /** 原始响应,审计用。 */
47
47
  raw: unknown;
48
48
  }
49
+ /**
50
+ * 每个账本保留多少条快照。**历史保留深度,不是功能开关。**
51
+ *
52
+ * **为什么住在 domain**:写入路径(`SourceLedger.persist`)与启动清理
53
+ * (`DomainCoreStore.initialize`)都要读它,而 adapters 与 services 互相不能依赖 ——
54
+ * `domain/` 是两层共同的下游,是唯一能放这个常量的地方。
55
+ * (最初它住在 `balance-source.ts` 顶部,理由是「紧挨唯一调用点」;启动清理加入后
56
+ * 调用点变成两个,那条理由就不再成立。)
57
+ *
58
+ * - 当前**没有任何功能读第二条以后的记录**:唯一读点是 `loadLatestSnapshot` 取最新一条,
59
+ * 仓内没有 `listSnapshots`,也没有历史端点(`docs/ARCHITECTURE.md` 的阶段边界
60
+ * 明写不做图表)。所以这个数**不是**为功能服务的,不要因为「用不到」就调到 1。
61
+ * - 它真正的作用是**故障回退余量**:最新那条写失败、写坏、或落盘途中崩了时还能退到上一条,
62
+ * 覆盖连续两次失败就要 ≥ 3。
63
+ * - **刻意不进配置面**:配置字段用户看得见、改得动,而这个数没有用户可感知的语义 ——
64
+ * 改小它用户看不出界面变化,只会在某天丢快照时才后悔。给一个看不出区别的旋钮比不给更糟。
65
+ *
66
+ * 量级:一条快照约 0.9 KB,20 条约 18 KB —— 余量与存储成本之间没有取舍压力。
67
+ */
68
+ export declare const SNAPSHOT_KEEP_N = 20;
49
69
  /** 一个币种的预警 / 告急阈值。 */
50
70
  export interface ThresholdPair {
51
71
  warn: Units;
@@ -4,5 +4,24 @@
4
4
  * 这里定义的是**契约形状**:金额一律 `bigint` 最小单位,序列化时才变字符串。
5
5
  * @module dsh-ds-balance/domain/balance
6
6
  */
7
- export {};
7
+ /**
8
+ * 每个账本保留多少条快照。**历史保留深度,不是功能开关。**
9
+ *
10
+ * **为什么住在 domain**:写入路径(`SourceLedger.persist`)与启动清理
11
+ * (`DomainCoreStore.initialize`)都要读它,而 adapters 与 services 互相不能依赖 ——
12
+ * `domain/` 是两层共同的下游,是唯一能放这个常量的地方。
13
+ * (最初它住在 `balance-source.ts` 顶部,理由是「紧挨唯一调用点」;启动清理加入后
14
+ * 调用点变成两个,那条理由就不再成立。)
15
+ *
16
+ * - 当前**没有任何功能读第二条以后的记录**:唯一读点是 `loadLatestSnapshot` 取最新一条,
17
+ * 仓内没有 `listSnapshots`,也没有历史端点(`docs/ARCHITECTURE.md` 的阶段边界
18
+ * 明写不做图表)。所以这个数**不是**为功能服务的,不要因为「用不到」就调到 1。
19
+ * - 它真正的作用是**故障回退余量**:最新那条写失败、写坏、或落盘途中崩了时还能退到上一条,
20
+ * 覆盖连续两次失败就要 ≥ 3。
21
+ * - **刻意不进配置面**:配置字段用户看得见、改得动,而这个数没有用户可感知的语义 ——
22
+ * 改小它用户看不出界面变化,只会在某天丢快照时才后悔。给一个看不出区别的旋钮比不给更糟。
23
+ *
24
+ * 量级:一条快照约 0.9 KB,20 条约 18 KB —— 余量与存储成本之间没有取舍压力。
25
+ */
26
+ export const SNAPSHOT_KEEP_N = 20;
8
27
  //# sourceMappingURL=balance.js.map
package/lib/index.js CHANGED
@@ -313,7 +313,7 @@ export async function apply(ctx, refs) {
313
313
  'deepseek-account',
314
314
  new SourceLedger({
315
315
  source: 'deepseek-account',
316
- reader: accountReader({ account: () => account.current(), salt }),
316
+ reader: accountReader({ account: () => account.current(), salt, logger }),
317
317
  store,
318
318
  config: configService,
319
319
  clock: systemClock,
@@ -20,6 +20,21 @@ export interface CoreStore {
20
20
  * @returns 最近一条快照,或 `null` 表示该账本还没有记录。
21
21
  */
22
22
  loadLatestSnapshot(accountTag: string): Promise<BalanceSnapshot | null>;
23
+ /**
24
+ * 只保留该账本最近 `keepN` 条快照,删掉更旧的。
25
+ *
26
+ * 与 {@link loadLatestSnapshot} 同构:两个都是 **tag 作用域**的操作 ——
27
+ * 「按账本分桶、只动自己那个桶」这条语义由本端口统一承担,调用方不必知道记录怎么组织。
28
+ *
29
+ * **`keepN` 由调用方(领域层)传入**:端口只执行「留几条」,不决定该留几条 ——
30
+ * 那是领域层的策略。端口也不暴露实现步骤(分几批删、批间隔多久、怎么排序)。
31
+ *
32
+ * **只动 `accountTag` 这一个桶**:别的账本一条都不许碰。
33
+ * @param accountTag - 要修剪的账本。
34
+ * @param keepN - 该账本要保留的条数(新到旧)。
35
+ * @returns 实际删掉的条数;`0` 表示本来就没超期(**必须是零写入的幂等操作**)。
36
+ */
37
+ pruneByTag(accountTag: string, keepN: number): Promise<number>;
23
38
  /** 端口自检。 */
24
39
  health(): Promise<StoreHealth>;
25
40
  /** 释放存储句柄。**必须由调用方在 `ctx.effect` 的 disposer 里调用。** */
@@ -23,7 +23,13 @@ import type { ConfigService } from './config-service.js';
23
23
  export interface SourceReader {
24
24
  /** 这条路的凭据此刻可用吗。 */
25
25
  available(): Promise<boolean>;
26
- /** 这次快照的账本标识;凭据拿不到时抛错(由调用方吸收)。 */
26
+ /**
27
+ * 这次快照的账本标识。
28
+ *
29
+ * **凭据 / 身份拿不到时抛错,由调用方吸收** —— 不能退化成某个占位标识:
30
+ * 账本键一旦落成别的值,那条快照就再也读不回来(`restore()` 会静默跳过,
31
+ * 下一轮拿到真标识后自愈)。
32
+ */
27
33
  tag(): Promise<string>;
28
34
  /** 抓一次余额,连账本标识一起给。 */
29
35
  read(): Promise<{
@@ -93,6 +99,8 @@ export declare class SourceLedger {
93
99
  private inflight;
94
100
  /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
95
101
  private persistWarned;
102
+ /** 修剪失败只报一次:与落盘分开记,两者是不同的降级事实。 */
103
+ private retentionWarned;
96
104
  constructor(options: SourceLedgerOptions);
97
105
  /** 这条路的 id。 */
98
106
  source(): BalanceSource;
@@ -145,9 +153,14 @@ export declare class SourceLedger {
145
153
  /** 跑一次真实抓取。**所有异常都在这里被吸收。** */
146
154
  private fetchOnce;
147
155
  /**
148
- * 把快照落盘。
156
+ * 把快照落盘,并按账本执行保留策略。
157
+ *
158
+ * **两件事分别记账**:落盘失败与修剪失败**不能并进同一个 catch** ——
159
+ * 写已经成功时若把它报成「snapshot not persisted」,日志就在说谎,
160
+ * 而排查存储问题的人正是按这条日志判断写没写成功的。
149
161
  *
150
- * **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
162
+ * 落盘失败只记一次 warn(每轮刷新都会重来,不限流就刷屏);
163
+ * 修剪失败同样只记一次 —— 它让文件继续变大,是需要被看见的降级,但不该淹没主流程。
151
164
  * @param snapshot - 刚归一化出来的快照。
152
165
  */
153
166
  private persist;
@@ -8,6 +8,7 @@
8
8
  * @module dsh-ds-balance/services/balance-source
9
9
  */
10
10
  import { classify, describeError, parseRetryAfter, } from '../domain/errors.js';
11
+ import { SNAPSHOT_KEEP_N } from '../domain/balance.js';
11
12
  import { normalize } from '../domain/normalize.js';
12
13
  import { pickBalance } from '../domain/select.js';
13
14
  import { severityOf, thresholdsFor } from '../domain/severity.js';
@@ -28,6 +29,8 @@ export class SourceLedger {
28
29
  inflight = null;
29
30
  /** 落盘失败只报一次,避免每轮刷新都刷屏。 */
30
31
  persistWarned = false;
32
+ /** 修剪失败只报一次:与落盘分开记,两者是不同的降级事实。 */
33
+ retentionWarned = false;
31
34
  constructor(options) {
32
35
  this.options = options;
33
36
  this.metrics = options.metrics ?? noopMetrics;
@@ -85,7 +88,11 @@ export class SourceLedger {
85
88
  this.publishGauge();
86
89
  }
87
90
  catch (error) {
88
- this.options.logger?.debug('ds-balance: no snapshot restored', {
91
+ // **warn 而非 debug**:这条账本没能从存储恢复,是一条降级事实 ——
92
+ // 记在 debug 级别等于藏起来,使用者只会看到界面空着而无从判断原因。
93
+ // 仍**不设 state / errorCode**:恢复失败后前端自己会 getView() 走正常抓取,
94
+ // 在这里塞错误态会让「本来就没有快照」与「存储坏了」混成同一个处境。
95
+ this.options.logger?.warn('ds-balance: could not restore snapshot for this ledger', {
89
96
  source: this.options.source,
90
97
  error: describeError(error),
91
98
  });
@@ -183,9 +190,14 @@ export class SourceLedger {
183
190
  }
184
191
  }
185
192
  /**
186
- * 把快照落盘。
193
+ * 把快照落盘,并按账本执行保留策略。
187
194
  *
188
- * **失败只记一次 warn,不往上抛**:存储层降级不该让整个余额功能不可用。
195
+ * **两件事分别记账**:落盘失败与修剪失败**不能并进同一个 catch** ——
196
+ * 写已经成功时若把它报成「snapshot not persisted」,日志就在说谎,
197
+ * 而排查存储问题的人正是按这条日志判断写没写成功的。
198
+ *
199
+ * 落盘失败只记一次 warn(每轮刷新都会重来,不限流就刷屏);
200
+ * 修剪失败同样只记一次 —— 它让文件继续变大,是需要被看见的降级,但不该淹没主流程。
189
201
  * @param snapshot - 刚归一化出来的快照。
190
202
  */
191
203
  async persist(snapshot) {
@@ -193,10 +205,24 @@ export class SourceLedger {
193
205
  await this.options.store.saveSnapshot(snapshot);
194
206
  }
195
207
  catch (error) {
196
- if (this.persistWarned)
208
+ if (!this.persistWarned) {
209
+ this.persistWarned = true;
210
+ this.options.logger?.warn('ds-balance: snapshot not persisted, continuing in memory', {
211
+ source: this.options.source,
212
+ error: describeError(error),
213
+ });
214
+ }
215
+ // 落盘都没成功,谈不上修剪:这一轮不删任何东西。
216
+ return;
217
+ }
218
+ try {
219
+ await this.options.store.pruneByTag(snapshot.accountTag, SNAPSHOT_KEEP_N);
220
+ }
221
+ catch (error) {
222
+ if (this.retentionWarned)
197
223
  return;
198
- this.persistWarned = true;
199
- this.options.logger?.warn('ds-balance: snapshot not persisted, continuing in memory', {
224
+ this.retentionWarned = true;
225
+ this.options.logger?.warn('ds-balance: snapshot retention failed, ledger will keep growing', {
200
226
  source: this.options.source,
201
227
  error: describeError(error),
202
228
  });
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { AccountSource } from '../ports/account.js';
9
9
  import type { DeepSeekClient } from '../ports/deepseek-client.js';
10
+ import type { Logger } from '../ports/logger.js';
10
11
  import type { SourceReader } from './balance-source.js';
11
12
  import type { ConfigService } from './config-service.js';
12
13
  import type { KeyResolver } from './key-resolver.js';
@@ -22,5 +23,6 @@ export declare function accountReader(options: {
22
23
  /** 取当前端口实现;服务可能晚到,所以这里必须是函数而不是实例。 */
23
24
  account: () => AccountSource | undefined;
24
25
  salt: string;
26
+ logger?: Logger;
25
27
  }): SourceReader;
26
28
  //# sourceMappingURL=source-readers.d.ts.map
@@ -40,7 +40,7 @@ export function keyReader(options) {
40
40
  }
41
41
  /** 账号那条路:登录态 → 钱包查询 → 投影成官方余额端点同一个形状。 */
42
42
  export function accountReader(options) {
43
- const { account, salt } = options;
43
+ const { account, salt, logger } = options;
44
44
  const require = () => {
45
45
  const current = account();
46
46
  if (current === undefined)
@@ -51,10 +51,20 @@ export function accountReader(options) {
51
51
  * 账号账本的派生输入。
52
52
  *
53
53
  * 用 `account:` 前缀把两个键空间分开:账号 id 与明文密钥因此不可能撞到同一个账本。
54
- * 宿主本进程还没读过 profile 时 id 拿不到,退回 `unknown` —— 代价是那种情况下换账号不换账本,
55
- * 由下一轮抓取修正(见决策记录的「已知代价」)。
54
+ *
55
+ * **拿不到账号 id 就抛错,不落盘**(兑现 `SourceReader.tag` 的契约):
56
+ * 宿主本进程还没读过 profile 时 id 会给不出值,而账本键一旦落成别的值,
57
+ * 那条快照就再也读不回来 —— 「重启后按同一个键读回自己那份快照」这条承诺会在那一刻落空。
58
+ * 抛错比换一个占位键诚实:`restore()` 会静默跳过,下一轮拿到真 id 后自然自愈。
56
59
  */
57
- const tagOf = async () => computeAccountTag(salt, `account:${(await require().accountId()) ?? 'unknown'}`);
60
+ const tagOf = async () => {
61
+ const id = await require().accountId();
62
+ if (id === null) {
63
+ logger?.debug('ds-balance: account identity unavailable, skipping this snapshot');
64
+ throw new NoKeyError('no credential: account identity is unavailable');
65
+ }
66
+ return computeAccountTag(salt, `account:${id}`);
67
+ };
58
68
  return {
59
69
  async available() {
60
70
  const current = account();
package/lib/version.d.ts CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export declare const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export declare const PLUGIN_VERSION = "2.2.0";
11
+ export declare const PLUGIN_VERSION = "2.3.0";
12
12
  //# sourceMappingURL=version.d.ts.map
package/lib/version.js CHANGED
@@ -8,5 +8,5 @@
8
8
  /** 线上 schema 版本。响应里逐条回传,消费方据此判断兼容性。 */
9
9
  export const SCHEMA_VERSION = 1;
10
10
  /** 插件版本。与 `package.json` 的 `version` 一致(有测试兜底)。 */
11
- export const PLUGIN_VERSION = '2.2.0';
11
+ export const PLUGIN_VERSION = '2.3.0';
12
12
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ds-balance",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "icon": "./icon.svg",
5
5
  "description": "DSH plugin: shows the DeepSeek account balance at the bottom of the sidebar, with a configuration card on the Plugins page.",
6
6
  "keywords": [