dsh-ds-balance 1.0.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/README_en.md +81 -0
  4. package/cordis.patch.yml +9 -0
  5. package/lib/adapters/console-logger.d.ts +25 -0
  6. package/lib/adapters/console-logger.js +35 -0
  7. package/lib/adapters/domain-core-store.d.ts +104 -0
  8. package/lib/adapters/domain-core-store.js +184 -0
  9. package/lib/adapters/http-deepseek-client.d.ts +36 -0
  10. package/lib/adapters/http-deepseek-client.js +101 -0
  11. package/lib/adapters/memory-metrics.d.ts +30 -0
  12. package/lib/adapters/memory-metrics.js +67 -0
  13. package/lib/adapters/salt-file.d.ts +32 -0
  14. package/lib/adapters/salt-file.js +40 -0
  15. package/lib/client.js +2120 -0
  16. package/lib/config.d.ts +127 -0
  17. package/lib/config.js +112 -0
  18. package/lib/domain/balance.d.ts +70 -0
  19. package/lib/domain/balance.js +8 -0
  20. package/lib/domain/errors.d.ts +84 -0
  21. package/lib/domain/errors.js +136 -0
  22. package/lib/domain/money.d.ts +39 -0
  23. package/lib/domain/money.js +74 -0
  24. package/lib/domain/normalize.d.ts +38 -0
  25. package/lib/domain/normalize.js +120 -0
  26. package/lib/domain/select.d.ts +26 -0
  27. package/lib/domain/select.js +51 -0
  28. package/lib/domain/severity.d.ts +34 -0
  29. package/lib/domain/severity.js +44 -0
  30. package/lib/http/handlers.d.ts +47 -0
  31. package/lib/http/handlers.js +261 -0
  32. package/lib/http/routes.d.ts +63 -0
  33. package/lib/http/routes.js +61 -0
  34. package/lib/http/wire.d.ts +82 -0
  35. package/lib/http/wire.js +68 -0
  36. package/lib/index.d.ts +36 -0
  37. package/lib/index.js +210 -0
  38. package/lib/ports/clock.d.ts +12 -0
  39. package/lib/ports/clock.js +6 -0
  40. package/lib/ports/core-store.d.ts +28 -0
  41. package/lib/ports/core-store.js +6 -0
  42. package/lib/ports/credentials.d.ts +30 -0
  43. package/lib/ports/credentials.js +9 -0
  44. package/lib/ports/deepseek-client.d.ts +47 -0
  45. package/lib/ports/deepseek-client.js +7 -0
  46. package/lib/ports/logger.d.ts +12 -0
  47. package/lib/ports/logger.js +6 -0
  48. package/lib/ports/metrics.d.ts +36 -0
  49. package/lib/ports/metrics.js +11 -0
  50. package/lib/services/account-tag.d.ts +24 -0
  51. package/lib/services/account-tag.js +29 -0
  52. package/lib/services/balance-service.d.ts +121 -0
  53. package/lib/services/balance-service.js +220 -0
  54. package/lib/services/config-service.d.ts +52 -0
  55. package/lib/services/config-service.js +51 -0
  56. package/lib/services/key-resolver.d.ts +42 -0
  57. package/lib/services/key-resolver.js +62 -0
  58. package/lib/services/scheduler.d.ts +93 -0
  59. package/lib/services/scheduler.js +142 -0
  60. package/lib/types/client/api-types.d.ts +54 -0
  61. package/lib/types/client/data.d.ts +103 -0
  62. package/lib/types/client/index.d.ts +18 -0
  63. package/lib/types/client/locales.d.ts +98 -0
  64. package/lib/types/client/mock/index.d.ts +36 -0
  65. package/lib/types/client/mock/scenarios.d.ts +51 -0
  66. package/lib/types/client/model.d.ts +93 -0
  67. package/lib/types/client/settings/BalanceSettingsCard.d.ts +22 -0
  68. package/lib/types/client/settings/fields.d.ts +191 -0
  69. package/lib/types/client/settings/use-config-form.d.ts +212 -0
  70. package/lib/types/client/settings/use-credential-state.d.ts +36 -0
  71. package/lib/types/client/sidebar/BalancePopover.d.ts +46 -0
  72. package/lib/types/client/sidebar/PercentRing.d.ts +36 -0
  73. package/lib/types/client/sidebar/SidebarBalance.d.ts +44 -0
  74. package/lib/version.d.ts +12 -0
  75. package/lib/version.js +12 -0
  76. package/package.json +112 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zlZayn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # ds-balance
