@mzzsfy/dsh-usage-dash 0.2.0 → 0.4.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/src/collector.js CHANGED
@@ -5,12 +5,36 @@
5
5
  // 游标(liveFirstSeq 分区 + backfilledSessions)保证。采集是观测性的:一切
6
6
  // store 失败计入 recordFailures,绝不成为逃逸拒绝。
7
7
 
8
+ import { createArchiveReader } from './archive-reader.js'
9
+
8
10
  const UNKNOWN_SESSION_ID = '(unknown-session)'
9
11
  const SEQ_UNKNOWN = -1
10
12
  const DEFAULT_BACKFILL_CONCURRENCY = 4
11
13
  const MARK_BATCH = 32
12
14
  const SCAN_LOG_MAX_ENTRIES = 200
13
15
 
16
+ // 不可读会话分类:宿主可见性/可读性边界的三代实测形态(见 README 存档兼容)
17
+ const SKIP_KINDS = { descriptor: 'descriptor', corrupt: 'corrupt', legacy: 'legacy', other: 'other' }
18
+ const SKIP_PATTERNS = [
19
+ // 宿主新校验拒读历史档(0.1.5 descriptor v2),宿主修复后自动补扫
20
+ [SKIP_KINDS.descriptor, /unsupported descriptor version/i],
21
+ // 存档损坏(seq gap 等 fail-closed 拒绝),宿主 repair 后自动补扫
22
+ [SKIP_KINDS.corrupt, /corrupt|seq gap|SessionFormatError/i],
23
+ // 超旧格式词汇(v0 时代字段),宿主迁移链覆盖后自动补扫
24
+ [SKIP_KINDS.legacy, /format v0|unexpected member|does not provide|not a function/i],
25
+ ]
26
+
27
+ function classifySkip(detail) {
28
+ for (const [kind, pattern] of SKIP_PATTERNS) {
29
+ if (pattern.test(detail)) return kind
30
+ }
31
+ return SKIP_KINDS.other
32
+ }
33
+
34
+ function emptySkipCounts() {
35
+ return { [SKIP_KINDS.descriptor]: 0, [SKIP_KINDS.corrupt]: 0, [SKIP_KINDS.legacy]: 0, [SKIP_KINDS.other]: 0 }
36
+ }
37
+
14
38
  // 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
