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.
@@ -4,27 +4,46 @@
4
4
  * The folder hands this layer one session at a time and asks nothing about storage. Two
5
5
  * backends sit behind that interface:
6
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.
7
+ * - **sqlite** (default). One row per (session, date) **copy**. A fold compares the session
8
+ * against its stored rows and writes only the days whose payload actually changed — measured
9
+ * 0.12 ms for a one-day session, and 4.98 ms for a 400-day one (the comparison still reads
10
+ * those rows, so the cost tracks the changed session's size, not the table's). Loading takes
11
+ * only the window the panel can show, which bounds resident memory the same way. Older rows
12
+ * stay on disk, which is the point: history is never deleted, it is just not all held in
13
+ * memory at once. It uses `node:sqlite`, which ships inside Node itself — no dependency to
14
+ * install, nothing to compile, no external tool for a user to run.
15
15
  * - **json**: the original single-document cache, kept as the fallback for a runtime without
16
16
  * `node:sqlite` and reachable on purpose with `DSH_USAGE_BADGE_CACHE=json`. It rewrites the
17
17
  * whole document on every save and loads all of it, which is exactly the shape SQLite exists
18
18
  * to avoid — so it is a compatibility path, not the recommended one.
19
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.
20
+ * Both backends keep whatever they are given: neither ever deletes a day on its own. A day goes
21
+ * away only when the folder folds a session whose log no longer contains it — and even then only
22
+ * if the fold is not *smaller* than what is stored, because a shorter fold is the signature of a
23
+ * shortened log or a restored backup and must not erase a fuller copy. A shorter fold is stored
24
+ * **beside** the fuller row (the rule and its reasoning live in `buckets.js`), and `resolveRows`
25
+ * picks the winner when reading.
22
26
  *
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.
27
+ * ## What a record looks like on the way in and out
28
+ *
29
+ * In: `{sessionId, path, fileSize, fileMtimeMs, seq, provider, model, days: Map<date, Map<key, bucket>>}`
30
+ * as the folder builds it.
31
+ *
32
+ * Out of `load()`: the same record with its rows **resolved** — one bucket map per date, the
33
+ * fuller log version having won — plus `rows`, every copy the store holds, each with its own
34
+ * `date`, `seq`, `fileSize`, `fileMtimeMs`, `source` and `at`. `scan()` is the same shape without
35
+ * resolution: it exists for the sync layer, which exports the local copies and must not export one
36
+ * it merely downloaded.
37
+ *
38
+ * Resolving inside the store rather than at the call site is what keeps the copies invisible to
39
+ * everything that only wants usage numbers: the folder, the snapshot and the year view all read
40
+ * `record.days` and know nothing about sources.
41
+ *
42
+ * Beyond `put`/`load` they answer two questions the panel needs: `years()` — which calendar years
43
+ * the stored history covers, so the year picker is built from the data and a new year appears the
44
+ * first day it has a request, with nothing to configure — and a bounded range read
45
+ * (`load({ since, until })`) for reproducing one of those years. A range read is a reporting path:
46
+ * it never feeds a write back, which is what keeps the window optimisation safe.
28
47
  *
29
48
  * @module dsh-usage-badge/cache-store
30
49
  */
@@ -33,7 +52,7 @@ import { createRequire } from 'node:module'
33
52
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
34
53
  import { basename, join } from 'node:path'
35
54
 
36
- import { deserializeDay, deserializeDays, serializeDay, serializeDays } from './buckets.js'
55
+ import { CURRENT, FALLBACK, LOCAL_SOURCE, legacyDayRows, normalizeRows, resolveRows, rowIsNewerOrEqual, rowIsStrictlyNewer, rowKind, rowSource, rowsOf } from './buckets.js'
37
56
  import { dayKey } from './pricing.js'
38
57
 
39
58
  /**
@@ -46,11 +65,19 @@ import { dayKey } from './pricing.js'
46
65
  */
47
66
  export const CACHE_WINDOW_DAYS = 430
48
67
 
49
- /** The document version the JSON backend writes; the original cache format, unchanged. */
50
- const JSON_CACHE_VERSION = 1
68
+ /** The document version this backend writes. Version 1 is the pre-row shape, still read. */
69
+ const JSON_CACHE_VERSION = 2
51
70
 
52
- /** The SQLite schema version, kept in `PRAGMA user_version`. */
53
- const SQLITE_SCHEMA_VERSION = 1
71
+ /**
72
+ * The SQLite schema version, kept in `PRAGMA user_version`.
73
+ *
74
+ * Version 2 added the row model: `source` (which device a row came from), `kind` (whether the row
75
+ * is the fold the cursor stands on or a superseded copy kept beside it), `at` (the fold time, the
76
+ * tie-break between two copies of the same log version) and `seq` / `file_size` / `file_mtime_ms`
77
+ * on the day row itself, so a **fallback** row keeps the stamp of the log version it was folded
78
+ * from even after the session cursor has moved on.
79
+ */
80
+ const SQLITE_SCHEMA_VERSION = 2
54
81
 
