@mzzsfy/dsh-usage-dash 0.10.4 → 0.10.5
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 +1 -1
- package/package.json +1 -1
- package/src/client.js +23 -3
- package/src/routes.js +18 -13
- package/src/store.js +18 -1
- package/test/routes.test.mjs +2 -0
- package/test/store.test.mjs +55 -33
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
规则存于设置存储域,经 `GET/POST /api/usage-dash/pricing` 读写(响应含单调 revision);设置面板编辑器为常规入口。单页内价格请求合并为一个在途请求,缓存有效期内复用;保存成功会同步更新已挂载底栏/费用芯片,并重取面板日范围、热力图及当前时/分数据。迟到的低 revision 不覆盖新价格,其他编辑器未保存草稿保持独立;价格不存本机持久缓存。
|
|
29
29
|
|
|
30
|
-
规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段)
|
|
30
|
+
规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。**服务端单币约束**:pricing POST 整表替换时非空 currency 全表必须一致,混币提交 400 拒绝——费用聚合按数值直接相加,混币相加无意义。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。**计价时点口径**:时间条件按「估算时点」匹配当前规则表——面板聚合以小时桶时点,回合费用芯片以回合结束事件被观测时刻(用量首达即冻结,重渲染不漂移);规则表变更后按当前规则重算历史为既定选择,不做价格版本化存储。
|
|
31
31
|
|
|
32
32
|
- 模型匹配:`model` 为两段式 `vendor/model`(首个 `/` 分段,模型段允许含 `/`),两段各自可 `*` 通配;匹配链为 全名精确 > 模型名精确(`*/model`,跨供应商同模型名同价)> 供应商精确(`vendor/*`,同供应商多模型同价)> `*/*` 全通,档位相同按数组序取首个命中,高档条件不满足逐层落低档;全链无命中不计费用并计 unpriced。提交(编辑器保存与 pricing POST)强制两段式,单段旧形态(`*` 或裸名)不再合法
|
|
33
33
|
- 同模型多规则(分时段定价):组内附加计费规则从上到下首个「条件全过」者生效,全不命中落组内默认价(无条件规则,恒兜底);附加规则卡带 ↑/↓ 调序。注意同一模型的多条规则请使用相同的模型键写法——不同写法(如 `*/model` 与 `vendor/model`)分属不同档位,按档位优先级而非数组序取胜
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
3
|
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏官方数据后追加增强项与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -2701,8 +2701,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
2701
2701
|
useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
|
|
2702
2702
|
const { rules: pricingRules } = usePricing()
|
|
2703
2703
|
const matched = usageSourceBuckets(usageSource)
|
|
2704
|
-
|
|
2705
|
-
|
|
2704
|
+
// 计价时点 = 回合结束事件被观测的时刻(F15 决策:统一用当时价格、按回合
|
|
2705
|
+
// 事件时点匹配当前规则表)。官方 tokenUsage 无事件时间戳,以用量首次到
|
|
2706
|
+
// 达的观测时刻冻结:后续规则重渲染不再漂移,同一回合计价时点恒定
|
|
2707
|
+
const observedAtRef = useRef(null)
|
|
2708
|
+
if (matched && observedAtRef.current === null) observedAtRef.current = Date.now()
|
|
2709
|
+
const price = matched && prefs.costDisplay && pricingRules !== null && observedAtRef.current !== null
|
|
2710
|
+
? matchPrice(pricingRules, turnModelOf(matched), new Date(observedAtRef.current))
|
|
2706
2711
|
: null
|
|
2707
2712
|
// 0 元(全零价/全零桶)与价未命中同不渲染,无占位符
|
|
2708
2713
|
const cost = matched && price ? turnCostAmountOf(matched, price) : null
|
|
@@ -3201,6 +3206,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3201
3206
|
const heatGenerationRef = useRef(0)
|
|
3202
3207
|
const panelRef = useRef(null)
|
|
3203
3208
|
const [heatDays, setHeatDays] = useState(null)
|
|
3209
|
+
// 热力图重取信号(F23):手动刷新/回扫完成即 bump,独立请求随之重取
|
|
3210
|
+
const [heatFetchTick, setHeatFetchTick] = useState(0)
|
|
3211
|
+
const bumpHeatFetch = () => setHeatFetchTick((tick) => tick + 1)
|
|
3204
3212
|
// 费用展示货币来源:价格规则异步首帧未回时空串即不带符号
|
|
3205
3213
|
const { rules: pricingRules, tick: pricingTick } = usePricing()
|
|
3206
3214
|
|
|
@@ -3226,7 +3234,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3226
3234
|
}))
|
|
3227
3235
|
})
|
|
3228
3236
|
return () => { heatGenerationRef.current++ }
|
|
3229
|
-
}, [pricingTick])
|
|
3237
|
+
}, [pricingTick, heatFetchTick])
|
|
3230
3238
|
|
|
3231
3239
|
const load = useCallback(async () => {
|
|
3232
3240
|
const request = range === 'custom'
|
|
@@ -3305,6 +3313,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3305
3313
|
|
|
3306
3314
|
const refresh = () => {
|
|
3307
3315
|
setError('')
|
|
3316
|
+
// 手动刷新同触热力图重取(F23):重建后热力图口径随日桶更新,
|
|
3317
|
+
// 独立请求此前只随挂载/价格变更重取,重建产物不进热力图
|
|
3318
|
+
bumpHeatFetch()
|
|
3308
3319
|
if (view === 'day') load()
|
|
3309
3320
|
else refreshPoints()
|
|
3310
3321
|
}
|
|
@@ -3335,9 +3346,18 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
3335
3346
|
baseDone = value.done
|
|
3336
3347
|
scheduleRefresh()
|
|
3337
3348
|
}
|
|
3349
|
+
wasRunning = true
|
|
3338
3350
|
schedule(STATUS_POLL_FAST_MS)
|
|
3351
|
+
} else if (wasRunning) {
|
|
3352
|
+
// running→idle 转换补最终刷新(F23):reset 同步等回扫,响应时 running 已 false,
|
|
3353
|
+
// done 增长分支永不触发——回扫产物的最终态靠此转换收尾(含热力图重取)
|
|
3354
|
+
wasRunning = false
|
|
3355
|
+
scheduleRefresh()
|
|
3356
|
+
bumpHeatFetch()
|
|
3357
|
+
schedule(STATUS_POLL_SLOW_MS)
|
|
3339
3358
|
}
|
|
3340
3359
|
}
|
|
3360
|
+
let wasRunning = false
|
|
3341
3361
|
tick()
|
|
3342
3362
|
statusMachineRef.current = { restart: () => { baseDone = 0; schedule(STATUS_POLL_FAST_MS) } }
|
|
3343
3363
|
return () => {
|
package/src/routes.js
CHANGED
|
@@ -73,20 +73,25 @@ const conditionSchema = z.union(
|
|
|
73
73
|
CONDITION_KINDS.map((kind) => z.object({ kind: z.literal(kind), ...CONDITION_FIELD_SCHEMAS[kind] })),
|
|
74
74
|
)
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
cacheWrite: z.number().min(0),
|
|
87
|
-
}),
|
|
88
|
-
conditions: z.array(conditionSchema),
|
|
76
|
+
const pricingRuleShape = z.object({
|
|
77
|
+
// 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
|
|
78
|
+
model: z.string().refine(isTwoSegmentModel),
|
|
79
|
+
unit: z.literal(UNIT_PER_MILLION).optional(),
|
|
80
|
+
currency: z.enum(CURRENCIES).nullable(),
|
|
81
|
+
price: z.object({
|
|
82
|
+
input: z.number().min(0),
|
|
83
|
+
output: z.number().min(0),
|
|
84
|
+
cacheRead: z.number().min(0),
|
|
85
|
+
cacheWrite: z.number().min(0),
|
|
89
86
|
}),
|
|
87
|
+
conditions: z.array(conditionSchema),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const pricingRulesSchema = z.array(pricingRuleShape).refine(
|
|
91
|
+
// 表级单币约束(F15):定价聚合按数值直接相加,混币相加无意义;
|
|
92
|
+
// 非空币种全表必须同一种(null 视为未指定,不参与币种一致对账)
|
|
93
|
+
(rules) => new Set(rules.map((rule) => rule.currency).filter((c) => c !== null)).size <= 1,
|
|
94
|
+
{ message: '定价表必须单币种:非空 currency 全表一致' },
|
|
90
95
|
)
|
|
91
96
|
|
|
92
97
|
// 桶串形态锚定 + 本地时区分量回读,回滚形(13 月/25 时/60 分)当场拒绝,
|
package/src/store.js
CHANGED
|
@@ -37,6 +37,7 @@ const TABLE_BUCKETS = 'buckets'
|
|
|
37
37
|
const MISSING_RECORD_PATTERN = /no record .* to update/
|
|
38
38
|
const MINUTE_KEY_PREFIX = `${GRANULARITY_MINUTE}|`
|
|
39
39
|
const HOUR_KEY_PREFIX = `${GRANULARITY_HOURLY}|`
|
|
40
|
+
const DAILY_KEY_PREFIX = `${GRANULARITY_DAILY}|`
|
|
40
41
|
|
|
41
42
|
// 保留值归一:非法回落默认,超出上限截到上限;0(禁用)合法保留
|
|
42
43
|
export function clampMinuteRetentionDays(days) {
|
|
@@ -70,6 +71,16 @@ export const GRANULARITIES = [
|
|
|
70
71
|
[GRANULARITY_MINUTE, minuteKey],
|
|
71
72
|
]
|
|
72
73
|
|
|
74
|
+
// 档保留资格(F14):样本桶 ≥ 该档 cutoff 即可写入。日档无上限恒写;
|
|
75
|
+
// 小时档固定留存;分钟档走可配置天数(0 = 分钟档关闭,一律不写)
|
|
76
|
+
function withinRetention(g, bucket, nowMs, minuteRetentionDays) {
|
|
77
|
+
if (g === GRANULARITY_DAILY) return true
|
|
78
|
+
if (g === GRANULARITY_HOURLY) return bucket >= hourKey(nowMs - HOUR_RETENTION_DAYS * DAY_MS)
|
|
79
|
+
const retention = clampMinuteRetentionDays(minuteRetentionDays)
|
|
80
|
+
if (retention === 0) return false
|
|
81
|
+
return bucket >= minuteKey(nowMs - retention * DAY_MS)
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
// vendor 段推导与定价匹配同源:首个 / 前段,无 / 或首位斜杠归 default
|
|
74
85
|
export function providerOf(modelRef) {
|
|
75
86
|
return splitRequestSegments(modelRef)[0]
|
|
@@ -259,7 +270,9 @@ export class UsageStore {
|
|
|
259
270
|
return this.readCursor() ?? {}
|
|
260
271
|
}
|
|
261
272
|
|
|
262
|
-
// 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush
|
|
273
|
+
// 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期。
|
|
274
|
+
// 入队按档保留资格过滤(F14):超出该档留存期的样本不写入——
|
|
275
|
+
// 写后即被 prune 删除是纯 IO 浪费;回扫历史导入经同一通道自动受控
|
|
263
276
|
async record(sample) {
|
|
264
277
|
await this.ready
|
|
265
278
|
this.requireTable()
|
|
@@ -268,6 +281,7 @@ export class UsageStore {
|
|
|
268
281
|
const provider = sample.turn ? PROVIDER_UNSET : providerOf(model)
|
|
269
282
|
const delta = deltaOf(sample, nowMs)
|
|
270
283
|
for (const [g, bucketOf] of GRANULARITIES) {
|
|
284
|
+
if (!withinRetention(g, bucketOf(sample.time), nowMs, this.retentionDays())) continue
|
|
271
285
|
const bucket = bucketOf(sample.time)
|
|
272
286
|
const entry = { ...delta, bucket, provider, model }
|
|
273
287
|
const existing = this.pending.get(rowKey(g, bucket, provider, model))
|
|
@@ -310,6 +324,9 @@ export class UsageStore {
|
|
|
310
324
|
if (outcome.status !== 'rejected') return []
|
|
311
325
|
const key = [...batch.keys()][index]
|
|
312
326
|
const [entryKey, entry] = [key, batch.get(key)]
|
|
327
|
+
// 重排前复核保留资格(F14):失败重试期间样本可能已滑出留存期,不再重排
|
|
328
|
+
const g = key.startsWith(DAILY_KEY_PREFIX) ? GRANULARITY_DAILY : key.startsWith(HOUR_KEY_PREFIX) ? GRANULARITY_HOURLY : GRANULARITY_MINUTE
|
|
329
|
+
if (!withinRetention(g, entry.bucket, this.now(), this.retentionDays())) return []
|
|
313
330
|
const existing = this.pending.get(entryKey)
|
|
314
331
|
this.pending.set(entryKey, existing ? addDelta(existing, entry) : entry)
|
|
315
332
|
return [outcome.reason]
|
package/test/routes.test.mjs
CHANGED
|
@@ -453,6 +453,8 @@ test('pricing POST 非法规则拒绝 400 且不写入', async () => {
|
|
|
453
453
|
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'unknown' }] }] },
|
|
454
454
|
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'weekdays', days: [7] }] }] },
|
|
455
455
|
{ rules: [{ ...PRICING_RULE, currency: '€' }] },
|
|
456
|
+
// F15 表级单币:非空币种混用整表拒绝;null 与单币并存合法
|
|
457
|
+
{ rules: [{ ...PRICING_RULE, currency: '¥' }, { ...PRICING_RULE, model: 'openai/gpt-x', currency: '$' }] },
|
|
456
458
|
]
|
|
457
459
|
for (const payload of invalidPayloads) {
|
|
458
460
|
const res = await invokeJson(routes, PATH_PRICING, { payload })
|
package/test/store.test.mjs
CHANGED
|
@@ -81,9 +81,13 @@ function fakeDomain({ failUpdate = () => null } = {}) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// F14 契约:record 按档保留资格过滤。夹具时钟固定在样本日次日,样本全部
|
|
85
|
+
// 落在留存期内;超期丢弃语义由专项用例验证
|
|
86
|
+
const FIXED_NOW = local(2026, 8, 3, 0, 0)
|
|
84
87
|
const facilityOf = (domain, backupDomain) => ({
|
|
85
88
|
open: async (definition) => (definition?.name === 'usage_stats_backup' ? (backupDomain ?? domain) : domain),
|
|
86
89
|
})
|
|
90
|
+
const makeStore = (domain, options = {}) => new UsageStore(facilityOf(domain), { now: () => FIXED_NOW, ...options })
|
|
87
91
|
|
|
88
92
|
const tokenSample = (time, extra = {}) => ({
|
|
89
93
|
time,
|
|
@@ -134,7 +138,7 @@ test('跨日跨时跨分边界:毫秒推进即换桶', () => {
|
|
|
134
138
|
|
|
135
139
|
test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
|
|
136
140
|
const domain = fakeDomain()
|
|
137
|
-
const store =
|
|
141
|
+
const store = makeStore(domain)
|
|
138
142
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
139
143
|
await store.flushNow()
|
|
140
144
|
const keys = ['D|2026-08-02', 'H|2026-08-02T14', 'M|2026-08-02T14:30'].map(
|
|
@@ -156,7 +160,7 @@ test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
|
|
|
156
160
|
|
|
157
161
|
test('turn 样本落合成行:provider default 与 model (turns)', async () => {
|
|
158
162
|
const domain = fakeDomain()
|
|
159
|
-
const store =
|
|
163
|
+
const store = makeStore(domain)
|
|
160
164
|
await store.record({ time: local(2026, 8, 2, 14, 37), turn: true })
|
|
161
165
|
await store.flushNow()
|
|
162
166
|
const seen = domain.rows.get('D|2026-08-02|default|(turns)')
|
|
@@ -167,7 +171,7 @@ test('turn 样本落合成行:provider default 与 model (turns)', async () => {
|
|
|
167
171
|
|
|
168
172
|
test('request 样本只计请求数,无归因落 (unknown)', async () => {
|
|
169
173
|
const domain = fakeDomain()
|
|
170
|
-
const store =
|
|
174
|
+
const store = makeStore(domain)
|
|
171
175
|
await store.record({ time: local(2026, 8, 2, 14, 37), request: true, model: 'deepseek-chat' })
|
|
172
176
|
await store.record({ time: local(2026, 8, 2, 14, 38), request: true })
|
|
173
177
|
await store.flushNow()
|
|
@@ -182,7 +186,7 @@ test('request 样本只计请求数,无归因落 (unknown)', async () => {
|
|
|
182
186
|
|
|
183
187
|
test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', async () => {
|
|
184
188
|
const domain = fakeDomain()
|
|
185
|
-
const store =
|
|
189
|
+
const store = makeStore(domain)
|
|
186
190
|
const at = local(2026, 8, 2, 14, 37)
|
|
187
191
|
await store.record(tokenSample(at, { durationMs: 6500 }))
|
|
188
192
|
await store.record(tokenSample(at + 1000, { durationMs: 2500 }))
|
|
@@ -204,7 +208,7 @@ test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', asy
|
|
|
204
208
|
|
|
205
209
|
test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
|
|
206
210
|
const domain = fakeDomain()
|
|
207
|
-
const store =
|
|
211
|
+
const store = makeStore(domain)
|
|
208
212
|
const at = local(2026, 8, 2, 14, 37)
|
|
209
213
|
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
210
214
|
await store.record(tokenSample(at))
|
|
@@ -221,7 +225,7 @@ test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
|
|
|
221
225
|
|
|
222
226
|
test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 token', async () => {
|
|
223
227
|
const domain = fakeDomain()
|
|
224
|
-
const store =
|
|
228
|
+
const store = makeStore(domain)
|
|
225
229
|
const at = local(2026, 8, 2, 14, 37)
|
|
226
230
|
// chunk 先发 token 样本:只有 token 桶
|
|
227
231
|
await store.record(tokenSample(at, { durationMs: undefined }))
|
|
@@ -245,7 +249,7 @@ test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 t
|
|
|
245
249
|
|
|
246
250
|
test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () => {
|
|
247
251
|
const domain = fakeDomain()
|
|
248
|
-
const store =
|
|
252
|
+
const store = makeStore(domain)
|
|
249
253
|
const at = local(2026, 8, 2, 14, 37)
|
|
250
254
|
await store.record(tokenSample(at))
|
|
251
255
|
await store.flushNow()
|
|
@@ -258,7 +262,7 @@ test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () =>
|
|
|
258
262
|
|
|
259
263
|
test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
|
|
260
264
|
const domain = fakeDomain()
|
|
261
|
-
const store =
|
|
265
|
+
const store = makeStore(domain)
|
|
262
266
|
const at = local(2026, 8, 2, 14, 37)
|
|
263
267
|
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
264
268
|
await store.record(tokenSample(at))
|
|
@@ -279,7 +283,7 @@ test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
|
|
|
279
283
|
|
|
280
284
|
test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
|
|
281
285
|
const domain = fakeDomain()
|
|
282
|
-
const store =
|
|
286
|
+
const store = makeStore(domain)
|
|
283
287
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
284
288
|
await store.flushNow()
|
|
285
289
|
assert.equal(domain.stats.puts, 3)
|
|
@@ -296,7 +300,7 @@ test('flush 中单粒度失败:错误经 onFlushError 上抛,失败行留 pendin
|
|
|
296
300
|
return new Error('simulated hour failure')
|
|
297
301
|
},
|
|
298
302
|
})
|
|
299
|
-
const store =
|
|
303
|
+
const store = makeStore(domain)
|
|
300
304
|
const errors = []
|
|
301
305
|
store.onFlushError = (err) => errors.push(err)
|
|
302
306
|
await store.readyPromise()
|
|
@@ -317,7 +321,7 @@ test('非 missing 错误不触发种子重试', async () => {
|
|
|
317
321
|
const domain = fakeDomain({
|
|
318
322
|
failUpdate: (candidate) => (candidate === key ? new Error('backend write failed') : null),
|
|
319
323
|
})
|
|
320
|
-
const store =
|
|
324
|
+
const store = makeStore(domain)
|
|
321
325
|
const errors = []
|
|
322
326
|
store.onFlushError = (err) => errors.push(err)
|
|
323
327
|
await store.readyPromise()
|
|
@@ -331,7 +335,7 @@ test('非 missing 错误不触发种子重试', async () => {
|
|
|
331
335
|
|
|
332
336
|
test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
|
|
333
337
|
const domain = fakeDomain()
|
|
334
|
-
const store =
|
|
338
|
+
const store = makeStore(domain)
|
|
335
339
|
const table = domain.table()
|
|
336
340
|
await table.put('D|2026-07-31|a|m', rowOf('2026-07-31', 'a', 'm'))
|
|
337
341
|
await table.put('D|2026-08-01|a|m', rowOf('2026-08-01', 'a', 'm'))
|
|
@@ -348,7 +352,7 @@ test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
|
|
|
348
352
|
|
|
349
353
|
test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async () => {
|
|
350
354
|
const domain = fakeDomain()
|
|
351
|
-
const store =
|
|
355
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
352
356
|
const table = domain.table()
|
|
353
357
|
await table.put('M|2026-08-08T11:00|a|m', rowOf('2026-08-08T11:00', 'a', 'm'))
|
|
354
358
|
await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
|
|
@@ -365,7 +369,7 @@ test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async (
|
|
|
365
369
|
|
|
366
370
|
test('保留窗口为 0 时分钟桶全部清理', async () => {
|
|
367
371
|
const domain = fakeDomain()
|
|
368
|
-
const store =
|
|
372
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
369
373
|
const table = domain.table()
|
|
370
374
|
await table.put('M|2026-08-10T11:00|a|m', rowOf('2026-08-10T11:00', 'a', 'm'))
|
|
371
375
|
await table.put('D|2026-08-10|a|m', rowOf('2026-08-10', 'a', 'm'))
|
|
@@ -376,7 +380,7 @@ test('保留窗口为 0 时分钟桶全部清理', async () => {
|
|
|
376
380
|
|
|
377
381
|
test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
|
|
378
382
|
const domain = fakeDomain()
|
|
379
|
-
const store =
|
|
383
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
380
384
|
const table = domain.table()
|
|
381
385
|
// clamp 后窗口起点 = 08-03T12:00,7 天前的桶一律清理,即使注入 30 天
|
|
382
386
|
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
@@ -388,7 +392,7 @@ test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
|
|
|
388
392
|
|
|
389
393
|
test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async () => {
|
|
390
394
|
const domain = fakeDomain()
|
|
391
|
-
const store =
|
|
395
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
392
396
|
const table = domain.table()
|
|
393
397
|
await table.put('H|2026-07-25T00:00|a|m', rowOf('2026-07-25T00:00', 'a', 'm'))
|
|
394
398
|
await table.put('H|2026-07-26T12:00|a|m', rowOf('2026-07-26T12:00', 'a', 'm'))
|
|
@@ -404,7 +408,7 @@ test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async
|
|
|
404
408
|
test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触发', async () => {
|
|
405
409
|
let clock = local(2026, 8, 10, 12, 0)
|
|
406
410
|
const domain = fakeDomain()
|
|
407
|
-
const store =
|
|
411
|
+
const store = makeStore(domain, { now: () => clock, retentionDays: () => 0 })
|
|
408
412
|
const table = domain.table()
|
|
409
413
|
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
410
414
|
await store.record(tokenSample(local(2026, 8, 10, 12, 0)))
|
|
@@ -422,7 +426,7 @@ test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触
|
|
|
422
426
|
|
|
423
427
|
test('并发 markSeenSessions 不丢 id', async () => {
|
|
424
428
|
const domain = fakeDomain()
|
|
425
|
-
const store =
|
|
429
|
+
const store = makeStore(domain)
|
|
426
430
|
await Promise.all([
|
|
427
431
|
store.markSeenSessions(['a']),
|
|
428
432
|
store.markSeenSessions(['b']),
|
|
@@ -433,7 +437,7 @@ test('并发 markSeenSessions 不丢 id', async () => {
|
|
|
433
437
|
|
|
434
438
|
test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重置', async () => {
|
|
435
439
|
const domain = fakeDomain()
|
|
436
|
-
const store =
|
|
440
|
+
const store = makeStore(domain)
|
|
437
441
|
const pending = store.markSeenSessions(['a'])
|
|
438
442
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
439
443
|
await store.reset(new Map([['live-a', 7]]))
|
|
@@ -444,7 +448,7 @@ test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重
|
|
|
444
448
|
|
|
445
449
|
test('reset 不带边界时游标清空', async () => {
|
|
446
450
|
const domain = fakeDomain()
|
|
447
|
-
const store =
|
|
451
|
+
const store = makeStore(domain)
|
|
448
452
|
await store.markLiveSequences([['s', 5]])
|
|
449
453
|
await store.reset()
|
|
450
454
|
assert.deepEqual(domain.global.get(), { backfilledSessions: [], liveFirstSeq: {} })
|
|
@@ -452,7 +456,7 @@ test('reset 不带边界时游标清空', async () => {
|
|
|
452
456
|
|
|
453
457
|
test('markLiveSequences 保留最小 seq 且无变化不写 global', async () => {
|
|
454
458
|
const domain = fakeDomain()
|
|
455
|
-
const store =
|
|
459
|
+
const store = makeStore(domain)
|
|
456
460
|
await store.markLiveSequences([['s', 5]])
|
|
457
461
|
assert.deepEqual(await store.liveSequences(), new Map([['s', 5]]))
|
|
458
462
|
await store.markLiveSequences([['s', 3], ['t', 9]])
|
|
@@ -474,7 +478,7 @@ test('游标写单次失败不断链', async () => {
|
|
|
474
478
|
}
|
|
475
479
|
return originalSet(value)
|
|
476
480
|
}
|
|
477
|
-
const store =
|
|
481
|
+
const store = makeStore(domain)
|
|
478
482
|
await assert.rejects(store.markSeenSessions(['a']))
|
|
479
483
|
await store.markSeenSessions(['b'])
|
|
480
484
|
assert.deepEqual([...(await store.seenSessions())].sort(), ['b'])
|
|
@@ -483,7 +487,7 @@ test('游标写单次失败不断链', async () => {
|
|
|
483
487
|
test('启动时游标为空且已有行则清空待重建', async () => {
|
|
484
488
|
const domain = fakeDomain()
|
|
485
489
|
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
486
|
-
const store =
|
|
490
|
+
const store = makeStore(domain)
|
|
487
491
|
await store.readyPromise()
|
|
488
492
|
assert.equal(domain.rows.size, 0)
|
|
489
493
|
})
|
|
@@ -492,7 +496,7 @@ test('启动时游标非空则既有行保留', async () => {
|
|
|
492
496
|
const domain = fakeDomain()
|
|
493
497
|
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
494
498
|
await domain.global.set({ backfilledSessions: ['old'], liveFirstSeq: {} })
|
|
495
|
-
const store =
|
|
499
|
+
const store = makeStore(domain)
|
|
496
500
|
await store.readyPromise()
|
|
497
501
|
assert.equal(domain.rows.size, 1)
|
|
498
502
|
})
|
|
@@ -500,7 +504,7 @@ test('启动时游标非空则既有行保留', async () => {
|
|
|
500
504
|
test('reset 前自动快照,restore 整体回退到快照态', async () => {
|
|
501
505
|
const domain = fakeDomain()
|
|
502
506
|
const backup = fakeDomain()
|
|
503
|
-
const store = new UsageStore(facilityOf(domain, backup))
|
|
507
|
+
const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
|
|
504
508
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
505
509
|
await store.markSeenSessions(['s1', 's2'])
|
|
506
510
|
assert.equal(store.backupInfo().available, false)
|
|
@@ -521,7 +525,7 @@ test('reset 前自动快照,restore 整体回退到快照态', async () => {
|
|
|
521
525
|
test('restore 前先快照当前态:回退动作自身可再回退', async () => {
|
|
522
526
|
const domain = fakeDomain()
|
|
523
527
|
const backup = fakeDomain()
|
|
524
|
-
const store = new UsageStore(facilityOf(domain, backup))
|
|
528
|
+
const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
|
|
525
529
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
526
530
|
await store.reset()
|
|
527
531
|
await store.record(tokenSample(local(2026, 8, 3, 14, 0), { inputTokens: 99 }))
|
|
@@ -544,7 +548,7 @@ test('备份域不可用:重建照常,backupInfo 标记不可用,restore 报错'
|
|
|
544
548
|
return domain
|
|
545
549
|
},
|
|
546
550
|
}
|
|
547
|
-
const store = new UsageStore(facility)
|
|
551
|
+
const store = new UsageStore(facility, { now: () => FIXED_NOW })
|
|
548
552
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
549
553
|
await store.reset()
|
|
550
554
|
assert.equal(domain.rows.size, 0)
|
|
@@ -631,7 +635,7 @@ for (const withCode of [true, false]) {
|
|
|
631
635
|
test('UO1 Given old owner with seen cursor and pending tokens, When new generation opens, Then old owner flushes every bucket once', async () => {
|
|
632
636
|
__resetSharedStoreForTests()
|
|
633
637
|
const facility = facilityWithReserved()
|
|
634
|
-
const old = new UsageStore(facility)
|
|
638
|
+
const old = new UsageStore(facility, { now: () => FIXED_NOW })
|
|
635
639
|
await old.readyPromise()
|
|
636
640
|
await old.markSeenSessions(['s1'])
|
|
637
641
|
await old.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
@@ -681,7 +685,7 @@ test('get 缝在场但孤儿已不存在:维持降级,不逃逸', async () => {
|
|
|
681
685
|
|
|
682
686
|
test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次 update', async () => {
|
|
683
687
|
const domain = fakeDomain()
|
|
684
|
-
const store =
|
|
688
|
+
const store = makeStore(domain)
|
|
685
689
|
await store.readyPromise()
|
|
686
690
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
687
691
|
assert.equal(domain.rows.size, 0)
|
|
@@ -693,7 +697,7 @@ test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次
|
|
|
693
697
|
|
|
694
698
|
test('同桶多样本合并:pending 累加,一次 update 落全部计数', async () => {
|
|
695
699
|
const domain = fakeDomain()
|
|
696
|
-
const store =
|
|
700
|
+
const store = makeStore(domain)
|
|
697
701
|
await store.readyPromise()
|
|
698
702
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
699
703
|
await store.record(tokenSample(local(2026, 8, 2, 14, 38)))
|
|
@@ -706,7 +710,7 @@ test('同桶多样本合并:pending 累加,一次 update 落全部计数', async
|
|
|
706
710
|
|
|
707
711
|
test('flush 周期自动落盘', async () => {
|
|
708
712
|
const domain = fakeDomain()
|
|
709
|
-
const store =
|
|
713
|
+
const store = makeStore(domain, { flushIntervalMs: 5 })
|
|
710
714
|
await store.readyPromise()
|
|
711
715
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
712
716
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
@@ -715,7 +719,7 @@ test('flush 周期自动落盘', async () => {
|
|
|
715
719
|
|
|
716
720
|
test('rangeRows 前自动 flush,读己之写', async () => {
|
|
717
721
|
const domain = fakeDomain()
|
|
718
|
-
const store =
|
|
722
|
+
const store = makeStore(domain)
|
|
719
723
|
await store.readyPromise()
|
|
720
724
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
721
725
|
const rows = await store.rangeRows('D', '2026-08-02', '2026-08-02')
|
|
@@ -725,7 +729,7 @@ test('rangeRows 前自动 flush,读己之写', async () => {
|
|
|
725
729
|
|
|
726
730
|
test('reset 前 pending 先落盘再清表', async () => {
|
|
727
731
|
const domain = fakeDomain()
|
|
728
|
-
const store =
|
|
732
|
+
const store = makeStore(domain)
|
|
729
733
|
await store.readyPromise()
|
|
730
734
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
731
735
|
await store.reset(new Map())
|
|
@@ -738,3 +742,21 @@ test('降级态 record 仍按调用失败', async () => {
|
|
|
738
742
|
await store.readyPromise()
|
|
739
743
|
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
740
744
|
})
|
|
745
|
+
|
|
746
|
+
// ── F14: 入队保留资格过滤 ──────────────────────────────────────────────────
|
|
747
|
+
test('F14: 超出档留存期的样本不入 pending 不落盘,回扫同通道受控', async () => {
|
|
748
|
+
const domain = fakeDomain()
|
|
749
|
+
// 时钟"今天" = 样本日次日 + 8 天:8/2 样本对分钟档(7 天)超期、小时档(15 天)与日档在期
|
|
750
|
+
const now = local(2026, 8, 10, 0, 0)
|
|
751
|
+
const store = new UsageStore(facilityOf(domain), { now: () => now })
|
|
752
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
753
|
+
await store.flushNow()
|
|
754
|
+
const prefixes = [...domain.rows.keys()].map((key) => key.split('|').slice(0, 2).join('|'))
|
|
755
|
+
assert.ok(!prefixes.some((p) => p.startsWith('M|')), `分钟档超期不得落行: ${prefixes.join(',')}`)
|
|
756
|
+
assert.ok(prefixes.some((p) => p.startsWith('D|')), '日档无留存上限恒写入')
|
|
757
|
+
assert.ok(prefixes.some((p) => p.startsWith('H|')), '小时档 15 天内在期')
|
|
758
|
+
// 分钟档保留 0(关闭):分钟样本一律不入队
|
|
759
|
+
const offStore = new UsageStore(facilityOf(fakeDomain()), { now: () => local(2026, 8, 2, 14, 38), retentionDays: () => 0 })
|
|
760
|
+
await offStore.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
761
|
+
assert.equal(offStore.pending.size, 2, '关闭分钟档后仅日/小时两行入队')
|
|
762
|
+
})
|