dsh-usage-badge 0.1.7 → 0.1.8

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
@@ -85,7 +85,7 @@ dsh plugin --profile web remove dsh-usage-badge
85
85
  - **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
86
86
  - **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
87
87
  - **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
88
- - **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后**三条自动触发**都会工作:**打开 DSH 后 8 秒自动同步一次**、**关闭 DSH 前自动同步一次**(最多等 3.5 秒,不会拖着不退出)、**改完价格表几秒后自动推送**;前两条在页签里各有一个开关。服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(WebDAV + Digest)—— 设计」一节。
88
+ - **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后**三条自动触发**都会工作:**打开 DSH 后 8 秒自动同步一次**、**关闭 DSH 前自动同步一次**(最多等 3.5 秒,不会拖着不退出)、**改完价格表几秒后自动推送**;前两条在页签里各有一个开关。服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。**同步来的用量立刻计入**:拉取结束后徽标、日区间、热力图在同一次运行里就更新,不用重启 DSH(「按年」本来就是直查库)。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(WebDAV + Digest)—— 设计」一节。
89
89
  - 每台机器有自己的**设备名**,它只决定「这台机器的新数据写进哪个分区」,**不是**会话身份;远端所有设备的历史都会合并,所以**改设备名不会让用量累增**。但设备名在服务器上必须**唯一**:两台机器用同一个名字会互相覆盖分片,插件会拒绝发布并在面板里说明。
90
90
  - **删除不会跨设备传播**:本机删掉日志后,新机器不会再拉到那段历史,但**已经拉过它的机器**库里那份仍然在(远端从不因为本机的改动而删东西)。
91
91
  - **会话是按日志目录识别的**:把某个会话目录改名,同一份用量会被当成两个会话、各算一份(实测 ¥1.5 → ¥3.0)。删日志、日志变短、还原旧备份这三种情况都不会丢历史。**这不包括「设备」字段** —— 那只是这台机器往哪个分区写,改设备名不会让用量累增。
@@ -624,9 +624,10 @@ storages/usage-badge/sync.json # 同步配置与状态
624
624
 
625
625
  - **来源维度**:`days` / `sessions` 增加一列来源(本设备 / 某个远端设备)。本地 fold 只能写本设备的行;远端导入的行**只读**,永不被本地 fold 波及 —— 否则「B 下载了 A 的历史,B 本地没有那些日志」会让 B 的下一轮 fold 把它们删光。
626
626
  - **导入远端行**时:若已存在同一 `(session, date)` 且那份的 `(maxSeq, fileSize)` 更大,则跳过(**旧机器不能覆盖新数据**);否则写入。
627
- - **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。
627
+ - **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。实现是 `runSync()` 里 `engine.run()` 返回之后的 `aggregator.loadCache({ quiet: true })` 加 `snapshot = null`,条件是引擎自己报的 **`storeChanged`**(真的是库里写进或删掉了行才为真,取值来自 `intakeRows()`/`dropSourceMonth()` 的返回值 —— 重放一份库里已经全有的分片写 0 行,就不该为此付一次全量重载,实测 16k 行的库一次约 0.9 秒)。宿主还要 `!system.syncAbort`:正在关闭的宿主不能去读一个马上要 close 的库(宽松条件实测会打印 `cache load failed: statement has been finalized`),而**因为别的原因被中止**(未来的「取消同步」)仍会重载,所以 `aborted` 分支也带上 `storeChanged`。**撤回(墓碑删行)走的是同一段**,否则「别人撤回了,你这里还在多算」会一直算到重启为止。`test/host-sync.verify.mjs` 的两个方向、**两个后端**都钉着。
628
+ - **两个后端对「这个会话空了」必须给出同一种形状**:SQLite 的墓碑是 `DELETE FROM days`,`sessions` 行还在,于是 `load()` 返回一条**零行记录**;JSON 后端原先在最后一行被删时把整个 session 条目 `delete` 掉,`load()` 于是**少返回一条**。重载是 merge 语义(不能改成替换:只读库的 `put` 返回 null,内存里那次折叠是唯一的一份),它分不出「空了」和「从来没有过」——所以在 JSON 后端上墓碑撤回一直算到重启。现在 JSON 也保留空条目(`days: []`)。这是**只有两个后端都跑**才看得见的差异:契约套件(`cache.verify.mjs`)两个后端都跑却放过了它,因为它在「宿主重载」这一层,所以 `host-sync.verify.mjs` 也改成两个后端各跑一遍。
628
629
  - **「变短」要说出来**:折叠时若发现某会话的天数或总量变小,记一行日志、并在面板标一次「这段历史已脱离日志,缓存里仍保留」,让使用者能分辨「格式升级」和「真丢了东西」。