55
82
  const SCHEMA = `
56
83
  CREATE TABLE IF NOT EXISTS sessions (
@@ -65,8 +92,14 @@ CREATE TABLE IF NOT EXISTS sessions (
65
92
  CREATE TABLE IF NOT EXISTS days (
66
93
  session_id TEXT NOT NULL,
67
94
  date TEXT NOT NULL,
95
+ source TEXT NOT NULL,
96
+ kind TEXT NOT NULL,
97
+ seq INTEGER NOT NULL,
98
+ file_size INTEGER NOT NULL,
99
+ file_mtime_ms REAL NOT NULL,
100
+ at REAL NOT NULL,
68
101
  payload TEXT NOT NULL,
69
- PRIMARY KEY (session_id, date)
102
+ PRIMARY KEY (session_id, date, source, kind)
70
103
  ) WITHOUT ROWID;
71
104
  CREATE INDEX IF NOT EXISTS days_by_date ON days (date);
72
105
  `
@@ -93,16 +126,27 @@ function resolveBounds({ windowDays, since, until } = {}) {
93
126
  }
94
127
  }
95
128
 
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)
129
+ /** A session's rows restricted to `[since, until]`, both optional. */
130
+ function within(rows, since, until) {
131
+ if (since === undefined && until === undefined) return rows
132
+ return rows.filter((row) => (since === undefined || row.date >= since) && (until === undefined || row.date <= until))
133
+ }
134
+
135
+ /**
136
+ * Parse one stored payload into a row, or null when it is not readable as one.
137
+ *
138
+ * The stored payload **is** the route map, so it is handed to `normalizeRows` under `routes`
139
+ * explicitly. Passing it as the row itself would send the normalizer looking for a `date` field
140
+ * inside a bucket, find none, and quietly drop every day.
141
+ */
142
+ function parseRow(source, date, raw, meta = {}) {
143
+ try {
144
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
145
+ return normalizeRows(source, [{ date, routes: parsed, ...meta }], { at: meta.at ?? 0 })[0] ?? null
146
+ } catch {
147
+ // A malformed row is skipped rather than losing the whole session.
148
+ return null
104
149
  }
105
- return kept
106
150
  }
107
151
 
108
152
  /** Read a JSON cache document, or null when it is missing or of an unknown version. */
@@ -113,7 +157,7 @@ function readJsonDocument(path) {
113
157
  } catch {
114
158
  return null
115
159
  }
116
- if (!raw || raw.version !== JSON_CACHE_VERSION || !raw.sessions || typeof raw.sessions !== 'object') return null
160
+ if (!raw || (raw.version !== JSON_CACHE_VERSION && raw.version !== 1) || !raw.sessions || typeof raw.sessions !== 'object') return null
117
161
  return raw
118
162
  }
119
163
 
@@ -123,15 +167,23 @@ const storedNumber = (value, fallback) => (Number.isFinite(Number(value)) ? Numb
123
167
  /** One stored session document → the record shape the folder works with. */
124
168
  function documentToRecord(sessionId, source) {
125
169
  if (!source || typeof source !== 'object') return null
170
+ const stamp = {
171
+ seq: storedNumber(source.seq, -1),
172
+ fileSize: Number(source.fileSize) || 0,
173
+ fileMtimeMs: Number(source.fileMtimeMs) || 0,
174
+ }
175
+ // Version 1 kept a single `{date: {key: bucket}}` map with one stamp per session. Its days become
176
+ // rows carrying that stamp, which is what lets an upgrade keep reading them.
177
+ const rows = Array.isArray(source.days)
178
+ ? source.days.flatMap((row) => normalizeRows(stamp, [row]))
179
+ : legacyDayRows(source.days ?? {}, stamp)
126
180
  return {
127
181
  sessionId,
128
182
  path: String(source.path ?? ''),
129
- fileSize: Number(source.fileSize) || 0,
130
- fileMtimeMs: Number(source.fileMtimeMs) || 0,
131
- seq: storedNumber(source.seq, -1),
183
+ ...stamp,
132
184
  provider: source.provider ?? null,
133
185
  model: source.model ?? null,
134
- days: deserializeDays(source.days),
186
+ rows,
135
187
  }
136
188
  }
137
189
 
@@ -203,58 +255,205 @@ function createJsonStore({ path, log }) {
203
255
  }
204
256
  }
205
257
 
