dsh-usage-badge 0.1.4 → 0.1.6
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/docs/design-notes.md +432 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +672 -128
- package/lib/client.js +470 -4
- package/lib/fold.js +20 -10
- package/lib/index.js +537 -7
- package/lib/sync.js +1410 -0
- package/lib/webdav.js +584 -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,253 @@ 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
|
+
* Drop one device's whole month — the reading side of a **tombstone**. Only that device's rows go:
|
|
308
|
+
* never this device's own fold, and never another device's rows.
|
|
309
|
+
*
|
|
310
|
+
* @returns {number} how many rows were removed.
|
|
311
|
+
*/
|
|
312
|
+
dropSourceMonth(source, month) {
|
|
313
|
+
if (!source || !/^\d{4}-\d{2}$/.test(String(month))) return 0
|
|
314
|
+
const doc = ensureDocument()
|
|
315
|
+
let removed = 0
|
|
316
|
+
for (const [sessionId, entry] of Object.entries(doc.sessions ?? {})) {
|
|
317
|
+
const record = documentToRecord(sessionId, entry)
|
|
318
|
+
if (!record) continue
|
|
319
|
+
const keep = record.rows.filter((row) => !(rowSource(row) === source && String(row.date).startsWith(month)))
|
|
320
|
+
removed += record.rows.length - keep.length
|
|
321
|
+
if (keep.length === 0) {
|
|
322
|
+
delete doc.sessions[sessionId]
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
entry.days = keep.map((row) => ({ ...row }))
|
|
326
|
+
}
|
|
327
|
+
if (removed > 0) {
|
|
328
|
+
dirty = true
|
|
329
|
+
flush()
|
|
330
|
+
}
|
|
331
|
+
return removed
|
|
332
|
+
},
|
|
333
|
+
/**
|
|
334
|
+
* Store one fold and hand back the resolved view of that session.
|
|
335
|
+
*
|
|
336
|
+
* The return value matters: a fold that covered **less** than what was already stored leaves
|
|
337
|
+
* the fuller copy in place, and the caller must go on showing the fuller one rather than the
|
|
338
|
+
* short fold it just produced. Returning the resolved record is what makes "the cache kept
|
|
339
|
+
* it" true in memory as well as on disk.
|
|
340
|
+
*
|
|
341
|
+
* The rules are the SQLite backend's, expressed on the document: a fold replaces its own
|
|
342
|
+
* `current` rows, a weaker fold lands in the `fallback` slot instead of over the fuller copy,
|
|
343
|
+
* another source's rows are never a fold's to judge, and a date the log no longer covers is
|
|
344
|
+
* dropped only when the log version is not itself weaker than what is stored (a shortened log is
|
|
345
|
+
* a rollback, not a deletion).
|
|
346
|
+
*/
|
|
347
|
+
put(record, options = {}) {
|
|
348
|
+
const doc = ensureDocument()
|
|
349
|
+
const at = options.at ?? Date.now()
|
|
350
|
+
const previous = documentToRecord(record.sessionId, doc.sessions[record.sessionId])
|
|
351
|
+
const incoming = rowsOf(record, { source: LOCAL_SOURCE, at })
|
|
352
|
+
const incomingKeys = new Set(incoming.map((row) => `${row.date}\u0000${row.source}`))
|
|
353
|
+
const cursor = { seq: record.seq ?? -1, fileSize: record.fileSize ?? 0 }
|
|
354
|
+
const kept = []
|
|
355
|
+
for (const row of previous?.rows ?? []) {
|
|
356
|
+
const key = `${row.date}\u0000${rowSource(row)}`
|
|
357
|
+
// A row survives the fold unless the fold is entitled to remove it:
|
|
358
|
+
// - another source's copy is never a fold's to judge, and neither is a `fallback` copy —
|
|
359
|
+
// that is a log version the cursor has moved past, kept on purpose;
|
|
360
|
+
// - its own `current` row the fold still covers is replaced by the incoming copy below;
|
|
361
|
+
// - its own `current` row the fold does **not** cover goes only if this log version still
|
|
362
|
+
// reaches back to it. A row older than the incoming cursor was folded from a fuller log
|
|
363
|
+
// — a shorter one now, or a restored backup — and it is all that is left of those days.
|
|
364
|
+
const theirs = rowSource(row) !== LOCAL_SOURCE || rowKind(row) === FALLBACK
|
|
365
|
+
const keep = theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
|
|
366
|
+
if (keep) kept.push(row)
|
|
367
|
+
}
|
|
368
|
+
// Grouped by `(date, source, kind)`: the `current` and `fallback` copies of one date share a
|
|
369
|
+
// date and a source but are different rows, and a map keyed without the slot would let one
|
|
370
|
+
// silently overwrite the other.
|
|
371
|
+
const group = (rows) => new Map(rows.map((row) => [`${row.date}\u0000${row.source}\u0000${rowKind(row)}`, row]))
|
|
372
|
+
const merged = group(kept)
|
|
373
|
+
for (const row of incoming) {
|
|
374
|
+
const key = `${row.date}\u0000${row.source}\u0000${CURRENT}`
|
|
375
|
+
const existing = merged.get(key) ?? null
|
|
376
|
+
if (!rowIsStrictlyNewer(row, existing)) {
|
|
377
|
+
// A short fold goes into the `fallback` slot beside the fuller copy rather than replacing
|
|
378
|
+
// it — that copy is the log's own current truth, and all that is left if the fuller one
|
|
379
|
+
// ever goes away.
|
|
380
|
+
merged.set(`${row.date}\u0000${row.source}\u0000${FALLBACK}`, { ...row, kind: FALLBACK })
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
merged.set(key, row)
|
|
384
|
+
}
|
|
385
|
+
kept.length = 0
|
|
386
|
+
kept.push(...merged.values())
|
|
387
|
+
doc.sessions[record.sessionId] = {
|
|
242
388
|
path: record.path,
|
|
243
389
|
fileSize: record.fileSize,
|
|
244
390
|
fileMtimeMs: record.fileMtimeMs,
|
|
245
391
|
seq: record.seq,
|
|
246
392
|
provider: record.provider,
|
|
247
393
|
model: record.model,
|
|
248
|
-
days:
|
|
394
|
+
days: kept.map((row) => ({
|
|
395
|
+
date: row.date,
|
|
396
|
+
routes: row.routes,
|
|
397
|
+
seq: row.seq,
|
|
398
|
+
fileSize: row.fileSize,
|
|
399
|
+
fileMtimeMs: row.fileMtimeMs,
|
|
400
|
+
source: rowSource(row),
|
|
401
|
+
kind: rowKind(row),
|
|
402
|
+
at: row.at ?? 0,
|
|
403
|
+
})),
|
|
249
404
|
}
|
|
250
405
|
dirty = true
|
|
251
|
-
if (timer)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
406
|
+
if (!timer) {
|
|
407
|
+
// Batched, because this backend pays for the whole history on every save.
|
|
408
|
+
timer = setTimeout(() => {
|
|
409
|
+
timer = null
|
|
410
|
+
if (dirty) write()
|
|
411
|
+
}, 2000)
|
|
412
|
+
timer.unref?.()
|
|
413
|
+
}
|
|
414
|
+
return resolveRows([documentToRecord(record.sessionId, doc.sessions[record.sessionId])])[0] ?? null
|
|
415
|
+
},
|
|
416
|
+
/**
|
|
417
|
+
* Store rows that came from another device, and hand back how many were new.
|
|
418
|
+
*
|
|
419
|
+
* The SQLite backend's rules on the document: never delete **another device's** rows, and store a row
|
|
420
|
+
* only when it is strictly fuller than the one already held for that `(session, date, source, slot)`.
|
|
421
|
+
* Grouped by session because a shard's rows arrive together but a document is edited per session.
|
|
422
|
+
*
|
|
423
|
+
* With `replace`, that device's own rows for one month are treated as *its* statement about that month:
|
|
424
|
+
* rows it no longer lists are removed. That is not the same as letting another machine erase this one's
|
|
425
|
+
* history — the rows removed belong to the device that is speaking, which is the only party entitled to
|
|
426
|
+
* say what it holds.
|
|
427
|
+
*/
|
|
428
|
+
intakeRows(rows, { replace = null } = {}) {
|
|
429
|
+
const doc = ensureDocument()
|
|
430
|
+
const bySession = new Map()
|
|
431
|
+
const incoming = new Set()
|
|
432
|
+
for (const row of rows ?? []) {
|
|
433
|
+
if (!row?.sessionId || typeof row.date !== 'string') continue
|
|
434
|
+
incoming.add(`${row.sessionId}\u0000${row.date}\u0000${rowSource(row)}\u0000${rowKind(row)}`)
|
|
435
|
+
let list = bySession.get(row.sessionId)
|
|
436
|
+
if (!list) {
|
|
437
|
+
list = []
|
|
438
|
+
bySession.set(row.sessionId, list)
|
|
439
|
+
}
|
|
440
|
+
list.push(row)
|
|
441
|
+
}
|
|
442
|
+
let written = 0
|
|
443
|
+
for (const [sessionId, arriving] of bySession) {
|
|
444
|
+
const existing = documentToRecord(sessionId, doc.sessions[sessionId])
|
|
445
|
+
const merged = new Map((existing?.rows ?? []).map((row) => [`${row.date}\u0000${rowSource(row)}\u0000${rowKind(row)}`, row]))
|
|
446
|
+
for (const row of arriving) {
|
|
447
|
+
const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
|
|
448
|
+
const key = `${row.date}\u0000${row.source}\u0000${kind}`
|
|
449
|
+
const known = merged.get(key)
|
|
450
|
+
if (known) {
|
|
451
|
+
if (JSON.stringify(known.routes ?? {}) === JSON.stringify(row.routes ?? {})) continue
|
|
452
|
+
// See the SQLite backend: replace mode takes the publisher's word for its own month, while a
|
|
453
|
+
// plain import only lets a strictly fuller copy through.
|
|
454
|
+
if (!replace && !rowIsStrictlyNewer(row, known)) continue
|
|
455
|
+
}
|
|
456
|
+
merged.set(key, { ...row, kind })
|
|
457
|
+
written++
|
|
458
|
+
}
|
|
459
|
+
if (replace) {
|
|
460
|
+
for (const [key, row] of [...merged]) {
|
|
461
|
+
if (rowSource(row) !== replace.source) continue
|
|
462
|
+
if (!String(row.date).startsWith(replace.month)) continue
|
|
463
|
+
if (incoming.has(`${sessionId}\u0000${key}`)) continue
|
|
464
|
+
merged.delete(key)
|
|
465
|
+
written++
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const rows_ = [...merged.values()]
|
|
469
|
+
const fullest = rows_.reduce((top, row) => (top === null || rowIsStrictlyNewer(row, top) ? row : top), null)
|
|
470
|
+
const previous = doc.sessions[sessionId]
|
|
471
|
+
// The cursor has to exist for the days to be readable at all (`readRecords` walks the
|
|
472
|
+
// sessions and hangs the days off them), and it must never regress: it is the stamp the fold
|
|
473
|
+
// path compares its own log version against.
|
|
474
|
+
const keepCursor = previous && !rowIsStrictlyNewer(fullest ?? {}, previous)
|
|
475
|
+
doc.sessions[sessionId] = {
|
|
476
|
+
path: previous?.path ?? '',
|
|
477
|
+
fileSize: keepCursor ? previous.fileSize : fullest?.fileSize ?? 0,
|
|
478
|
+
fileMtimeMs: keepCursor ? previous.fileMtimeMs : fullest?.fileMtimeMs ?? 0,
|
|
479
|
+
seq: keepCursor ? previous.seq : fullest?.seq ?? -1,
|
|
480
|
+
provider: previous?.provider ?? null,
|
|
481
|
+
model: previous?.model ?? null,
|
|
482
|
+
days: rows_.map((row) => ({
|
|
483
|
+
date: row.date,
|
|
484
|
+
routes: row.routes,
|
|
485
|
+
seq: row.seq,
|
|
486
|
+
fileSize: row.fileSize,
|
|
487
|
+
fileMtimeMs: row.fileMtimeMs,
|
|
488
|
+
source: rowSource(row),
|
|
489
|
+
kind: rowKind(row),
|
|
490
|
+
at: row.at ?? 0,
|
|
491
|
+
})),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (written > 0) {
|
|
495
|
+
dirty = true
|
|
496
|
+
if (!timer) {
|
|
497
|
+
timer = setTimeout(() => {
|
|
498
|
+
timer = null
|
|
499
|
+
if (dirty) write()
|
|
500
|
+
}, 2000)
|
|
501
|
+
timer.unref?.()
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return written
|
|
258
505
|
},
|
|
259
506
|
flush,
|
|
260
507
|
// The same thing here, and a plain function rather than `this.flush()`, so a caller that
|
|
@@ -314,6 +561,7 @@ function openDatabase(DatabaseSync, path, log) {
|
|
|
314
561
|
// A cache does not need full durability: the WAL survives a process crash, and a power loss
|
|
315
562
|
// can only cost the last few folded days, which the session logs can supply again.
|
|
316
563
|
probe.exec('PRAGMA synchronous = NORMAL')
|
|
564
|
+
migrateSchema(probe, version, log)
|
|
317
565
|
probe.exec(SCHEMA)
|
|
318
566
|
if (version !== SQLITE_SCHEMA_VERSION) probe.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`)
|
|
319
567
|
return { db: probe, version, writable: true }
|
|
@@ -323,6 +571,66 @@ function openDatabase(DatabaseSync, path, log) {
|
|
|
323
571
|
}
|
|
324
572
|
}
|
|
325
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Bring a database written by an older build up to this schema.
|
|
576
|
+
*
|
|
577
|
+
* Checked by asking the file which columns it has, not by trusting `user_version` alone: a file
|
|
578
|
+
* whose version number was rewritten, or whose `days` table predates the stamp columns, must be
|
|
579
|
+
* migrated rather than hit with an INSERT naming columns it does not have. The old rows are
|
|
580
|
+
* brought across rather than dropped — an upgrade that silently emptied the cache would look
|
|
581
|
+
* exactly like a bug.
|
|
582
|
+
*/
|
|
583
|
+
function migrateSchema(db, version, log) {
|
|
584
|
+
const columns = (table) => {
|
|
585
|
+
try {
|
|
586
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().map((row) => String(row.name))
|
|
587
|
+
} catch {
|
|
588
|
+
return []
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const dayColumns = columns('days')
|
|
592
|
+
if (dayColumns.length === 0 || dayColumns.includes('kind')) return
|
|
593
|
+
|
|
594
|
+
log(`cache schema ${version} predates the row model; migrating its days table`)
|
|
595
|
+
const previous = []
|
|
596
|
+
try {
|
|
597
|
+
for (const row of db.prepare('SELECT session_id, date, payload FROM days').all()) {
|
|
598
|
+
const cursor = db.prepare('SELECT seq, file_size, file_mtime_ms FROM sessions WHERE id = ?').get(row.session_id) ?? {}
|
|
599
|
+
previous.push({
|
|
600
|
+
sessionId: row.session_id,
|
|
601
|
+
date: row.date,
|
|
602
|
+
source: LOCAL_SOURCE,
|
|
603
|
+
kind: CURRENT,
|
|
604
|
+
seq: storedNumber(cursor.seq, -1),
|
|
605
|
+
fileSize: Number(cursor.file_size) || 0,
|
|
606
|
+
fileMtimeMs: Number(cursor.file_mtime_ms) || 0,
|
|
607
|
+
payload: row.payload,
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
} catch (error) {
|
|
611
|
+
log(`cache migration could not read the old rows (${error?.message ?? error}); the days table starts fresh`)
|
|
612
|
+
previous.length = 0
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
db.exec('BEGIN IMMEDIATE')
|
|
616
|
+
try {
|
|
617
|
+
db.exec('DROP TABLE IF EXISTS days')
|
|
618
|
+
db.exec(SCHEMA)
|
|
619
|
+
const insert = db.prepare('INSERT OR REPLACE INTO days (session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
620
|
+
for (const row of previous) {
|
|
621
|
+
insert.run(row.sessionId, row.date, row.source, row.kind, row.seq, row.fileSize, row.fileMtimeMs, 0, row.payload)
|
|
622
|
+
}
|
|
623
|
+
db.exec('COMMIT')
|
|
624
|
+
} catch (error) {
|
|
625
|
+
try {
|
|
626
|
+
db.exec('ROLLBACK')
|
|
627
|
+
} catch {
|
|
628
|
+
// the transaction is already gone
|
|
629
|
+
}
|
|
630
|
+
throw error
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
326
634
|
/**
|
|
327
635
|
* Whether a failure means "these bytes are not a database", as opposed to "this process may not
|
|
328
636
|
* use this database right now". Only the first is worth repairing by moving the file aside.
|
|
@@ -358,7 +666,6 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
358
666
|
const { db } = opened
|
|
359
667
|
const writable = opened.writable !== false
|
|
360
668
|
|
|
361
|
-
/** Prepare, marking any failure as "do not touch this file" — it opened, so it is a database. */
|
|
362
669
|
const prepare = (sql) => {
|
|
363
670
|
try {
|
|
364
671
|
return db.prepare(sql)
|
|
@@ -373,53 +680,219 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
373
680
|
}
|
|
374
681
|
}
|
|
375
682
|
|
|
376
|
-
|
|
377
|
-
|
|
683
|
+
/**
|
|
684
|
+
* A statement that may be impossible against this file's schema, or null.
|
|
685
|
+
*
|
|
686
|
+
* This exists for the read-only path: a database written by a newer version may have moved a
|
|
687
|
+
* column this build's queries name, and the alternative would be to give up on the file
|
|
688
|
+
* entirely — which the design refuses to do, because a downgrade must not cost the user their
|
|
689
|
+
* history. A query that cannot run is skipped, so such a file reads as far as it can be read
|
|
690
|
+
* and is then left untouched.
|
|
691
|
+
*/
|
|
692
|
+
const tryPrepare = (sql) => {
|
|
693
|
+
try {
|
|
694
|
+
return db.prepare(sql)
|
|
695
|
+
} catch {
|
|
696
|
+
return null
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const all = (statement, ...args) => (statement ? statement.all(...args) : [])
|
|
700
|
+
const get = (statement, ...args) => (statement ? statement.get(...args) : undefined)
|
|
701
|
+
|
|
702
|
+
const selectSessions = tryPrepare('SELECT id, path, file_size, file_mtime_ms, seq, provider, model FROM sessions')
|
|
703
|
+
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')
|
|
704
|
+
const selectSessionDays = tryPrepare('SELECT date, source, kind, seq, file_size, file_mtime_ms, at, payload FROM days WHERE session_id = ?')
|
|
705
|
+
const selectEveryDay = tryPrepare('SELECT session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload FROM days')
|
|
378
706
|
// Which years exist is a question only the store can answer: the folder holds a window, not
|
|
379
707
|
// 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
|
-
const
|
|
391
|
-
const
|
|
708
|
+
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")
|
|
709
|
+
const countSessions = tryPrepare('SELECT COUNT(*) AS n FROM sessions')
|
|
710
|
+
const selectCursor = tryPrepare('SELECT seq, file_size, file_mtime_ms FROM sessions WHERE id = ?')
|
|
711
|
+
const insertRow = writable
|
|
712
|
+
? prepare('INSERT OR REPLACE INTO days (session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
713
|
+
: null
|
|
714
|
+
const deleteRow = writable ? prepare('DELETE FROM days WHERE session_id = ? AND date = ? AND source = ? AND kind = ?') : null
|
|
715
|
+
/** Every row one device claims for one month — what a replacing import has to compare against. */
|
|
716
|
+
const selectSourceMonth = tryPrepare('SELECT session_id, date, kind, payload FROM days WHERE source = ? AND substr(date, 1, 7) = ?')
|
|
717
|
+
/** Drop one device's whole month: what a device's own "I no longer hold this" means to a reader. */
|
|
718
|
+
const deleteSourceMonth = writable ? prepare("DELETE FROM days WHERE source = ? AND substr(date, 1, 7) = ?") : null
|
|
719
|
+
const upsertSession = writable
|
|
720
|
+
? prepare(
|
|
721
|
+
`INSERT INTO sessions (id, path, file_size, file_mtime_ms, seq, provider, model)
|
|
722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
723
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
724
|
+
path = excluded.path, file_size = excluded.file_size, file_mtime_ms = excluded.file_mtime_ms,
|
|
725
|
+
seq = excluded.seq, provider = excluded.provider, model = excluded.model`,
|
|
726
|
+
)
|
|
727
|
+
: null
|
|
392
728
|
|
|
393
729
|
/**
|
|
394
|
-
*
|
|
730
|
+
* Every row a session holds, or every row in the store when no session is named, in whatever
|
|
731
|
+
* schema the file turned out to have.
|
|
732
|
+
*/
|
|
733
|
+
const readSessionRows = (sessionId, { source, kind } = {}) =>
|
|
734
|
+
(sessionId === undefined ? all(selectEveryDay) : all(selectSessionDays, sessionId))
|
|
735
|
+
.filter((row) => (source === undefined || row.source === source) && (kind === undefined || (row.kind ?? CURRENT) === kind))
|
|
736
|
+
.map((row) => ({
|
|
737
|
+
sessionId: row.session_id ?? sessionId,
|
|
738
|
+
date: row.date,
|
|
739
|
+
source: row.source,
|
|
740
|
+
kind: row.kind ?? CURRENT,
|
|
741
|
+
seq: storedNumber(row.seq, 0),
|
|
742
|
+
fileSize: Number(row.file_size) || 0,
|
|
743
|
+
fileMtimeMs: Number(row.file_mtime_ms) || 0,
|
|
744
|
+
at: Number(row.at) || 0,
|
|
745
|
+
payload: String(row.payload ?? ''),
|
|
746
|
+
}))
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Store one row.
|
|
395
750
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
751
|
+
* `INSERT OR REPLACE` rather than a delete-then-insert pair: the primary key is
|
|
752
|
+
* `(session, date, source)`, and a second copy for the same key is the local fold's own
|
|
753
|
+
* superseded row set — exactly the case a plain `INSERT` fails on. Failing there would roll the
|
|
754
|
+
* whole fold back, which is the loudest possible way to lose the shortened fold this model exists
|
|
755
|
+
* to keep.
|
|
401
756
|
*/
|
|
402
|
-
const
|
|
757
|
+
const writeRow = (sessionId, row) =>
|
|
758
|
+
insertRow.run(sessionId, row.date, row.source, row.kind ?? CURRENT, row.seq, row.fileSize, row.fileMtimeMs, row.at ?? 0, JSON.stringify(row.routes ?? {}))
|
|
759
|
+
|
|
760
|
+
/** Store a session's cursor: the log version this device last folded it from. */
|
|
761
|
+
const writeCursor = (record) =>
|
|
403
762
|
upsertSession.run(
|
|
404
763
|
record.sessionId,
|
|
405
|
-
record.path,
|
|
406
|
-
record.fileSize,
|
|
407
|
-
record.fileMtimeMs,
|
|
408
|
-
record.seq,
|
|
409
|
-
record.provider,
|
|
410
|
-
record.model,
|
|
764
|
+
record.path ?? '',
|
|
765
|
+
record.fileSize ?? 0,
|
|
766
|
+
record.fileMtimeMs ?? 0,
|
|
767
|
+
record.seq ?? -1,
|
|
768
|
+
record.provider ?? null,
|
|
769
|
+
record.model ?? null,
|
|
411
770
|
)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
771
|
+
|
|
772
|
+
/** A session's stored cursor, as a stamp the row comparisons understand. */
|
|
773
|
+
const readCursor = (sessionId) => {
|
|
774
|
+
const row = get(selectCursor, sessionId)
|
|
775
|
+
return row ? { seq: storedNumber(row.seq, -1), fileSize: Number(row.file_size) || 0, fileMtimeMs: Number(row.file_mtime_ms) || 0 } : null
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Write one session's fold.
|
|
780
|
+
*
|
|
781
|
+
* Three rules, all about not losing history:
|
|
782
|
+
*
|
|
783
|
+
* - a fold owns **its own** rows only. A day the log no longer has is deleted from this device's
|
|
784
|
+
* `current` rows and from nowhere else — a row that arrived from another device is not the
|
|
785
|
+
* folder's to remove;
|
|
786
|
+
* - a `current` row is replaced only when the new fold describes **at least as much** of the
|
|
787
|
+
* session (higher `seq`, or equal `seq` and no smaller file). A shorter fold goes to the
|
|
788
|
+
* `fallback` slot beside the fuller copy instead of over it;
|
|
789
|
+
* - when the fold is shorter *and* drops a day, the day is rolled back rather than deleted —
|
|
790
|
+
* this is what a restored backup looks like, and it is the case the whole row model exists for.
|
|
791
|
+
*/
|
|
792
|
+
const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now() } = {}) => {
|
|
793
|
+
writeCursor(record)
|
|
794
|
+
const cursor = readCursor(record.sessionId)
|
|
795
|
+
const incoming = rowsOf(record, { source, at })
|
|
796
|
+
const stored = new Map(readSessionRows(record.sessionId, { source, kind: CURRENT }).map((row) => [row.date, row]))
|
|
797
|
+
for (const row of incoming) {
|
|
798
|
+
const known = stored.get(row.date)
|
|
799
|
+
if (known) stored.delete(row.date)
|
|
800
|
+
if (known && !rowIsNewerOrEqual(row, known)) {
|
|
801
|
+
// The fold covers less than the copy already stored for that day. The fuller copy keeps the
|
|
802
|
+
// `current` slot — the log has been shortened, but that is no reason to forget what it said —
|
|
803
|
+
// and this short fold is written as the `fallback`: it is what is left if the fuller copy is
|
|
804
|
+
// ever gone (the machine is restored from the same backup, say).
|
|
805
|
+
if (known.payload !== JSON.stringify(row.routes)) writeRow(record.sessionId, { ...row, kind: FALLBACK })
|
|
806
|
+
continue
|
|
807
|
+
}
|
|
808
|
+
if (known && known.payload === JSON.stringify(row.routes)) continue
|
|
809
|
+
if (known) deleteRow.run(record.sessionId, row.date, source, CURRENT)
|
|
810
|
+
writeRow(record.sessionId, row)
|
|
811
|
+
}
|
|
812
|
+
// A date this device folded before, that the record no longer covers at all, is gone from the
|
|
813
|
+
// log. Its `current` row goes with it — unless this log version is itself weaker than the copy
|
|
814
|
+
// already stored, in which case a shortened log is treated as a rollback rather than as a
|
|
815
|
+
// deletion, and the day is kept.
|
|
816
|
+
for (const row of stored.values()) {
|
|
817
|
+
if (cursor && !rowIsNewerOrEqual(cursor, row)) continue
|
|
818
|
+
deleteRow.run(record.sessionId, row.date, source, CURRENT)
|
|
420
819
|
}
|
|
421
|
-
|
|
422
|
-
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Take rows that came from somewhere other than this device's own fold.
|
|
824
|
+
*
|
|
825
|
+
* This is the **sync import path**, and it takes rows rather than records because an incoming row
|
|
826
|
+
* carries its own stamp and its own slot: a shard is a set of rows, not a session's current fold.
|
|
827
|
+
* Two rules, both about not losing history:
|
|
828
|
+
*
|
|
829
|
+
* - it never deletes. Only this device's own fold may remove this device's rows; a machine whose
|
|
830
|
+
* logs were pruned must not be able to erase what another machine published;
|
|
831
|
+
* - a row is stored only when it is **strictly fuller** than the one already held for that
|
|
832
|
+
* `(date, source, slot)`. An equal-stamp copy is the same bytes and is left as it is; a weaker
|
|
833
|
+
* one is a worse answer to the same question.
|
|
834
|
+
*
|
|
835
|
+
* With `replace`, one device's rows for one month are taken as *that device's own statement* about the
|
|
836
|
+
* month, so rows it no longer lists are removed. Without it, two machines that both pulled a month keep
|
|
837
|
+
* different totals forever: the publisher drops a row, republishes, and every reader keeps the old one,
|
|
838
|
+
* because an import only ever adds. The rows removed belong to the device that is speaking, which is the
|
|
839
|
+
* only party entitled to say what it holds — not the "another machine erased my history" case the first
|
|
840
|
+
* rule forbids.
|
|
841
|
+
*
|
|
842
|
+
* @param {object[]} rows
|
|
843
|
+
* @param {{replace?:{source:string, month:string}}} [options]
|
|
844
|
+
* @returns {number} how many rows were written or removed.
|
|
845
|
+
*/
|
|
846
|
+
const intakeRows = (rows, { replace = null } = {}) => {
|
|
847
|
+
const known = new Map(readSessionRows(undefined).map((row) => [`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${row.kind}`, row]))
|
|
848
|
+
let written = 0
|
|
849
|
+
const arriving = new Set()
|
|
850
|
+
for (const row of rows ?? []) {
|
|
851
|
+
if (!row?.sessionId || typeof row.date !== 'string') continue
|
|
852
|
+
const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
|
|
853
|
+
arriving.add(`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${kind}`)
|
|
854
|
+
// The day rows are only reachable through a session row (`readRecords` walks `sessions` and
|
|
855
|
+
// hangs the days off it), so an import has to make sure one exists — otherwise the history is
|
|
856
|
+
// written into a table nothing reads.
|
|
857
|
+
const existing = known.get(`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${kind}`)
|
|
858
|
+
const payload = JSON.stringify(row.routes ?? {})
|
|
859
|
+
if (existing) {
|
|
860
|
+
// Same bytes: nothing to write, and nothing to argue about.
|
|
861
|
+
if (existing.payload === payload) continue
|
|
862
|
+
// Outside replace mode only a **strictly fuller** copy may overwrite what is held — that is what
|
|
863
|
+
// keeps a shorter log from erasing a longer one. In replace mode the incoming shard is the
|
|
864
|
+
// publisher's own statement about that month, so it wins even at the same log version: a corrected
|
|
865
|
+
// day (100 → 400) carries the same stamp, and vetoing it would leave the two machines disagreeing.
|
|
866
|
+
if (!replace && !rowIsStrictlyNewer(row, existing)) continue
|
|
867
|
+
deleteRow.run(row.sessionId, row.date, row.source, kind)
|
|
868
|
+
}
|
|
869
|
+
writeRow(row.sessionId, { ...row, kind })
|
|
870
|
+
written++
|
|
871
|
+
}
|
|
872
|
+
if (replace && writable) {
|
|
873
|
+
// Everything this device used to claim for the month that it does not claim now. Deleting only its
|
|
874
|
+
// own rows is what makes this safe: this device's own fold and every other device's rows are untouched.
|
|
875
|
+
for (const row of all(selectSourceMonth, replace.source, replace.month)) {
|
|
876
|
+
const kind = row.kind ?? CURRENT
|
|
877
|
+
if (arriving.has(`${row.session_id}\u0000${row.date}\u0000${replace.source}\u0000${kind}`)) continue
|
|
878
|
+
deleteRow.run(row.session_id, row.date, replace.source, kind)
|
|
879
|
+
written++
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// One cursor per session that arrived, from the fullest row that arrived for it.
|
|
883
|
+
const bySession = new Map()
|
|
884
|
+
for (const row of rows ?? []) {
|
|
885
|
+
if (!row?.sessionId) continue
|
|
886
|
+
const best = bySession.get(row.sessionId)
|
|
887
|
+
if (!best || rowIsStrictlyNewer(row, best)) bySession.set(row.sessionId, row)
|
|
888
|
+
}
|
|
889
|
+
for (const [sessionId, best] of bySession) {
|
|
890
|
+
const cursor = readCursor(sessionId)
|
|
891
|
+
if (!cursor || rowIsStrictlyNewer(best, cursor)) {
|
|
892
|
+
writeCursor({ sessionId, path: '', fileSize: best.fileSize, fileMtimeMs: best.fileMtimeMs, seq: best.seq })
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return written
|
|
423
896
|
}
|
|
424
897
|
|
|
425
898
|
/** Bring an existing JSON cache across, once, keeping the old file for inspection. */
|
|
@@ -428,7 +901,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
428
901
|
// database that already has rows is never re-read from the JSON file, so a failed rename
|
|
429
902
|
// below cannot cause the same history to be folded in twice.
|
|
430
903
|
if (!writable) return
|
|
431
|
-
if ((
|
|
904
|
+
if ((get(countSessions)?.n ?? 0) > 0) {
|
|
432
905
|
// Rows already exist, so this JSON file is a leftover — from a run that used the JSON
|
|
433
906
|
// backend deliberately, from a period spent on the fallback, or from an older host that
|
|
434
907
|
// wrote it after the migration. Those folds are not merged (a day-by-day merge of two
|
|
@@ -449,9 +922,15 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
449
922
|
.map(([sessionId, source]) => documentToRecord(sessionId, source))
|
|
450
923
|
.filter(Boolean)
|
|
451
924
|
if (records.length === 0) return
|
|
925
|
+
// The rows are written directly rather than through `writeSession`: a migrated record is
|
|
926
|
+
// already row-shaped (version 1's day map was turned into rows with the session's stamp on
|
|
927
|
+
// the way in), and re-deriving rows from a `days` Map there is not would import nothing.
|
|
452
928
|
try {
|
|
453
929
|
db.exec('BEGIN IMMEDIATE')
|
|
454
|
-
for (const record of records)
|
|
930
|
+
for (const record of records) {
|
|
931
|
+
writeCursor(record)
|
|
932
|
+
for (const row of record.rows) writeRow(record.sessionId, row)
|
|
933
|
+
}
|
|
455
934
|
db.exec('COMMIT')
|
|
456
935
|
} catch (error) {
|
|
457
936
|
try {
|
|
@@ -480,52 +959,117 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
480
959
|
}
|
|
481
960
|
migrateFromJson()
|
|
482
961
|
|
|
962
|
+
/** Every session, with the rows inside `[from, until]`. */
|
|
963
|
+
const readRecords = (from, until) => {
|
|
964
|
+
const byId = new Map()
|
|
965
|
+
for (const row of all(selectSessions)) {
|
|
966
|
+
byId.set(row.id, {
|
|
967
|
+
sessionId: row.id,
|
|
968
|
+
path: row.path,
|
|
969
|
+
fileSize: Number(row.file_size) || 0,
|
|
970
|
+
fileMtimeMs: Number(row.file_mtime_ms) || 0,
|
|
971
|
+
seq: storedNumber(row.seq, -1),
|
|
972
|
+
provider: row.provider ?? null,
|
|
973
|
+
model: row.model ?? null,
|
|
974
|
+
rows: [],
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
for (const row of all(selectDays, from, until)) {
|
|
978
|
+
const record = byId.get(row.session_id)
|
|
979
|
+
if (!record) continue
|
|
980
|
+
const parsed = parseRow(record, row.date, row.payload, {
|
|
981
|
+
seq: Number(row.seq) || 0,
|
|
982
|
+
fileSize: Number(row.file_size) || 0,
|
|
983
|
+
fileMtimeMs: Number(row.file_mtime_ms) || 0,
|
|
984
|
+
source: row.source,
|
|
985
|
+
kind: row.kind ?? CURRENT,
|
|
986
|
+
at: Number(row.at) || 0,
|
|
987
|
+
})
|
|
988
|
+
if (!parsed) continue
|
|
989
|
+
record.rows.push(parsed)
|
|
990
|
+
}
|
|
991
|
+
return [...byId.values()]
|
|
992
|
+
}
|
|
993
|
+
|
|
483
994
|
return {
|
|
484
995
|
kind: 'sqlite',
|
|
485
996
|
path,
|
|
486
997
|
writable,
|
|
487
998
|
load(bounds = {}) {
|
|
488
999
|
const { from, until } = resolveBounds(bounds)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1000
|
+
return resolveRows(readRecords(from, until))
|
|
1001
|
+
},
|
|
1002
|
+
scan() {
|
|
1003
|
+
return readRecords('0000-01-01', '9999-12-31')
|
|
1004
|
+
},
|
|
1005
|
+
years() {
|
|
1006
|
+
return all(selectYears).map((row) => String(row.year))
|
|
1007
|
+
},
|
|
1008
|
+
/**
|
|
1009
|
+
* Drop one device's whole month.
|
|
1010
|
+
*
|
|
1011
|
+
* This is what a **tombstone** means on the reading side: the device that published the month says it
|
|
1012
|
+
* no longer holds any of it, so the copy this store imported is no longer backed by anything. Only that
|
|
1013
|
+
* device's rows go — never this device's own fold, and never another device's rows.
|
|
1014
|
+
*
|
|
1015
|
+
* @returns {number} how many rows were removed.
|
|
1016
|
+
*/
|
|
1017
|
+
dropSourceMonth(source, month) {
|
|
1018
|
+
if (!writable || !source || !/^\d{4}-\d{2}$/.test(String(month))) return 0
|
|
1019
|
+
try {
|
|
1020
|
+
const result = deleteSourceMonth.run(String(source), String(month))
|
|
1021
|
+
return Number(result?.changes) || 0
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
log(`could not drop ${source}/${month}: ${error?.message ?? error}`)
|
|
1024
|
+
return 0
|
|
501
1025
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1026
|
+
},
|
|
1027
|
+
/**
|
|
1028
|
+
* Store one fold and hand back the resolved view of that session.
|
|
1029
|
+
*
|
|
1030
|
+
* The return value matters: a fold that covered **less** than what was already stored leaves
|
|
1031
|
+
* the fuller copy in place, and the caller must go on showing the fuller one rather than the
|
|
1032
|
+
* short fold it just produced. Returning the resolved record is what makes "the cache kept it"
|
|
1033
|
+
* true in memory as well as on disk.
|
|
1034
|
+
*/
|
|
1035
|
+
put(record, { at = Date.now() } = {}) {
|
|
1036
|
+
if (!writable) return null
|
|
1037
|
+
try {
|
|
1038
|
+
db.exec('BEGIN IMMEDIATE')
|
|
1039
|
+
writeSession(record, { source: LOCAL_SOURCE, at })
|
|
1040
|
+
db.exec('COMMIT')
|
|
1041
|
+
} catch (error) {
|
|
505
1042
|
try {
|
|
506
|
-
|
|
1043
|
+
db.exec('ROLLBACK')
|
|
507
1044
|
} catch {
|
|
508
|
-
//
|
|
1045
|
+
// the transaction is already gone
|
|
509
1046
|
}
|
|
1047
|
+
log(`cache write failed for ${record.sessionId}: ${error?.message ?? error}`)
|
|
1048
|
+
return null
|
|
510
1049
|
}
|
|
511
|
-
return
|
|
512
|
-
},
|
|
513
|
-
years() {
|
|
514
|
-
return selectYears.all().map((row) => String(row.year))
|
|
1050
|
+
return resolveRows(readRecords('0000-01-01', '9999-12-31').filter((row) => row.sessionId === record.sessionId))[0] ?? null
|
|
515
1051
|
},
|
|
516
|
-
|
|
517
|
-
|
|
1052
|
+
/**
|
|
1053
|
+
* Store rows that came from another device, and hand back how many were new.
|
|
1054
|
+
*
|
|
1055
|
+
* Wrapped in one transaction: an imported shard is a month of somebody's history, and a partial
|
|
1056
|
+
* month would leave the two devices disagreeing about which days exist.
|
|
1057
|
+
*/
|
|
1058
|
+
intakeRows(rows, options) {
|
|
1059
|
+
if (!writable) return 0
|
|
518
1060
|
try {
|
|
519
1061
|
db.exec('BEGIN IMMEDIATE')
|
|
520
|
-
|
|
1062
|
+
const written = intakeRows(rows, options)
|
|
521
1063
|
db.exec('COMMIT')
|
|
1064
|
+
return written
|
|
522
1065
|
} catch (error) {
|
|
523
1066
|
try {
|
|
524
1067
|
db.exec('ROLLBACK')
|
|
525
1068
|
} catch {
|
|
526
1069
|
// the transaction is already gone
|
|
527
1070
|
}
|
|
528
|
-
log(`cache
|
|
1071
|
+
log(`cache import failed: ${error?.message ?? error}`)
|
|
1072
|
+
return 0
|
|
529
1073
|
}
|
|
530
1074
|
},
|
|
531
1075
|
flush() {
|
|
@@ -559,7 +1103,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
559
1103
|
* must not destroy data it does not understand.
|
|
560
1104
|
*
|
|
561
1105
|
* @param {{dir:string, log?:Function, env?:object}} options
|
|
562
|
-
* @returns {{kind:string, path:string, load:Function, years:Function, put:Function, flush:Function, close:Function}}
|
|
1106
|
+
* @returns {{kind:string, path:string, load:Function, scan:Function, years:Function, put:Function, flush:Function, close:Function}}
|
|
563
1107
|
*/
|
|
564
1108
|
export function createCacheStore({ dir, log = () => {}, env = process.env }) {
|
|
565
1109
|
const jsonPath = join(dir, 'cache.json')
|