dsh-all-usage 1.1.2 → 1.1.3

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,464 @@
1
+ import { calculateCost, normalizeCostSnapshot } from './pricing.js'
2
+ import { extractUsageEvent, normalizeUsageValues as usageValues, 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
+ const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
193
+ for (let index = startIndex; index < events.length; index += 1) {
194
+ const event = events[index]
195
+ if (event === null || typeof event !== 'object') continue
196
+ if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
197
+ const data = event.data
198
+ if (event.type === 'request/context' || event.type === 'request/header') {
199
+ currentIdentity = identityFromRoute(data, currentIdentity)
200
+ continue
201
+ }
202
+ if (event.type === 'turn/end') {
203
+ if (!validEventTime(event.time)) continue
204
+ const key = ledgerEventKey(event, index)
205
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
206
+ turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
207
+ continue
208
+ }
209
+ if (!validEventTime(event.time)) continue
210
+ const usageEvent = extractUsageEvent(event)
211
+ if (usageEvent === null) continue
212
+ const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
213
+ const key = usageStepKey(sid, data, event.seq)
214
+ const result = upsertUsageSample(usage, {
215
+ key,
216
+ seq: validLedgerSeq(event.seq) ? event.seq : -1,
217
+ time: event.time,
218
+ workspaceId,
219
+ identity,
220
+ modelId: identity.label,
221
+ turn: usageEvent.turn,
222
+ step: usageEvent.step,
223
+ values: usageEvent.values,
224
+ }, { costPrevious: previousUsage.get(key) })
225
+ if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
226
+ }
227
+ 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, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
228
+ }
229
+ function normalizeLedgerRecord(raw, key) {
230
+ 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
231
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
232
+ 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)))
233
+ 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) })
234
+ // Older versions could write records whose top-level workspaceId belonged to
235
+ // a recreated workspace while the historical items kept the previous one;
236
+ // such mixed records must never be reused as a fast path.
237
+ const mixedWorkspace = raw.turns.some((turn) => turn && turn.workspaceId !== raw.workspaceId) || raw.usage.some((item) => item && item.workspaceId !== raw.workspaceId)
238
+ // The rebuild reason is persisted (enumerable) so the flag survives a
239
+ // failure-and-restart cycle even when normalized content alone no longer
240
+ // carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
241
+ const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
242
+ 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
243
+ const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
244
+ const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
245
+ const turnMap = new Map()
246
+ for (const turn of raw.turns) {
247
+ 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) })
248
+ }
249
+ const usageMap = new Map()
250
+ for (const item of raw.usage) {
251
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
252
+ const identity = coerceIdentity(item.identity || item.modelId)
253
+ const normalizedCost = normalizeCostSnapshot(item.cost)
254
+ 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 }) }
255
+ upsertUsageSampleState(usageMap, normalized)
256
+ }
257
+ const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
258
+ state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
259
+ const lastUsage = Array.from(usageMap.values()).at(-1)
260
+ const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
261
+ 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, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
262
+ if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
263
+ Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
264
+ return normalizedRecord
265
+ }
266
+ function prepareLedgerRecord(record) {
267
+ if (record === null || record === undefined) return record
268
+ let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
269
+ for (const item of record.usage) {
270
+ const cost = normalizeCostSnapshot(item.cost)
271
+ if (isLegacyTieredCost(cost)) {
272
+ item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
273
+ changed = true
274
+ continue
275
+ }
276
+ if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
277
+ const identity = item.identity || identityFromLegacy(item.modelId)
278
+ item.cost = calculateCost(item.values, resolveCurrentPricing(identity))
279
+ changed = true
280
+ }
281
+ if (changed) {
282
+ // Repacking costs must never clear the rebuild flag: only a complete
283
+ // rebuild from the source log produces a record that is safe to reuse.
284
+ record.version = LEDGER_VERSION
285
+ record.updatedAt = nextLedgerRevision()
286
+ state.ledgerRecords.set(record.sessionId, record)
287
+ void persistLedgerRecord(record)
288
+ }
289
+ return record
290
+ }
291
+ function applyLedgerRecord(record, materialization = 'ledger-reuse') {
292
+ if (record === null || record === undefined) return
293
+ prepareLedgerRecord(record)
294
+ if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
295
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
296
+ for (const item of record.usage) {
297
+ const identity = item.identity || identityFromLegacy(item.modelId)
298
+ const dates = dateKeys(item.time)
299
+ upsertUsageSample(state.usageByStep, {
300
+ key: item.key,
301
+ seq: item.seq,
302
+ wsId: item.workspaceId,
303
+ time: item.time,
304
+ date: dates.local,
305
+ dateUtc: dates.utc,
306
+ values: item.values,
307
+ identity,
308
+ modelId: identity.label,
309
+ cost: item.cost,
310
+ turn: item.turn,
311
+ step: item.step,
312
+ materialization,
313
+ sid: record.sessionId,
314
+ })
315
+ }
316
+ if (record.turns.length > 0 || record.usage.length > 0) {
317
+ state.sessionCount.add(record.sessionId)
318
+ if (record.usage.length > 0) markStatsChanged('scan')
319
+ }
320
+ }
321
+ function ledgerRank(record) {
322
+ return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
323
+ }
324
+ function replaceLedgerRecord(record) {
325
+ assertLedgerRecord(record)
326
+ state.ledgerRecords.set(record.sessionId, record)
327
+ return record
328
+ }
329
+ function assertLedgerRecord(record) {
330
+ if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
331
+ return record
332
+ }
333
+ function storeLedgerRecord(record) {
334
+ assertLedgerRecord(record)
335
+ const current = state.ledgerRecords.get(record.sessionId)
336
+ if (current !== undefined) {
337
+ const nextRank = ledgerRank(record)
338
+ const currentRank = ledgerRank(current)
339
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
340
+ }
341
+ state.ledgerRecords.set(record.sessionId, record)
342
+ return record
343
+ }
344
+ function resolveLedgerWaiters(sid) {
345
+ const waiters = state.ledgerWriteWaiters.get(sid)
346
+ if (waiters === undefined) return
347
+ state.ledgerWriteWaiters.delete(sid)
348
+ for (const resolve of waiters) resolve()
349
+ }
350
+ async function drainPendingLedgerWrites() {
351
+ while (state.ledgerPending.size > 0) {
352
+ const entry = state.ledgerPending.entries().next().value
353
+ if (entry === undefined) break
354
+ const [sid, record] = entry
355
+ state.ledgerPending.delete(sid)
356
+ if (state.ledgerUnit === null || state.ledgerRecords.get(sid) !== record) {
357
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
358
+ continue
359
+ }
360
+ try {
361
+ await state.ledgerUnit.putRecord('sessions', sid, record)
362
+ state.ledgerWriteFailedSessions.delete(sid)
363
+ } catch (err) {
364
+ state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
365
+ state.ledgerWriteFailedSessions.add(sid)
366
+ state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
367
+ state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
368
+ state.ledgerDirtySessions.add(sid)
369
+ state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
370
+ console.error('[all-usage] usage ledger write failed:', err)
371
+ }
372
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
373
+ }
374
+ }
375
+ function startLedgerWrite() {
376
+ if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
377
+ state.ledgerWriteRunning = true
378
+ const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
379
+ state.ledgerWriteChain = write.catch((err) => {
380
+ console.error('[all-usage] usage ledger queue failed:', err)
381
+ }).finally(() => {
382
+ state.ledgerWriteRunning = false
383
+ if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
384
+ })
385
+ }
386
+ function scheduleLedgerWrite() {
387
+ if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
388
+ state.ledgerWriteScheduled = true
389
+ const run = () => {
390
+ state.ledgerWriteScheduled = false
391
+ state.ledgerWriteTimer = null
392
+ startLedgerWrite()
393
+ }
394
+ state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
395
+ // Keep a reference while write waiters are pending: the debounce timer is
396
+ // what eventually resolves persistLedgerRecord promises, and an unreferenced
397
+ // timer lets the event loop (and test runs) settle before the write happens.
398
+ if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
399
+ }
400
+ function persistLedgerRecord(record) {
401
+ if (record !== null && record !== undefined) assertLedgerRecord(record)
402
+ if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
403
+ if (state.ledgerRecords.get(record.sessionId) !== record) return Promise.resolve()
404
+ const waiter = new Promise((resolve) => {
405
+ const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
406
+ waiters.push(resolve)
407
+ state.ledgerWriteWaiters.set(record.sessionId, waiters)
408
+ })
409
+ state.ledgerPending.set(record.sessionId, record)
410
+ scheduleLedgerWrite()
411
+ return waiter
412
+ }
413
+ async function drainLedgerWrites() {
414
+ if (state.ledgerWriteTimer !== null) {
415
+ clearTimeout(state.ledgerWriteTimer)
416
+ state.ledgerWriteTimer = null
417
+ }
418
+ state.ledgerWriteScheduled = false
419
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
420
+ await state.ledgerWriteChain
421
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
422
+ startLedgerWrite()
423
+ await state.ledgerWriteChain
424
+ }
425
+ for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
426
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
427
+ }
428
+ }
429
+ async function loadLedger() {
430
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
431
+ try {
432
+ const backend = storage.backend.get('json')
433
+ if (backend === undefined || backend === null || backend.kv === undefined) return
434
+ const opened = await openLedgerUnitStore(backend)
435
+ if (state.disposed) { await opened.store.close(); return }
436
+ state.ledgerUnit = opened.store
437
+ for (const [key, raw] of Object.entries(opened.rows)) {
438
+ const record = normalizeLedgerRecord(raw, key)
439
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
440
+ else state.ledgerRecords.set(key, record)
441
+ }
442
+ } catch (err) {
443
+ console.error('[all-usage] usage ledger unavailable:', err)
444
+ }
445
+ }
446
+
447
+ return {
448
+ nextLedgerRevision,
449
+ ledgerEventKey,
450
+ buildLedgerRecord,
451
+ normalizeLedgerRecord,
452
+ prepareLedgerRecord,
453
+ applyLedgerRecord,
454
+ ledgerRank,
455
+ replaceLedgerRecord,
456
+ storeLedgerRecord,
457
+ persistLedgerRecord,
458
+ drainLedgerWrites,
459
+ assertLedgerRecord,
460
+ loadLedger
461
+ }
462
+ }
463
+
464
+ export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, ledgerShardIndex }