dsh-usage-badge 0.1.2 → 0.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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * dsh-usage-badge — where the folded history is kept between runs.
3
+ *
4
+ * The folder hands this layer one session at a time and asks nothing about storage. Two
5
+ * backends sit behind that interface:
6
+ *
7
+ * - **sqlite** (default). One row per (session, day). A fold compares the session against its
8
+ * stored rows and writes only the days whose payload actually changed — measured 0.12 ms for a
9
+ * one-day session, and 4.98 ms for a 400-day one (the comparison still reads those rows, so the
10
+ * cost tracks the changed session's size, not the table's). Loading takes only the window the
11
+ * panel can show, which bounds resident memory the same way. Older rows stay on disk, which is
12
+ * the point: history is never deleted, it is just not all held in memory at once.
13
+ * It uses `node:sqlite`, which ships inside Node itself — no dependency to install, nothing
14
+ * to compile, no external tool for a user to run.
15
+ * - **json**: the original single-document cache, kept as the fallback for a runtime without
16
+ * `node:sqlite` and reachable on purpose with `DSH_USAGE_BADGE_CACHE=json`. It rewrites the
17
+ * whole document on every save and loads all of it, which is exactly the shape SQLite exists
18
+ * to avoid — so it is a compatibility path, not the recommended one.
19
+ *
20
+ * Both backends keep whatever they are given: neither ever deletes history on its own. The only
21
+ * thing that removes a day is the folder folding a session whose log no longer contains it.
22
+ *
23
+ * Beyond `put`/`load` they answer two questions the panel needs: `years()` — which calendar
24
+ * years the stored history covers, so the year picker is built from the data and a new year
25
+ * appears the first day it has a request, with nothing to configure — and a bounded range read
26
+ * (`load({ until })`) for reproducing one of those years. A range read is a reporting path: it
27
+ * never feeds a write back, which is what keeps the window optimisation safe.
28
+ *
29
+ * @module dsh-usage-badge/cache-store
30
+ */
31
+
32
+ import { createRequire } from 'node:module'
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
34
+ import { basename, join } from 'node:path'
35
+
36
+ import { deserializeDay, deserializeDays, serializeDay, serializeDays } from './buckets.js'
37
+ import { dayKey } from './pricing.js'
38
+
39
+ /**
40
+ * How much history is read back into memory at once, in calendar days.
41
+ *
42
+ * Wider than what the snapshot asks for (`DAYS_KEPT` in `index.js` is 400 *days that have usage*,
43
+ * which can reach further back than 400 calendar days), so the window never truncates a day the
44
+ * snapshot would have wanted. Older rows are not deleted — only left on disk until a caller asks
45
+ * for them, which is what the year view does.
46
+ */
47
+ export const CACHE_WINDOW_DAYS = 430
48
+
49
+ /** The document version the JSON backend writes; the original cache format, unchanged. */
50
+ const JSON_CACHE_VERSION = 1
51
+
52
+ /** The SQLite schema version, kept in `PRAGMA user_version`. */
53
+ const SQLITE_SCHEMA_VERSION = 1
54
+
55
+ const SCHEMA = `
56
+ CREATE TABLE IF NOT EXISTS sessions (
57
+ id TEXT PRIMARY KEY,
58
+ path TEXT NOT NULL,
59
+ file_size INTEGER NOT NULL,
60
+ file_mtime_ms REAL NOT NULL,
61
+ seq INTEGER NOT NULL,
62
+ provider TEXT,
63
+ model TEXT
64
+ );
65
+ CREATE TABLE IF NOT EXISTS days (
66
+ session_id TEXT NOT NULL,
67
+ date TEXT NOT NULL,
68
+ payload TEXT NOT NULL,
69
+ PRIMARY KEY (session_id, date)
70
+ ) WITHOUT ROWID;
71
+ CREATE INDEX IF NOT EXISTS days_by_date ON days (date);
72
+ `
73
+
74
+ /** The oldest day key a caller may ask for, given a window in days. */
75
+ const windowStart = (days, now = Date.now()) => dayKey(now - days * 86400000)
76
+
77
+ /**
78
+ * Resolve the bounds `load` accepts.
79
+ *
80
+ * Two kinds, deliberately named apart because confusing them is silent: `windowDays` is
81
+ * relative ("the last N days", what the folder loads) while `since`/`until` are absolute day
82
+ * keys ("that calendar year", what the year view asks for). `absolute` says which kind was
83
+ * used, because a backend that cannot window without losing history must ignore a relative
84
+ * bound and return everything.
85
+ */
86
+ function resolveBounds({ windowDays, since, until } = {}) {
87
+ return {
88
+ // An absolute upper bound with no lower bound means "everything up to then"; defaulting the
89
+ // lower bound to the loading window would quietly hide older days from a range read.
90
+ from: since ?? (until ? '0000-01-01' : windowStart(windowDays ?? CACHE_WINDOW_DAYS)),
91
+ until: until ?? '9999-12-31',
92
+ absolute: since !== undefined || until !== undefined,
93
+ }
94
+ }
95
+
96
+ /** A session's days restricted to `[since, until]`, both optional. */
97
+ function filterDays(days, since, until) {
98
+ if (since === undefined && until === undefined) return days
99
+ const kept = new Map()
100
+ for (const [date, dayObj] of days) {
101
+ if (since !== undefined && date < since) continue
102
+ if (until !== undefined && date > until) continue
103
+ kept.set(date, dayObj)
104
+ }
105
+ return kept
106
+ }
107
+
108
+ /** Read a JSON cache document, or null when it is missing or of an unknown version. */
109
+ function readJsonDocument(path) {
110
+ let raw
111
+ try {
112
+ raw = JSON.parse(readFileSync(path, 'utf8'))
113
+ } catch {
114
+ return null
115
+ }
116
+ if (!raw || raw.version !== JSON_CACHE_VERSION || !raw.sessions || typeof raw.sessions !== 'object') return null
117
+ return raw
118
+ }
119
+
120
+ /** A numeric field stored as JSON, keeping a legitimate `0` (and its sign) instead of `-1`. */
121
+ const storedNumber = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback)
122
+
123
+ /** One stored session document → the record shape the folder works with. */
124
+ function documentToRecord(sessionId, source) {
125
+ if (!source || typeof source !== 'object') return null
126
+ return {
127
+ sessionId,
128
+ path: String(source.path ?? ''),
129
+ fileSize: Number(source.fileSize) || 0,
130
+ fileMtimeMs: Number(source.fileMtimeMs) || 0,
131
+ seq: storedNumber(source.seq, -1),
132
+ provider: source.provider ?? null,
133
+ model: source.model ?? null,
134
+ days: deserializeDays(source.days),
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The original single-document cache.
140
+ *
141
+ * `load()` returns everything it holds: it cannot answer a window without losing the rest,
142
+ * because a save rewrites the document from what was loaded. That is the trade the SQLite
143
+ * backend removes, and the only reason it is still here is runtimes without `node:sqlite`.
144
+ */
145
+ function createJsonStore({ path, log }) {
146
+ let document = null
147
+ let dirty = false
148
+ let timer = null
149
+
150
+ const write = () => {
151
+ dirty = false
152
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
153
+ try {
154
+ mkdirSync(join(path, '..'), { recursive: true })
155
+ writeFileSync(temp, JSON.stringify(document))
156
+ try {
157
+ renameSync(temp, path)
158
+ } catch {
159
+ // Windows cannot rename over an existing file; the complete temp file is already on
160
+ // disk, so removing the old one first loses nothing.
161
+ try {
162
+ unlinkSync(path)
163
+ } catch {
164
+ // absent is fine
165
+ }
166
+ renameSync(temp, path)
167
+ }
168
+ } catch (error) {
169
+ try {
170
+ unlinkSync(temp)
171
+ } catch {
172
+ // best-effort cleanup only
173
+ }
174
+ log(`cache save failed: ${error?.message ?? error}`)
175
+ }
176
+ }
177
+
178
+ /** Cancel the pending batch, if any, and write now. */
179
+ const flush = () => {
180
+ if (timer) {
181
+ clearTimeout(timer)
182
+ timer = null
183
+ }
184
+ if (dirty) write()
185
+ }
186
+
187
+ /**
188
+ * Move an unreadable `cache.json` aside, once, instead of overwriting it.
189
+ *
190
+ * A cache file this build cannot parse — a torn write, a hand-edit gone wrong, a document from
191
+ * a newer version — would otherwise be replaced by the first save, losing whatever it held for
192
+ * no reason. The SQLite side already keeps such a file; this is the same promise for the
193
+ * fallback. A file that does not exist is nothing to preserve.
194
+ */
195
+ const quarantine = () => {
196
+ if (!existsSync(path)) return
197
+ const aside = `${basename(path)}.corrupt-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
198
+ try {
199
+ renameSync(path, join(path, '..', aside))
200
+ log(`${basename(path)} could not be parsed as a cache document; moved it aside as ${aside} and started a fresh one`)
201
+ } catch (error) {
202
+ log(`${basename(path)} could not be parsed and could not be moved aside (${error?.message ?? error}); it will be replaced on the next save`)
203
+ }
204
+ }
205
+
206
+ return {
207
+ kind: 'json',
208
+ path,
209
+ load(bounds = {}) {
210
+ const { from, until, absolute } = resolveBounds(bounds)
211
+ // Keep the document this store already holds while a write is pending: re-reading the file
212
+ // here would silently discard the fold that `put` just accepted, because the pending timer
213
+ // would then write that stale copy back. This is why an absolute range read (the year view)
214
+ // must not reload either — it is a reporting read, not a refresh.
215
+ if (!document || !dirty) {
216
+ const loaded = readJsonDocument(path)
217
+ if (loaded) document = loaded
218
+ else {
219
+ quarantine()
220
+ document = { version: JSON_CACHE_VERSION, sessions: {} }
221
+ }
222
+ }
223
+ const records = Object.entries(document.sessions)
224
+ .map(([sessionId, source]) => documentToRecord(sessionId, source))
225
+ .filter(Boolean)
226
+ // This backend writes back exactly what it holds, so it cannot honour a *relative* window
227
+ // without dropping history on the next save: it returns everything. An absolute range is a
228
+ // reporting read and safe to filter.
229
+ if (!absolute) return records
230
+ return records.map((record) => ({ ...record, days: filterDays(record.days, from, until) }))
231
+ },
232
+ years() {
233
+ const years = new Set()
234
+ for (const source of Object.values(document?.sessions ?? {})) {
235
+ for (const date of Object.keys(source?.days ?? {})) years.add(String(date).slice(0, 4))
236
+ }
237
+ return [...years].filter((year) => /^\d{4}$/.test(year)).sort().reverse()
238
+ },
239
+ put(record) {
240
+ if (!document) document = { version: JSON_CACHE_VERSION, sessions: {} }
241
+ document.sessions[record.sessionId] = {
242
+ path: record.path,
243
+ fileSize: record.fileSize,
244
+ fileMtimeMs: record.fileMtimeMs,
245
+ seq: record.seq,
246
+ provider: record.provider,
247
+ model: record.model,
248
+ days: serializeDays(record.days),
249
+ }
250
+ dirty = true
251
+ if (timer) return
252
+ // Batched, because this backend pays for the whole history on every save.
253
+ timer = setTimeout(() => {
254
+ timer = null
255
+ if (dirty) write()
256
+ }, 2000)
257
+ timer.unref?.()
258
+ },
259
+ flush,
260
+ // The same thing here, and a plain function rather than `this.flush()`, so a caller that
261
+ // pulled it off the object still closes cleanly.
262
+ close: flush,
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Open a database, apply the pragmas and bring the schema up to this version.
268
+ *
269
+ * Anything that can fail here — the file is not a database, it is truncated, it is locked by
270
+ * another program — closes the handle before rethrowing, so a caller that decides to give up on
271
+ * the file does not leave it locked (on Windows that would also stop a user deleting it).
272
+ *
273
+ * The schema version is read **before** any write pragma runs, for two reasons: `journal_mode =
274
+ * WAL` is persistent (running it on a newer version's file would modify a database this build
275
+ * does not understand), and on a read-only file it fails with a message that would otherwise be
276
+ * mistaken for corruption — and be "repaired" by throwing the user's history away.
277
+ *
278
+ * @returns {{db:object, version:number, writable:boolean}} the open handle, the schema version it
279
+ * declared, and whether this build may write to it.
280
+ */
281
+ function openDatabase(DatabaseSync, path, log) {
282
+ const closeQuietly = (handle) => {
283
+ try {
284
+ handle?.close()
285
+ } catch {
286
+ // the handle is already unusable
287
+ }
288
+ }
289
+
290
+ const probe = new DatabaseSync(path)
291
+ let version
292
+ try {
293
+ version = Number(probe.prepare('PRAGMA user_version').get()?.user_version ?? 0)
294
+ } catch (error) {
295
+ closeQuietly(probe)
296
+ throw error
297
+ }
298
+
299
+ if (version > SQLITE_SCHEMA_VERSION) {
300
+ // A downgrade. This handle is read-only from the start, so nothing here — not even a pragma —
301
+ // changes a byte of a file written by a version that knows more than this one does.
302
+ closeQuietly(probe)
303
+ const db = new DatabaseSync(path, { readOnly: true })
304
+ db.exec('PRAGMA busy_timeout = 3000')
305
+ log(`cache ${basename(path)} was written by a newer version (schema ${version}); using it read-only`)
306
+ return { db, version, writable: false }
307
+ }
308
+
309
+ try {
310
+ // Before `journal_mode`: a lock held by another host must be waited out, not read as a
311
+ // failure to open (which used to end in the file being moved aside).
312
+ probe.exec('PRAGMA busy_timeout = 3000')
313
+ probe.exec('PRAGMA journal_mode = WAL')
314
+ // A cache does not need full durability: the WAL survives a process crash, and a power loss
315
+ // can only cost the last few folded days, which the session logs can supply again.
316
+ probe.exec('PRAGMA synchronous = NORMAL')
317
+ probe.exec(SCHEMA)
318
+ if (version !== SQLITE_SCHEMA_VERSION) probe.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`)
319
+ return { db: probe, version, writable: true }
320
+ } catch (error) {
321
+ closeQuietly(probe)
322
+ throw error
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Whether a failure means "these bytes are not a database", as opposed to "this process may not
328
+ * use this database right now". Only the first is worth repairing by moving the file aside.
329
+ */
330
+ function isUnreadableFile(error) {
331
+ const code = Number(error?.errcode)
332
+ if (code === 11 || code === 26) return true // SQLITE_CORRUPT, SQLITE_NOTADB
333
+ return /not a database|malformed|file is encrypted/i.test(String(error?.message ?? ''))
334
+ }
335
+
336
+ /** Open a database read-only, or null when even that is not possible. */
337
+ function openReadOnly(DatabaseSync, path, log) {
338
+ try {
339
+ const db = new DatabaseSync(path, { readOnly: true })
340
+ db.exec('PRAGMA busy_timeout = 3000')
341
+ return db
342
+ } catch (error) {
343
+ log(`cache ${basename(path)} could not be opened read-only either: ${error?.message ?? error}`)
344
+ return null
345
+ }
346
+ }
347
+
348
+ /**
349
+ * The SQLite backend. Opens (creating if needed), migrates an old `cache.json` into it once,
350
+ * and refuses to write to a database written by a newer schema version rather than guessing.
351
+ *
352
+ * A failure thrown out of here carries `cacheKeepFile` when the file itself is fine but this
353
+ * build cannot use it, which tells the caller not to touch it.
354
+ */
355
+ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null, writable: givenWritable = null }) {
356
+ mkdirSync(join(path, '..'), { recursive: true })
357
+ const opened = given ? { db: given, writable: givenWritable, version: null } : openDatabase(DatabaseSync, path, log)
358
+ const { db } = opened
359
+ const writable = opened.writable !== false
360
+
361
+ /** Prepare, marking any failure as "do not touch this file" — it opened, so it is a database. */
362
+ const prepare = (sql) => {
363
+ try {
364
+ return db.prepare(sql)
365
+ } catch (error) {
366
+ try {
367
+ db.close()
368
+ } catch {
369
+ // nothing else to do
370
+ }
371
+ error.cacheKeepFile = true
372
+ throw error
373
+ }
374
+ }
375
+
376
+ const selectSessions = prepare('SELECT id, path, file_size, file_mtime_ms, seq, provider, model FROM sessions')
377
+ const selectDays = prepare('SELECT session_id, date, payload FROM days WHERE date >= ? AND date <= ? ORDER BY date')
378
+ // Which years exist is a question only the store can answer: the folder holds a window, not
379
+ // the whole history, and the panel's year list has to include years older than that window.
380
+ const selectYears = prepare("SELECT DISTINCT substr(date, 1, 4) AS year FROM days WHERE date GLOB '[0-9][0-9][0-9][0-9]-*' ORDER BY year DESC")
381
+ const countSessions = prepare('SELECT COUNT(*) AS n FROM sessions')
382
+ const upsertSession = prepare(
383
+ `INSERT INTO sessions (id, path, file_size, file_mtime_ms, seq, provider, model)
384
+ VALUES (?, ?, ?, ?, ?, ?, ?)
385
+ ON CONFLICT(id) DO UPDATE SET
386
+ path = excluded.path, file_size = excluded.file_size, file_mtime_ms = excluded.file_mtime_ms,
387
+ seq = excluded.seq, provider = excluded.provider, model = excluded.model`,
388
+ )
389
+ const selectSessionDays = prepare('SELECT date, payload FROM days WHERE session_id = ?')
390
+ const deleteDay = prepare('DELETE FROM days WHERE session_id = ? AND date = ?')
391
+ const insertDay = prepare('INSERT INTO days (session_id, date, payload) VALUES (?, ?, ?)')
392
+
393
+ /**
394
+ * Write one session: its cursor row, plus only the days whose stored payload really differs.
395
+ *
396
+ * The folder hands over a whole session, so the day *set* is authoritative — a day the record no
397
+ * longer has is deleted, which is how a log that shrank behaves (exactly as it did when this was
398
+ * one JSON document). But a day that did not change is not rewritten: a long-lived session is
399
+ * re-folded on every poll that sees its log grow, and re-inserting its whole history each time
400
+ * would make a save cost grow with that session's age.
401
+ */
402
+ const writeSession = (record) => {
403
+ upsertSession.run(
404
+ record.sessionId,
405
+ record.path,
406
+ record.fileSize,
407
+ record.fileMtimeMs,
408
+ record.seq,
409
+ record.provider,
410
+ record.model,
411
+ )
412
+ const stored = new Map(selectSessionDays.all(record.sessionId).map((row) => [row.date, row.payload]))
413
+ for (const [date, dayObj] of record.days ?? []) {
414
+ const payload = JSON.stringify(serializeDay(dayObj))
415
+ const known = stored.get(date)
416
+ stored.delete(date)
417
+ if (known === payload) continue
418
+ if (known !== undefined) deleteDay.run(record.sessionId, date)
419
+ insertDay.run(record.sessionId, date, payload)
420
+ }
421
+ // Whatever is left was stored and is not in the record any more.
422
+ for (const date of stored.keys()) deleteDay.run(record.sessionId, date)
423
+ }
424
+
425
+ /** Bring an existing JSON cache across, once, keeping the old file for inspection. */
426
+ const migrateFromJson = () => {
427
+ // Only into an empty database, and only when there is something to bring across. A
428
+ // database that already has rows is never re-read from the JSON file, so a failed rename
429
+ // below cannot cause the same history to be folded in twice.
430
+ if (!writable) return
431
+ if ((countSessions.get()?.n ?? 0) > 0) {
432
+ // Rows already exist, so this JSON file is a leftover — from a run that used the JSON
433
+ // backend deliberately, from a period spent on the fallback, or from an older host that
434
+ // wrote it after the migration. Those folds are not merged (a day-by-day merge of two
435
+ // generations of the same session could double count), but silently ignoring history is
436
+ // exactly what this project does not do, so it is said out loud.
437
+ if (existsSync(jsonPath)) {
438
+ log(
439
+ `note: ${basename(jsonPath)} sits beside a non-empty ${basename(path)} and is not read; ` +
440
+ 'its days are either already in the database or re-foldable from the session logs, and it can be deleted',
441
+ )
442
+ }
443
+ return
444
+ }
445
+ if (!existsSync(jsonPath)) return
446
+ const document = readJsonDocument(jsonPath)
447
+ if (!document) return
448
+ const records = Object.entries(document.sessions)
449
+ .map(([sessionId, source]) => documentToRecord(sessionId, source))
450
+ .filter(Boolean)
451
+ if (records.length === 0) return
452
+ try {
453
+ db.exec('BEGIN IMMEDIATE')
454
+ for (const record of records) writeSession(record)
455
+ db.exec('COMMIT')
456
+ } catch (error) {
457
+ try {
458
+ db.exec('ROLLBACK')
459
+ } catch {
460
+ // the transaction is already gone
461
+ }
462
+ log(`cache migration failed, keeping the JSON cache: ${error?.message ?? error}`)
463
+ return
464
+ }
465
+ const kept = `${jsonPath}.migrated`
466
+ // Fold the migrated rows out of the write-ahead log and into the database file, so the
467
+ // result of a migration is one file a user can look at rather than a large `-wal` beside it.
468
+ try {
469
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)')
470
+ } catch {
471
+ // A checkpoint that does not happen now will happen on the next open.
472
+ }
473
+ try {
474
+ renameSync(jsonPath, kept)
475
+ log(`cache migrated into ${basename(path)} (${records.length} session(s)); the old file is kept as ${basename(kept)}`)
476
+ } catch {
477
+ // Non-fatal: the database is no longer empty, so the JSON file will not be read again.
478
+ log(`cache migrated into ${basename(path)}; ${basename(jsonPath)} could not be renamed and is now ignored`)
479
+ }
480
+ }
481
+ migrateFromJson()
482
+
483
+ return {
484
+ kind: 'sqlite',
485
+ path,
486
+ writable,
487
+ load(bounds = {}) {
488
+ const { from, until } = resolveBounds(bounds)
489
+ const byId = new Map()
490
+ for (const row of selectSessions.all()) {
491
+ byId.set(row.id, {
492
+ sessionId: row.id,
493
+ path: row.path,
494
+ fileSize: Number(row.file_size) || 0,
495
+ fileMtimeMs: Number(row.file_mtime_ms) || 0,
496
+ seq: storedNumber(row.seq, -1),
497
+ provider: row.provider ?? null,
498
+ model: row.model ?? null,
499
+ days: new Map(),
500
+ })
501
+ }
502
+ for (const row of selectDays.all(from, until)) {
503
+ const record = byId.get(row.session_id)
504
+ if (!record) continue
505
+ try {
506
+ record.days.set(row.date, deserializeDay(JSON.parse(row.payload)))
507
+ } catch {
508
+ // A malformed row is skipped rather than losing the whole session.
509
+ }
510
+ }
511
+ return [...byId.values()]
512
+ },
513
+ years() {
514
+ return selectYears.all().map((row) => String(row.year))
515
+ },
516
+ put(record) {
517
+ if (!writable) return
518
+ try {
519
+ db.exec('BEGIN IMMEDIATE')
520
+ writeSession(record)
521
+ db.exec('COMMIT')
522
+ } catch (error) {
523
+ try {
524
+ db.exec('ROLLBACK')
525
+ } catch {
526
+ // the transaction is already gone
527
+ }
528
+ log(`cache write failed for ${record.sessionId}: ${error?.message ?? error}`)
529
+ }
530
+ },
531
+ flush() {
532
+ // Every put is already committed; nothing is batched.
533
+ },
534
+ close() {
535
+ try {
536
+ db.close()
537
+ } catch {
538
+ // closing twice, or a database already closed with the host, is not an error here
539
+ }
540
+ },
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Open the cache store this runtime should use.
546
+ *
547
+ * Several different failures can stop the SQLite backend, and they are answered differently rather
548
+ * than under one message:
549
+ *
550
+ * - **the module is missing** (a runtime without `node:sqlite`): fall back to the JSON cache;
551
+ * - **the file is not a database** (a torn write, another program's file, a half-copied backup):
552
+ * move it aside once, bytes intact, and start a fresh one. History is re-derivable from the
553
+ * session logs, so this is a repair rather than a loss — and it is the difference between one
554
+ * bad start and paying the slow backend on every start from then on;
555
+ * - **the file cannot be written** (permissions, a lock, read-only media, a restored backup whose
556
+ * ACLs did not come with it): use it **read-only** and leave it exactly where it is. Rotating a
557
+ * healthy database aside to fix a permission problem would throw the history away;
558
+ * - **the file is a newer version's**: same answer — read-only, untouched — because a downgrade
559
+ * must not destroy data it does not understand.
560
+ *
561
+ * @param {{dir:string, log?:Function, env?:object}} options
562
+ * @returns {{kind:string, path:string, load:Function, years:Function, put:Function, flush:Function, close:Function}}
563
+ */
564
+ export function createCacheStore({ dir, log = () => {}, env = process.env }) {
565
+ const jsonPath = join(dir, 'cache.json')
566
+ const sqlitePath = join(dir, 'cache.sqlite')
567
+ const preference = String(env.DSH_USAGE_BADGE_CACHE ?? '').trim().toLowerCase()
568
+ const jsonFallback = () => createJsonStore({ path: jsonPath, log })
569
+ if (preference === 'json') return jsonFallback()
570
+
571
+ let DatabaseSync
572
+ try {
573
+ // Touching the module is the availability check: an older runtime that cannot load it falls
574
+ // back below rather than failing the host half.
575
+ ;({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite'))
576
+ } catch (error) {
577
+ log(
578
+ preference === 'sqlite'
579
+ ? `the sqlite cache was requested but node:sqlite could not be loaded (${error?.message ?? error}); using the JSON cache`
580
+ : `node:sqlite is unavailable here (${error?.message ?? error}); using the JSON cache`,
581
+ )
582
+ return jsonFallback()
583
+ }
584
+
585
+ try {
586
+ return createSqliteStore({ path: sqlitePath, jsonPath, log, DatabaseSync })
587
+ } catch (error) {
588
+ // Not being able to *write* a database is not the same as the file being broken, and the
589
+ // difference decides whether the user's history survives: permissions, a lock held by another
590
+ // host, read-only media or a restored backup are all "cannot use it right now". Repairing
591
+ // those by moving the file aside would throw away years of history to fix an ACL, so the file
592
+ // is left alone and read read-only instead.
593
+ if (!isUnreadableFile(error)) {
594
+ if (error?.cacheKeepFile) {
595
+ log(`cache ${basename(sqlitePath)} belongs to a newer version of this plugin and cannot be read here; leaving it untouched and using the JSON cache`)
596
+ return jsonFallback()
597
+ }
598
+ const db = openReadOnly(DatabaseSync, sqlitePath, log)
599
+ if (db) {
600
+ log(`cache ${basename(sqlitePath)} could not be opened for writing (${error?.message ?? error}); using it read-only and leaving it alone`)
601
+ try {
602
+ return createSqliteStore({ path: sqlitePath, jsonPath, log, DatabaseSync, db, writable: false })
603
+ } catch (readOnlyError) {
604
+ log(`cache ${basename(sqlitePath)} could not be used read-only either (${readOnlyError?.message ?? readOnlyError})`)
605
+ }
606
+ }
607
+ log(`cache ${basename(sqlitePath)} could not be opened (${error?.message ?? error}); leaving it alone and using the JSON cache`)
608
+ return jsonFallback()
609
+ }
610
+
611
+ // Genuinely not a database: repair it once, keeping the bytes for inspection. The name
612
+ // carries pid, time and entropy so two rotations cannot collide.
613
+ const aside = `${basename(sqlitePath)}.corrupt-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
614
+ try {
615
+ renameSync(sqlitePath, join(dir, aside))
616
+ for (const sibling of ['-wal', '-shm']) {
617
+ try {
618
+ renameSync(`${sqlitePath}${sibling}`, join(dir, `${aside}${sibling}`))
619
+ } catch {
620
+ // Usually absent; a leftover one is ignored by SQLite when the header does not match.
621
+ }
622
+ }
623
+ log(`cache ${basename(sqlitePath)} could not be read (${error?.message ?? error}); moved it aside as ${aside} and started a fresh one`)
624
+ return createSqliteStore({ path: sqlitePath, jsonPath, log, DatabaseSync })
625
+ } catch (retryError) {
626
+ log(
627
+ `cache ${basename(sqlitePath)} could not be read (${error?.message ?? error}) and could not be moved aside ` +
628
+ `(${retryError?.message ?? retryError}); using the JSON cache`,
629
+ )
630
+ return jsonFallback()
631
+ }
632
+ }
633
+ }