258
+ /**
259
+ * The document this store holds, read from disk the first time.
260
+ *
261
+ * Kept in memory across `put` calls while a write is pending: re-reading here would discard the
262
+ * fold that `put` just accepted, because the pending timer would then write that stale copy
263
+ * back. This is also why an absolute range read (the year view) must not reload — it is a
264
+ * reporting read, not a refresh.
265
+ */
266
+ const ensureDocument = () => {
267
+ if (document && dirty) return document
268
+ const loaded = readJsonDocument(path)
269
+ if (loaded) document = loaded
270
+ else {
271
+ quarantine()
272
+ document = { version: JSON_CACHE_VERSION, sessions: {} }
273
+ }
274
+ return document
275
+ }
276
+
206
277
  return {
207
278
  kind: 'json',
208
279
  path,
209
280
  load(bounds = {}) {
210
281
  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)
282
+ const doc = ensureDocument()
283
+ const records = Object.entries(doc.sessions)
224
284
  .map(([sessionId, source]) => documentToRecord(sessionId, source))
225
285
  .filter(Boolean)
226
286
  // This backend writes back exactly what it holds, so it cannot honour a *relative* window
227
287
  // without dropping history on the next save: it returns everything. An absolute range is a
228
288
  // reporting read and safe to filter.
229
- if (!absolute) return records
230
- return records.map((record) => ({ ...record, days: filterDays(record.days, from, until) }))
289
+ if (!absolute) return resolveRows(records)
290
+ return resolveRows(records.map((record) => ({ ...record, rows: within(record.rows, from, until) })))
291
+ },
292
+ scan() {
293
+ const doc = ensureDocument()
294
+ return Object.entries(doc.sessions)
295
+ .map(([sessionId, source]) => documentToRecord(sessionId, source))
296
+ .filter(Boolean)
231
297
  },
232
298
  years() {
233
299
  const years = new Set()
234
300
  for (const source of Object.values(document?.sessions ?? {})) {
235
- for (const date of Object.keys(source?.days ?? {})) years.add(String(date).slice(0, 4))
301
+ const dates = Array.isArray(source?.days) ? source.days.map((row) => row?.date) : Object.keys(source?.days ?? {})
302
+ for (const date of dates) if (date) years.add(String(date).slice(0, 4))
236
303
  }
237
304
  return [...years].filter((year) => /^\d{4}$/.test(year)).sort().reverse()
238
305
  },