2
+
3
+ [English](README_en.md)
4
+
5
+ [![ci](https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg?branch=main)](.github/workflows/ci.yml)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![node](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.node&label=node&color=brightgreen)](package.json)
8
+ [![dsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.dsh&label=dsh&color=blueviolet)](package.json)
9
+
10
+ > **NOTE**
11
+ > 本包尚未发布到 npm,现在只能从源码安装。发布流程与版本号判定链见
12
+ > [docs/PUBLISHING.md](docs/PUBLISHING.md)。
13
+
14
+ 在 DSH 的左边栏底部显示 DeepSeek 账户余额,并在设置里提供一张配置卡片。
15
+ 余额真实读自官方 `GET /user/balance`;颜色只由后端返回的 `severity` 决定。
16
+
17
+ ![设置卡片](assets/settings-card.png)
18
+
19
+ ## 能力
20
+
21
+ - 侧栏底部常驻一个状态圆环加名称,点击展开浮层看余额明细;弧长是「余额占 warn 阈值」的比例,折叠与展开共用同一个圆环。
22
+ - 真实读取 DeepSeek 官方余额(`GET /user/balance`),按 `serverRefreshSeconds` 在服务端刷新,
23
+ 浏览器按 `clientPollSeconds` 取缓存,不穿透到上游。
24
+ - 浮层:余额、赠送 / 充值拆分、数据新鲜度与手动刷新(带冷却)。
25
+ - 多币种:由**后端**选定展示币种;设置里选的币种不在账户里时,浮层说明并给出一键改用。
26
+ - 设置卡片分四组、各自可折叠:连接 / 展示 / 阈值 / 刷新,默认四组全收起;组内有非法草稿时该组强制展开。
27
+ - 凭据字段带「已由启动环境提供 / 已配置 / 未配置 / 已覆盖」徽标;只读时框内留空,由徽标与说明行交代原因。凭据默认继承官方模型页配好的那一份,不必重填。
28
+ - 只按后端给的 `severity` 上色;阈值只用来定圆环弧长,不参与配色。
29
+
30
+ ## 安装
31
+
32
+ 前置:DSH `^0.1.6-alpha.1`,Node `>=20`。
33
+
34
+ 从 npm 安装尚未开放(包未发布)。从源码安装:
35
+
36
+ ```bash
37
+ git clone https://github.com/zlZayn/dsh-ds-balance.git
38
+ cd dsh-ds-balance
39
+ npm install
40
+ npm run build
41
+ dsh plugin --profile <profile> add .
42
+ ```
43
+
44
+ 装完还要把插件行写进该 profile 的 `cordis.patch.yml`;完整步骤与回滚方式见
45
+ [AGENTS.md](AGENTS.md) 的「常用命令」。
46
+
47
+ **宿主半边改了代码必须重启 DSH**;浏览器半边由客户端热更换入。
48
+
49
+ 装好之后它在两处出现:设置 → 插件 → 插件配置里的配置卡片,以及左边栏底部的状态圆环。
50
+
51
+ ## 配置
52
+
53
+ 卡片分四组,默认全收起:
54
+
55
+ - 连接:只读的凭据状态、可编辑的 API 地址,以及收在二级「自定义设置」里的 apiKey 与 apiKeyRef。
56
+ - 展示:金额用哪种币种,或让它自动跟随账户。
57
+ - 阈值:几档提醒线。**由后端判定** —— 前端只拿 `warn` 当圆环弧长的刻度,不据此上色。同一币种的告急必须低于预警,否则保存按钮是灰的。
58
+ - 刷新:服务端刷新周期与浏览器轮询周期。
59
+
60
+ ## 数据从哪来、到哪去
61
+
62
+ - 端点由宿主半边通过 `ctx.connection.fetch` 注册在 `/api/v1/*` 下,物理载体已做完信任与浏览器鉴权。
63
+ - **API Key 永不返回前端**:配置接口只回一个固定长度的掩码串,连末几位也不给。
64
+ - 密钥只经 DSH 的凭据通道解析,不落日志、不落本插件的文件。
65
+ - 余额快照落在 DSH 自己的存储目录(`ctx.storageDomain`),按凭据派生出的账本标识分组;
66
+ 换 key 自动开新账本,旧快照不会被混用。
67
+ - 一个非记录型文件 `.salt` 落在 `$DSH_HOME` 下,用来派生账本标识;**它丢了旧快照会读不回来**。
68
+
69
+ ## 界面与后端的边界
70
+
71
+ - 颜色只由后端 `severity` 决定。前端只在圆环弧长里读 `warn` 阈值当刻度,不用它配色。
72
+ - 金额一律是八位小数的字符串,前端按字符串裁两位显示,不经过浮点数。
73
+ - 完整响应形状与配置契约 → [docs/ui-handoff.md](docs/ui-handoff.md)。
74
+
75
+ ## 贡献
76
+
77
+ 报 bug、提功能与提 PR 的前置条件见 [CONTRIBUTING.md](CONTRIBUTING.md)。
78
+
79
+ ## License
80
+
81
+ MIT,见 [LICENSE](LICENSE)。
package/README_en.md ADDED
@@ -0,0 +1,81 @@
1
+ # ds-balance
2
+
3
+ [中文](README.md)
4
+
5
+ [![ci](https://github.com/zlZayn/dsh-ds-balance/actions/workflows/ci.yml/badge.svg?branch=main)](.github/workflows/ci.yml)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![node](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.node&label=node&color=brightgreen)](package.json)
8
+ [![dsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FzlZayn%2Fdsh-ds-balance%2Fmain%2Fpackage.json&query=%24.engines.dsh&label=dsh&color=blueviolet)](package.json)
9
+
10
+ > **NOTE**
11
+ > This package is not on npm yet; it installs from source only.
12
+ > The release flow and the version-bump decision chain are in
13
+ > [docs/PUBLISHING.md](docs/PUBLISHING.md).
14
+
15
+ Shows the DeepSeek account balance at the bottom of the DSH sidebar, with a configuration card in settings.
16
+ The balance is read from the real `GET /user/balance`; colour comes only from the `severity` the server returns.
17
+
18
+ ![settings card](assets/settings-card_en.png)
19
+
20
+ ## Capabilities
21
+
22
+ - A permanent status ring plus label at the bottom of the sidebar; click it to open a popover with the balance breakdown. Its arc is the balance as a fraction of the `warn` threshold, and the collapsed and expanded states share the same ring.
23
+ - Reads the real DeepSeek balance (`GET /user/balance`); the server refreshes on `serverRefreshSeconds` and the browser reads the cache on `clientPollSeconds` without hitting the upstream.
24
+ - Popover: total balance, granted / topped-up split, data freshness, and a manual refresh with cooldown.
25
+ - Multiple currencies: the **server** picks which currency to show; when the currency chosen in settings is absent from the account, the popover says so and offers a one-click switch.
26
+ - The settings card has four collapsible groups — Connection / Display / Thresholds / Refresh — all collapsed by default; a group with an invalid draft is forced open.
27
+ - Credential fields carry a "provided by launch environment / configured / not configured / overridden" badge; a read-only field stays empty and lets the badge and the line under it explain why. The credential is inherited from the official model settings by default, so there is nothing to re-enter.
28
+ - Colour comes only from the `severity` the server returns; thresholds only scale the ring arc and never affect colour.
29
+
30
+ ## Installation
31
+
32
+ Prerequisites: DSH `^0.1.6-alpha.1`, Node `>=20`.
33
+
34
+ Installing from npm is not open yet (the package is unpublished). From source:
35
+
36
+ ```bash
37
+ git clone https://github.com/zlZayn/dsh-ds-balance.git
38
+ cd dsh-ds-balance
39
+ npm install
40
+ npm run build
41
+ dsh plugin --profile <profile> add .
42
+ ```
43
+
44
+ After installing, add the plugin row to that profile's `cordis.patch.yml`; the full steps and rollback are in
45
+ [AGENTS.md](AGENTS.md), section "常用命令".
46
+
47
+ **Changing the host half requires restarting DSH**; the browser half is hot-swapped by the client.
48
+
49
+ Once installed it shows up in two places: a configuration card under Settings → Plugins → Plugin configuration,
50
+ and a status ring at the bottom of the sidebar.
51
+
52
+ ## Configuration
53
+
54
+ The card has four groups, all collapsed by default:
55
+
56
+ - Connection: the read-only credential state, an editable API base URL, and the apiKey / apiKeyRef kept inside the nested "Customised settings".
57
+ - Display: which currency to use for amounts, or let it follow the account.
58
+ - Thresholds: the alert lines. They are **evaluated on the server** — the frontend only scales the ring arc by `warn`, and never colours anything from them. Within one currency the critical line must be lower than the warning, or Save stays disabled.
59
+ - Refresh: the server refresh interval and the browser poll interval.
60
+
61
+ ## Where the data comes from and goes
62
+
63
+ - Endpoints are registered by the host half under `/api/v1/*` via `ctx.connection.fetch`; the physical carrier already applies trust and browser authentication.
64
+ - **The API key is never returned to the frontend**: the config endpoint returns only a fixed-length mask, not even the last few characters.
65
+ - The key is resolved only through the DSH credential channel; it is never logged and never written to a file owned by this plugin.
66
+ - Balance snapshots land in DSH's own storage (`ctx.storageDomain`), grouped by a ledger identifier derived from the credential. Changing the key opens a new ledger; old snapshots are never mixed in.
67
+ - One non-record file, `.salt`, lives under `$DSH_HOME` and derives the ledger identifier. **Losing it makes old snapshots unreadable.**
68
+
69
+ ## Boundary between UI and backend
70
+
71
+ - Colour is decided only by the server's `severity`. The frontend reads the `warn` threshold only to scale the ring arc, never to colour anything.
72
+ - Amounts are always eight-decimal strings; the frontend trims them to two characters-wise and never goes through floating point.
73
+ - Full response shape and configuration contract → [docs/ui-handoff.md](docs/ui-handoff.md).
74
+
75
+ ## Contributing
76
+
77
+ Prerequisites for bug reports, feature proposals, and pull requests are in [CONTRIBUTING_en.md](CONTRIBUTING_en.md).
78
+
79
+ ## License
80
+
81
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ # dsh-ds-balance bundle patch —— 把本插件作为一行插入 dsh profile。
2
+ #
3
+ # 格式对齐 DSH 官方 bundle patch:顶层是 loader patch 条目数组;
4
+ # `insert` 在 profile 根上追加插件行。
5
+ #
6
+ # name 必须是 loader 能从 profile 的 node_modules 解析到的模块 id,即本包自身的包名。
7
+ - insert:
8
+ - id: dsh-ds-balance
9
+ name: dsh-ds-balance
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 控制台日志。
3
+ *
4
+ * 宿主半边**没有统一的 logger 服务**,所以直接落 `console`。
5
+ * 字段对象原样序列化,因此**调用方不许把凭据传进来** ——
6
+ * 这是 `services/` 与 `adapters/` 共同的规则。
7
+ * @module dsh-ds-balance/adapters/console-logger
8
+ */
9
+ import type { Logger } from '../ports/logger.js';
10
+ /** 每条日志的前缀,便于在宿主输出里筛。 */
11
+ export declare const LOG_PREFIX = "[ds-balance]";
12
+ /** 日志落点。测试注入替身,生产用全局 `console`。 */
13
+ export interface ConsoleLike {
14
+ debug(...args: unknown[]): void;
15
+ info(...args: unknown[]): void;
16
+ warn(...args: unknown[]): void;
17
+ error(...args: unknown[]): void;
18
+ }
19
+ /**
20
+ * 造一个落 `console` 的日志器。
21
+ * @param sink - 落点;默认全局 `console`。
22
+ * @returns 端口实现。
23
+ */
24
+ export declare function createConsoleLogger(sink?: ConsoleLike): Logger;
25
+ //# sourceMappingURL=console-logger.d.ts.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 控制台日志。
3
+ *
4
+ * 宿主半边**没有统一的 logger 服务**,所以直接落 `console`。
5
+ * 字段对象原样序列化,因此**调用方不许把凭据传进来** ——
6
+ * 这是 `services/` 与 `adapters/` 共同的规则。
7
+ * @module dsh-ds-balance/adapters/console-logger
8
+ */
9
+ /** 每条日志的前缀,便于在宿主输出里筛。 */
10
+ export const LOG_PREFIX = '[ds-balance]';
11
+ /** 把字段对象序列化;序列化不了就退回占位串,绝不因为日志把流程打断。 */
12
+ function render(fields) {
13
+ if (fields === undefined)
14
+ return '';
15
+ try {
16
+ return ' ' + JSON.stringify(fields);
17
+ }
18
+ catch {
19
+ return ' [unserializable fields]';
20
+ }
21
+ }
22
+ /**
23
+ * 造一个落 `console` 的日志器。
24
+ * @param sink - 落点;默认全局 `console`。
25
+ * @returns 端口实现。
26
+ */
27
+ export function createConsoleLogger(sink = console) {
28
+ return {
29
+ debug: (message, fields) => { sink.debug(`${LOG_PREFIX} ${message}${render(fields)}`); },
30
+ info: (message, fields) => { sink.info(`${LOG_PREFIX} ${message}${render(fields)}`); },
31
+ warn: (message, fields) => { sink.warn(`${LOG_PREFIX} ${message}${render(fields)}`); },
32
+ error: (message, fields) => { sink.error(`${LOG_PREFIX} ${message}${render(fields)}`); },
33
+ };
34
+ }
35
+ //# sourceMappingURL=console-logger.js.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 用 dsh 官方存储接缝实现 {@link CoreStore}。
3
+ *
4
+ * 关键约束(见 docs/backend-architecture.md §10):
5
+ * - `open` 每进程只能一次,重名抛 `already-open`;
6
+ * - **`close()` 必须由调用方在 `ctx.effect` 的 disposer 里调用**,否则热重挂会锁死;
7
+ * - 打开失败要**降级**而不是抛出去 —— 未观察的 rejection 曾把宿主整个拖下水。
8
+ * @module dsh-ds-balance/adapters/domain-core-store
9
+ */
10
+ import type { BalanceSnapshot } from '../domain/balance.js';
11
+ import type { CoreStore, StoreHealth } from '../ports/core-store.js';
12
+ import type { Logger } from '../ports/logger.js';
13
+ /** 域里的表名。 */
14
+ export declare const SNAPSHOT_TABLE = "snapshots";
15
+ /** 域记录:JSON 安全形状,金额存字符串最小单位。 */
16
+ export interface StoredSnapshot {
17
+ snapshotId: string;
18
+ accountTag: string;
19
+ fetchedAt: number;
20
+ isAvailable: boolean;
21
+ balances: Array<{
22
+ currency: string;
23
+ total: string;
24
+ granted: string;
25
+ toppedUp: string;
26
+ }>;
27
+ source: 'deepseek-http';
28
+ }
29
+ /**
30
+ * 域声明。
31
+ *
32
+ * `backend` **不写**:路由归部署方(默认组合只有 json 后端),插件无权指定。
33
+ */
34
+ export declare const DS_BALANCE_DOMAIN: {
35
+ name: string;
36
+ version: number;
37
+ tables: {
38
+ snapshots: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, StoredSnapshot>;
39
+ };
40
+ };
41
+ /** 表句柄的最小结构化面;测试用替身实现它。 */
42
+ export interface KvTableLike {
43
+ get(key: string): StoredSnapshot | undefined;
44
+ keys(): IterableIterator<string>;
45
+ put(key: string, value: StoredSnapshot): Promise<void>;
46
+ }
47
+ /** 域句柄的最小结构化面。 */
48
+ export interface DomainLike {
49
+ table(name: string): KvTableLike;
50
+ close(): Promise<void>;
51
+ }
52
+ /** 打开域的函数;生产里传 `(spec) => ctx.storageDomain.open(spec)`。 */
53
+ export type DomainOpener = (spec: typeof DS_BALANCE_DOMAIN) => Promise<DomainLike>;
54
+ /** 构造参数。 */
55
+ export interface DomainCoreStoreOptions {
56
+ open: DomainOpener;
57
+ logger?: Logger;
58
+ }
59
+ /** 把领域快照转成可 JSON 化的记录。 */
60
+ export declare function toStored(snapshot: BalanceSnapshot): StoredSnapshot;
61
+ /** 把记录还原成领域快照。金额坏掉时抛 `ParseError`,绝不静默归零。 */
62
+ export declare function fromStored(record: StoredSnapshot): BalanceSnapshot;
63
+ /**
64
+ * 官方存储接缝上的快照存储。
65
+ *
66
+ * 构造期**永不抛错**,而且**打开是懒的、失败可重试**:
67
+ *
68
+ * - **懒打开**:`storageDomain` 可能在插件装载之后才就绪;构造期就打开会把一次
69
+ * 过早的失败永久钉死。第一次用到时才开。
70
+ * - **失败可重试**:一轮打开失败只记一次 error,之后每次操作重新试一次;
71
+ * 服务后到了就自动接上,不必重挂插件。
72
+ * - **永不产生未观察的 rejection**:`ensureOpen` 返回的 promise 永远 resolve,
73
+ * 错误记在 `openError` 上,由调用方按需翻成 {@link StorageError}。
74
+ * 已装插件的注释说,逃出去的 rejection 曾经能把整个宿主拖下水。
75
+ */
76
+ export declare class DomainCoreStore implements CoreStore {
77
+ private readonly options;
78
+ /** 正在进行的这一轮打开;成功后常驻,失败后置回 `null` 以便重试。 */
79
+ private ready;
80
+ private handle;
81
+ private table;
82
+ private openError;
83
+ /** 打开失败只记一次日志,免得每次操作都刷屏。 */
84
+ private openFailureLogged;
85
+ private closed;
86
+ constructor(options: DomainCoreStoreOptions);
87
+ /** 确保域已打开。**永不 reject。** */
88
+ private ensureOpen;
89
+ private initialize;
90
+ /** 拿到可用表,或在降级状态下抛出可读错误。 */
91
+ private requireTable;
92
+ saveSnapshot(snapshot: BalanceSnapshot): Promise<void>;
93
+ /**
94
+ * 读该账本最近一条快照。
95
+ *
96
+ * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
97
+ * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
98
+ */
99
+ loadLatestSnapshot(accountTag: string): Promise<BalanceSnapshot | null>;
100
+ health(): Promise<StoreHealth>;
101
+ /** 幂等关闭。**必须挂在 `ctx.effect` 的 disposer 上。** */
102
+ close(): Promise<void>;
103
+ }
104
+ //# sourceMappingURL=domain-core-store.d.ts.map
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 用 dsh 官方存储接缝实现 {@link CoreStore}。
3
+ *
4
+ * 关键约束(见 docs/backend-architecture.md §10):
5
+ * - `open` 每进程只能一次,重名抛 `already-open`;
6
+ * - **`close()` 必须由调用方在 `ctx.effect` 的 disposer 里调用**,否则热重挂会锁死;
7
+ * - 打开失败要**降级**而不是抛出去 —— 未观察的 rejection 曾把宿主整个拖下水。
8
+ * @module dsh-ds-balance/adapters/domain-core-store
9
+ */
10
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
11
+ import { z } from 'zod';
12
+ import { StorageError } from '../domain/errors.js';
13
+ import { formatMoney, parseMoney } from '../domain/money.js';
14
+ /** 域里的表名。 */
15
+ export const SNAPSHOT_TABLE = 'snapshots';
16
+ const storedBalanceSchema = z.object({
17
+ currency: z.string(),
18
+ total: z.string(),
19
+ granted: z.string(),
20
+ toppedUp: z.string(),
21
+ });
22
+ const storedSnapshotSchema = z.object({
23
+ snapshotId: z.string(),
24
+ accountTag: z.string(),
25
+ fetchedAt: z.number(),
26
+ isAvailable: z.boolean(),
27
+ balances: z.array(storedBalanceSchema),
28
+ source: z.literal('deepseek-http'),
29
+ });
30
+ /**
31
+ * 域声明。
32
+ *
33
+ * `backend` **不写**:路由归部署方(默认组合只有 json 后端),插件无权指定。
34
+ */
35
+ export const DS_BALANCE_DOMAIN = defineDomain({
36
+ name: 'ds_balance',
37
+ version: 1,
38
+ tables: {
39
+ [SNAPSHOT_TABLE]: domainTable(storedSnapshotSchema),
40
+ },
41
+ });
42
+ /** 把领域快照转成可 JSON 化的记录。 */
43
+ export function toStored(snapshot) {
44
+ return {
45
+ snapshotId: snapshot.snapshotId,
46
+ accountTag: snapshot.accountTag,
47
+ fetchedAt: snapshot.fetchedAt,
48
+ isAvailable: snapshot.isAvailable,
49
+ balances: snapshot.balances.map((item) => ({
50
+ currency: item.currency,
51
+ total: formatMoney(item.total),
52
+ granted: formatMoney(item.granted),
53
+ toppedUp: formatMoney(item.toppedUp),
54
+ })),
55
+ source: snapshot.source,
56
+ };
57
+ }
58
+ /** 把记录还原成领域快照。金额坏掉时抛 `ParseError`,绝不静默归零。 */
59
+ export function fromStored(record) {
60
+ const balances = record.balances.map((item) => ({
61
+ currency: item.currency,
62
+ total: parseMoney(item.total),
63
+ granted: parseMoney(item.granted),
64
+ toppedUp: parseMoney(item.toppedUp),
65
+ }));
66
+ return {
67
+ snapshotId: record.snapshotId,
68
+ accountTag: record.accountTag,
69
+ fetchedAt: record.fetchedAt,
70
+ isAvailable: record.isAvailable,
71
+ balances,
72
+ source: record.source,
73
+ raw: undefined,
74
+ };
75
+ }
76
+ /** 把未知异常压成一行可读文本。 */
77
+ function describe(error) {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
80
+ /**
81
+ * 官方存储接缝上的快照存储。
82
+ *
83
+ * 构造期**永不抛错**,而且**打开是懒的、失败可重试**:
84
+ *
85
+ * - **懒打开**:`storageDomain` 可能在插件装载之后才就绪;构造期就打开会把一次
86
+ * 过早的失败永久钉死。第一次用到时才开。
87
+ * - **失败可重试**:一轮打开失败只记一次 error,之后每次操作重新试一次;
88
+ * 服务后到了就自动接上,不必重挂插件。
89
+ * - **永不产生未观察的 rejection**:`ensureOpen` 返回的 promise 永远 resolve,
90
+ * 错误记在 `openError` 上,由调用方按需翻成 {@link StorageError}。
91
+ * 已装插件的注释说,逃出去的 rejection 曾经能把整个宿主拖下水。
92
+ */
93
+ export class DomainCoreStore {
94
+ options;
95
+ /** 正在进行的这一轮打开;成功后常驻,失败后置回 `null` 以便重试。 */
96
+ ready = null;
97
+ handle = null;
98
+ table = null;
99
+ openError;
100
+ /** 打开失败只记一次日志,免得每次操作都刷屏。 */
101
+ openFailureLogged = false;
102
+ closed = false;
103
+ constructor(options) {
104
+ this.options = options;
105
+ }
106
+ /** 确保域已打开。**永不 reject。** */
107
+ ensureOpen() {
108
+ if (this.closed)
109
+ return Promise.resolve();
110
+ if (this.ready === null) {
111
+ const attempt = this.initialize().catch((error) => {
112
+ this.openError = error;
113
+ this.handle = null;
114
+ this.table = null;
115
+ this.ready = null;
116
+ if (!this.openFailureLogged) {
117
+ this.openFailureLogged = true;
118
+ this.options.logger?.error('ds-balance: storage domain unavailable, running degraded', { error: describe(error) });
119
+ }
120
+ });
121
+ this.ready = attempt;
122
+ }
123
+ return this.ready;
124
+ }
125
+ async initialize() {
126
+ const handle = await this.options.open(DS_BALANCE_DOMAIN);
127
+ this.handle = handle;
128
+ this.table = handle.table(SNAPSHOT_TABLE);
129
+ this.openError = undefined;
130
+ }
131
+ /** 拿到可用表,或在降级状态下抛出可读错误。 */
132
+ async requireTable() {
133
+ await this.ensureOpen();
134
+ if (this.openError !== undefined) {
135
+ throw new StorageError(`storage domain unavailable: ${describe(this.openError)}`, { cause: this.openError });
136
+ }
137
+ if (this.table === null)
138
+ throw new StorageError('storage domain is not open');
139
+ return this.table;
140
+ }
141
+ async saveSnapshot(snapshot) {
142
+ const table = await this.requireTable();
143
+ await table.put(snapshot.snapshotId, toStored(snapshot));
144
+ }
145
+ /**
146
+ * 读该账本最近一条快照。
147
+ *
148
+ * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
149
+ * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
150
+ */
151
+ async loadLatestSnapshot(accountTag) {
152
+ const table = await this.requireTable();
153
+ let newest = null;
154
+ for (const key of table.keys()) {
155
+ const record = table.get(key);
156
+ if (record === undefined || record.accountTag !== accountTag)
157
+ continue;
158
+ if (newest === null || record.snapshotId > newest.snapshotId)
159
+ newest = record;
160
+ }
161
+ return newest === null ? null : fromStored(newest);
162
+ }
163
+ async health() {
164
+ if (this.closed)
165
+ return { ok: false, detail: 'closed' };
166
+ await this.ensureOpen();
167
+ return this.openError === undefined ? { ok: true } : { ok: false, detail: describe(this.openError) };
168
+ }
169
+ /** 幂等关闭。**必须挂在 `ctx.effect` 的 disposer 上。** */
170
+ async close() {
171
+ if (this.closed)
172
+ return;
173
+ this.closed = true;
174
+ const pending = this.ready;
175
+ if (pending !== null)
176
+ await pending;
177
+ const handle = this.handle;
178
+ this.handle = null;
179
+ this.table = null;
180
+ if (handle !== null)
181
+ await handle.close();
182
+ }
183
+ }
184
+ //# sourceMappingURL=domain-core-store.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * DeepSeek 上游的 HTTP 实现。
3
+ * @module dsh-ds-balance/adapters/http-deepseek-client
4
+ */
5
+ import type { RawBalanceResponse } from '../domain/balance.js';
6
+ import type { DeepSeekCallOptions, DeepSeekClient, TestConnectionResult } from '../ports/deepseek-client.js';
7
+ /** 可替换的 fetch,便于测试注入。 */
8
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
9
+ /** 构造参数。 */
10
+ export interface HttpDeepSeekClientOptions {
11
+ /** 注入 fetch;默认用全局 `fetch`。 */
12
+ fetchImpl?: FetchLike;
13
+ /** 注入时刻函数,测延迟用。 */
14
+ now?: () => number;
15
+ }
16
+ /**
17
+ * 用 WHATWG fetch 调 DeepSeek 的 `GET /user/balance`。
18
+ *
19
+ * 超时用自建的 `AbortController + setTimeout`(而不是 `AbortSignal.timeout`),
20
+ * 这样测试可以在不睡真实时间的前提下驱动超时分支。
21
+ */
22
+ export declare class HttpDeepSeekClient implements DeepSeekClient {
23
+ private readonly fetchImpl;
24
+ private readonly now;
25
+ constructor(options?: HttpDeepSeekClientOptions);
26
+ fetchBalance(options: DeepSeekCallOptions): Promise<RawBalanceResponse>;
27
+ testConnection(options: DeepSeekCallOptions): Promise<TestConnectionResult>;
28
+ /**
29
+ * 发一次请求并处理状态码。
30
+ * @throws {UpstreamError} 非 2xx。
31
+ * @throws {TimeoutError} 超时。
32
+ * @throws {NetworkError} 连不上。
33
+ */
34
+ private request;
35
+ }
36
+ //# sourceMappingURL=http-deepseek-client.d.ts.map