dsh-all-usage 1.1.2 → 1.1.4

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/lib/ledger.js ADDED
@@ -0,0 +1,491 @@
1
+ import { calculateCost, normalizeCostSnapshot } from './pricing.js'
2
+ import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
3
+
4
+ const LEDGER_VERSION = 3
5
+ const PREVIOUS_LEDGER_VERSION = 2
6
+ const LEGACY_LEDGER_VERSION = 1
7
+ const LEDGER_SHARD_COUNT = 32
8
+ const LEDGER_UNIT_NAME = 'all_usage_ledger'
9
+ const LEDGER_META_KEY = '__all_usage_ledger_meta__'
10
+ const LEDGER_WRITE_DEBOUNCE_MS = 25
11
+
12
+ // Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
13
+ // sequence, otherwise a non-negative safe integer is required.
14
+ function validLedgerSeq(value) {
15
+ return value === -1 || (Number.isSafeInteger(value) && value >= 0)
16
+ }
17
+
18
+ function pollutedLedgerKey(key) {
19
+ // Older builds stored raw String(seq) in event keys. Only keys that are
20
+ // entirely numeric text (leading zeros, signs, whitespace, decimals,
21
+ // exponents, huge values) are treated as bare sequence keys and rebuilt;
22
+ // composite keys such as positional event:N and usageStepKey forms that
23
+ // contain colons, letters, or hyphens are never flagged.
24
+ if (typeof key !== 'string') return true
25
+ // Bare whole values only: composite keys such as 'Infinity-session:step:1:1'
26
+ // are valid usageStepKey output and must keep the revision fast path.
27
+ if (key === 'Infinity' || key === '-Infinity' || key === 'NaN' || key === '-NaN') return true
28
+ if (!/^[+\-0-9.eE \t]+$/.test(key)) return false
29
+ if (!/^(?:0|[1-9]\d*)$/.test(key)) return true
30
+ return !Number.isSafeInteger(Number(key))
31
+ }
32
+
33
+ function ledgerShardIndex(sessionId) {
34
+ let hash = 2166136261
35
+ const value = typeof sessionId === 'string' ? sessionId : String(sessionId)
36
+ for (let index = 0; index < value.length; index += 1) {
37
+ hash ^= value.charCodeAt(index)
38
+ hash = Math.imul(hash, 16777619)
39
+ }
40
+ return (hash >>> 0) % LEDGER_SHARD_COUNT
41
+ }
42
+
43
+ function ledgerShardName(index) {
44
+ return LEDGER_UNIT_NAME + '_' + String(index).padStart(2, '0')
45
+ }
46
+
47
+ function sessionRows(snapshot) {
48
+ const rows = snapshot && snapshot.tables && snapshot.tables.sessions
49
+ return rows !== null && rows !== undefined && typeof rows === 'object' ? rows : {}
50
+ }
51
+
52
+ function uniqueUnits(units) {
53
+ return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
54
+ }
55
+
56
+ function createLedgerUnitStore(legacyUnit, shardUnits) {
57
+ const shards = uniqueUnits(shardUnits)
58
+ const allUnits = uniqueUnits([legacyUnit, ...shards])
59
+ return {
60
+ async loadAll() {
61
+ const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
62
+ const rows = Object.create(null)
63
+ for (const snapshot of snapshots) {
64
+ for (const [key, value] of Object.entries(sessionRows(snapshot))) {
65
+ if (key !== LEDGER_META_KEY) rows[key] = value
66
+ }
67
+ }
68
+ return { global: {}, tables: { sessions: rows } }
69
+ },
70
+ async hasMigrationMarker() {
71
+ if (shardUnits[0] === undefined) return false
72
+ return Object.hasOwn(sessionRows(await shardUnits[0].loadAll()), LEDGER_META_KEY)
73
+ },
74
+ async putRecord(table, key, value) {
75
+ if (table !== 'sessions') throw new Error('usage ledger only supports the sessions table')
76
+ const index = key === LEDGER_META_KEY ? 0 : ledgerShardIndex(key)
77
+ const unit = shardUnits[index]
78
+ if (unit === undefined) throw new Error('usage ledger shard is unavailable: ' + index)
79
+ return unit.putRecord(table, key, value)
80
+ },
81
+ async close() {
82
+ await Promise.all(allUnits.map((unit) => unit.close().catch(() => {})))
83
+ },
84
+ shardCount: LEDGER_SHARD_COUNT,
85
+ }
86
+ }
87
+
88
+ async function openLedgerUnitStore(backend) {
89
+ const shardUnits = []
90
+ let legacyUnit
91
+ try {
92
+ for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
93
+ shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false }))
94
+ }
95
+ const shardStore = createLedgerUnitStore(undefined, shardUnits)
96
+ const shardRows = sessionRows(await shardStore.loadAll())
97
+ if (await shardStore.hasMigrationMarker()) return { store: shardStore, rows: shardRows }
98
+
99
+ // Read the legacy monolith only during the one-time migration. Once shard 00
100
+ // carries the marker, startup never parses the old multi-megabyte file again.
101
+ legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
102
+ const store = createLedgerUnitStore(legacyUnit, shardUnits)
103
+ const rows = Object.assign(Object.create(null), shardRows)
104
+ const legacyRows = sessionRows(await legacyUnit.loadAll())
105
+ for (const [key, value] of Object.entries(legacyRows)) {
106
+ if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
107
+ await store.putRecord('sessions', key, value)
108
+ rows[key] = value
109
+ }
110
+ await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
111
+ return { store, rows }
112
+ } catch (error) {
113
+ await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
114
+ throw error
115
+ }
116
+ }
117
+
118
+ export function createLedger(host) {
119
+ const { state, markStatsChanged } = host
120
+ const { storage } = host.services
121
+ state.ledgerPending = state.ledgerPending || new Map()
122
+ state.ledgerWriteWaiters = state.ledgerWriteWaiters || new Map()
123
+ state.ledgerWriteRunning = state.ledgerWriteRunning === true
124
+ state.ledgerWriteScheduled = state.ledgerWriteScheduled === true
125
+ state.ledgerWriteTimer = state.ledgerWriteTimer || null
126
+ state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
127
+ const {
128
+ addTurn,
129
+ coerceIdentity,
130
+ identityFromLegacy,
131
+ identityFromMessage,
132
+ identityFromRoute,
133
+ makeIdentity,
134
+ costForUsage,
135
+ resolveCurrentPricing,
136
+ dateKeys,
137
+ validEventTime,
138
+ upsertUsageSample,
139
+ } = host.aggregation
140
+ const lastSeqOf = (...args) => host.sessionSync.lastSeqOf(...args)
141
+ function isLegacyTieredCost(cost) {
142
+ const selectedTier = cost && cost.selectedTier
143
+ const hasSelectedTier = selectedTier && selectedTier.type === 'context' && Number.isSafeInteger(selectedTier.size) && selectedTier.size >= 0
144
+ return cost !== null && cost.tiered === true && cost.status === 'priced' && !hasSelectedTier
145
+ }
146
+
147
+ function nextLedgerRevision() {
148
+ const current = Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0
149
+ state.ledgerRevision = Math.max(current + 1, Date.now())
150
+ return state.ledgerRevision
151
+ }
152
+ function ledgerEventKey(event, index) {
153
+ // Only finite safe-integer sequences are canonical event ids; anything else
154
+ // falls back to the positional index so two invalid events cannot overwrite
155
+ // each other in the ledger.
156
+ return Number.isSafeInteger(event.seq) && event.seq >= 0 ? String(event.seq) : 'event:' + index
157
+ }
158
+ function canFoldLedgerTail(previousRecord, events, workspaceId) {
159
+ if (previousRecord === null || previousRecord === undefined || previousRecord.needsUpgrade === true || previousRecord.rebuildRequired !== undefined || !Number.isSafeInteger(previousRecord.lastSeq) || previousRecord.lastSeq < 0 || !Array.isArray(events) || events.length === 0) return false
160
+ // A changed workspace identity (deleted and re-added workspace gets a new id)
161
+ // assigns every historical event to a different bucket, so the previous
162
+ // record can never be reused as a tail seed for the new workspace.
163
+ if (workspaceId !== undefined && previousRecord.workspaceId !== undefined && previousRecord.workspaceId !== workspaceId) return false
164
+ const startIndex = previousRecord.lastSeq + 1
165
+ if (startIndex >= events.length || events[startIndex] === null || typeof events[startIndex] !== 'object' || events[startIndex].seq !== startIndex) return false
166
+ const previousTurns = new Set(Array.isArray(previousRecord.turns) ? previousRecord.turns.map((turn) => turn && turn.turn).filter((turn) => Number.isSafeInteger(turn)) : [])
167
+ for (let index = startIndex, expectedSeq = startIndex; index < events.length; index += 1, expectedSeq += 1) {
168
+ const event = events[index]
169
+ if (event === null || typeof event !== 'object' || event.seq !== expectedSeq) return false
170
+ if (event.type === 'turn/end' && event.data && Number.isSafeInteger(event.data.turn) && previousTurns.has(event.data.turn)) return false
171
+ }
172
+ return true
173
+ }
174
+ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
175
+ const sid = session && typeof session.id === 'string' ? session.id : ''
176
+ const events = session && Array.isArray(session.events) ? session.events : []
177
+ if (sid === '' || workspaceId === undefined) return null
178
+ // DSH sequences every persisted event with seq === log index, so the last
179
+ // event's seq is the record's lastSeq in O(1); fall back to a full scan only
180
+ // when the tail carries no seq (legacy or truncated logs).
181
+ const tailEvent = events.length > 0 ? events[events.length - 1] : null
182
+ const lastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(events)
183
+ // A changed incremental log that contains invalid sequences anywhere must
184
+ // be rebuilt wholesale: the fold check only validates the tail, so a bad
185
+ // event before the fold start would otherwise be skipped in memory while
186
+ // the persisted record still contains everything.
187
+ const canFoldTail = sequenceClean === true && canFoldLedgerTail(previousRecord, events, workspaceId)
188
+ const turns = canFoldTail ? new Map((previousRecord.turns || []).map((item) => [item.key, { ...item }])) : new Map()
189
+ const usage = canFoldTail ? new Map((previousRecord.usage || []).map((item) => [item.key, { ...item }])) : new Map()
190
+ const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
191
+ let currentIdentity = canFoldTail ? coerceIdentity(previousRecord.lastIdentity) : makeIdentity(null, null, null, null)
192
+ // Request-context instants are kept per turn/step so parallel requests each
193
+ // carry their own billing time while scanning the persisted log. When the
194
+ // tail is folded onto a persisted record, the previous context archive seeds
195
+ // the map so an open request whose context arrived in the previous batch
196
+ // still bills its usage with the matching request instant after a restart.
197
+ const contextTimes = canFoldTail ? new Map(Array.isArray(previousRecord.contextTimes) ? previousRecord.contextTimes.map((entry) => [entry.key, entry.time]) : []) : new Map()
198
+ const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
199
+ for (let index = startIndex; index < events.length; index += 1) {
200
+ const event = events[index]
201
+ if (event === null || typeof event !== 'object') continue
202
+ if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
203
+ const data = event.data
204
+ if (event.type === 'request/context') {
205
+ currentIdentity = identityFromRoute(data, currentIdentity)
206
+ if (validEventTime(event.time)) touchContextTimes(contextTimes, contextTimeKey(data && data.turn, data && data.step), event.time)
207
+ continue
208
+ }
209
+ if (event.type === 'request/header') {
210
+ currentIdentity = identityFromRoute(data, currentIdentity)
211
+ continue
212
+ }
213
+ if (event.type === 'turn/end') {
214
+ if (!validEventTime(event.time)) continue
215
+ const key = ledgerEventKey(event, index)
216
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
217
+ turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
218
+ continue
219
+ }
220
+ if (!validEventTime(event.time)) continue
221
+ const usageEvent = extractUsageEvent(event)
222
+ if (usageEvent === null) continue
223
+ const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
224
+ const key = usageStepKey(sid, data, event.seq)
225
+ const pricing = pickPricingTime(contextTimes, event.time, usageEvent.turn, usageEvent.step)
226
+ const result = upsertUsageSample(usage, {
227
+ key,
228
+ seq: validLedgerSeq(event.seq) ? event.seq : -1,
229
+ time: event.time,
230
+ workspaceId,
231
+ identity,
232
+ modelId: identity.label,
233
+ turn: usageEvent.turn,
234
+ step: usageEvent.step,
235
+ values: usageEvent.values,
236
+ pricingAt: pricing.time,
237
+ pricingTimeSource: pricing.source,
238
+ }, { costPrevious: previousUsage.get(key) })
239
+ if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
240
+ }
241
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, contextTimes: Array.from(contextTimes.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
242
+ }
243
+ function normalizeLedgerRecord(raw, key) {
244
+ if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
245
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
246
+ const invalidTurn = raw.turns.some((turn) => turn === null || typeof turn !== 'object' || typeof turn.key !== 'string' || turn.workspaceId === undefined || !validEventTime(turn.time) || pollutedLedgerKey(turn.key) || (turn.seq !== undefined && !validLedgerSeq(turn.seq)))
247
+ const invalidUsage = raw.usage.some((item) => { const cost = normalizeCostSnapshot(item && item.cost); return item === null || typeof item !== 'object' || !validEventTime(item.time) || item.identity === undefined || pollutedLedgerKey(item.key) || (item.seq !== undefined && !validLedgerSeq(item.seq)) || cost === null || cost.pricingMode !== 'official-model' || isLegacyTieredCost(cost) })
248
+ // Older versions could write records whose top-level workspaceId belonged to
249
+ // a recreated workspace while the historical items kept the previous one;
250
+ // such mixed records must never be reused as a fast path.
251
+ const mixedWorkspace = raw.turns.some((turn) => turn && turn.workspaceId !== raw.workspaceId) || raw.usage.some((item) => item && item.workspaceId !== raw.workspaceId)
252
+ // The rebuild reason is persisted (enumerable) so the flag survives a
253
+ // failure-and-restart cycle even when normalized content alone no longer
254
+ // carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
255
+ const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
256
+ const computedReason = invalidTurn ? 'invalid-turn' : invalidUsage ? 'invalid-usage' : mixedWorkspace ? 'mixed-workspace' : (raw.lastSeq !== undefined && !validLedgerSeq(raw.lastSeq)) ? 'invalid-last-seq' : raw.version !== LEDGER_VERSION ? 'legacy-version' : !Number.isFinite(raw.updatedAt) ? 'invalid-updated-at' : undefined
257
+ const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
258
+ const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
259
+ const turnMap = new Map()
260
+ for (const turn of raw.turns) {
261
+ if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && validEventTime(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: Number.isSafeInteger(turn.seq) ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
262
+ }
263
+ const usageMap = new Map()
264
+ for (const item of raw.usage) {
265
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
266
+ const identity = coerceIdentity(item.identity || item.modelId)
267
+ const normalizedCost = normalizeCostSnapshot(item.cost)
268
+ const normalized = { key: item.key, seq: Number.isSafeInteger(item.seq) ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
269
+ upsertUsageSampleState(usageMap, normalized)
270
+ }
271
+ const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
272
+ state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
273
+ const contextMap = new Map()
274
+ if (Array.isArray(raw.contextTimes)) {
275
+ for (const entry of raw.contextTimes) {
276
+ if (entry === null || typeof entry !== 'object' || typeof entry.key !== 'string' || !entry.key.startsWith('context:') || entry.key.length > 128 || !validEventTime(entry.time)) continue
277
+ touchContextTimes(contextMap, entry.key, entry.time)
278
+ }
279
+ }
280
+ const lastUsage = Array.from(usageMap.values()).at(-1)
281
+ const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
282
+ const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, contextTimes: Array.from(contextMap.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
283
+ if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
284
+ Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
285
+ return normalizedRecord
286
+ }
287
+ function prepareLedgerRecord(record) {
288
+ if (record === null || record === undefined) return record
289
+ let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
290
+ for (const item of record.usage) {
291
+ const cost = normalizeCostSnapshot(item.cost)
292
+ if (isLegacyTieredCost(cost)) {
293
+ item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
294
+ changed = true
295
+ continue
296
+ }
297
+ if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
298
+ const identity = item.identity || identityFromLegacy(item.modelId)
299
+ const billing = billingInstantOf(item, cost)
300
+ item.cost = calculateCost(item.values, resolveCurrentPricing(identity), billing.at, billing.source)
301
+ changed = true
302
+ }
303
+ if (changed) {
304
+ // Repacking costs must never clear the rebuild flag: only a complete
305
+ // rebuild from the source log produces a record that is safe to reuse.
306
+ record.version = LEDGER_VERSION
307
+ record.updatedAt = nextLedgerRevision()
308
+ state.ledgerRecords.set(record.sessionId, record)
309
+ void persistLedgerRecord(record)
310
+ }
311
+ return record
312
+ }
313
+ function applyLedgerRecord(record, materialization = 'ledger-reuse') {
314
+ if (record === null || record === undefined) return
315
+ prepareLedgerRecord(record)
316
+ if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
317
+ if (Array.isArray(record.contextTimes)) {
318
+ const times = new Map()
319
+ for (const entry of record.contextTimes) if (entry && typeof entry.key === 'string' && Number.isFinite(entry.time)) times.set(entry.key, entry.time)
320
+ state.sessionContextTimes.set(record.sessionId, times)
321
+ }
322
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
323
+ for (const item of record.usage) {
324
+ const identity = item.identity || identityFromLegacy(item.modelId)
325
+ const dates = dateKeys(item.time)
326
+ upsertUsageSample(state.usageByStep, {
327
+ key: item.key,
328
+ seq: item.seq,
329
+ wsId: item.workspaceId,
330
+ time: item.time,
331
+ date: dates.local,
332
+ dateUtc: dates.utc,
333
+ values: item.values,
334
+ identity,
335
+ modelId: identity.label,
336
+ cost: item.cost,
337
+ turn: item.turn,
338
+ step: item.step,
339
+ materialization,
340
+ sid: record.sessionId,
341
+ })
342
+ }
343
+ if (record.turns.length > 0 || record.usage.length > 0) {
344
+ state.sessionCount.add(record.sessionId)
345
+ if (record.usage.length > 0) markStatsChanged('scan')
346
+ }
347
+ }
348
+ function ledgerRank(record) {
349
+ return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
350
+ }
351
+ function replaceLedgerRecord(record) {
352
+ assertLedgerRecord(record)
353
+ state.ledgerRecords.set(record.sessionId, record)
354
+ return record
355
+ }
356
+ function assertLedgerRecord(record) {
357
+ if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
358
+ return record
359
+ }
360
+ function storeLedgerRecord(record) {
361
+ assertLedgerRecord(record)
362
+ const current = state.ledgerRecords.get(record.sessionId)
363
+ if (current !== undefined) {
364
+ const nextRank = ledgerRank(record)
365
+ const currentRank = ledgerRank(current)
366
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
367
+ }
368
+ state.ledgerRecords.set(record.sessionId, record)
369
+ return record
370
+ }
371
+ function resolveLedgerWaiters(sid) {
372
+ const waiters = state.ledgerWriteWaiters.get(sid)
373
+ if (waiters === undefined) return
374
+ state.ledgerWriteWaiters.delete(sid)
375
+ for (const resolve of waiters) resolve()
376
+ }
377
+ async function drainPendingLedgerWrites() {
378
+ while (state.ledgerPending.size > 0) {
379
+ const entry = state.ledgerPending.entries().next().value
380
+ if (entry === undefined) break
381
+ const [sid, record] = entry
382
+ state.ledgerPending.delete(sid)
383
+ if (state.ledgerUnit === null || state.ledgerRecords.get(sid) !== record) {
384
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
385
+ continue
386
+ }
387
+ try {
388
+ await state.ledgerUnit.putRecord('sessions', sid, record)
389
+ state.ledgerWriteFailedSessions.delete(sid)
390
+ } catch (err) {
391
+ state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
392
+ state.ledgerWriteFailedSessions.add(sid)
393
+ state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
394
+ state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
395
+ state.ledgerDirtySessions.add(sid)
396
+ state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
397
+ console.error('[all-usage] usage ledger write failed:', err)
398
+ }
399
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
400
+ }
401
+ }
402
+ function startLedgerWrite() {
403
+ if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
404
+ state.ledgerWriteRunning = true
405
+ const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
406
+ state.ledgerWriteChain = write.catch((err) => {
407
+ console.error('[all-usage] usage ledger queue failed:', err)
408
+ }).finally(() => {
409
+ state.ledgerWriteRunning = false
410
+ if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
411
+ })
412
+ }
413
+ function scheduleLedgerWrite() {
414
+ if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
415
+ state.ledgerWriteScheduled = true
416
+ const run = () => {
417
+ state.ledgerWriteScheduled = false
418
+ state.ledgerWriteTimer = null
419
+ startLedgerWrite()
420
+ }
421
+ state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
422
+ // Keep a reference while write waiters are pending: the debounce timer is
423
+ // what eventually resolves persistLedgerRecord promises, and an unreferenced
424
+ // timer lets the event loop (and test runs) settle before the write happens.
425
+ if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
426
+ }
427
+ function persistLedgerRecord(record) {
428
+ if (record !== null && record !== undefined) assertLedgerRecord(record)
429
+ if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
430
+ if (state.ledgerRecords.get(record.sessionId) !== record) return Promise.resolve()
431
+ const waiter = new Promise((resolve) => {
432
+ const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
433
+ waiters.push(resolve)
434
+ state.ledgerWriteWaiters.set(record.sessionId, waiters)
435
+ })
436
+ state.ledgerPending.set(record.sessionId, record)
437
+ scheduleLedgerWrite()
438
+ return waiter
439
+ }
440
+ async function drainLedgerWrites() {
441
+ if (state.ledgerWriteTimer !== null) {
442
+ clearTimeout(state.ledgerWriteTimer)
443
+ state.ledgerWriteTimer = null
444
+ }
445
+ state.ledgerWriteScheduled = false
446
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
447
+ await state.ledgerWriteChain
448
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
449
+ startLedgerWrite()
450
+ await state.ledgerWriteChain
451
+ }
452
+ for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
453
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
454
+ }
455
+ }
456
+ async function loadLedger() {
457
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
458
+ try {
459
+ const backend = storage.backend.get('json')
460
+ if (backend === undefined || backend === null || backend.kv === undefined) return
461
+ const opened = await openLedgerUnitStore(backend)
462
+ if (state.disposed) { await opened.store.close(); return }
463
+ state.ledgerUnit = opened.store
464
+ for (const [key, raw] of Object.entries(opened.rows)) {
465
+ const record = normalizeLedgerRecord(raw, key)
466
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
467
+ else state.ledgerRecords.set(key, record)
468
+ }
469
+ } catch (err) {
470
+ console.error('[all-usage] usage ledger unavailable:', err)
471
+ }
472
+ }
473
+
474
+ return {
475
+ nextLedgerRevision,
476
+ ledgerEventKey,
477
+ buildLedgerRecord,
478
+ normalizeLedgerRecord,
479
+ prepareLedgerRecord,
480
+ applyLedgerRecord,
481
+ ledgerRank,
482
+ replaceLedgerRecord,
483
+ storeLedgerRecord,
484
+ persistLedgerRecord,
485
+ drainLedgerWrites,
486
+ assertLedgerRecord,
487
+ loadLedger
488
+ }
489
+ }
490
+
491
+ export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, ledgerShardIndex }