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/cache-store.js
CHANGED
|
@@ -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,
|
|
8
|
-
* stored rows and writes only the days whose payload actually changed — measured
|
|
9
|
-
* one-day session, and 4.98 ms for a 400-day one (the comparison still reads
|
|
10
|
-
* cost tracks the changed session's size, not the table's). Loading takes
|
|
11
|
-
* panel can show, which bounds resident memory the same way. Older rows
|
|
12
|
-
* the point: history is never deleted, it is just not all held in
|
|
13
|
-
* It uses `node:sqlite`, which ships inside Node itself — no dependency to
|
|
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
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
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
|
|
50
|
-
const JSON_CACHE_VERSION =
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
|
97
|
-
function
|
|
98
|
-
if (since === undefined && until === undefined) return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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:
|
|
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)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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 =
|
|
381
|
-
const countSessions =
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
*
|
|
697
|
+
* Store one row.
|
|
395
698
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
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
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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 ((
|
|
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)
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
944
|
+
db.exec('ROLLBACK')
|
|
507
945
|
} catch {
|
|
508
|
-
//
|
|
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
|
|
951
|
+
return resolveRows(readRecords('0000-01-01', '9999-12-31').filter((row) => row.sessionId === record.sessionId))[0] ?? null
|
|
512
952
|
},
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
|
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')
|