15
39
  export class UsageFold {
16
40
  constructor() {
@@ -95,8 +119,9 @@ function sessionIdOf(target) {
95
119
  return typeof id === 'string' && id !== '' ? id : UNKNOWN_SESSION_ID
96
120
  }
97
121
 
98
- // 规范 provider/model 引用:双全拼引用,仅 model 用裸名
99
- function refOf(route) {
122
+ // 规范 provider/model 引用:双全拼引用,仅 model 用裸名;
123
+ // 与 client 侧注入点B turnModelOf 双实现同源(routes[].model 是裸模型名,展示侧才拼 provider),改一侧必须同步 parity
124
+ export function refOf(route) {
100
125
  if (!route) return undefined
101
126
  if (route.provider && route.model) return `${route.provider}/${route.model}`
102
127
  return route.model || undefined
@@ -126,6 +151,10 @@ export class UsageCollector {
126
151
  lastSessionId: undefined,
127
152
  error: undefined,
128
153
  log: [],
154
+ // 计数独立累加,不受日志截断影响;skipped 细分供面板归因
155
+ skippedTotal: 0,
156
+ recordTotal: 0,
157
+ skipCounts: emptySkipCounts(),
129
158
  }
130
159
  // error 保留给采集器自身故障;单会话读取失败走日志 skipped 条目,计数由日志派生
131
160
  }
@@ -141,17 +170,24 @@ export class UsageCollector {
141
170
  scannedSessions: this.#state.scannedSessions,
142
171
  lastSessionId: this.#state.lastSessionId,
143
172
  error: this.#state.error,
144
- skippedSessions: log.filter((entry) => entry.kind === 'skipped').length,
145
- recordFailures: log.filter((entry) => entry.kind === 'record').length,
173
+ skippedSessions: this.#state.skippedTotal,
174
+ recordFailures: this.#state.recordTotal,
175
+ skippedBreakdown: { ...this.#state.skipCounts },
146
176
  log,
147
177
  }
148
178
  }
149
179
 
150
- // 扫描异常日志:供面板明细展示,超上限丢最旧
180
+ // 扫描异常日志:供面板明细展示,超上限丢最旧;计数独立累加不随截断漂移
151
181
  pushLog(kind, detail) {
152
182
  const log = this.#state.log
153
183
  log.push({ time: Date.now(), kind, detail })
154
184
  if (log.length > SCAN_LOG_MAX_ENTRIES) log.splice(0, log.length - SCAN_LOG_MAX_ENTRIES)
185
+ if (kind === 'skipped') {
186
+ this.#state.skippedTotal += 1
187
+ this.#state.skipCounts[classifySkip(detail)] += 1
188
+ } else if (kind === 'record') {
189
+ this.#state.recordTotal += 1
190
+ }
155
191
  }
156
192
 
157
193
  get running() {
@@ -239,7 +275,9 @@ export class UsageCollector {
239
275
  const controller = new AbortController()
240
276
  this.scanController = controller
241
277
  await this.store.readyPromise()
242
- await this.backfill(this.ctx.sessionPersistence, this.ctx.sessions, controller.signal)
278
+ // 适配发生在宿主服务边界:回扫主体只面向 ArchiveReader 内部契约
279
+ const reader = createArchiveReader(this.ctx.sessionPersistence)
280
+ await this.backfill(reader, this.ctx.sessions, controller.signal)
243
281
  }
244
282
 
245
283
  abort() {
@@ -271,22 +309,30 @@ export class UsageCollector {
271
309
  return this.resetInFlight
272
310
  }
273
311
 
274
- // 回扫:persistence.list 驱动,逐会话全新 fold 重放;seen 独自决定是否重扫,
275
- // liveFirstSeq 边界划走实时已拥区间,liveness 复查防陈旧快照放大重放范围,
276
- // inheritedCut 跳过 fork 继承前缀。只有干净重放完的会话进游标,失败下轮重试
277
- async backfill(persistence, sessions, signal) {
312
+ // 回扫:ArchiveReader 契约驱动(宿主 persistence 多版本适配见 archive-reader),
313
+ // 逐会话全新 fold 重放;seen 独自决定是否重扫,liveFirstSeq 边界划走实时
314
+ // 已拥区间,liveness 复查防陈旧快照放大重放范围,inheritedCut 跳过 fork
315
+ // 继承前缀。只有干净重放完的会话进游标,失败下轮重试
316
+ async backfill(reader, sessions, signal) {
278
317
  if (this.#state.running) return
279
318
  if (signal?.aborted) return
280
319
  this.#state.running = true
281
320
  this.#state.error = undefined
282
321
  try {
283
- const headers = await persistence.list(signal)
322
+ const headers = await reader.list(signal).catch((error) => {
323
+ // 枚举失败(含宿主 API 未识别)按采集器自身故障呈现,面板可见
324
+ this.#state.error = error instanceof Error ? error.message : String(error)
325
+ throw error
326
+ })
284
327
  const seen = await this.store.seenSessions()
285
328
  const liveSeq = await this.store.liveSequences()
286
329
  const targets = headers.filter((header) => !seen.has(header.id))
287
330
  this.#state.total = targets.length
288
331
  this.#state.done = 0
289
332
  this.#state.log = []
333
+ this.#state.skippedTotal = 0
334
+ this.#state.recordTotal = 0
335
+ this.#state.skipCounts = emptySkipCounts()
290
336
  const workerCount = Math.min(DEFAULT_BACKFILL_CONCURRENCY, Math.max(1, targets.length))
291
337
  let next = 0
292
338
  const completed = []
@@ -321,7 +367,7 @@ export class UsageCollector {
321
367
  const fold = new UsageFold()
322
368
  let route = ''
323
369
  try {
324
- const inspection = await persistence.inspect(header.id, signal)
370
+ const inspection = await reader.readLog(header.id, signal)
325
371
  const inheritedCut = inspection.inheritedEventCount
326
372
  for (const event of inspection.events) {
327
373
  if (signal?.aborted) return
@@ -0,0 +1,126 @@
1
+ // 会话档案文件直读器:宿主 persistence 服务 fail-closed 拒读(descriptor 校验、
2
+ // 格式校验、seq gap 等)时,对磁盘档案做降级直读。统计只消费 usage 词汇
3
+ // (assistant/message 的 usage 与 request/context 路由),无需完整会话语义,
4
+ // 宽松解析即可恢复;corrupt(seq gap)与 legacy(未知成员)对统计无影响。
5
+ //
6
+ // 物理格式(与宿主 jsonl 后端同构):首行 header JSON + 事件行 JSONL 文本,
7
+ // 按 zstd 帧独立压缩顺序拼接;帧以 magic 28 b5 2f fd 起始。压缩由 Node 内置
8
+ // node:zlib 提供(Node >= 24),不依赖宿主模块,不受宿主升级影响。
9
+
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import { zstdDecompressSync } from 'node:zlib'
14
+
15
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
16
+
17
+ // 档案根目录:DSH_HOME 可重定向(多版本共存测试的隔离约定),默认 ~/.dsh
18
+ function sessionsRoot() {
19
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
20
+ return join(home, 'sessions')
21
+ }
22
+
23
+ // 按帧边界切分 zstd 流:帧首 magic 定位,各帧解压范围到下一 magic 或流尾
24
+ function frameRanges(bytes) {
25
+ const ranges = []
26
+ let at = bytes.indexOf(ZSTD_MAGIC)
27
+ while (at >= 0) {
28
+ if (ranges.length > 0) ranges[ranges.length - 1].end = at
29
+ ranges.push({ start: at, end: bytes.length })
30
+ at = bytes.indexOf(ZSTD_MAGIC, at + ZSTD_MAGIC.length)
31
+ }
32
+ return ranges
33
+ }
34
+
35
+ // 多帧解压;任何一帧失败即整体失败(宁缺勿错,调用方按原拒读路径上报)
36
+ export function decodeZstdFile(bytes) {
37
+ const ranges = frameRanges(bytes)
38
+ if (ranges.length === 0) throw new Error('no zstd frame found')
39
+ let text = ''
40
+ for (const range of ranges) {
41
+ text += zstdDecompressSync(bytes.subarray(range.start, range.end)).toString('utf8')
42
+ }
43
+ return text
44
+ }
45
+
46
+ // JSONL 文本 → 宽松事件流:首行为档案 header,其余行逐行解析,坏行跳过;
47
+ // 只要求行能解出 JSON 对象,词汇/代际差异由上层折叠消化
48
+ export function parseJsonlEvents(text) {
49
+ const events = []
50
+ for (const line of text.split('\n')) {
51
+ const trimmed = line.trim()
52
+ if (trimmed === '') continue
53
+ try {
54
+ const row = JSON.parse(trimmed)
55
+ if (row && typeof row === 'object' && row.type !== undefined && row.type !== 'session/header') {
56
+ events.push(row)
57
+ }
58
+ } catch {}
59
+ }
60
+ return events
61
+ }
62
+
63
+ // 会话目录定位:sessions/<cwd 编码桶>/<id>/;桶名是宿主对 cwd 的编码,不解析,
64
+ // 逐桶探测 id 子目录
65
+ export function findSessionDir(id, root = sessionsRoot()) {
66
+ const sessions = root
67
+ if (!existsSync(sessions)) return undefined
68
+ for (const bucket of readdirSync(sessions)) {
69
+ const candidate = join(sessions, bucket, id)
70
+ if (existsSync(candidate) && statDirSync(candidate)) return candidate
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ function statDirSync(path) {
76
+ try {
77
+ return readdirSync(path).length >= 0
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ // 会话目录内选最新代档案:V3 专用名优先,退化为任意 jsonl(压缩/明文)
84
+ function pickLogFile(dir) {
85
+ const names = readdirSync(dir)
86
+ const v3 = names.find((name) => name.startsWith('session.v3.') && name.endsWith('.zstd'))
87
+ if (v3) return join(dir, v3)
88
+ const compressed = names.find((name) => name.endsWith('.jsonl.zstd'))
89
+ if (compressed) return join(dir, compressed)
90
+ const plain = names.find((name) => name.endsWith('.jsonl'))
91
+ if (plain) return join(dir, plain)
92
+ return undefined
93
+ }
94
+
95
+ // 直读单会话事件流;文件缺失/无法解压抛错,由调用方决定降级链终止
96
+ export function readSessionLogDirect(id, root) {
97
+ const dir = findSessionDir(id, root)
98
+ if (dir === undefined) throw new Error(`session artifact directory not found: ${id}`)
99
+ const file = pickLogFile(dir)
100
+ if (file === undefined) throw new Error(`session artifact file not found: ${id}`)
101
+ const bytes = readFileSync(file)
102
+ const text = file.endsWith('.zstd') ? decodeZstdFile(bytes) : bytes.toString('utf8')
103
+ return parseJsonlEvents(text)
104
+ }
105
+
106
+ // 磁盘全量会话 id:sessions/<cwd 编码桶>/<id>/;旧代宿主的 list 不枚举新代
107
+ // 文件名的档案,直读侧补齐可见集
108
+ export function listSessionIdsDirect(root = sessionsRoot()) {
109
+ if (!existsSync(root)) return []
110
+ const ids = []
111
+ for (const bucket of readdirSync(root)) {
112
+ const bucketPath = join(root, bucket)
113
+ let entries
114
+ try {
115
+ entries = readdirSync(bucketPath)
116
+ } catch {
117
+ continue
118
+ }
119
+ for (const id of entries) {
120
+ if (existsSync(join(bucketPath, id, 'session.jsonl.zstd')) || existsSync(join(bucketPath, id, 'session.v3.jsonl.zstd'))) {
121
+ ids.push(id)
122
+ }
123
+ }
124
+ }
125
+ return ids
126
+ }
package/src/pricing.js CHANGED
@@ -30,15 +30,14 @@ const toMinutesOfDay = (hhmm) => {
30
30
  return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
31
31
  }
32
32
 
33
- // from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
33
+ // 所有范围条件统一双侧包含;from>to 跨午夜/跨月环绕;from===to 单点/单日
34
34
  const dailyWindowMatches = (condition, date) => {
35
35
  const from = toMinutesOfDay(condition.from)
36
36
  const to = toMinutesOfDay(condition.to)
37
37
  if (Number.isNaN(from) || Number.isNaN(to)) return false
38
38
  const m = minutesOfDay(date)
39
- if (from < to) return m >= from && m < to
40
- if (from > to) return m >= from || m < to
41
- return true
39
+ if (from <= to) return m >= from && m <= to
40
+ return m >= from || m <= to
42
41
  }
43
42
 
44
43
  // days 空数组不成立;0=周日,取 getDay()
@@ -47,7 +46,7 @@ const weekdaysMatches = (condition, date) => {
47
46
  return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
48
47
  }
49
48
 
50
- // 号段双闭;from>to 跨月环绕(如 26~25 账单周期);日号必须整数,2 月无 31 号自然不触发
49
+ // 号段双侧包含;from>to 跨月环绕(账单周期);from===to 单日,2 月无 31 号自然不触发
51
50
  const monthDaysMatches = (condition, date) => {
52
51
  const { from, to } = condition
53
52
  if (!Number.isInteger(from) || !Number.isInteger(to)) return false
@@ -55,7 +54,7 @@ const monthDaysMatches = (condition, date) => {
55
54
  return from <= to ? d >= from && d <= to : d >= from || d <= to
56
55
  }
57
56
 
58
- // 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
57
+ // 零填充 YYYY-MM-DD 字典序双侧包含;from>to 配置错误不成立(编辑器校验拦截)
59
58
  const dateRangeMatches = (condition, date) => {
60
59
  const { from, to } = condition
61
60
  if (typeof from !== 'string' || typeof to !== 'string') return false
package/src/query.js CHANGED
@@ -105,6 +105,8 @@ export function aggregateRange(rows, g, from, to) {
105
105
  const modelTotals = new Map()
106
106
  const providerTotals = new Map()
107
107
  const activeBuckets = new Set()
108
+ // 槽级速度配对:桶串 → {outputTokens, durationMs},与模型级同口径(仅带时长行)
109
+ const slotSpeeds = new Map()
108
110
  for (const row of rows) {
109
111
  const slot = slotByKey.get(row.bucket)
110
112
  // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
@@ -115,6 +117,12 @@ export function aggregateRange(rows, g, from, to) {
115
117
  activeBuckets.add(row.bucket)
116
118
  slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
117
119
  slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
120
+ if (row.durationMs) {
121
+ const pair = slotSpeeds.get(row.bucket) ?? { outputTokens: 0, durationMs: 0 }
122
+ pair.outputTokens += row.outputTokens
123
+ pair.durationMs += row.durationMs
124
+ slotSpeeds.set(row.bucket, pair)
125
+ }
118
126
  const modelTotal = modelTotals.get(row.model)
119
127
  if (modelTotal) {
120
128
  modelTotal.tokens += tokens
@@ -130,6 +138,11 @@ export function aggregateRange(rows, g, from, to) {
130
138
  }
131
139
  providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
132
140
  }
141
+ // 槽级 speed 条件挂:无时长数据的槽不挂字段(存量槽形契约不变)
142
+ for (const slot of slots) {
143
+ const pair = slotSpeeds.get(slot.day)
144
+ if (pair && pair.durationMs > 0) slot.speed = pair.outputTokens / (pair.durationMs / MS_PER_SECOND)
145
+ }
133
146
  const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
134
147
  for (const slot of slots) {
135
148
  totals.tokens += slot.total
package/src/routes.js CHANGED
@@ -274,7 +274,10 @@ const pricingHandler = (deps) => async (req, res) => {
274
274
  }
275
275
 
276
276
  const statusHandler = (deps) => async (req, res) => {
277
- writeJson(res, { ok: true, value: deps.collector.status() })
277
+ writeJson(res, {
278
+ ok: true,
279
+ value: { ...deps.collector.status(), backup: deps.store.backupInfo() },
280
+ })
278
281
  }
279
282
 
280
283
  // 409 只拒 boot 期扫描;reset 自身引发的重扫同样 running 为真,经 rebuilding 放行
@@ -286,6 +289,15 @@ const resetHandler = (deps) => async (req, res) => {
286
289
  writeJson(res, { ok: true, value: deps.collector.status() })
287
290
  }
288
291
 
292
+ // 回退到最近一次重建(或恢复)前的快照;扫描进行中同样拒绝,避免写交错
293
+ const restoreHandler = (deps) => async (req, res) => {
294
+ if (deps.collector.running) {
295
+ throw usageError(HTTP_STATUS_CONFLICT, MESSAGE_BACKFILL_RUNNING)
296
+ }
297
+ const result = await deps.store.restoreFromBackup()
298
+ writeJson(res, { ok: true, value: result })
299
+ }
300
+
289
301
  const STANDARD_GUARDS = [rejectWrongMethod, rejectCrossOrigin, rejectNonJson]
290
302
  const PRICING_GUARDS = [rejectPricingMethod, rejectCrossOrigin]
291
303
 
@@ -295,6 +307,7 @@ const ENDPOINTS = [
295
307
  { path: `${ROUTE_PREFIX}/minutes`, mount: minuteHandler },
296
308
  { path: `${ROUTE_PREFIX}/status`, mount: statusHandler },
297
309
  { path: `${ROUTE_PREFIX}/reset`, mount: resetHandler },
310
+ { path: `${ROUTE_PREFIX}/restore`, mount: restoreHandler },
298
311
  { path: `${ROUTE_PREFIX}/pricing`, mount: pricingHandler, guards: PRICING_GUARDS },
299
312
  ]
300
313
 
package/src/store.js CHANGED
@@ -104,6 +104,24 @@ export const usageStatsDomain = defineDomain({
104
104
  },
105
105
  })
106
106
 
107
+ // 重建回退备份域:与主域同构表,global 多记 takenAt(快照时刻)。
108
+ // 独立域而非主域内备份表——reset 清主域数据,同域备份会陪葬
109
+ export const usageBackupDomain = defineDomain({
110
+ name: `${DOMAIN_NAME}_backup`,
111
+ version: 1,
112
+ tables: {
113
+ [TABLE_BUCKETS]: domainTable(usageRowSchema),
114
+ },
115
+ global: {
116
+ schema: z.object({
117
+ backfilledSessions: z.array(z.string()),
118
+ liveFirstSeq: z.record(z.string(), z.number()).optional(),
119
+ takenAt: z.number().optional(),
120
+ }),
121
+ initial: { backfilledSessions: [], liveFirstSeq: {}, takenAt: undefined },
122
+ },
123
+ })
124
+
107
125
  export function rowKey(g, bucket, provider, model) {
108
126
  return `${g}|${bucket}|${provider}|${model}`
109
127
  }
@@ -171,6 +189,8 @@ export class UsageStore {
171
189
  this.onFlushError = options.onFlushError
172
190
  this.table = null
173
191
  this.domain = null
192
+ this.backupDomain = undefined
193
+ this.backupTable = undefined
174
194
  this.openError = undefined
175
195
  this.lastPruneDay = ''
176
196
  this.markChain = Promise.resolve()
@@ -191,6 +211,9 @@ export class UsageStore {
191
211
  if ((cursor?.backfilledSessions?.length ?? 0) === 0 && !this.table.keys().next().done) {
192
212
  for (const key of [...this.table.keys()]) await this.table.delete(key)
193
213
  }
214
+ // 备份域打开失败仅禁用回退能力,不拖垮主采集(openError 只覆盖主域)
215
+ this.backupDomain = await facility.open(usageBackupDomain).catch(() => undefined)
216
+ this.backupTable = this.backupDomain?.table(TABLE_BUCKETS)
194
217
  }
195
218
 
196
219
  get degradation() {
@@ -375,6 +398,9 @@ export class UsageStore {
375
398
  reset(boundaries) {
376
399
  return this.enqueueGlobalWrite(async () => {
377
400
  await this.flushBatch()
401
+ // 清空前快照到备份域:重建丢数据(规则缺陷/中断/口径变化)可整体回退;
402
+ // 备份不可用视为 reset 失败——回退安全带是重建的前置条件
403
+ await this.snapshotToBackup()
378
404
  const table = this.requireTable()
379
405
  for (const key of [...table.keys()]) await table.delete(key)
380
406
  const liveFirstSeq = {}
@@ -383,11 +409,53 @@ export class UsageStore {
383
409
  })
384
410
  }
385
411
 
386
- // global 只有整值覆写:全部游标写挂同一链,读改写不再交错
412
+ // 当前全量(表行 + 游标 + 时刻)写入备份域,覆盖旧备份:恢复点恒为最近一次清空/恢复前;
413
+ // 备份域不可用时跳过(重建不被卡死,仅失去回退点,backupInfo 透明展示)
414
+ async snapshotToBackup() {
415
+ if (this.backupTable === undefined) return
416
+ const cursor = this.readCursor() ?? {}
417
+ for (const key of [...this.backupTable.keys()]) await this.backupTable.delete(key)
418
+ for (const [key, row] of this.requireTable().entries()) await this.backupTable.put(key, row)
419
+ await this.backupDomain.global.set({
420
+ backfilledSessions: cursor.backfilledSessions ?? [],
421
+ liveFirstSeq: cursor.liveFirstSeq ?? {},
422
+ takenAt: this.now(),
423
+ })
424
+ }
425
+
426
+ // 备份写回主域;当前态先入备份(恢复动作自身可回退),故必须先取备份内容
427
+ // 再覆盖备份域;返回快照时刻与回写行数
428
+ async restoreFromBackup() {
429
+ if (this.backupTable === undefined) throw new Error('usage backup domain unavailable')
430
+ return this.enqueueGlobalWrite(async () => {
431
+ await this.flushBatch()
432
+ const savedRows = [...this.backupTable.entries()]
433
+ const saved = this.backupDomain.global.get()
434
+ await this.snapshotToBackup()
435
+ for (const key of [...this.requireTable().keys()]) await this.table.delete(key)
436
+ for (const [key, row] of savedRows) await this.table.put(key, row)
437
+ await this.writeGlobal(saved?.backfilledSessions ?? [], saved?.liveFirstSeq ?? {})
438
+ return { takenAt: saved?.takenAt, rows: savedRows.length }
439
+ })
440
+ }
441
+
442
+ // 备份元信息:available=false 表示无回退点(从未重建/备份域不可用)
443
+ backupInfo() {
444
+ if (this.backupTable === undefined) return { available: false }
445
+ const saved = this.backupDomain.global.get()
446
+ return {
447
+ available: typeof saved?.takenAt === 'number',
448
+ takenAt: saved?.takenAt,
449
+ rows: [...this.backupTable.keys()].length,
450
+ sessions: saved?.backfilledSessions?.length ?? 0,
451
+ }
452
+ }
453
+
454
+ // global 只有整值覆写:全部游标写挂同一链,读改写不再交错;write 返回值透传
387
455
  enqueueGlobalWrite(write) {
388
456
  const pending = this.markChain.then(async () => {
389
457
  await this.ready
390
- await write()
458
+ return write()
391
459
  })
392
460
  this.markChain = pending.then(() => {}, () => {})
393
461
  return pending