dsh-usage-badge 0.1.3 → 0.1.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 +18 -3
- package/assets/usage-dialog.png +0 -0
- package/docs/design-notes.md +389 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +574 -129
- package/lib/client.js +350 -5
- package/lib/fold.js +20 -10
- package/lib/index.js +469 -5
- package/lib/sync.js +1179 -0
- package/lib/webdav.js +532 -0
- package/package.json +3 -1
package/lib/buckets.js
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-usage-badge — the token bucket model
|
|
2
|
+
* dsh-usage-badge — the token bucket model, its stored rows, and how rows resolve.
|
|
3
3
|
*
|
|
4
4
|
* A bucket is one (provider, model) route on one day: token totals, a 24-slot hourly
|
|
5
5
|
* breakdown for the day chart, and the raw per-request records that let the pricing layer
|
|
6
6
|
* apply a different multiplier to each request rather than pricing a day flat.
|
|
7
7
|
*
|
|
8
|
-
* This module owns
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* This module owns two things that two very different places must agree on:
|
|
9
|
+
*
|
|
10
|
+
* 1. **the bucket shape and its JSON form** — the folder builds buckets while reading
|
|
11
|
+
* session logs, and the cache store moves them in and out;
|
|
12
|
+
* 2. **row identity and the resolve rule** — because a day can exist in more than one copy
|
|
13
|
+
* (a session folded before its log was shortened, or history synced in from another
|
|
14
|
+
* device), and which copy counts has to be decided in exactly one place.
|
|
15
|
+
*
|
|
16
|
+
* ## Why rows, and why they can be plural
|
|
17
|
+
*
|
|
18
|
+
* A **row** is one `(session, date)` fold: the buckets for that day, plus the stamp of the log
|
|
19
|
+
* version it came from. The stamp must travel *with the row* rather than be read from the
|
|
20
|
+
* session cursor, because a later fold of the same session overwrites that cursor.
|
|
21
|
+
*
|
|
22
|
+
* A fold normally **replaces** the row for a date it folded: a log that grows produces a
|
|
23
|
+
* strictly larger fold, and keeping every generation of a day would cost a full day's
|
|
24
|
+
* per-request records per fold — measured at ~75 KB for a thousand requests, and a long-lived
|
|
25
|
+
* session is re-folded daily. A row is kept as *superseded* only when the new fold is
|
|
26
|
+
* **smaller**, which is exactly the signal of a shortened log, a restored backup, or a renamed
|
|
27
|
+
* session directory. That keeps the normal path free of duplicates while making the loss of a
|
|
28
|
+
* log non-destructive: the fuller fold stays until something fuller still arrives.
|
|
29
|
+
*
|
|
30
|
+
* `resolveRows` then collapses the copies per `(session, date)`: the more complete log version
|
|
31
|
+
* wins (higher `seq`, then larger `fileSize`). The tie-break is "last fold wins", because two
|
|
32
|
+
* folds of an identical stamp are the same bytes, and preferring the freshest keeps the result
|
|
33
|
+
* stable rather than dependent on insertion order.
|
|
12
34
|
*
|
|
13
35
|
* @module dsh-usage-badge/buckets
|
|
14
36
|
*/
|
|
@@ -113,3 +135,213 @@ export function deserializeDays(plain) {
|
|
|
113
135
|
}
|
|
114
136
|
return days
|
|
115
137
|
}
|
|
138
|
+
|
|
139
|
+
// ── rows and resolution ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Where a row came from: this device's own fold, or history synced in from another. */
|
|
142
|
+
export const LOCAL_SOURCE = 'local'
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Which copy of a `(session, date, source)` a row is.
|
|
146
|
+
*
|
|
147
|
+
* `current` is the fold the stored cursor stands on — the one that answers the question "what does
|
|
148
|
+
* this session's log say about that day". `fallback` is a copy that was folded from a log version
|
|
149
|
+
* the cursor has since moved past: a shorter log, a restored backup, a session directory that got
|
|
150
|
+
* renamed. Two copies of the same key must be able to coexist (a full one and a short one), which is
|
|
151
|
+
* why the slot is part of the row key rather than something inferred at read time — inferring it
|
|
152
|
+
* would mean an unsatisfiable "insert a second row for this key" during a fold.
|
|
153
|
+
*/
|
|
154
|
+
export const CURRENT = 'current'
|
|
155
|
+
export const FALLBACK = 'fallback'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The stamp a row carries: which log version this fold was made from.
|
|
159
|
+
*
|
|
160
|
+
* `seq` is the largest event sequence number seen in the log. It, not `mtime`, is the
|
|
161
|
+
* cross-machine arbiter: a file's mtime moves for reasons that have nothing to do with its
|
|
162
|
+
* content (a restore, a copy, a cloud client touching it), and clocks differ between machines,
|
|
163
|
+
* while `seq` comes from the log itself.
|
|
164
|
+
*/
|
|
165
|
+
export const stampOf = (record) => ({
|
|
166
|
+
seq: Number(record?.seq ?? -1) || 0,
|
|
167
|
+
fileSize: Number(record?.fileSize ?? 0) || 0,
|
|
168
|
+
fileMtimeMs: Number(record?.fileMtimeMs ?? 0) || 0,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/** The stamp stored on a row, so it stays readable after the session cursor moves on. */
|
|
172
|
+
export const rowStamp = (row) => ({
|
|
173
|
+
seq: Number(row?.seq ?? -1) || 0,
|
|
174
|
+
fileSize: Number(row?.fileSize ?? 0) || 0,
|
|
175
|
+
fileMtimeMs: Number(row?.fileMtimeMs ?? 0) || 0,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether `candidate` describes at least as much of the session as `incumbent`.
|
|
180
|
+
*
|
|
181
|
+
* Strictly greater on `seq`, or an equal `seq` with a file at least as large. Equal stamps
|
|
182
|
+
* describe equal folds; the caller decides those by recency.
|
|
183
|
+
*/
|
|
184
|
+
export function rowIsNewerOrEqual(candidate, incumbent) {
|
|
185
|
+
if (!incumbent) return true
|
|
186
|
+
const a = rowStamp(candidate)
|
|
187
|
+
const b = rowStamp(incumbent)
|
|
188
|
+
if (a.seq !== b.seq) return a.seq > b.seq
|
|
189
|
+
return a.fileSize >= b.fileSize
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A row's `source`, defaulting to this device for anything stored before rows existed. */
|
|
193
|
+
export const rowSource = (row) => String(row?.source ?? LOCAL_SOURCE)
|
|
194
|
+
|
|
195
|
+
/** A row's slot: `current` for the fold the cursor stands on, `fallback` for a superseded copy. */
|
|
196
|
+
export const rowKind = (row) => (String(row?.kind ?? CURRENT) === FALLBACK ? FALLBACK : CURRENT)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether `candidate` describes a **strictly** more complete fold than `incumbent`.
|
|
200
|
+
*
|
|
201
|
+
* The distinction from {@link rowIsNewerOrEqual} is what keeps resolution from depending on arrival
|
|
202
|
+
* order: two copies of the same log version are the same bytes, so neither should replace the other
|
|
203
|
+
* on sight — the tie belongs to the later fold's `at`, decided in one place by the resolver.
|
|
204
|
+
*/
|
|
205
|
+
export function rowIsStrictlyNewer(candidate, incumbent) {
|
|
206
|
+
if (!incumbent) return true
|
|
207
|
+
const a = rowStamp(candidate)
|
|
208
|
+
const b = rowStamp(incumbent)
|
|
209
|
+
if (a.seq !== b.seq) return a.seq > b.seq
|
|
210
|
+
return a.fileSize > b.fileSize
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The rows a session record stands for: one per date, all carrying the record's stamp.
|
|
215
|
+
*
|
|
216
|
+
* `at` is the fold time. It is the tie-break when two copies describe the same log version —
|
|
217
|
+
* the same bytes folded twice — where "the last one to arrive wins" keeps the outcome from
|
|
218
|
+
* depending on insertion order.
|
|
219
|
+
*/
|
|
220
|
+
export function rowsOf(record, { source = LOCAL_SOURCE, at = Date.now(), kind = CURRENT } = {}) {
|
|
221
|
+
const stamp = stampOf(record)
|
|
222
|
+
const rows = []
|
|
223
|
+
for (const [date, dayObj] of record?.days ?? []) {
|
|
224
|
+
rows.push({ date, routes: serializeDay(dayObj), ...stamp, source, kind, at })
|
|
225
|
+
}
|
|
226
|
+
return rows
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Normalize whatever a backend read into rows.
|
|
231
|
+
*
|
|
232
|
+
* Legacy shapes are accepted rather than rejected: a row with no stamp takes the session
|
|
233
|
+
* cursor's, and a row whose buckets are nested under `payload` (or which *is* the bucket map)
|
|
234
|
+
* is read as-is. Every field is optional because the point is to keep an older database
|
|
235
|
+
* readable, not to enforce a schema at read time.
|
|
236
|
+
*/
|
|
237
|
+
export function normalizeRows(record, rows, { source = LOCAL_SOURCE, at = 0 } = {}) {
|
|
238
|
+
const fallback = stampOf(record)
|
|
239
|
+
const out = []
|
|
240
|
+
for (const row of rows ?? []) {
|
|
241
|
+
if (!row || typeof row !== 'object') continue
|
|
242
|
+
const date = row.date ?? row.day
|
|
243
|
+
if (typeof date !== 'string' || date === '') continue
|
|
244
|
+
const rawRoutes = row.routes ?? row.payload ?? row
|
|
245
|
+
out.push({
|
|
246
|
+
date,
|
|
247
|
+
routes: rawRoutes && typeof rawRoutes === 'object' ? rawRoutes : {},
|
|
248
|
+
seq: row.seq ?? fallback.seq,
|
|
249
|
+
fileSize: row.fileSize ?? fallback.fileSize,
|
|
250
|
+
fileMtimeMs: row.fileMtimeMs ?? fallback.fileMtimeMs,
|
|
251
|
+
source: row.source ?? source,
|
|
252
|
+
kind: rowKind(row),
|
|
253
|
+
at: Number(row.at ?? at) || 0,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Collapse each session's rows to one row per date, and rebuild the `days` maps the rest of
|
|
261
|
+
* the plugin expects.
|
|
262
|
+
*
|
|
263
|
+
* Resolution is per `(session, date)` and never across sessions: two different sessions on the
|
|
264
|
+
* same day are two different pieces of usage and are summed by `mergeRecords`, while two copies
|
|
265
|
+
* of the *same* session's day are the same usage seen twice and must not be.
|
|
266
|
+
*
|
|
267
|
+
* @param {Iterable<object>} records - records whose `rows` arrays may hold several copies.
|
|
268
|
+
* @returns {object[]} records with a resolved `days` Map and their winning stamp.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveRows(records) {
|
|
271
|
+
const sessions = new Map()
|
|
272
|
+
for (const record of records ?? []) {
|
|
273
|
+
const sessionId = String(record?.sessionId ?? '')
|
|
274
|
+
let entry = sessions.get(sessionId)
|
|
275
|
+
if (!entry) {
|
|
276
|
+
entry = { sessionId, path: record?.path ?? '', provider: record?.provider ?? null, model: record?.model ?? null, rows: [], winners: new Map(), fileSize: record?.fileSize, fileMtimeMs: record?.fileMtimeMs, seq: record?.seq }
|
|
277
|
+
sessions.set(sessionId, entry)
|
|
278
|
+
}
|
|
279
|
+
for (const row of normalizeRows(record, record?.rows)) {
|
|
280
|
+
entry.rows.push(row)
|
|
281
|
+
// A `fallback` copy never competes: it is kept for the day the fuller copy is gone, not to
|
|
282
|
+
// argue with it. It still travels in `rows`, because the sync layer exports copies.
|
|
283
|
+
if (rowKind(row) === FALLBACK) continue
|
|
284
|
+
const incumbent = entry.winners.get(row.date)
|
|
285
|
+
// A fuller log version wins outright; between two copies of the same version (the same
|
|
286
|
+
// bytes folded twice) the later fold wins, so the result does not depend on read order.
|
|
287
|
+
const equalVersion = incumbent !== undefined && rowStamp(row).seq === rowStamp(incumbent).seq && rowStamp(row).fileSize === rowStamp(incumbent).fileSize
|
|
288
|
+
const wins = incumbent === undefined
|
|
289
|
+
|| (rowIsNewerOrEqual(row, incumbent) && (equalVersion ? (row.at ?? 0) >= (incumbent.at ?? -1) : true))
|
|
290
|
+
if (wins) entry.winners.set(row.date, row)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const resolved = []
|
|
295
|
+
for (const entry of sessions.values()) {
|
|
296
|
+
const days = new Map()
|
|
297
|
+
const byDate = [...entry.winners.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
298
|
+
for (const row of byDate) days.set(row.date, deserializeDay(row.routes))
|
|
299
|
+
const winner = byDate[0] ?? null
|
|
300
|
+
resolved.push({
|
|
301
|
+
sessionId: entry.sessionId,
|
|
302
|
+
path: entry.path,
|
|
303
|
+
fileSize: winner?.fileSize ?? entry.fileSize ?? 0,
|
|
304
|
+
fileMtimeMs: winner?.fileMtimeMs ?? entry.fileMtimeMs ?? 0,
|
|
305
|
+
seq: winner?.seq ?? entry.seq ?? -1,
|
|
306
|
+
provider: entry.provider,
|
|
307
|
+
model: entry.model,
|
|
308
|
+
days,
|
|
309
|
+
rows: entry.rows,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return resolved
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One row restricted to `[since, until]`, both optional and both inclusive day keys. */
|
|
316
|
+
export const rowWithin = (row, since, until) =>
|
|
317
|
+
(since === undefined || row.date >= since) && (until === undefined || row.date <= until)
|
|
318
|
+
|
|
319
|
+
/** Keep only the rows whose date falls inside the bounds. */
|
|
320
|
+
export function filterRows(rows, since, until) {
|
|
321
|
+
if (since === undefined && until === undefined) return rows ?? []
|
|
322
|
+
return (rows ?? []).filter((row) => rowWithin(row, since, until))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Bring a day map written in the pre-row storage format in as rows.
|
|
327
|
+
*
|
|
328
|
+
* The old document kept `{ date: { routeKey: bucket } }` with one stamp per session, so every
|
|
329
|
+
* date it holds becomes a row carrying that stamp. Those rows contain no unresolved
|
|
330
|
+
* duplicates, which is why an upgrade can import them unchanged.
|
|
331
|
+
*/
|
|
332
|
+
export function legacyDayRows(days, stamp = {}) {
|
|
333
|
+
const rows = []
|
|
334
|
+
for (const [date, routes] of Object.entries(days ?? {})) {
|
|
335
|
+
if (!routes || typeof routes !== 'object') continue
|
|
336
|
+
rows.push({
|
|
337
|
+
date,
|
|
338
|
+
routes,
|
|
339
|
+
seq: stamp.seq ?? -1,
|
|
340
|
+
fileSize: stamp.fileSize ?? 0,
|
|
341
|
+
fileMtimeMs: stamp.fileMtimeMs ?? 0,
|
|
342
|
+
source: LOCAL_SOURCE,
|
|
343
|
+
at: 0,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
return rows
|
|
347
|
+
}
|