629
- - 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:614-623](../lib/index.js#L614-L623))。
630
+ - 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:1088-1099](../lib/index.js#L1088-L1099) —— 这条链接此前指向 `syncSystem()`,顺手改正)。
630
631
 
631
632
  ### 远端布局
632
633
 
@@ -787,9 +788,9 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
787
788
  | `test/webdav.verify.mjs` ✅ **已实现(60 项)** | 纯函数:RFC 2617 向量(`Mufasa` 例子逐字段)、无 `qop` 的 RFC 2069 写法、`stale=true` 重试、UTF-8 与带逗号的 realm、`uri` 必须是请求行里的原始路径(含 `%XX`);以及对着假服务端的端到端:七个方法、`MOVE` 的 `Overwrite: F` 保护、404 语义、错误密码 / 一直挑战 / `SHA-256` / `Basic`-only 的四种失败各自报对原因 |
788
789
  | `test/webdav-server.mjs` ✅ **已实现(harness)** | 一个**假 WebDAV 服务端**(`node:http`,真发 Digest 挑战并按 `MD5(HA1:nonce:nc:cnonce:qop:HA2)` 校验,检查 `nc` 不回退、`uri` 与请求行一致),可切换 `qop: null`、`staleOnce`、`challengeEveryRequest`、`algorithm`、`scheme` 等恶意行为 |
789
790
  | `test/sync.verify.mjs` ✅ **已实现** | 分片与两个方向:路径/键的卫生(设备 id 与 `YYYY-MM` 不能带斜杠或穿越)、分片往返(stamp 与桶逐项保留)、**`source` 被改写成发布设备**、版本不符的分片与清单被具名拒绝、只发布本机 `current` 行(`fallback` 与别人的行都不发)、内容 sha256 决定「未变的月不重传」、首次拉取的窗口只限制拉取、`rowsToRecords()` 取最全 stamp、以及一次完整 trip(导出 → 空库导入 → 数字逐项相同、且**再导入一次不写任何东西**)。行模型的语义(截断/回滚/跨来源/从不删)由 `cache.verify.mjs` 的 section 8/9 覆盖,不在这里重测 |
790
- | `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、以及**配置写入不会覆盖上一次的状态** |
791
+ | `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、**配置写入不会覆盖上一次的状态**,以及三条「run 没走到 `ok`、但库里已经变了」的路径都报 `storeChanged`:**拉到行之后发布失败**、**分片在途中被中止(`aborted`)**、**分片行数比清单少(导入照做、失败照报)**;再加上「重放一份已经全有的分片写 0 行 ⇒ `storeChanged: false`」(宿主不必为此重载) |
791
792
  | `test/sync.live.mjs` ✅ **已实现(联网)** | 真实的 `bytemark/webdav` 容器上跑完整流水线:A 发布一天 → B 空库拉取并计数 → 再同步一次零传输 → 清理自己建的两个设备目录(**不删 `usage-badge/` 本身**)。URL / 账号 / 密码从环境变量取,缺失就跳过 |
792
- | `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、以及第二个实例拿到同一份配置但**被拒绝同步** |
793
+ | `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、第二个实例拿到同一份配置但**被拒绝同步**、以及**另一台机器同步来的用量在同一次进程里就计入徽标与日窗口(不重启)**——撤回方向也一样,且**两个后端各跑一遍**(这条差异只在 JSON 回退上出现,见「本地缓存」一节) |
793
794
  | `test/sync-triggers.verify.mjs` ✅ **已实现** | 两条自动触发:没配过同步的 home **什么都不做**(不建 `sync.json`、不拿锁、不发请求);配好并开启的 home 启动后**自己同步一次**(服务器上出现设备目录、状态记下那次运行);改一次价格表会**自动发布**(带上那次编辑、带设备副本、且不拖任何分片工作);服务器连不上时本地编辑**照样成功**、本地表照样生效 |
794
795
  | ⏳ **未做**:跨机同 `deviceId` 的 `instanceId` 报警 | 设计里写了、代码里还没接;本机并发的 owner 锁已经实现并测过 |
795
796
  | `test/webdav.live.mjs` ✅ **已实现(联网)** | 对着真实的 `bytemark/webdav` 容器跑:`OPTIONS`(打 realm 与 `DAV` 能力)→ `MKCOL` → `PUT` → `GET` 逐字节比对 → `PROPFIND` → `MOVE`(含撞名 412)→ `DELETE`,并在结尾清理自己创建的目录。URL / 账号 / 密码从环境变量取,缺失就跳过并打印怎么起容器 |
@@ -318,10 +318,14 @@ function createJsonStore({ path, log }) {
318
318
  if (!record) continue
319
319
  const keep = record.rows.filter((row) => !(rowSource(row) === source && String(row.date).startsWith(month)))
320
320
  removed += record.rows.length - keep.length
321
- if (keep.length === 0) {
322
- delete doc.sessions[sessionId]
323
- continue
324
- }
321
+ // A session left with **no rows keeps its entry**, empty. The SQLite backend's `sessions` row
322
+ // survives the same delete (`DELETE FROM days`), and the two backends have to answer this question
323
+ // the same way: `load()` returning a session with no rows means "this session is now empty", while
324
+ // dropping the entry means "there was never such a session" — and a reader that merges what it is
325
+ // given instead of replacing its world with it cannot tell those apart. The host's `loadCache` is
326
+ // exactly that reader (it must not drop a fold the store could not accept — a read-only store's
327
+ // `put` returns null, and the fold in memory is then the only copy), so a dropped entry here left
328
+ // the withdrawn month counted in memory until the host restarted.
325
329
  entry.days = keep.map((row) => ({ ...row }))
326
330
  }
327
331
  if (removed > 0) {
package/lib/fold.js CHANGED
@@ -302,8 +302,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
302
302
  * The store has already resolved the copies it may hold for a `(session, date)` down to the
303
303
  * fuller log version; what arrives here is the merged view, with the raw `rows` alongside for
304
304
  * anything that needs to know where a day came from.
305
+ *
306
+ * It is also the only way rows that **arrived from another device** can enter this process: `refresh()`
307
+ * walks the logs, and a synced row has no log here to be discovered by. That is why the host calls it
308
+ * again after a sync run touches the store, and why that second call passes `quiet` — a reload is
309
+ * bookkeeping, not news, and a line per sync would read like the cache was being rebuilt from scratch.
310
+ *
311
+ * @param {{quiet?:boolean}} [options] `quiet` suppresses the summary line (the reload after a sync).
305
312
  */
306
- function loadCache() {
313
+ function loadCache({ quiet = false } = {}) {
307
314
  let loaded
308
315
  try {
309
316
  loaded = store.load()
@@ -323,7 +330,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
323
330
  })
324
331
  }
325
332
  mergeDays()
326
- log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
333
+ if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
327
334
  return records.size > 0
328
335
  }
329
336
 
package/lib/index.js CHANGED
@@ -852,6 +852,33 @@ export function apply(ctx) {
852
852
  if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
853
853
  try {
854
854
  const result = await instance.engine.run({ lock: attempt })
855
+ // A run changes the store in two ways that **no log cursor can see**: a pull imports rows another
856
+ // device published, and a withdrawn month deletes the rows that came from it. `refresh()` walks the
857
+ // session logs, so neither one would ever reach the in-memory view — the panel would keep showing
858
+ // what it had until the host process restarted, while the sync page (reading `sync.json`) reported
859
+ // the successful run. Reloading here is what makes "pulled 1 shard" and the numbers agree, and the
860
+ // engine's own `storeChanged` is the signal: it is true only when rows were written or deleted.
861
+ //
862
+ // Not "whenever the run was not locked": a run that never got past a failed request has nothing to
863
+ // reload, `storeChanged` is the engine's own answer to "did rows move", and a host that is on its
864
+ // way out must not touch a cache whose statements are about to be finalized — `syncAbort` is that
865
+ // flag, set by the disposer just before it closes the store. (A future way to *cancel* a run
866
+ // without unloading would leave `syncAbort` alone and would get its reload, which is right: this
867
+ // process keeps living and its view still has to catch up.)
868
+ if (result?.storeChanged && !instance.syncAbort) {
869
+ // Losing this must not turn a sync that worked into a reported failure, which is what an
870
+ // exception here would do on the way out of this `try`: the numbers stay stale (the very thing
871
+ // this reload is for) and the log says why, but the run is still what it was. `loadCache`
872
+ // already swallows a store it cannot read; this covers everything else.
873
+ try {
874
+ aggregator.loadCache({ quiet: true })
875
+ } catch (error) {
876
+ log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
877
+ }
878
+ // Nulled rather than left alone: the next poll would otherwise be allowed to answer from a
879
+ // snapshot built before these rows existed (it is reused for `SNAPSHOT_TTL_MS`).
880
+ snapshot = null
881
+ }
855
882
  // A shard may have carried a newer price table; the panel's next poll should see it.
856
883
  pricing = readPricing(pricingPaths)
857
884
  rules = resolveHolidayRules(pricing.holidays)
package/lib/sync.js CHANGED
@@ -827,7 +827,12 @@ export class SyncEngine {
827
827
  /**
828
828
  * Pull: fetch the shards this device does not already hold, and store their rows.
829
829
  *
830
- * @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>}}
830
+ * `wrote` is the honest answer to "did the store change", which is not the same as "did rows arrive":
831
+ * a shard that decodes into fewer rows than its manifest names is still imported (that part is
832
+ * idempotent) and then reported as a failure, and a tombstone **deletes** rows without any arriving at
833
+ * all. The host uses it to decide whether its in-memory view is now stale.
834
+ *
835
+ * @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>, wrote:boolean}}
831
836
  */
832
837
  async pull(client, manifests) {
833
838
  const wanted = planDownload({
@@ -851,6 +856,11 @@ export class SyncEngine {
851
856
  })
852
857
  let rows = 0
853
858
  let fetched = 0
859
+ // `wrote` comes from the two places that touch the store, and it is the **store's own count** that
860
+ // decides it: a shard replayed against a store that already holds every one of its rows writes nothing,
861
+ // and claiming the view is stale then would cost a full reload for nothing (`rows` cannot answer this —
862
+ // it counts rows the manifest vouched for, which is not the same as rows that changed).
863
+ let wrote = false
854
864
  const failed = []
855
865
  for (const item of wanted) {
856
866
  // One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
@@ -886,7 +896,10 @@ export class SyncEngine {
886
896
  // and the honest answer is to keep asking. This build never writes such a shard; an older one or a
887
897
  // hand-made file can.
888
898
  if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
889
- this.store?.intakeRows(decoded.rows)
899
+ // Imported even though the shard is about to be reported as a failure, so the store's own count is
900
+ // what says whether the host's view moved. (`rows` stays at zero here: it counts what the manifest
901
+ // vouched for, and the manifest is exactly what is in question.)
902
+ if (this.store && this.store.intakeRows(decoded.rows) > 0) wrote = true
890
903
  failed.push({
891
904
  path: item.entry.path,
892
905
  error: `the shard holds ${decoded.rows.length} of the ${item.entry.rows || decoded.rows.length} rows its manifest names` + (decoded.malformed ? ` (${decoded.malformed} unreadable line(s))` : ''),
@@ -904,7 +917,10 @@ export class SyncEngine {
904
917
  if (this.store) {
905
918
  // `replace`: the month is this device's own statement about it, so rows it no longer lists go.
906
919
  // Without that, a publisher that dropped a row keeps every reader counting it forever.
907
- this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
920
+ const imported = this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
921
+ // In replace mode the count includes the rows it *deleted* as well as the ones it wrote, which is
922
+ // exactly the question being asked here.
923
+ if (imported > 0) wrote = true
908
924
  // `held` is only advanced once the rows are in the store: if the import throws, the shard
909
925
  // must be fetched again rather than remembered as applied.
910
926
  this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
@@ -929,6 +945,7 @@ export class SyncEngine {
929
945
  // Only what this reader actually imported: a tombstone for a month it never held is nothing.
930
946
  if (!this.state.held?.[context]) continue
931
947
  const removed = this.store?.dropSourceMonth(device, key) ?? 0
948
+ if (removed > 0) wrote = true
932
949
  const next = { ...(this.state.held ?? {}) }
933
950
  delete next[context]
934
951
  this.state.held = next
@@ -937,7 +954,7 @@ export class SyncEngine {
937
954
  this.log(`dropped ${removed} row(s) from ${context}: the device that published it no longer holds that month (withdrawn ${entry.at ? new Date(entry.at).toISOString().slice(0, 10) : 'at some point'})`)
938
955
  }
939
956
  }
940
- return { fetched, rows, failed, dropped, droppedKeys }
957
+ return { fetched, rows, failed, dropped, droppedKeys, wrote }
941
958
  }
942
959
 
943
960
  /**
@@ -1318,9 +1335,15 @@ export class SyncEngine {
1318
1335
  // statements are finalized, so continuing is not harmless.
1319
1336
  if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1320
1337
 
1338
+ // Whether this run changed the local store, which is a different question from whether it succeeded:
1339
+ // a pull that imported rows and then failed to publish them has still made the host's in-memory view
1340
+ // stale. Declared outside the `try` so every path out of it — including a failure and an abort — can
1341
+ // report it. The host decides what to do with it: an abort because *it* is unloading is skipped there
1342
+ // (`syncAbort`), while an abort for any other reason still has a live process to catch up.
1343
+ let storeChanged = false
1321
1344
  try {
1322
1345
  const { manifests } = await this.readManifests(client)
1323
- if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1346
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
1324
1347
  // **Pull first, refuse only the push.** An installation whose device name is taken still wants the
1325
1348
  // history back, and that is the case the feature exists for (reinstall, another machine): the name
1326
1349
  // is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
@@ -1334,8 +1357,9 @@ export class SyncEngine {
1334
1357
  // refusing to publish — the one case where the two readings of `collision` differ.
1335
1358
  this.pullOwnDirectory = Boolean(clash)
1336
1359
  const pulled = await this.pull(client, manifests)
1360
+ storeChanged = pulled.wrote
1337
1361
  this.pullOwnDirectory = false
1338
- if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
1362
+ if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started, extra: { storeChanged } })
1339
1363
  const pushed = await this.push(client)
1340
1364
  if (pushed.manifest) {
1341
1365
  this.state.published = pushed.manifest.chunks
@@ -1345,7 +1369,7 @@ export class SyncEngine {
1345
1369
  }
1346
1370
  // The price table last: it is the only hand-written thing here, and it should not be touched by
1347
1371
  // a run that failed to move the usage it was about to move.
1348
- if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1372
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
1349
1373
  const pricing = await this.syncPricing(client)
1350
1374
  const parts = [
1351
1375
  `pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
@@ -1381,12 +1405,14 @@ export class SyncEngine {
1381
1405
  refused: pushed.refused ?? null,
1382
1406
  withdrawn: pushed.withdrawn ?? 0,
1383
1407
  dropped: pulled.dropped ?? 0,
1408
+ // Named for the host, not for the panel: it is what tells the host its aggregate is now stale.
1409
+ storeChanged,
1384
1410
  },
1385
1411
  })
1386
1412
  } catch (error) {
1387
1413
  const message = String(error?.message ?? error)
1388
1414
  this.log(`sync failed: ${message}`)
1389
- return this.finish('failed', message, { started })
1415
+ return this.finish('failed', message, { started, extra: { storeChanged } })
1390
1416
  }
1391
1417
  }
1392
1418
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",