239
- put(record) {
240
- if (!document) document = { version: JSON_CACHE_VERSION, sessions: {} }
241
- document.sessions[record.sessionId] = {
306
+ /**
307
+ * Store one fold and hand back the resolved view of that session.
308
+ *
309
+ * The return value matters: a fold that covered **less** than what was already stored leaves
310
+ * the fuller copy in place, and the caller must go on showing the fuller one rather than the
311
+ * short fold it just produced. Returning the resolved record is what makes "the cache kept
312
+ * it" true in memory as well as on disk.
313
+ *
314
+ * The rules are the SQLite backend's, expressed on the document: a fold replaces its own
315
+ * `current` rows, a weaker fold lands in the `fallback` slot instead of over the fuller copy,
316
+ * another source's rows are never a fold's to judge, and a date the log no longer covers is
317
+ * dropped only when the log version is not itself weaker than what is stored (a shortened log is
318
+ * a rollback, not a deletion).
319
+ */
320
+ put(record, options = {}) {
321
+ const doc = ensureDocument()
322
+ const at = options.at ?? Date.now()
323
+ const previous = documentToRecord(record.sessionId, doc.sessions[record.sessionId])
324
+ const incoming = rowsOf(record, { source: LOCAL_SOURCE, at })
325
+ const incomingKeys = new Set(incoming.map((row) => `${row.date}\u0000${row.source}`))
326
+ const cursor = { seq: record.seq ?? -1, fileSize: record.fileSize ?? 0 }
327
+ const kept = []
328
+ for (const row of previous?.rows ?? []) {
329
+ const key = `${row.date}\u0000${rowSource(row)}`
330
+ // A row survives the fold unless the fold is entitled to remove it:
331
+ // - another source's copy is never a fold's to judge, and neither is a `fallback` copy —
332
+ // that is a log version the cursor has moved past, kept on purpose;
333
+ // - its own `current` row the fold still covers is replaced by the incoming copy below;
334
+ // - its own `current` row the fold does **not** cover goes only if this log version still
335
+ // reaches back to it. A row older than the incoming cursor was folded from a fuller log
336
+ // — a shorter one now, or a restored backup — and it is all that is left of those days.
337
+ const theirs = rowSource(row) !== LOCAL_SOURCE || rowKind(row) === FALLBACK
338
+ const keep = theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
339
+ if (keep) kept.push(row)
340
+ }
341
+ // Grouped by `(date, source, kind)`: the `current` and `fallback` copies of one date share a
342
+ // date and a source but are different rows, and a map keyed without the slot would let one
343
+ // silently overwrite the other.
344
+ const group = (rows) => new Map(rows.map((row) => [`${row.date}\u0000${row.source}\u0000${rowKind(row)}`, row]))
345
+ const merged = group(kept)
346
+ for (const row of incoming) {
347
+ const key = `${row.date}\u0000${row.source}\u0000${CURRENT}`
348
+ const existing = merged.get(key) ?? null
349
+ if (!rowIsStrictlyNewer(row, existing)) {
350
+ // A short fold goes into the `fallback` slot beside the fuller copy rather than replacing
351
+ // it — that copy is the log's own current truth, and all that is left if the fuller one
352
+ // ever goes away.
353
+ merged.set(`${row.date}\u0000${row.source}\u0000${FALLBACK}`, { ...row, kind: FALLBACK })
354
+ continue
355
+ }
356
+ merged.set(key, row)
357
+ }
358
+ kept.length = 0
359
+ kept.push(...merged.values())
360
+ doc.sessions[record.sessionId] = {
242
361
  path: record.path,
243
362
  fileSize: record.fileSize,
244
363
  fileMtimeMs: record.fileMtimeMs,
245
364
  seq: record.seq,
246
365
  provider: record.provider,
247
366
  model: record.model,
248
- days: serializeDays(record.days),
367
+ days: kept.map((row) => ({
368
+ date: row.date,
369
+ routes: row.routes,
370
+ seq: row.seq,
371
+ fileSize: row.fileSize,
372
+ fileMtimeMs: row.fileMtimeMs,
373
+ source: rowSource(row),
374
+ kind: rowKind(row),
375
+ at: row.at ?? 0,
376
+ })),
249
377
  }
250
378
  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?.()
379
+ if (!timer) {
380
+ // Batched, because this backend pays for the whole history on every save.
381
+ timer = setTimeout(() => {
382
+ timer = null
383
+ if (dirty) write()
384
+ }, 2000)
385
+ timer.unref?.()
386
+ }
387
+ return resolveRows([documentToRecord(record.sessionId, doc.sessions[record.sessionId])])[0] ?? null
388
+ },
389
+ /**
390
+ * Store rows that came from another device, and hand back how many were new.
391
+ *
392
+ * The SQLite backend's rules on the document: never delete, and store a row only when it is
393
+ * strictly fuller than the one already held for that `(session, date, source, slot)`. Grouped by
394
+ * session because a shard's rows arrive together but a document is edited per session.
395
+ */
396
+ intakeRows(rows) {
397
+ const doc = ensureDocument()
398
+ const bySession = new Map()
399
+ for (const row of rows ?? []) {
400
+ if (!row?.sessionId || typeof row.date !== 'string') continue
401
+ let list = bySession.get(row.sessionId)
402
+ if (!list) {
403
+ list = []
404
+ bySession.set(row.sessionId, list)
405
+ }
406
+ list.push(row)
407
+ }
408
+ let written = 0
409
+ for (const [sessionId, incoming] of bySession) {
410
+ const existing = documentToRecord(sessionId, doc.sessions[sessionId])
411
+ const merged = new Map((existing?.rows ?? []).map((row) => [`${row.date}\u0000${rowSource(row)}\u0000${rowKind(row)}`, row]))
412
+ for (const row of incoming) {
413
+ const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
414
+ const key = `${row.date}\u0000${row.source}\u0000${kind}`
415
+ const known = merged.get(key)
416
+ if (known && !rowIsStrictlyNewer(row, known)) continue
417
+ merged.set(key, { ...row, kind })
418
+ written++
419
+ }
420
+ const rows_ = [...merged.values()]
421
+ const fullest = rows_.reduce((top, row) => (top === null || rowIsStrictlyNewer(row, top) ? row : top), null)
422
+ const previous = doc.sessions[sessionId]
423
+ // The cursor has to exist for the days to be readable at all (`readRecords` walks the
424
+ // sessions and hangs the days off them), and it must never regress: it is the stamp the fold
425
+ // path compares its own log version against.
426
+ const keepCursor = previous && !rowIsStrictlyNewer(fullest ?? {}, previous)
427
+ doc.sessions[sessionId] = {
428
+ path: previous?.path ?? '',
429
+ fileSize: keepCursor ? previous.fileSize : fullest?.fileSize ?? 0,
430
+ fileMtimeMs: keepCursor ? previous.fileMtimeMs : fullest?.fileMtimeMs ?? 0,
431
+ seq: keepCursor ? previous.seq : fullest?.seq ?? -1,
432
+ provider: previous?.provider ?? null,
433
+ model: previous?.model ?? null,
434
+ days: rows_.map((row) => ({
435
+ date: row.date,
436
+ routes: row.routes,
437
+ seq: row.seq,
438
+ fileSize: row.fileSize,
439
+ fileMtimeMs: row.fileMtimeMs,
440
+ source: rowSource(row),
441
+ kind: rowKind(row),
442
+ at: row.at ?? 0,
443
+ })),
444
+ }
445
+ }
446
+ if (written > 0) {
447
+ dirty = true
448
+ if (!timer) {
449
+ timer = setTimeout(() => {
450
+ timer = null
451
+ if (dirty) write()
452
+ }, 2000)
453
+ timer.unref?.()
454
+ }
455
+ }
456
+ return written
258
457
  },
259
458
  flush,
260
459
  // The same thing here, and a plain function rather than `this.flush()`, so a caller that
@@ -314,6 +513,7 @@ function openDatabase(DatabaseSync, path, log) {
314
513
  // A cache does not need full durability: the WAL survives a process crash, and a power loss
315
514
  // can only cost the last few folded days, which the session logs can supply again.
316
515
  probe.exec('PRAGMA synchronous = NORMAL')
516
+ migrateSchema(probe, version, log)
317
517
  probe.exec(SCHEMA)
318
518
  if (version !== SQLITE_SCHEMA_VERSION) probe.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`)
319
519
  return { db: probe, version, writable: true }
@@ -323,6 +523,66 @@ function openDatabase(DatabaseSync, path, log) {
323
523
  }
324
524
  }
325
525
 
526
+ /**
527
+ * Bring a database written by an older build up to this schema.
528
+ *
529
+ * Checked by asking the file which columns it has, not by trusting `user_version` alone: a file
530
+ * whose version number was rewritten, or whose `days` table predates the stamp columns, must be
531
+ * migrated rather than hit with an INSERT naming columns it does not have. The old rows are
532
+ * brought across rather than dropped — an upgrade that silently emptied the cache would look
533
+ * exactly like a bug.
534
+ */
535
+ function migrateSchema(db, version, log) {
536
+ const columns = (table) => {
537
+ try {
538
+ return db.prepare(`PRAGMA table_info(${table})`).all().map((row) => String(row.name))
539
+ } catch {
540
+ return []
541
+ }
542
+ }
543
+ const dayColumns = columns('days')
544
+ if (dayColumns.length === 0 || dayColumns.includes('kind')) return
545
+
546
+ log(`cache schema ${version} predates the row model; migrating its days table`)
547
+ const previous = []
548
+ try {
549
+ for (const row of db.prepare('SELECT session_id, date, payload FROM days').all()) {
550
+ const cursor = db.prepare('SELECT seq, file_size, file_mtime_ms FROM sessions WHERE id = ?').get(row.session_id) ?? {}
551
+ previous.push({
552
+ sessionId: row.session_id,
553
+ date: row.date,
554
+ source: LOCAL_SOURCE,
555
+ kind: CURRENT,
556
+ seq: storedNumber(cursor.seq, -1),
557
+ fileSize: Number(cursor.file_size) || 0,
558
+ fileMtimeMs: Number(cursor.file_mtime_ms) || 0,
559
+ payload: row.payload,
560
+ })
561
+ }
562
+ } catch (error) {
563
+ log(`cache migration could not read the old rows (${error?.message ?? error}); the days table starts fresh`)
564
+ previous.length = 0
565
+ }
566
+
567
+ db.exec('BEGIN IMMEDIATE')
568
+ try {
569
+ db.exec('DROP TABLE IF EXISTS days')
570
+ db.exec(SCHEMA)
571
+ const insert = db.prepare('INSERT OR REPLACE INTO days (session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
572
+ for (const row of previous) {
573
+ insert.run(row.sessionId, row.date, row.source, row.kind, row.seq, row.fileSize, row.fileMtimeMs, 0, row.payload)
574
+ }
575
+ db.exec('COMMIT')
576
+ } catch (error) {
577
+ try {
578
+ db.exec('ROLLBACK')
579
+ } catch {
580
+ // the transaction is already gone
581
+ }
582
+ throw error
583
+ }
584
+ }
585
+
326
586
  /**
327
587
  * Whether a failure means "these bytes are not a database", as opposed to "this process may not
328
588
  * use this database right now". Only the first is worth repairing by moving the file aside.
@@ -358,7 +618,6 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
358
618
  const { db } = opened
359
619
  const writable = opened.writable !== false
360
620
 
361
- /** Prepare, marking any failure as "do not touch this file" — it opened, so it is a database. */
362
621
  const prepare = (sql) => {
363
622
  try {
364
623
  return db.prepare(sql)
@@ -373,53 +632,187 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
373
632
  }
374
633
  }
375
634
 
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')
635
+ /**
636
+ * A statement that may be impossible against this file's schema, or null.
637
+ *
638
+ * This exists for the read-only path: a database written by a newer version may have moved a
639
+ * column this build's queries name, and the alternative would be to give up on the file
640
+ * entirely — which the design refuses to do, because a downgrade must not cost the user their
641
+ * history. A query that cannot run is skipped, so such a file reads as far as it can be read
642
+ * and is then left untouched.
643
+ */
644
+ const tryPrepare = (sql) => {
645
+ try {
646
+ return db.prepare(sql)
647
+ } catch {
648
+ return null
649
+ }
650
+ }
651
+ const all = (statement, ...args) => (statement ? statement.all(...args) : [])
652
+ const get = (statement, ...args) => (statement ? statement.get(...args) : undefined)
653
+
654
+ const selectSessions = tryPrepare('SELECT id, path, file_size, file_mtime_ms, seq, provider, model FROM sessions')
655
+ const selectDays = tryPrepare('SELECT session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload FROM days WHERE date >= ? AND date <= ? ORDER BY date')
656
+ const selectSessionDays = tryPrepare('SELECT date, source, kind, seq, file_size, file_mtime_ms, at, payload FROM days WHERE session_id = ?')
657
+ const selectEveryDay = tryPrepare('SELECT session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload FROM days')
378
658
  // Which years exist is a question only the store can answer: the folder holds a window, not
379
659
  // 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 (?, ?, ?)')
660
+ const selectYears = tryPrepare("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")
661
+ const countSessions = tryPrepare('SELECT COUNT(*) AS n FROM sessions')
662
+ const selectCursor = tryPrepare('SELECT seq, file_size, file_mtime_ms FROM sessions WHERE id = ?')
663
+ const insertRow = writable
664
+ ? prepare('INSERT OR REPLACE INTO days (session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
665
+ : null
666
+ const deleteRow = writable ? prepare('DELETE FROM days WHERE session_id = ? AND date = ? AND source = ? AND kind = ?') : null
667
+ const upsertSession = writable
668
+ ? prepare(
669
+ `INSERT INTO sessions (id, path, file_size, file_mtime_ms, seq, provider, model)
670
+ VALUES (?, ?, ?, ?, ?, ?, ?)
671
+ ON CONFLICT(id) DO UPDATE SET
672
+ path = excluded.path, file_size = excluded.file_size, file_mtime_ms = excluded.file_mtime_ms,
673
+ seq = excluded.seq, provider = excluded.provider, model = excluded.model`,
674
+ )
675
+ : null
676
+
677
+ /**
678
+ * Every row a session holds, or every row in the store when no session is named, in whatever
679
+ * schema the file turned out to have.
680
+ */
681
+ const readSessionRows = (sessionId, { source, kind } = {}) =>
682
+ (sessionId === undefined ? all(selectEveryDay) : all(selectSessionDays, sessionId))
683
+ .filter((row) => (source === undefined || row.source === source) && (kind === undefined || (row.kind ?? CURRENT) === kind))
684
+ .map((row) => ({
685
+ sessionId: row.session_id ?? sessionId,
686
+ date: row.date,
687
+ source: row.source,
688
+ kind: row.kind ?? CURRENT,
689
+ seq: storedNumber(row.seq, 0),
690
+ fileSize: Number(row.file_size) || 0,
691
+ fileMtimeMs: Number(row.file_mtime_ms) || 0,
692
+ at: Number(row.at) || 0,
693
+ payload: String(row.payload ?? ''),
694
+ }))
392
695
 
393
696
  /**
394
- * Write one session: its cursor row, plus only the days whose stored payload really differs.
697
+ * Store one row.
395
698
  *
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.
699
+ * `INSERT OR REPLACE` rather than a delete-then-insert pair: the primary key is
700
+ * `(session, date, source)`, and a second copy for the same key is the local fold's own
701
+ * superseded row set — exactly the case a plain `INSERT` fails on. Failing there would roll the
702
+ * whole fold back, which is the loudest possible way to lose the shortened fold this model exists
703
+ * to keep.
401
704
  */
402
- const writeSession = (record) => {
705
+ const writeRow = (sessionId, row) =>
706
+ insertRow.run(sessionId, row.date, row.source, row.kind ?? CURRENT, row.seq, row.fileSize, row.fileMtimeMs, row.at ?? 0, JSON.stringify(row.routes ?? {}))
707
+
708
+ /** Store a session's cursor: the log version this device last folded it from. */
709
+ const writeCursor = (record) =>
403
710
  upsertSession.run(
404
711
  record.sessionId,
405
- record.path,
406
- record.fileSize,
407
- record.fileMtimeMs,
408
- record.seq,
409
- record.provider,
410
- record.model,
712
+ record.path ?? '',
713
+ record.fileSize ?? 0,
714
+ record.fileMtimeMs ?? 0,
715
+ record.seq ?? -1,
716
+ record.provider ?? null,
717
+ record.model ?? null,
411
718
  )
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)
719
+
720
+ /** A session's stored cursor, as a stamp the row comparisons understand. */
721
+ const readCursor = (sessionId) => {
722
+ const row = get(selectCursor, sessionId)
723
+ return row ? { seq: storedNumber(row.seq, -1), fileSize: Number(row.file_size) || 0, fileMtimeMs: Number(row.file_mtime_ms) || 0 } : null
724
+ }
725
+
726
+ /**
727
+ * Write one session's fold.
728
+ *
729
+ * Three rules, all about not losing history:
730
+ *
731
+ * - a fold owns **its own** rows only. A day the log no longer has is deleted from this device's
732
+ * `current` rows and from nowhere else — a row that arrived from another device is not the
733
+ * folder's to remove;
734
+ * - a `current` row is replaced only when the new fold describes **at least as much** of the
735
+ * session (higher `seq`, or equal `seq` and no smaller file). A shorter fold goes to the
736
+ * `fallback` slot beside the fuller copy instead of over it;
737
+ * - when the fold is shorter *and* drops a day, the day is rolled back rather than deleted —
738
+ * this is what a restored backup looks like, and it is the case the whole row model exists for.
739
+ */
740
+ const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now() } = {}) => {
741
+ writeCursor(record)
742
+ const cursor = readCursor(record.sessionId)
743
+ const incoming = rowsOf(record, { source, at })
744
+ const stored = new Map(readSessionRows(record.sessionId, { source, kind: CURRENT }).map((row) => [row.date, row]))
745
+ for (const row of incoming) {
746
+ const known = stored.get(row.date)
747
+ if (known) stored.delete(row.date)
748
+ if (known && !rowIsNewerOrEqual(row, known)) {
749
+ // The fold covers less than the copy already stored for that day. The fuller copy keeps the
750
+ // `current` slot — the log has been shortened, but that is no reason to forget what it said —
751
+ // and this short fold is written as the `fallback`: it is what is left if the fuller copy is
752
+ // ever gone (the machine is restored from the same backup, say).
753
+ if (known.payload !== JSON.stringify(row.routes)) writeRow(record.sessionId, { ...row, kind: FALLBACK })
754
+ continue
755
+ }
756
+ if (known && known.payload === JSON.stringify(row.routes)) continue
757
+ if (known) deleteRow.run(record.sessionId, row.date, source, CURRENT)
758
+ writeRow(record.sessionId, row)
759
+ }
760
+ // A date this device folded before, that the record no longer covers at all, is gone from the
761
+ // log. Its `current` row goes with it — unless this log version is itself weaker than the copy
762
+ // already stored, in which case a shortened log is treated as a rollback rather than as a
763
+ // deletion, and the day is kept.
764
+ for (const row of stored.values()) {
765
+ if (cursor && !rowIsNewerOrEqual(cursor, row)) continue
766
+ deleteRow.run(record.sessionId, row.date, source, CURRENT)
420
767
  }
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)
768
+ }
769
+
770
+ /**
771
+ * Take rows that came from somewhere other than this device's own fold.
772
+ *
773
+ * This is the **sync import path**, and it takes rows rather than records because an incoming row
774
+ * carries its own stamp and its own slot: a shard is a set of rows, not a session's current fold.
775
+ * Two rules, both about not losing history:
776
+ *
777
+ * - it never deletes. Only this device's own fold may remove this device's rows; a machine whose
778
+ * logs were pruned must not be able to erase what another machine published;
779
+ * - a row is stored only when it is **strictly fuller** than the one already held for that
780
+ * `(date, source, slot)`. An equal-stamp copy is the same bytes and is left as it is; a weaker
781
+ * one is a worse answer to the same question.
782
+ *
783
+ * @returns {number} how many rows were actually written.
784
+ */
785
+ const intakeRows = (rows) => {
786
+ const known = new Map(readSessionRows(undefined).map((row) => [`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${row.kind}`, row]))
787
+ let written = 0
788
+ for (const row of rows ?? []) {
789
+ if (!row?.sessionId || typeof row.date !== 'string') continue
790
+ const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
791
+ // The day rows are only reachable through a session row (`readRecords` walks `sessions` and
792
+ // hangs the days off it), so an import has to make sure one exists — otherwise the history is
793
+ // written into a table nothing reads.
794
+ const existing = known.get(`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${kind}`)
795
+ if (existing && !rowIsStrictlyNewer(row, existing)) continue
796
+ const payload = JSON.stringify(row.routes ?? {})
797
+ if (existing && existing.payload === payload) continue
798
+ if (existing) deleteRow.run(row.sessionId, row.date, row.source, kind)
799
+ writeRow(row.sessionId, { ...row, kind })
800
+ written++
801
+ }
802
+ // One cursor per session that arrived, from the fullest row that arrived for it.
803
+ const bySession = new Map()
804
+ for (const row of rows ?? []) {
805
+ if (!row?.sessionId) continue
806
+ const best = bySession.get(row.sessionId)
807
+ if (!best || rowIsStrictlyNewer(row, best)) bySession.set(row.sessionId, row)
808
+ }
809
+ for (const [sessionId, best] of bySession) {
810
+ const cursor = readCursor(sessionId)
811
+ if (!cursor || rowIsStrictlyNewer(best, cursor)) {
812
+ writeCursor({ sessionId, path: '', fileSize: best.fileSize, fileMtimeMs: best.fileMtimeMs, seq: best.seq })
813
+ }
814
+ }
815
+ return written
423
816
  }
424
817
 
425
818
  /** Bring an existing JSON cache across, once, keeping the old file for inspection. */
@@ -428,7 +821,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
428
821
  // database that already has rows is never re-read from the JSON file, so a failed rename
429
822
  // below cannot cause the same history to be folded in twice.
430
823
  if (!writable) return
431
- if ((countSessions.get()?.n ?? 0) > 0) {
824
+ if ((get(countSessions)?.n ?? 0) > 0) {
432
825
  // Rows already exist, so this JSON file is a leftover — from a run that used the JSON
433
826
  // backend deliberately, from a period spent on the fallback, or from an older host that
434
827
  // wrote it after the migration. Those folds are not merged (a day-by-day merge of two
@@ -449,9 +842,15 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
449
842
  .map(([sessionId, source]) => documentToRecord(sessionId, source))
450
843
  .filter(Boolean)
451
844
  if (records.length === 0) return
845
+ // The rows are written directly rather than through `writeSession`: a migrated record is
846
+ // already row-shaped (version 1's day map was turned into rows with the session's stamp on
847
+ // the way in), and re-deriving rows from a `days` Map there is not would import nothing.
452
848
  try {
453
849
  db.exec('BEGIN IMMEDIATE')
454
- for (const record of records) writeSession(record)
850
+ for (const record of records) {
851
+ writeCursor(record)
852
+ for (const row of record.rows) writeRow(record.sessionId, row)
853
+ }
455
854
  db.exec('COMMIT')
456
855
  } catch (error) {
457
856
  try {
@@ -480,52 +879,98 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
480
879
  }
481
880
  migrateFromJson()
482
881
 
882
+ /** Every session, with the rows inside `[from, until]`. */
883
+ const readRecords = (from, until) => {
884
+ const byId = new Map()
885
+ for (const row of all(selectSessions)) {
886
+ byId.set(row.id, {
887
+ sessionId: row.id,
888
+ path: row.path,
889
+ fileSize: Number(row.file_size) || 0,
890
+ fileMtimeMs: Number(row.file_mtime_ms) || 0,
891
+ seq: storedNumber(row.seq, -1),
892
+ provider: row.provider ?? null,
893
+ model: row.model ?? null,
894
+ rows: [],
895
+ })
896
+ }
897
+ for (const row of all(selectDays, from, until)) {
898
+ const record = byId.get(row.session_id)
899
+ if (!record) continue
900
+ const parsed = parseRow(record, row.date, row.payload, {
901
+ seq: Number(row.seq) || 0,
902
+ fileSize: Number(row.file_size) || 0,
903
+ fileMtimeMs: Number(row.file_mtime_ms) || 0,
904
+ source: row.source,
905
+ kind: row.kind ?? CURRENT,
906
+ at: Number(row.at) || 0,
907
+ })
908
+ if (!parsed) continue
909
+ record.rows.push(parsed)
910
+ }
911
+ return [...byId.values()]
912
+ }
913
+
483
914
  return {
484
915
  kind: 'sqlite',
485
916
  path,
486
917
  writable,
487
918
  load(bounds = {}) {
488
919
  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
920
+ return resolveRows(readRecords(from, until))
921
+ },
922
+ scan() {
923
+ return readRecords('0000-01-01', '9999-12-31')
924
+ },
925
+ years() {
926
+ return all(selectYears).map((row) => String(row.year))
927
+ },
928
+ /**
929
+ * Store one fold and hand back the resolved view of that session.
930
+ *
931
+ * The return value matters: a fold that covered **less** than what was already stored leaves
932
+ * the fuller copy in place, and the caller must go on showing the fuller one rather than the
933
+ * short fold it just produced. Returning the resolved record is what makes "the cache kept it"
934
+ * true in memory as well as on disk.
935
+ */
936
+ put(record, { at = Date.now() } = {}) {
937
+ if (!writable) return null
938
+ try {
939
+ db.exec('BEGIN IMMEDIATE')
940
+ writeSession(record, { source: LOCAL_SOURCE, at })
941
+ db.exec('COMMIT')
942
+ } catch (error) {
505
943
  try {
506
- record.days.set(row.date, deserializeDay(JSON.parse(row.payload)))
944
+ db.exec('ROLLBACK')
507
945
  } catch {
508
- // A malformed row is skipped rather than losing the whole session.
946
+ // the transaction is already gone
509
947
  }
948
+ log(`cache write failed for ${record.sessionId}: ${error?.message ?? error}`)
949
+ return null
510
950
  }
511
- return [...byId.values()]
951
+ return resolveRows(readRecords('0000-01-01', '9999-12-31').filter((row) => row.sessionId === record.sessionId))[0] ?? null
512
952
  },
513
- years() {
514
- return selectYears.all().map((row) => String(row.year))
515
- },
516
- put(record) {
517
- if (!writable) return
953
+ /**
954
+ * Store rows that came from another device, and hand back how many were new.
955
+ *
956
+ * Wrapped in one transaction: an imported shard is a month of somebody's history, and a partial
957
+ * month would leave the two devices disagreeing about which days exist.
958
+ */
959
+ intakeRows(rows) {
960
+ if (!writable) return 0
518
961
  try {
519
962
  db.exec('BEGIN IMMEDIATE')
520
- writeSession(record)
963
+ const written = intakeRows(rows)
521
964
  db.exec('COMMIT')
965
+ return written
522
966
  } catch (error) {
523
967
  try {
524
968
  db.exec('ROLLBACK')
525
969
  } catch {
526
970
  // the transaction is already gone
527
971
  }
528
- log(`cache write failed for ${record.sessionId}: ${error?.message ?? error}`)
972
+ log(`cache import failed: ${error?.message ?? error}`)
973
+ return 0
529
974
  }
530
975
  },
531
976
  flush() {
@@ -559,7 +1004,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
559
1004
  * must not destroy data it does not understand.
560
1005
  *
561
1006
  * @param {{dir:string, log?:Function, env?:object}} options
562
- * @returns {{kind:string, path:string, load:Function, years:Function, put:Function, flush:Function, close:Function}}
1007
+ * @returns {{kind:string, path:string, load:Function, scan:Function, years:Function, put:Function, flush:Function, close:Function}}
563
1008
  */
564
1009
  export function createCacheStore({ dir, log = () => {}, env = process.env }) {
565
1010
  const jsonPath = join(dir, 'cache.json')