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/sync.js
ADDED
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — the shard format and the two directions of a sync.
|
|
3
|
+
*
|
|
4
|
+
* The cache's own store stays local; this module is the **export/import boundary**. It lives apart
|
|
5
|
+
* from the store because it needs two things the store must not know about:
|
|
6
|
+
*
|
|
7
|
+
* - **a wire format.** One shard is one calendar month of one device's rows, as NDJSON compressed
|
|
8
|
+
* with `zstd` (`node:zlib`, already used to read `.jsonl.zstd` logs). A month is the granularity
|
|
9
|
+
* because a day would mean hundreds of files and a request each, while a year would mean
|
|
10
|
+
* re-uploading ~27 MB whenever any single day inside it changed.
|
|
11
|
+
* - **a manifest.** Each device publishes **its own** `index.json`, listing the shards it wrote
|
|
12
|
+
* with a sha256 each. There is deliberately no shared manifest: a file two devices both wrote
|
|
13
|
+
* would need locking, and this design has no locking.
|
|
14
|
+
*
|
|
15
|
+
* ## What a device must never publish
|
|
16
|
+
*
|
|
17
|
+
* Only rows with `source = local` are exported. A row that *arrived* from another device is stored so
|
|
18
|
+
* the panel can count it, but exporting it back would make this device claim another's history — and
|
|
19
|
+
* if that other device then deleted it, the copy here would resurrect it forever.
|
|
20
|
+
*
|
|
21
|
+
* `fallback` rows are not exported either: they are copies of a log version this device has already
|
|
22
|
+
* moved past, kept as local insurance rather than as history to publish.
|
|
23
|
+
*
|
|
24
|
+
* ## The import side
|
|
25
|
+
*
|
|
26
|
+
* A shard's rows carry their own `source` (the id of the device that wrote them) and their own
|
|
27
|
+
* stamp, and are handed to the store as they are. The store then applies the rule it applies to any
|
|
28
|
+
* weak copy: a row that is not strictly fuller than the one already held for that `(session, date,
|
|
29
|
+
* source, slot)` is dropped. That is what stops a machine whose logs were pruned from overwriting the
|
|
30
|
+
* fuller history another machine still has.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-usage-badge/sync
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from 'node:crypto'
|
|
36
|
+
import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
37
|
+
import { hostname } from 'node:os'
|
|
38
|
+
import { join } from 'node:path'
|
|
39
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
40
|
+
|
|
41
|
+
import { CURRENT, FALLBACK, LOCAL_SOURCE, rowKind, rowSource } from './buckets.js'
|
|
42
|
+
import { WebDavClient } from './webdav.js'
|
|
43
|
+
|
|
44
|
+
/** The shard format this build writes. A shard of another version is refused, not guessed at. */
|
|
45
|
+
export const SHARD_VERSION = 1
|
|
46
|
+
|
|
47
|
+
/** The manifest format this build writes. */
|
|
48
|
+
export const MANIFEST_VERSION = 1
|
|
49
|
+
|
|
50
|
+
/** The file extension a shard carries; the file name is its month. */
|
|
51
|
+
export const SHARD_EXTENSION = '.ndjson.zst'
|
|
52
|
+
|
|
53
|
+
/** Everything this plugin owns on the server lives under this prefix. */
|
|
54
|
+
export const REMOTE_PREFIX = 'usage-badge/'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A file beside each device's `index.json`.
|
|
58
|
+
*
|
|
59
|
+
* Apache's `PROPFIND` lists the collections under `usage-badge/`, which is how a device discovers
|
|
60
|
+
* the others — but a collection holding only `index.json` may list without its `index.json` (the
|
|
61
|
+
* server is free to omit a member), and discovery is the one thing that must not depend on which of
|
|
62
|
+
* the two a reader happens to see. So each device directory also gets this small marker, created
|
|
63
|
+
* right after the collection and never rewritten: if a reader can see the directory at all, it sees
|
|
64
|
+
* the marker, and a directory without it is treated as half-created rather than as a device.
|
|
65
|
+
*/
|
|
66
|
+
export const DEVICE_MARKER = '.device'
|
|
67
|
+
|
|
68
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
|
69
|
+
|
|
70
|
+
/** The month a day key belongs to: `2026-09-14` → `2026-09`. */
|
|
71
|
+
export const monthOf = (date) => String(date ?? '').slice(0, 7)
|
|
72
|
+
|
|
73
|
+
/** One shard's path, given a device id and a month. */
|
|
74
|
+
export const shardPath = (device, key) => `${REMOTE_PREFIX}${device}/${key}${SHARD_EXTENSION}`
|
|
75
|
+
|
|
76
|
+
/** One device's manifest path. */
|
|
77
|
+
export const manifestPath = (device) => `${REMOTE_PREFIX}${device}/index.json`
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A device id as it may appear in a path: no slashes, no traversal, nothing exotic.
|
|
81
|
+
*
|
|
82
|
+
* `local` is refused on purpose. A device's own rows are stored with `source = 'local'` until they are
|
|
83
|
+
* exported, and `chunkRows` decides ownership by that exact value — so a device *named* `local` would
|
|
84
|
+
* make every other device mistake its rows for their own and re-publish them under their own name, after
|
|
85
|
+
* which this device's own fold could delete them. The name is a path segment and an ownership tag; it
|
|
86
|
+
* cannot be the tag that means "not from the sync".
|
|
87
|
+
*/
|
|
88
|
+
export const safeDeviceId = (device) => {
|
|
89
|
+
const id = String(device ?? '').trim()
|
|
90
|
+
if (id === LOCAL_SOURCE) throw new Error(`\`${LOCAL_SOURCE}\` is reserved for this machine's own rows and cannot be a device id`)
|
|
91
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(id) || id === '.' || id === '..') {
|
|
92
|
+
throw new Error(`a device id must be 1-64 of [A-Za-z0-9._-], got ${JSON.stringify(device)}`)
|
|
93
|
+
}
|
|
94
|
+
return id
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A shard key as it may appear in a path: exactly one `YYYY-MM`. */
|
|
98
|
+
export const safeShardKey = (key) => {
|
|
99
|
+
const value = String(key ?? '').trim()
|
|
100
|
+
if (!/^\d{4}-\d{2}$/.test(value)) throw new Error(`a shard key must be YYYY-MM, got ${JSON.stringify(key)}`)
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One device's local rows grouped into month shards.
|
|
106
|
+
*
|
|
107
|
+
* Only this installation's own `current` rows are exported (`owns` selects them; it is a parameter so a
|
|
108
|
+
* test can exercise the filter without pretending to be another device). Each row is copied out with its
|
|
109
|
+
* `sessionId` attached, because a shard has to be able to rebuild a session record on the other side.
|
|
110
|
+
*
|
|
111
|
+
* `owns` is usually `'local'`, but it may be a **set** of names: after a rename, a device pulls its own
|
|
112
|
+
* previous directory and imports those rows under the old name, and they are still this installation's
|
|
113
|
+
* history. Leaving them out means the device counts them (they resolve like any other row) while never
|
|
114
|
+
* publishing them, so a fresh machine sees less than its owner does — 18766 against 10000 in the case this
|
|
115
|
+
* was found with. A name only gets here after being checked against this installation's `instanceId`, so
|
|
116
|
+
* another machine's rows are never claimed.
|
|
117
|
+
*
|
|
118
|
+
* @param {object[]} records - records as `store.scan()` returns them, each with a `rows` array.
|
|
119
|
+
* @param {{owns?:string|string[]}} [options]
|
|
120
|
+
* @returns {Map<string, object[]>} shard key → rows, sorted by date then session.
|
|
121
|
+
*/
|
|
122
|
+
export function chunkRows(records, { owns = LOCAL_SOURCE } = {}) {
|
|
123
|
+
const owners = new Set(Array.isArray(owns) ? owns : [owns])
|
|
124
|
+
const shards = new Map()
|
|
125
|
+
for (const record of records ?? []) {
|
|
126
|
+
for (const row of record?.rows ?? []) {
|
|
127
|
+
if (!owners.has(rowSource(row))) continue
|
|
128
|
+
if (rowKind(row) === FALLBACK) continue
|
|
129
|
+
const key = monthOf(row.date)
|
|
130
|
+
if (!/^\d{4}-\d{2}$/.test(key)) continue
|
|
131
|
+
let list = shards.get(key)
|
|
132
|
+
if (!list) {
|
|
133
|
+
list = []
|
|
134
|
+
shards.set(key, list)
|
|
135
|
+
}
|
|
136
|
+
list.push({
|
|
137
|
+
date: row.date,
|
|
138
|
+
source: rowSource(row),
|
|
139
|
+
kind: CURRENT,
|
|
140
|
+
seq: Number(row.seq) || 0,
|
|
141
|
+
fileSize: Number(row.fileSize) || 0,
|
|
142
|
+
fileMtimeMs: Number(row.fileMtimeMs) || 0,
|
|
143
|
+
at: Number(row.at) || 0,
|
|
144
|
+
sessionId: String(record.sessionId ?? ''),
|
|
145
|
+
path: String(record.path ?? ''),
|
|
146
|
+
routes: row.routes ?? {},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const list of shards.values()) {
|
|
151
|
+
list.sort((a, b) => (a.date === b.date ? (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0) : a.date < b.date ? -1 : 1))
|
|
152
|
+
}
|
|
153
|
+
return shards
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One shard's bytes: a header line, then one JSON row per line, compressed as a whole.
|
|
158
|
+
*
|
|
159
|
+
* Two properties are load-bearing:
|
|
160
|
+
*
|
|
161
|
+
* - **the bytes are deterministic.** Nothing about *when* the shard was written goes into them, so
|
|
162
|
+
* the upload plan can decide "this month did not change" by comparing a content hash. If the
|
|
163
|
+
* timestamp were part of the payload, every re-plan would look like a new revision and every sync
|
|
164
|
+
* would re-upload everything. When a month was written is manifest metadata, not shard data.
|
|
165
|
+
* - **a row's `source` is rewritten to the publishing device.** A row stored here says
|
|
166
|
+
* `source = local`, meaning "this device folded it"; on the receiving side the same bytes must
|
|
167
|
+
* mean "device `<id>` published it". Carrying `local` across would make the receiver treat
|
|
168
|
+
* another machine's history as **its own** — it would then publish it onward under its own name,
|
|
169
|
+
* and a later fold here could delete rows it does not own.
|
|
170
|
+
*
|
|
171
|
+
* For the same reason every **other** name of this installation is rewritten too: a device that has
|
|
172
|
+
* been renamed re-publishes rows it once imported under its old name, and they are published *now* by
|
|
173
|
+
* this name. Leaving the old name in them would put a source in the receiver's store that no directory
|
|
174
|
+
* on the server answers for.
|
|
175
|
+
*/
|
|
176
|
+
export function encodeShard(rows, { device }) {
|
|
177
|
+
const id = safeDeviceId(device)
|
|
178
|
+
const header = JSON.stringify({ v: SHARD_VERSION, device: id })
|
|
179
|
+
// Everything in a shard was published by the device whose name is on it: `chunkRows` only ever hands over
|
|
180
|
+
// this installation's own rows, so the source is stamped rather than inherited.
|
|
181
|
+
const body = [header, ...rows.map((row) => JSON.stringify({ ...row, source: id }))].join('\n')
|
|
182
|
+
const compressed = zstdCompressSync(Buffer.from(body, 'utf8'))
|
|
183
|
+
return { body: compressed, sha256: sha256(compressed), rows: rows.length }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decode one shard.
|
|
188
|
+
*
|
|
189
|
+
* A shard whose version is not this build's is refused **by name** rather than parsed leniently: a
|
|
190
|
+
* half-understood shard would be merged as if it were understood, and the result would be wrong
|
|
191
|
+
* numbers that look right.
|
|
192
|
+
*
|
|
193
|
+
* @param {Buffer|Uint8Array} buffer
|
|
194
|
+
* @returns {{version:number, device:string, exportedAt:number, rows:object[]}}
|
|
195
|
+
*/
|
|
196
|
+
export function decodeShard(buffer) {
|
|
197
|
+
let text
|
|
198
|
+
try {
|
|
199
|
+
text = zstdDecompressSync(buffer).toString('utf8')
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new Error(`a shard could not be decompressed: ${error?.message ?? error}`)
|
|
202
|
+
}
|
|
203
|
+
const lines = text.split('\n')
|
|
204
|
+
if (!lines[0]) throw new Error('a shard is empty')
|
|
205
|
+
let header
|
|
206
|
+
try {
|
|
207
|
+
header = JSON.parse(lines[0])
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(`a shard header is not JSON: ${error?.message ?? error}`)
|
|
210
|
+
}
|
|
211
|
+
if (header?.v !== SHARD_VERSION) {
|
|
212
|
+
throw new Error(`a shard is version ${String(header?.v)}, but this build writes version ${SHARD_VERSION}`)
|
|
213
|
+
}
|
|
214
|
+
const rows = []
|
|
215
|
+
let malformed = 0
|
|
216
|
+
for (let i = 1; i < lines.length; i++) {
|
|
217
|
+
if (!lines[i]) continue
|
|
218
|
+
let row
|
|
219
|
+
try {
|
|
220
|
+
row = JSON.parse(lines[i])
|
|
221
|
+
} catch {
|
|
222
|
+
malformed++
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (typeof row?.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(row.date)) {
|
|
226
|
+
malformed++
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
rows.push({
|
|
230
|
+
date: row.date,
|
|
231
|
+
source: String(row.source ?? header.device ?? LOCAL_SOURCE),
|
|
232
|
+
kind: row.kind === FALLBACK ? FALLBACK : CURRENT,
|
|
233
|
+
seq: Number(row.seq) || 0,
|
|
234
|
+
fileSize: Number(row.fileSize) || 0,
|
|
235
|
+
fileMtimeMs: Number(row.fileMtimeMs) || 0,
|
|
236
|
+
at: Number(row.at) || 0,
|
|
237
|
+
sessionId: String(row.sessionId ?? ''),
|
|
238
|
+
routes: row.routes && typeof row.routes === 'object' ? row.routes : {},
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
return { version: header.v, device: String(header.device ?? ''), exportedAt: Number(header.at) || 0, rows, malformed }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One chunk's manifest entry, describing the bytes that were actually written. */
|
|
245
|
+
export function chunkEntry({ device, key, body, rows, at }) {
|
|
246
|
+
const dates = rows.map((row) => row.date).sort()
|
|
247
|
+
return {
|
|
248
|
+
key,
|
|
249
|
+
path: shardPath(device, key),
|
|
250
|
+
from: dates[0] ?? '',
|
|
251
|
+
to: dates[dates.length - 1] ?? '',
|
|
252
|
+
bytes: body.length,
|
|
253
|
+
sha256: sha256(body),
|
|
254
|
+
rows: rows.length,
|
|
255
|
+
at,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A device's manifest: what this device has published, and what it has withdrawn. */
|
|
260
|
+
export function buildManifest({ device, label = '', chunks = {}, at = Date.now(), instanceId = '', retired = {} }) {
|
|
261
|
+
// `instanceId` is written so that the *other* installations can tell whether the name in this
|
|
262
|
+
// directory is theirs. Two machines that both call themselves `office` would otherwise each overwrite
|
|
263
|
+
// the other's month — the shard path is the same — and the rows would be gone with no warning at all.
|
|
264
|
+
//
|
|
265
|
+
// `retired` is the **tombstone**: months this device published and no longer holds any of. A month that
|
|
266
|
+
// simply disappears from `chunks` is not enough, because a reader that already imported it has no way to
|
|
267
|
+
// learn that it is gone — it would keep those rows forever and the two machines would disagree for good.
|
|
268
|
+
// An entry is dropped as soon as the month comes back, so a restored log un-retires it naturally.
|
|
269
|
+
return { v: MANIFEST_VERSION, device, label, generatedAt: at, instanceId, chunks, retired }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Parse a manifest, refusing a version this build does not write and a device id it cannot use. */
|
|
273
|
+
export function parseManifest(text) {
|
|
274
|
+
let raw
|
|
275
|
+
try {
|
|
276
|
+
raw = typeof text === 'string' || Buffer.isBuffer(text) ? JSON.parse(text.toString('utf8')) : text
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw new Error(`the manifest is not JSON: ${error?.message ?? error}`)
|
|
279
|
+
}
|
|
280
|
+
if (raw?.v !== MANIFEST_VERSION) {
|
|
281
|
+
throw new Error(`the manifest is version ${String(raw?.v)}, but this build writes version ${MANIFEST_VERSION}`)
|
|
282
|
+
}
|
|
283
|
+
const device = safeDeviceId(raw.device)
|
|
284
|
+
const chunks = {}
|
|
285
|
+
for (const [key, entry] of Object.entries(raw.chunks ?? {})) {
|
|
286
|
+
if (!entry || typeof entry !== 'object' || typeof entry.sha256 !== 'string') continue
|
|
287
|
+
chunks[key] = {
|
|
288
|
+
key,
|
|
289
|
+
path: String(entry.path ?? shardPath(device, key)),
|
|
290
|
+
from: String(entry.from ?? ''),
|
|
291
|
+
to: String(entry.to ?? ''),
|
|
292
|
+
bytes: Number(entry.bytes) || 0,
|
|
293
|
+
sha256: entry.sha256,
|
|
294
|
+
rows: Number(entry.rows) || 0,
|
|
295
|
+
at: Number(entry.at) || 0,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// The tombstones travel with the manifest. Only the key and when it was withdrawn are needed; a reader
|
|
299
|
+
// compares them against what it holds, so an entry for a month it never imported costs nothing.
|
|
300
|
+
const retired = {}
|
|
301
|
+
for (const [key, entry] of Object.entries(raw.retired ?? {})) {
|
|
302
|
+
if (!/^\d{4}-\d{2}$/.test(key)) continue
|
|
303
|
+
retired[key] = { at: Number(entry?.at) || 0, sha256: String(entry?.sha256 ?? '') }
|
|
304
|
+
}
|
|
305
|
+
return { version: raw.v, device, label: String(raw.label ?? ''), generatedAt: Number(raw.generatedAt) || 0, instanceId: String(raw.instanceId ?? ''), chunks, retired }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Decide what to upload: the shards whose bytes differ from what this device last published.
|
|
310
|
+
*
|
|
311
|
+
* The comparison is by **content hash**, not by date or mtime: the sync is incremental and has to
|
|
312
|
+
* skip a month that has not changed, and a re-fold that reproduces identical bytes must not become
|
|
313
|
+
* an upload just because it happened.
|
|
314
|
+
*
|
|
315
|
+
* @param {Map<string, object[]>} shards - as {@link chunkRows} returns.
|
|
316
|
+
* @param {object} [previous] - the manifest this device published last time, or `{}`.
|
|
317
|
+
* @returns {{pending: Array<{key:string, body:Buffer, entry:object, rows:number}>, manifest:object, unchanged:number}}
|
|
318
|
+
*/
|
|
319
|
+
export function planUpload(shards, previous = {}, { device, label = '', at = Date.now(), instanceId = '' } = {}) {
|
|
320
|
+
const safeDevice = safeDeviceId(device)
|
|
321
|
+
const chunks = { ...(previous?.chunks ?? {}) }
|
|
322
|
+
const pending = []
|
|
323
|
+
let unchanged = 0
|
|
324
|
+
for (const [key, rows] of [...(shards ?? new Map())].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
325
|
+
const { body } = encodeShard(rows, { device: safeDevice })
|
|
326
|
+
const hash = sha256(body)
|
|
327
|
+
const known = chunks[key]
|
|
328
|
+
if (known?.sha256 === hash) {
|
|
329
|
+
// Left exactly as published, timestamp included: re-stamping it would say the month changed.
|
|
330
|
+
unchanged++
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
const entry = chunkEntry({ device: safeDevice, key, body, rows, at })
|
|
334
|
+
chunks[key] = entry
|
|
335
|
+
pending.push({ key, body, entry, rows: rows.length })
|
|
336
|
+
}
|
|
337
|
+
// A month that is published again is no longer withdrawn, so its tombstone goes: otherwise a reader that
|
|
338
|
+
// imported the month before it was withdrawn would keep dropping it on every run.
|
|
339
|
+
const retired = { ...(previous?.retired ?? {}) }
|
|
340
|
+
for (const key of Object.keys(chunks)) delete retired[key]
|
|
341
|
+
return { pending, unchanged, manifest: buildManifest({ device: safeDevice, label, chunks, at, instanceId, retired }) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The shards a reader should fetch.
|
|
346
|
+
*
|
|
347
|
+
* `windowYears` bounds the **first** pull: a decade of history is real, but downloading all of it
|
|
348
|
+
* before the panel shows anything would be the worst first impression this feature could make. The
|
|
349
|
+
* bound applies to fetching, never to keeping — shards already held stay held, and each device's
|
|
350
|
+
* manifest still lists everything it has.
|
|
351
|
+
*
|
|
352
|
+
* @param {{manifests:object[], held?:object, now?:number, windowYears?:number, except?:string}} options
|
|
353
|
+
* `held` maps `<device>/<key>` to what this reader already applied — either the sha256 (what older
|
|
354
|
+
* builds wrote) or `{sha, at}`. `except` is the reader's own device id: its own manifest lists what it
|
|
355
|
+
* published, which is already in its store.
|
|
356
|
+
*/
|
|
357
|
+
export function planDownload({ manifests, held = {}, now = Date.now(), windowYears = 5, except = '' }) {
|
|
358
|
+
const cutoff = `${new Date(now).getFullYear() - Math.max(0, windowYears) + 1}-01`
|
|
359
|
+
const wanted = []
|
|
360
|
+
for (const manifest of manifests ?? []) {
|
|
361
|
+
if (except && manifest?.device === except) continue
|
|
362
|
+
for (const [key, entry] of Object.entries(manifest?.chunks ?? {})) {
|
|
363
|
+
if (!entry?.path || !entry?.sha256) continue
|
|
364
|
+
if (windowYears > 0 && key < cutoff) continue
|
|
365
|
+
if (heldSha(held[`${manifest.device}/${key}`]) === entry.sha256) continue
|
|
366
|
+
wanted.push({ device: manifest.device, key, entry })
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
wanted.sort((a, b) => (a.key === b.key ? (a.device < b.device ? -1 : 1) : a.key < b.key ? -1 : 1))
|
|
370
|
+
return wanted
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* What a `held` entry says was applied, whichever shape it is in.
|
|
375
|
+
*
|
|
376
|
+
* Older builds stored the bare sha; this one stores `{sha, at}` so that a shard **written earlier** than
|
|
377
|
+
* what is already held can be recognised as a rollback. A reader that cannot tell the difference would
|
|
378
|
+
* apply a restored old shard over newer rows — and with a replacing import that means deleting them.
|
|
379
|
+
*/
|
|
380
|
+
export function heldEntry(value) {
|
|
381
|
+
if (typeof value === 'string') return value ? { sha: value, at: 0 } : null
|
|
382
|
+
if (value && typeof value === 'object' && typeof value.sha === 'string') return { sha: value.sha, at: Number(value.at) || 0 }
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const heldSha = (value) => heldEntry(value)?.sha ?? null
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Turn decoded shard rows into the records the store's import takes.
|
|
390
|
+
*
|
|
391
|
+
* Grouped by `(source, sessionId)` because that is the store's write unit, and every row keeps the
|
|
392
|
+
* stamp it was written with: the receiving store compares those stamps to decide whether the
|
|
393
|
+
* incoming copy is worth storing at all. The cursor fields on the record are the best stamp among
|
|
394
|
+
* its rows, so a caller that only needs "the newest log version this device knows" can read them.
|
|
395
|
+
*/
|
|
396
|
+
export function rowsToRecords(rows) {
|
|
397
|
+
const bySource = new Map()
|
|
398
|
+
for (const row of rows ?? []) {
|
|
399
|
+
if (!row?.sessionId) continue
|
|
400
|
+
const key = `${row.source}\u0000${row.sessionId}`
|
|
401
|
+
let record = bySource.get(key)
|
|
402
|
+
if (!record) {
|
|
403
|
+
record = { sessionId: row.sessionId, path: '', source: row.source, rows: [] }
|
|
404
|
+
bySource.set(key, record)
|
|
405
|
+
}
|
|
406
|
+
record.rows.push(row)
|
|
407
|
+
}
|
|
408
|
+
for (const record of bySource.values()) {
|
|
409
|
+
const best = record.rows.reduce((top, row) => (top === null || row.seq > top.seq || (row.seq === top.seq && row.fileSize > top.fileSize) ? row : top), null)
|
|
410
|
+
record.seq = best?.seq ?? -1
|
|
411
|
+
record.fileSize = best?.fileSize ?? 0
|
|
412
|
+
record.fileMtimeMs = best?.fileMtimeMs ?? 0
|
|
413
|
+
}
|
|
414
|
+
return [...bySource.values()]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A short, display-safe prefix of a hash, for logs and the panel. */
|
|
418
|
+
export const shortHash = (hash) => String(hash ?? '').slice(0, 12)
|
|
419
|
+
|
|
420
|
+
// ── the price table: one shared file, and one copy per device ────────────────
|
|
421
|
+
|
|
422
|
+
/** The shared price table, which every device reads and the last writer wins. */
|
|
423
|
+
export const PRICING_FILE = `${REMOTE_PREFIX}pricing.json`
|
|
424
|
+
|
|
425
|
+
/** The price table this device wrote, kept so a conflict can always be resolved by hand. */
|
|
426
|
+
export const pricingDevicePath = (device) => `${REMOTE_PREFIX}${safeDeviceId(device)}/pricing-${safeDeviceId(device)}.json`
|
|
427
|
+
|
|
428
|
+
/** Byte-exact comparison, since the plan is about *whether* the file changed, not how. */
|
|
429
|
+
const sameBytes = (a, b) => (a === null || b === null ? a === b : Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0)
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Decide what to do about the shared price table.
|
|
433
|
+
*
|
|
434
|
+
* The price table is the one thing here that a human writes by hand, so it cannot be merged by a
|
|
435
|
+
* rule — two people editing the same table is a decision only a person can make. What this can do is
|
|
436
|
+
* tell the three cases apart without ever losing a version:
|
|
437
|
+
*
|
|
438
|
+
* - **nothing changed** (`remote === baseline`) → do nothing, whichever device wrote last;
|
|
439
|
+
* - **only the remote moved** → take it;
|
|
440
|
+
* - **only we moved** → publish ours;
|
|
441
|
+
* - **both moved, differently** → a conflict: the remote is kept beside ours as
|
|
442
|
+
* `.conflict-pricing-<device>-<at>.json` before either wins, and the later `at` wins. Losing a
|
|
443
|
+
* price row silently is exactly the outcome this whole design is built to avoid.
|
|
444
|
+
*
|
|
445
|
+
* @param {{localBytes?:Buffer|null, remoteBytes?:Buffer|null, baseline?:string|null,
|
|
446
|
+
* localAt?:number, remoteAt?:number, device?:string, at?:number}} options
|
|
447
|
+
* @returns {{action:'none'|'pull'|'push'|'conflict', winner:'local'|'remote'|null, conflictFile:string|null}}
|
|
448
|
+
*/
|
|
449
|
+
export function planPricing({ localBytes = null, remoteBytes = null, baseline = null, localAt = 0, remoteAt = 0, device = '', at = 0 } = {}) {
|
|
450
|
+
const localHash = localBytes === null ? null : sha256(localBytes)
|
|
451
|
+
const remoteHash = remoteBytes === null ? null : sha256(remoteBytes)
|
|
452
|
+
// A missing local file means "we have not published anything yet", not "we changed something":
|
|
453
|
+
// otherwise a fresh install calls the server's very first table a conflict. An *empty* file is a
|
|
454
|
+
// different thing — it exists, and it is not what the server has.
|
|
455
|
+
const localChanged = localHash === null ? baseline !== null : localHash !== baseline
|
|
456
|
+
const remoteChanged = remoteHash !== null && remoteHash !== baseline
|
|
457
|
+
|
|
458
|
+
// Nothing to take and nothing to publish: either both sides agree, or the file we published is
|
|
459
|
+
// still the one on the server.
|
|
460
|
+
if (!localChanged && !remoteChanged) return { action: 'none', winner: null, conflictFile: null }
|
|
461
|
+
if (!localChanged && remoteChanged) return { action: 'pull', winner: 'remote', conflictFile: null }
|
|
462
|
+
if (localChanged && !remoteChanged) return { action: 'push', winner: 'local', conflictFile: null }
|
|
463
|
+
if (sameBytes(localBytes, remoteBytes)) return { action: 'none', winner: 'remote', conflictFile: null }
|
|
464
|
+
|
|
465
|
+
// Both sides changed, and they disagree. Keep the one that would have been lost.
|
|
466
|
+
const winner = remoteAt >= localAt ? 'remote' : 'local'
|
|
467
|
+
const loserPath = remoteAt >= localAt ? localBytes : remoteBytes
|
|
468
|
+
const conflictFile = device && loserPath !== null ? `.conflict-pricing-${safeDeviceId(device)}-${at || Date.now()}.json` : null
|
|
469
|
+
return { action: 'conflict', winner, conflictFile }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── the data directory is owned by one process at a time ─────────────────────
|
|
473
|
+
|
|
474
|
+
/** The lock file a host holds while it may write this data directory. */
|
|
475
|
+
export const LOCK_FILE = 'host.lock'
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Whether the process that wrote a lock file is still alive.
|
|
479
|
+
*
|
|
480
|
+
* `process.kill(pid, 0)` is the portable probe: it sends no signal, and its failure modes separate
|
|
481
|
+
* "no such process" (ESRCH — the lock is stale, take it) from "not allowed to signal it" (EPERM —
|
|
482
|
+
* there *is* a process, and on Windows that is the normal answer for a process owned by another
|
|
483
|
+
* session). Anything else is treated as "cannot tell", which means **not** taking the lock: a
|
|
484
|
+
* second writer is worse than a read-only instance.
|
|
485
|
+
*/
|
|
486
|
+
export function processLooksAlive(pid) {
|
|
487
|
+
const value = Number(pid)
|
|
488
|
+
if (!Number.isInteger(value) || value <= 0) return false
|
|
489
|
+
if (value === process.pid) return true
|
|
490
|
+
try {
|
|
491
|
+
process.kill(value, 0)
|
|
492
|
+
return true
|
|
493
|
+
} catch (error) {
|
|
494
|
+
const code = error?.code
|
|
495
|
+
if (code === 'ESRCH') return false
|
|
496
|
+
if (code === 'EPERM') return true
|
|
497
|
+
return true
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Write the lock, and read back what a previous holder left.
|
|
503
|
+
*
|
|
504
|
+
* `process.pid` alone is not enough: pid reuse after a crash would make a live-looking lock out of a
|
|
505
|
+
* dead process. Comparing the recorded process start time catches that, and the comparison is
|
|
506
|
+
* deliberately lax — a mismatch in the *started* field only downgrades the lock to "someone else
|
|
507
|
+
* holds it", which is the safe direction.
|
|
508
|
+
*/
|
|
509
|
+
export function readLock(dir) {
|
|
510
|
+
const path = join(dir, LOCK_FILE)
|
|
511
|
+
try {
|
|
512
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
513
|
+
return {
|
|
514
|
+
pid: Number(raw?.pid) || 0,
|
|
515
|
+
startedAt: Number(raw?.startedAt) || 0,
|
|
516
|
+
instanceId: String(raw?.instanceId ?? ''),
|
|
517
|
+
hostname: String(raw?.hostname ?? ''),
|
|
518
|
+
at: Number(raw?.at) || 0,
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Whether a lock belongs to a process that is no longer running. */
|
|
526
|
+
export const lockIsStale = (lock) => lock === null || !processLooksAlive(lock.pid)
|
|
527
|
+
|
|
528
|
+
/** Just this process's own start, in epoch ms. */
|
|
529
|
+
const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Try to take ownership of a data directory.
|
|
533
|
+
*
|
|
534
|
+
* Ownership decides who may *write*: the cache, the fold, and the sync all need one writer, and the
|
|
535
|
+
* sync needs it most — two processes sharing a `deviceId` would each believe the same remote shard is
|
|
536
|
+
* theirs to overwrite. The caller that fails here is not broken and does not refuse to start: it
|
|
537
|
+
* uses the cache read-only and skips syncing, which is what the design promises for this case.
|
|
538
|
+
*
|
|
539
|
+
* @returns {{acquired:boolean, owner?:object, reason?:string}}
|
|
540
|
+
*/
|
|
541
|
+
export function acquireLock(dir, { instanceId, log = () => {} } = {}) {
|
|
542
|
+
try {
|
|
543
|
+
// Creating the data directory is part of taking the lock, and it can fail for reasons that have
|
|
544
|
+
// nothing to do with a lock: a read-only home, or a `storages` that turned out to be a file. Those are
|
|
545
|
+
// reported like any other "cannot write here" — an exception escaping here reached the panel as a 500
|
|
546
|
+
// with no explanation.
|
|
547
|
+
mkdirSync(dir, { recursive: true })
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return { acquired: false, owner: null, reason: `the data directory cannot be created: ${error?.message ?? error}` }
|
|
550
|
+
}
|
|
551
|
+
const path = join(dir, LOCK_FILE)
|
|
552
|
+
const body = { pid: process.pid, startedAt: PROCESS_STARTED_AT, instanceId, hostname: hostname(), at: Date.now() }
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Create the file **only if it is not there**.
|
|
556
|
+
*
|
|
557
|
+
* `wx` is the whole point: the earlier read-then-write had a window in which two processes could both
|
|
558
|
+
* see no lock (or both see a stale one) and both believe they had taken it. Now the kernel decides — one
|
|
559
|
+
* `open` wins, and the loser gets `EEXIST`.
|
|
560
|
+
*/
|
|
561
|
+
const claim = () => {
|
|
562
|
+
try {
|
|
563
|
+
writeFileSync(path, JSON.stringify(body), { flag: 'wx' })
|
|
564
|
+
return { acquired: true, owner: body }
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error?.code !== 'EEXIST') return { acquired: false, owner: null, reason: `could not write ${LOCK_FILE}: ${error?.message ?? error}` }
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const first = claim()
|
|
572
|
+
if (first) return first
|
|
573
|
+
|
|
574
|
+
const existing = readLock(dir)
|
|
575
|
+
if (existing && !lockIsStale(existing)) {
|
|
576
|
+
return {
|
|
577
|
+
acquired: false,
|
|
578
|
+
owner: existing,
|
|
579
|
+
reason: `this data directory is open by another DSH instance (pid ${existing.pid} on ${existing.hostname || 'this host'})`,
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Stale: remove it and try once more. If another process gets there first, its `wx` wins and ours sees
|
|
583
|
+
// `EEXIST` again — which is exactly the answer we want, and the reason this is safe without a lock file
|
|
584
|
+
// to guard the lock file.
|
|
585
|
+
if (existing) log(`taking over a stale ${LOCK_FILE} left by pid ${existing.pid}`)
|
|
586
|
+
try {
|
|
587
|
+
unlinkSync(path)
|
|
588
|
+
} catch {
|
|
589
|
+
// Someone else removed it, or it was never there. Either way the next claim decides.
|
|
590
|
+
}
|
|
591
|
+
const second = claim()
|
|
592
|
+
if (second) return second
|
|
593
|
+
const now = readLock(dir)
|
|
594
|
+
return {
|
|
595
|
+
acquired: false,
|
|
596
|
+
owner: now,
|
|
597
|
+
reason: now ? `this data directory is open by another DSH instance (pid ${now.pid} on ${now.hostname || 'this host'})` : `could not take ${LOCK_FILE}: another process took it first`,
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Release the lock, but only if this process is the one holding it. */
|
|
602
|
+
export function releaseLock(dir) {
|
|
603
|
+
const existing = readLock(dir)
|
|
604
|
+
if (!existing || existing.pid !== process.pid) return false
|
|
605
|
+
try {
|
|
606
|
+
unlinkSync(join(dir, LOCK_FILE))
|
|
607
|
+
return true
|
|
608
|
+
} catch {
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── sync.json: what the user configured, and what the last run learned ───────
|
|
614
|
+
|
|
615
|
+
/** The file holding the sync configuration and the last run's state. */
|
|
616
|
+
export const SYNC_FILE = 'sync.json'
|
|
617
|
+
|
|
618
|
+
/** The defaults a sync configuration starts from. */
|
|
619
|
+
export const defaultSyncConfig = () => ({
|
|
620
|
+
enabled: false,
|
|
621
|
+
url: '',
|
|
622
|
+
username: '',
|
|
623
|
+
realm: '',
|
|
624
|
+
ha1: '',
|
|
625
|
+
allowInsecureTls: false,
|
|
626
|
+
deviceId: '',
|
|
627
|
+
deviceLabel: hostname(),
|
|
628
|
+
instanceId: '',
|
|
629
|
+
windowYears: 5,
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
/** Read `sync.json`, or a default document when it is missing or unreadable. */
|
|
633
|
+
export function readSyncDocument(dir, log = () => {}) {
|
|
634
|
+
const path = join(dir, SYNC_FILE)
|
|
635
|
+
try {
|
|
636
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
637
|
+
if (!raw || typeof raw !== 'object') return { ...defaultSyncConfig(), state: {}, known: {} }
|
|
638
|
+
return { ...defaultSyncConfig(), ...raw }
|
|
639
|
+
} catch (error) {
|
|
640
|
+
if (error?.code !== 'ENOENT') log(`${SYNC_FILE} could not be read (${error?.message ?? error}); using the defaults`)
|
|
641
|
+
return { ...defaultSyncConfig(), state: {}, known: {} }
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Write `sync.json` atomically, so a crash cannot leave a half-written configuration. */
|
|
646
|
+
export function writeSyncDocument(dir, document) {
|
|
647
|
+
mkdirSync(dir, { recursive: true })
|
|
648
|
+
const path = join(dir, SYNC_FILE)
|
|
649
|
+
const temp = `${path}.tmp-${process.pid}`
|
|
650
|
+
writeFileSync(temp, JSON.stringify(document, null, 2))
|
|
651
|
+
try {
|
|
652
|
+
renameSync(temp, path)
|
|
653
|
+
} catch {
|
|
654
|
+
try {
|
|
655
|
+
unlinkSync(path)
|
|
656
|
+
} catch {
|
|
657
|
+
// absent is fine
|
|
658
|
+
}
|
|
659
|
+
renameSync(temp, path)
|
|
660
|
+
}
|
|
661
|
+
return path
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── the orchestration: discover, pull, push ──────────────────────────────────
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* One device's view of the whole remote, kept between runs.
|
|
668
|
+
*
|
|
669
|
+
* `known.shards` maps `<device>/<key>` to the sha256 already applied, which is what makes a second
|
|
670
|
+
* run cheap: it is also the record that proves a shard was read rather than merely listed. It is
|
|
671
|
+
* rebuilt from the remote on every run, so losing it costs a re-download and nothing else.
|
|
672
|
+
*/
|
|
673
|
+
export const defaultSyncState = () => ({
|
|
674
|
+
lastSyncAt: 0,
|
|
675
|
+
lastResult: 'never',
|
|
676
|
+
message: '',
|
|
677
|
+
held: {},
|
|
678
|
+
published: {},
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The remote orchestration.
|
|
683
|
+
*
|
|
684
|
+
* It owns the order of operations, because the order is the correctness argument: take the lock,
|
|
685
|
+
* **pull before pushing** (so a shard this device is about to overwrite is already accounted for),
|
|
686
|
+
* import into the store, publish only what changed, and write the manifest last — a manifest that
|
|
687
|
+
* names a shard which is not there yet would send every other device after a 404.
|
|
688
|
+
*/
|
|
689
|
+
export class SyncEngine {
|
|
690
|
+
/**
|
|
691
|
+
* @param {{dataDir:string, store?:object, log?:Function, client?:object, now?:Function,
|
|
692
|
+
* config?:object, state?:object, instanceId?:string}} options
|
|
693
|
+
* `store` may be null: a sync without a store still publishes and reads manifests, which is what
|
|
694
|
+
* the first step's live check exercises.
|
|
695
|
+
*/
|
|
696
|
+
constructor({ dataDir, store = null, log = () => {}, client = null, now = Date.now, config = null, state = null, instanceId = '' }) {
|
|
697
|
+
this.dataDir = dataDir
|
|
698
|
+
this.store = store
|
|
699
|
+
this.log = log
|
|
700
|
+
this.now = now
|
|
701
|
+
const document = config ? { ...defaultSyncConfig(), ...config } : readSyncDocument(dataDir, log)
|
|
702
|
+
this.config = document
|
|
703
|
+
this.state = state ?? { ...defaultSyncState(), ...(document.state ?? {}) }
|
|
704
|
+
this.instanceId = instanceId || document.instanceId || `${hostname()}-${process.pid}`
|
|
705
|
+
this.client = client
|
|
706
|
+
this.lock = null
|
|
707
|
+
this.ownsLock = false
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Give up ownership of the data directory.
|
|
712
|
+
*
|
|
713
|
+
* Called on unload by the host half, and by anything embedding this engine for one run. It only
|
|
714
|
+
* removes a lock this engine took: a lock handed in by the caller belongs to the caller.
|
|
715
|
+
*/
|
|
716
|
+
release() {
|
|
717
|
+
if (!this.ownsLock) return false
|
|
718
|
+
const released = releaseLock(this.dataDir)
|
|
719
|
+
this.ownsLock = false
|
|
720
|
+
return released
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Ask a run in progress to stop at its next step boundary.
|
|
725
|
+
*
|
|
726
|
+
* Requests already on the wire cannot be recalled, but the remaining phases can be skipped. That matters
|
|
727
|
+
* when the host is unloading: without it a run keeps going after `aggregator.close()`, writing state for
|
|
728
|
+
* a process that is going away and touching a store whose statements have been finalized.
|
|
729
|
+
*/
|
|
730
|
+
abort() {
|
|
731
|
+
this.aborted = true
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
get deviceId() {
|
|
735
|
+
return this.config.deviceId
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Persist the configuration (and the latest state) to `sync.json`. */
|
|
739
|
+
save({ config = {}, state = {} } = {}) {
|
|
740
|
+
this.config = { ...this.config, ...config, instanceId: this.instanceId }
|
|
741
|
+
this.state = { ...this.state, ...state }
|
|
742
|
+
writeSyncDocument(this.dataDir, { ...this.config, state: this.state })
|
|
743
|
+
return this.config
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A WebDAV client for the configured server, or a clear failure. */
|
|
747
|
+
connect() {
|
|
748
|
+
if (this.client) return this.client
|
|
749
|
+
const { url, username, ha1 } = this.config
|
|
750
|
+
if (!url) throw new Error('no WebDAV URL is configured')
|
|
751
|
+
if (!username) throw new Error('no WebDAV username is configured')
|
|
752
|
+
if (!ha1) throw new Error('no WebDAV credential is configured')
|
|
753
|
+
return new WebDavClient({
|
|
754
|
+
baseUrl: url,
|
|
755
|
+
username,
|
|
756
|
+
// The stored secret is the digest HA1 — `MD5(user:realm:pass)`, exactly what the server keeps in
|
|
757
|
+
// its own password file. Passing it as `passwordHash` means the plaintext never has to be
|
|
758
|
+
// written down here, and the response is still the one the server expects.
|
|
759
|
+
passwordHash: ha1,
|
|
760
|
+
allowInsecureTls: Boolean(this.config.allowInsecureTls),
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The device directories under `usage-badge/`.
|
|
766
|
+
*
|
|
767
|
+
* It lists the `usage-badge/` collection itself rather than the root: the root's children include
|
|
768
|
+
* the collection's own name, and treating that as a device would send this sync after
|
|
769
|
+
* `usage-badge/usage-badge/index.json`. A collection that does not exist yet means an empty list —
|
|
770
|
+
* the first device to sync creates it.
|
|
771
|
+
*/
|
|
772
|
+
async discover(client) {
|
|
773
|
+
let entries
|
|
774
|
+
try {
|
|
775
|
+
entries = await client.propfind(REMOTE_PREFIX)
|
|
776
|
+
} catch (error) {
|
|
777
|
+
// `PROPFIND` answers 404 for a path that has no collection yet; that is "nothing here", not a
|
|
778
|
+
// failure to talk to the server.
|
|
779
|
+
if (/answered 404/.test(String(error?.message ?? ''))) return []
|
|
780
|
+
throw new Error(`could not list ${REMOTE_PREFIX}: ${error?.message ?? error}`)
|
|
781
|
+
}
|
|
782
|
+
const devices = []
|
|
783
|
+
for (const entry of entries) {
|
|
784
|
+
if (!entry.collection) continue
|
|
785
|
+
const name = String(entry.path ?? entry.name).replace(/\/+$/, '').split('/').pop() ?? ''
|
|
786
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(name) || name === '.' || name === '..') continue
|
|
787
|
+
devices.push(name)
|
|
788
|
+
}
|
|
789
|
+
return devices
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Read one device's manifest, or null when it has none yet. */
|
|
793
|
+
async readManifest(client, device) {
|
|
794
|
+
const body = await client.get(manifestPath(device))
|
|
795
|
+
if (body === null) return null
|
|
796
|
+
try {
|
|
797
|
+
return parseManifest(body)
|
|
798
|
+
} catch (error) {
|
|
799
|
+
// A manifest that cannot be parsed is reported and skipped: refusing the whole sync over one
|
|
800
|
+
// unreadable device would stop the devices that are fine.
|
|
801
|
+
this.log(`manifest of ${device} could not be read: ${error?.message ?? error}`)
|
|
802
|
+
return null
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Everything the remote knows: the directories that exist, and the manifests that could be read.
|
|
808
|
+
*
|
|
809
|
+
* Both are returned because they answer different questions. A directory whose manifest is missing or
|
|
810
|
+
* unreadable is not "nothing there" — it is a directory whose owner cannot be established, which is
|
|
811
|
+
* exactly when writing into it is most dangerous.
|
|
812
|
+
*/
|
|
813
|
+
async readManifests(client) {
|
|
814
|
+
const devices = await this.discover(client)
|
|
815
|
+
const manifests = []
|
|
816
|
+
for (const device of devices) {
|
|
817
|
+
const manifest = await this.readManifest(client, device)
|
|
818
|
+
if (manifest) manifests.push(manifest)
|
|
819
|
+
}
|
|
820
|
+
return { manifests, devices }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Pull: fetch the shards this device does not already hold, and store their rows.
|
|
825
|
+
*
|
|
826
|
+
* @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>}}
|
|
827
|
+
*/
|
|
828
|
+
async pull(client, manifests) {
|
|
829
|
+
const wanted = planDownload({
|
|
830
|
+
manifests,
|
|
831
|
+
held: this.state.held ?? {},
|
|
832
|
+
now: this.now(),
|
|
833
|
+
windowYears: Number(this.config.windowYears) || 0,
|
|
834
|
+
// This device's own manifest is not something to fetch: it lists the shards this device
|
|
835
|
+
// published, and its rows are already in this store under their local source. Reading them back
|
|
836
|
+
// would re-attribute them to this device's id and import duplicates of what is already here.
|
|
837
|
+
//
|
|
838
|
+
// Unless that directory is **someone else's**, which is the name clash: the shards in it are then a
|
|
839
|
+
// history this machine does not have, and it is exactly the history a reinstalled machine came
|
|
840
|
+
// back for. Importing them is safe — the merge key is the session, not the device — and the
|
|
841
|
+
// alternative is an empty panel until the user resolves a naming question they may not understand.
|
|
842
|
+
//
|
|
843
|
+
// The decision comes from **this run's** finding, never from the persisted `state.collision`: a
|
|
844
|
+
// refusal can stay recorded for months, and reading one's own directory because of a stale note
|
|
845
|
+
// means re-fetching one's own shards — including the half-written one that caused the refusal.
|
|
846
|
+
except: this.pullOwnDirectory ? '' : this.deviceId,
|
|
847
|
+
})
|
|
848
|
+
let rows = 0
|
|
849
|
+
let fetched = 0
|
|
850
|
+
const failed = []
|
|
851
|
+
for (const item of wanted) {
|
|
852
|
+
// One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
|
|
853
|
+
// and the untouched months to be fetched next run.
|
|
854
|
+
if (this.aborted) break
|
|
855
|
+
let body
|
|
856
|
+
try {
|
|
857
|
+
body = await client.get(item.entry.path)
|
|
858
|
+
} catch (error) {
|
|
859
|
+
failed.push({ path: item.entry.path, error: String(error?.message ?? error) })
|
|
860
|
+
continue
|
|
861
|
+
}
|
|
862
|
+
if (body === null) {
|
|
863
|
+
failed.push({ path: item.entry.path, error: 'the manifest lists a shard the server does not have' })
|
|
864
|
+
continue
|
|
865
|
+
}
|
|
866
|
+
let decoded
|
|
867
|
+
try {
|
|
868
|
+
decoded = decodeShard(body)
|
|
869
|
+
} catch (error) {
|
|
870
|
+
failed.push({ path: item.entry.path, error: String(error?.message ?? error) })
|
|
871
|
+
continue
|
|
872
|
+
}
|
|
873
|
+
// A shard whose hash does not match its manifest is not stored: the store keeps whatever is
|
|
874
|
+
// newer, so importing a truncated month could not be undone by re-importing the good one.
|
|
875
|
+
if (item.entry.sha256 && item.entry.sha256 !== sha256(body)) {
|
|
876
|
+
failed.push({ path: item.entry.path, error: 'the shard does not match the hash its manifest advertises' })
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
// A shard that does not decode into every row its manifest advertises is **not** remembered as
|
|
880
|
+
// applied. The good rows are still imported (that is idempotent), but advancing `held` would mark the
|
|
881
|
+
// month done forever while some of it was silently dropped — the row count and the manifest disagree,
|
|
882
|
+
// and the honest answer is to keep asking. This build never writes such a shard; an older one or a
|
|
883
|
+
// hand-made file can.
|
|
884
|
+
if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
|
|
885
|
+
this.store?.intakeRows(decoded.rows)
|
|
886
|
+
failed.push({
|
|
887
|
+
path: item.entry.path,
|
|
888
|
+
error: `the shard holds ${decoded.rows.length} of the ${item.entry.rows || decoded.rows.length} rows its manifest names` + (decoded.malformed ? ` (${decoded.malformed} unreadable line(s))` : ''),
|
|
889
|
+
})
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
// A shard written **earlier** than the one already applied is a rollback, not news: a restored
|
|
893
|
+
// backup or an older manifest would otherwise delete rows this reader has that the publisher no
|
|
894
|
+
// longer knows about. Skipped rather than applied, and said out loud.
|
|
895
|
+
const previous = heldEntry(this.state.held?.[`${item.device}/${item.key}`])
|
|
896
|
+
if (previous && item.entry.at && previous.at && previous.at > item.entry.at) {
|
|
897
|
+
this.log(`keeping ${item.device}/${item.key}: the server's copy is older than the one already applied`)
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
900
|
+
if (this.store) {
|
|
901
|
+
// `replace`: the month is this device's own statement about it, so rows it no longer lists go.
|
|
902
|
+
// Without that, a publisher that dropped a row keeps every reader counting it forever.
|
|
903
|
+
this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
|
|
904
|
+
// `held` is only advanced once the rows are in the store: if the import throws, the shard
|
|
905
|
+
// must be fetched again rather than remembered as applied.
|
|
906
|
+
this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
|
|
907
|
+
}
|
|
908
|
+
rows += decoded.rows.length
|
|
909
|
+
fetched++
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ── what the server says is gone ─────────────────────────────────────────
|
|
913
|
+
//
|
|
914
|
+
// A month that vanished from a manifest used to be invisible to a reader that had already imported
|
|
915
|
+
// it: `intakeRows` only adds, so the reader kept rows its publisher had dropped and the two machines
|
|
916
|
+
// disagreed for good. A device that withdraws a month now leaves a tombstone, and this is the other
|
|
917
|
+
// half — drop what came from that device for that month.
|
|
918
|
+
let dropped = 0
|
|
919
|
+
const droppedKeys = []
|
|
920
|
+
for (const manifest of manifests ?? []) {
|
|
921
|
+
const device = manifest?.device
|
|
922
|
+
if (!device || (this.pullOwnDirectory ? false : device === this.deviceId)) continue
|
|
923
|
+
for (const [key, entry] of Object.entries(manifest?.retired ?? {})) {
|
|
924
|
+
const context = `${device}/${key}`
|
|
925
|
+
// Only what this reader actually imported: a tombstone for a month it never held is nothing.
|
|
926
|
+
if (!this.state.held?.[context]) continue
|
|
927
|
+
const removed = this.store?.dropSourceMonth(device, key) ?? 0
|
|
928
|
+
const next = { ...(this.state.held ?? {}) }
|
|
929
|
+
delete next[context]
|
|
930
|
+
this.state.held = next
|
|
931
|
+
dropped += removed
|
|
932
|
+
droppedKeys.push(context)
|
|
933
|
+
this.log(`dropped ${removed} row(s) from ${context}: the device that published it no longer holds that month (withdrawn ${entry.at ? new Date(entry.at).toISOString().slice(0, 10) : 'at some point'})`)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return { fetched, rows, failed, dropped, droppedKeys }
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Push: publish this device's changed months, then its manifest.
|
|
941
|
+
*
|
|
942
|
+
* The manifest is written **last** on purpose. A manifest naming a shard that has not landed yet
|
|
943
|
+
* would send every other device after a 404 on their next run.
|
|
944
|
+
*/
|
|
945
|
+
async push(client) {
|
|
946
|
+
if (!this.store) return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: null }
|
|
947
|
+
// Whose name is this? The shard path is `usage-badge/<device>/<month>`, so a second installation
|
|
948
|
+
// using the same device name does not merge with the first — it **overwrites** its months, and the
|
|
949
|
+
// rows are gone. Nothing about the bytes reveals that, so it has to be caught here, where the answer
|
|
950
|
+
// is "do not write" rather than "write and hope".
|
|
951
|
+
//
|
|
952
|
+
// The owner is recorded in the directory's own manifest. A directory with no recorded owner is one
|
|
953
|
+
// published by a build that did not write the field (or the first publish of anything), and adopting
|
|
954
|
+
// it is what makes an upgrade work; an owner that is a *different* installation is a real clash.
|
|
955
|
+
const { manifests: discovered, devices: directories } = await this.readManifests(client)
|
|
956
|
+
const remote = discovered.find((manifest) => manifest.device === this.deviceId)
|
|
957
|
+
// A directory that exists but whose manifest cannot be read is not "nothing there": nobody knows whose
|
|
958
|
+
// it is. Writing into it because the owner check had nothing to look at is the one shape of this bug
|
|
959
|
+
// the instanceId guard cannot see, so an **unreadable** manifest counts as occupied — unless this
|
|
960
|
+
// device has published here before, in which case it is this device's own torn manifest and repairing
|
|
961
|
+
// it is the whole point of the next publish.
|
|
962
|
+
const unknownHere = directories.includes(this.deviceId) && !remote && Object.keys(this.state.published ?? {}).length === 0
|
|
963
|
+
if (unknownHere) {
|
|
964
|
+
this.state.collision = { device: this.deviceId, owner: null, kind: 'unreadable', at: this.now() }
|
|
965
|
+
this.log(
|
|
966
|
+
`not publishing: usage-badge/${this.deviceId}/ exists but its manifest could not be read, so this device cannot tell whose it is. ` +
|
|
967
|
+
`Pick a device name that is not taken, or remove that directory on the server if it is yours.`,
|
|
968
|
+
)
|
|
969
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'unreadable' }
|
|
970
|
+
}
|
|
971
|
+
// Two directories whose names differ only in case land in **one** directory on a case-insensitive
|
|
972
|
+
// server (Windows/SMB-backed DAV), where the owner check by exact name would never fire. Refused rather
|
|
973
|
+
// than merged: on the target server (Linux Apache) they are genuinely two, and a name that differs only
|
|
974
|
+
// in case is never what someone means.
|
|
975
|
+
const caseTwin = discovered.find((manifest) => manifest.device !== this.deviceId && manifest.device.toLowerCase() === String(this.deviceId).toLowerCase())
|
|
976
|
+
if (caseTwin) {
|
|
977
|
+
this.state.collision = { device: this.deviceId, owner: caseTwin.instanceId || 'unknown', kind: 'case', at: this.now() }
|
|
978
|
+
this.log(`not publishing: usage-badge/${caseTwin.device}/ differs from "${this.deviceId}" only in case, which is one directory on a case-insensitive server`)
|
|
979
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'case' }
|
|
980
|
+
}
|
|
981
|
+
// **This installation**, not this name. A rename leaves the previous directory behind with its own
|
|
982
|
+
// manifest, and every reader treats every manifest it finds as authoritative — so a month withdrawn
|
|
983
|
+
// after a rename would come straight back through the old name. The `instanceId` is what identifies the
|
|
984
|
+
// installation across names; two installations never share one, and a manifest without one is not ours
|
|
985
|
+
// to speak for.
|
|
986
|
+
const mine = this.instanceId ? discovered.filter((manifest) => manifest.instanceId === this.instanceId) : []
|
|
987
|
+
if (remote && remote.instanceId && remote.instanceId !== this.instanceId) {
|
|
988
|
+
this.state.collision = { device: this.deviceId, owner: remote.instanceId, at: this.now() }
|
|
989
|
+
this.log(
|
|
990
|
+
`not publishing: usage-badge/${this.deviceId}/ was published by another installation ` +
|
|
991
|
+
`(${remote.instanceId}, this one is ${this.instanceId}). Pulling is unaffected — everything on the ` +
|
|
992
|
+
`server is merged into this device's history. To publish from this machine, give it a device name ` +
|
|
993
|
+
`that is not already there; "usage-badge/${this.deviceId}/" only decides where new months are written.`,
|
|
994
|
+
)
|
|
995
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'collision' }
|
|
996
|
+
}
|
|
997
|
+
if (remote && !remote.instanceId && !this.instanceId) {
|
|
998
|
+
// No owner on either side: there is no way to tell the two installations apart, and the whole point
|
|
999
|
+
// of the check is not to guess.
|
|
1000
|
+
this.state.collision = { device: this.deviceId, owner: null, at: this.now() }
|
|
1001
|
+
this.log(`refusing to publish: usage-badge/${this.deviceId}/ exists and neither side has an identity to compare`)
|
|
1002
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'unidentified' }
|
|
1003
|
+
}
|
|
1004
|
+
delete this.state.collision
|
|
1005
|
+
|
|
1006
|
+
const rows = chunkRows(this.store.scan(), { owns: [LOCAL_SOURCE, this.deviceId, ...mine.map((manifest) => manifest.device)] })
|
|
1007
|
+
// `state.published` is the manifest's **chunks map**, so it is wrapped back into a manifest shape
|
|
1008
|
+
// here. Passing the map itself would leave `previous.chunks` empty, and every run would re-upload
|
|
1009
|
+
// every month while looking perfectly healthy. `retired` rides along for the same reason: it is the
|
|
1010
|
+
// record of withdrawals a reader needs in order to drop what it once imported.
|
|
1011
|
+
const { pending, unchanged, manifest } = planUpload(rows, { chunks: this.state.published ?? {}, retired: this.state.retired ?? {} }, {
|
|
1012
|
+
device: this.deviceId,
|
|
1013
|
+
label: this.config.deviceLabel ?? '',
|
|
1014
|
+
at: this.now(),
|
|
1015
|
+
instanceId: this.instanceId,
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
// Is the directory still saying what we last put there? An identity check cannot answer this: a
|
|
1019
|
+
// directory copied to another machine carries `sync.json` with it, `instanceId` included, so that
|
|
1020
|
+
// machine passes every owner check and then `MOVE`s its months over this one's. The **bytes** answer
|
|
1021
|
+
// it — a month we recorded at one hash and the server now holds at another was written by something
|
|
1022
|
+
// that was not us, and we have nothing of our own to put there.
|
|
1023
|
+
const diverged = Object.entries(this.state.published ?? {}).find(([key, known]) => {
|
|
1024
|
+
const remoteEntry = remote?.chunks?.[key]
|
|
1025
|
+
if (!remoteEntry || remoteEntry.sha256 === known.sha256) return false
|
|
1026
|
+
return !pending.some((item) => item.key === key)
|
|
1027
|
+
})
|
|
1028
|
+
if (diverged) {
|
|
1029
|
+
const [key] = diverged
|
|
1030
|
+
this.state.collision = { device: this.deviceId, owner: remote?.instanceId || 'unknown', kind: 'diverged', month: key, at: this.now() }
|
|
1031
|
+
this.log(
|
|
1032
|
+
`not publishing: usage-badge/${this.deviceId}/${key}.ndjson.zst is not the shard this device last published, ` +
|
|
1033
|
+
`and this device has no change of its own for that month — another installation is writing into this directory`,
|
|
1034
|
+
)
|
|
1035
|
+
return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'diverged' }
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// The collection comes **before** anything is written into it. `mod_dav_fs` refuses a write whose
|
|
1039
|
+
// parent does not exist (403/409, not the 404 an HTTP client might expect), so creating it on the
|
|
1040
|
+
// way past would fail the first publish of a device — and mkcol is idempotent anyway, so it costs
|
|
1041
|
+
// nothing to do it here.
|
|
1042
|
+
await client.mkcol(`${REMOTE_PREFIX}${this.deviceId}/`)
|
|
1043
|
+
let bytes = 0
|
|
1044
|
+
const shards = []
|
|
1045
|
+
for (const item of pending) {
|
|
1046
|
+
// Same boundary as the pull: an upload already on the wire cannot be recalled, but the rest of the
|
|
1047
|
+
// list can wait for a run that will still have a store to write to.
|
|
1048
|
+
if (this.aborted) break
|
|
1049
|
+
// Upload under a temporary name and rename: a crash mid-upload leaves a name nobody reads,
|
|
1050
|
+
// instead of a shard that looks complete and is half a month.
|
|
1051
|
+
const temp = `${item.entry.path}.upload-${process.pid}-${Math.random().toString(16).slice(2, 8)}`
|
|
1052
|
+
await client.put(temp, item.body, { contentType: 'application/octet-stream' })
|
|
1053
|
+
try {
|
|
1054
|
+
await client.move(temp, item.entry.path, { overwrite: true })
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
// Leave no debris behind if the rename fails; a leftover temp file is harmless but untidy.
|
|
1057
|
+
await client.delete(temp).catch(() => {})
|
|
1058
|
+
throw error
|
|
1059
|
+
}
|
|
1060
|
+
bytes += item.body.length
|
|
1061
|
+
shards.push({ key: item.key, bytes: item.body.length, rows: item.rows })
|
|
1062
|
+
}
|
|
1063
|
+
// The marker exists before the manifest names the directory a device, and the manifest is written
|
|
1064
|
+
// **last** on purpose: a manifest naming a shard that has not landed yet would send every other
|
|
1065
|
+
// device after a 404 on their next run.
|
|
1066
|
+
await client.put(`${REMOTE_PREFIX}${this.deviceId}/${DEVICE_MARKER}`, Buffer.from(`${this.deviceId}\n`))
|
|
1067
|
+
// ── what every other name of this installation must stop claiming ──────────
|
|
1068
|
+
//
|
|
1069
|
+
// A rename leaves the previous directory behind, and **every reader treats every manifest it finds as
|
|
1070
|
+
// authoritative**. So a month that was corrected or dropped after a rename keeps feeding readers its old
|
|
1071
|
+
// rows through the old name: the fix is that this installation's own months live in exactly one
|
|
1072
|
+
// directory, and every other directory of ours is brought in line with what we actually hold.
|
|
1073
|
+
//
|
|
1074
|
+
// The comparison is by content hash, not by month: a month can stay while one of its rows goes away
|
|
1075
|
+
// (a log version that no longer covers that day), and that difference is invisible to a key-vs-key test.
|
|
1076
|
+
// A key we no longer publish at all is simply missing from the current chunks, which is the same test.
|
|
1077
|
+
//
|
|
1078
|
+
// The current name's manifest is the one `planUpload` built; the others are rebuilt through
|
|
1079
|
+
// `buildManifest` — **not** written back as parsed. A parsed manifest carries `version` while the file
|
|
1080
|
+
// format is `v`, so writing one back produces a manifest no reader can parse, which would hide that
|
|
1081
|
+
// directory's shards from everyone instead of correcting them.
|
|
1082
|
+
const targets = new Map([[this.deviceId, manifest]])
|
|
1083
|
+
for (const other of mine) {
|
|
1084
|
+
if (targets.has(other.device)) continue
|
|
1085
|
+
targets.set(other.device, buildManifest({ device: other.device, label: other.label, chunks: { ...other.chunks }, at: other.generatedAt || this.now(), instanceId: other.instanceId, retired: { ...(other.retired ?? {}) } }))
|
|
1086
|
+
}
|
|
1087
|
+
const withdrawals = []
|
|
1088
|
+
for (const [key, entry] of Object.entries(manifest.chunks)) {
|
|
1089
|
+
if (rows.has(key)) continue
|
|
1090
|
+
withdrawals.push({ key, device: this.deviceId, manifest, path: entry.path ?? shardPath(this.deviceId, key) })
|
|
1091
|
+
}
|
|
1092
|
+
for (const [device, target] of targets) {
|
|
1093
|
+
if (device === this.deviceId) continue
|
|
1094
|
+
for (const [key, entry] of Object.entries(target.chunks)) {
|
|
1095
|
+
const current = manifest.chunks[key]
|
|
1096
|
+
if (current && current.sha256 === entry.sha256) continue
|
|
1097
|
+
withdrawals.push({ key, device, manifest: target, path: entry.path ?? shardPath(device, key) })
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
// **Delete first, then write.** Each deletion is recorded, and a month whose shard could not be removed
|
|
1101
|
+
// stays in the manifest that lists it — so the claim and the bytes never disagree, and the failure is
|
|
1102
|
+
// retried on the next run instead of being half-recorded.
|
|
1103
|
+
let withdrawn = 0
|
|
1104
|
+
const withdrawnKeys = []
|
|
1105
|
+
const withdrawErrors = []
|
|
1106
|
+
for (const item of withdrawals) {
|
|
1107
|
+
try {
|
|
1108
|
+
// Gone from the manifest *and* from the server: leaving the bytes behind while dropping the claim
|
|
1109
|
+
// would leave a shard nobody lists. These shards are this installation's own, which is the one
|
|
1110
|
+
// direction where deleting is not the remote deciding anything.
|
|
1111
|
+
await client.delete(item.path)
|
|
1112
|
+
const gone = item.manifest.chunks[item.key]
|
|
1113
|
+
delete item.manifest.chunks[item.key]
|
|
1114
|
+
// A **tombstone**, so a device that already imported this month learns that it is gone. Without it
|
|
1115
|
+
// a reader keeps those rows forever — the publisher drops them, and the two machines disagree for
|
|
1116
|
+
// good, in the direction of "everyone else counts more than the machine that has the data".
|
|
1117
|
+
item.manifest.retired = { ...(item.manifest.retired ?? {}), [item.key]: { at: this.now(), sha256: String(gone?.sha256 ?? '') } }
|
|
1118
|
+
withdrawn++
|
|
1119
|
+
if (!withdrawnKeys.includes(item.key)) withdrawnKeys.push(item.key)
|
|
1120
|
+
this.log(`withdrew ${item.key} from ${item.device}/: this device publishes it under ${this.deviceId}/ or no longer holds it`)
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
withdrawErrors.push(`could not withdraw ${item.key} from ${item.device}/: ${error?.message ?? error}`)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// Written even when a withdrawal failed: the manifests now say exactly what the server holds, and the
|
|
1126
|
+
// current one still names every shard that was uploaded above (so `uploaded` is not an exaggeration).
|
|
1127
|
+
for (const [device, target] of targets) {
|
|
1128
|
+
await client.put(manifestPath(device), Buffer.from(JSON.stringify(target, null, 2)), { contentType: 'application/json' })
|
|
1129
|
+
}
|
|
1130
|
+
return { uploaded: pending.length, unchanged, bytes, shards, manifest, withdrawn, withdrawnKeys, withdrawErrors }
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Reconcile the shared price table.
|
|
1135
|
+
*
|
|
1136
|
+
* Runs after the shards, because losing a price row is worse than a delayed month: whatever this
|
|
1137
|
+
* does, it happens while the shard sync is already recorded as done. The device copy is published
|
|
1138
|
+
* first, so that even a conflict the user never looks at leaves both versions on the server.
|
|
1139
|
+
*
|
|
1140
|
+
* @returns {{action:string, status:string}}
|
|
1141
|
+
*/
|
|
1142
|
+
async syncPricing(client) {
|
|
1143
|
+
const localPath = join(this.dataDir, 'pricing.json')
|
|
1144
|
+
// A table this device pulled is still "ours" for the next comparison, so local edits are stamped
|
|
1145
|
+
// by the file's own mtime rather than by when the last sync happened — otherwise taking a remote
|
|
1146
|
+
// copy would make the *next* run see a local change that nobody made.
|
|
1147
|
+
const localStamp = () => {
|
|
1148
|
+
try {
|
|
1149
|
+
return Math.round(statSync(localPath).mtimeMs) || 0
|
|
1150
|
+
} catch {
|
|
1151
|
+
return 0
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
let localBytes = null
|
|
1155
|
+
try {
|
|
1156
|
+
localBytes = readFileSync(localPath)
|
|
1157
|
+
} catch {
|
|
1158
|
+
// No price table of our own: the built-in template is in force, and there is nothing to publish.
|
|
1159
|
+
localBytes = null
|
|
1160
|
+
}
|
|
1161
|
+
let remoteBytes = null
|
|
1162
|
+
try {
|
|
1163
|
+
remoteBytes = await client.get(PRICING_FILE)
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
this.log(`the shared price table could not be read (${error?.message ?? error}); leaving ours alone`)
|
|
1166
|
+
return { action: 'none', status: 'unreadable' }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const known = this.state.pricing ?? {}
|
|
1170
|
+
const remoteHash = remoteBytes === null ? null : sha256(remoteBytes)
|
|
1171
|
+
const plan = planPricing({
|
|
1172
|
+
localBytes,
|
|
1173
|
+
remoteBytes,
|
|
1174
|
+
baseline: known.baseline ?? null,
|
|
1175
|
+
localAt: localStamp(),
|
|
1176
|
+
remoteAt: known.remoteAt ?? 0,
|
|
1177
|
+
device: this.deviceId,
|
|
1178
|
+
at: this.now(),
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
if (plan.action === 'none') {
|
|
1182
|
+
// `baseline` records what the server had when this device last looked. It is only meaningful
|
|
1183
|
+
// when there *is* a remote file: writing the hash of nothing would make the next run treat the
|
|
1184
|
+
// baseline as a real version and call a fresh install's first sight of the table a conflict.
|
|
1185
|
+
this.state.pricing = remoteHash === null ? { ...known, remoteHash } : { ...known, baseline: remoteHash, remoteHash }
|
|
1186
|
+
// In step, but the file on disk may not match — a device that has no table of its own, or whose
|
|
1187
|
+
// last run left the server's copy as a conflict file, still needs it written out.
|
|
1188
|
+
if (remoteBytes !== null && !sameBytes(localBytes, remoteBytes)) {
|
|
1189
|
+
writeFileSync(localPath, remoteBytes)
|
|
1190
|
+
return { action: 'pull', status: 'took the server’s copy' }
|
|
1191
|
+
}
|
|
1192
|
+
return { action: plan.action, status: plan.winner ?? 'in step' }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (plan.conflictFile) {
|
|
1196
|
+
// Written locally, not remotely: a conflict copy is for whoever opens this machine. The losing
|
|
1197
|
+
// side is always written, and the winner is never applied over a person's edit — the table is
|
|
1198
|
+
// hand-written, so a silent overwrite is the one outcome this must not produce.
|
|
1199
|
+
const losing = plan.winner === 'remote' ? localBytes ?? Buffer.alloc(0) : remoteBytes ?? Buffer.alloc(0)
|
|
1200
|
+
writeFileSync(join(this.dataDir, plan.conflictFile), losing)
|
|
1201
|
+
this.log(`the price table changed on both sides; kept the copy that would have been lost as ${plan.conflictFile}`)
|
|
1202
|
+
if (plan.winner === 'local') {
|
|
1203
|
+
// Ours is the later write, so it is the one that gets published — and the server's copy has
|
|
1204
|
+
// just been saved beside it, so nothing was lost either way.
|
|
1205
|
+
if (localBytes !== null) {
|
|
1206
|
+
await client.put(pricingDevicePath(this.deviceId), localBytes, { contentType: 'application/json' })
|
|
1207
|
+
await client.put(PRICING_FILE, localBytes, { contentType: 'application/json' })
|
|
1208
|
+
const hash = sha256(localBytes)
|
|
1209
|
+
this.state.pricing = { ...known, baseline: hash, remoteHash: hash, remoteAt: this.now() }
|
|
1210
|
+
return { action: plan.action, status: 'published ours' }
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// The server's version is the later write. It is **not** applied over the user's file: their
|
|
1214
|
+
// edit stays where it is, and the server's copy is the conflict file that was just written.
|
|
1215
|
+
this.state.pricing = { ...known, baseline: remoteHash, remoteHash, remoteAt: this.now() }
|
|
1216
|
+
return { action: plan.action, status: 'kept the server’s copy as a conflict file' }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (plan.winner === 'remote') {
|
|
1220
|
+
writeFileSync(localPath, remoteBytes ?? Buffer.alloc(0))
|
|
1221
|
+
this.state.pricing = { ...known, baseline: remoteHash, remoteHash, remoteAt: this.now() }
|
|
1222
|
+
return { action: plan.action, status: 'took the server’s copy' }
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (localBytes !== null) {
|
|
1226
|
+
await client.put(pricingDevicePath(this.deviceId), localBytes, { contentType: 'application/json' })
|
|
1227
|
+
await client.put(PRICING_FILE, localBytes, { contentType: 'application/json' })
|
|
1228
|
+
const hash = sha256(localBytes)
|
|
1229
|
+
this.state.pricing = { ...known, baseline: hash, remoteHash: hash, remoteAt: this.now() }
|
|
1230
|
+
return { action: plan.action, status: 'published ours' }
|
|
1231
|
+
}
|
|
1232
|
+
return { action: plan.action, status: 'nothing to publish' }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Publish only the price table, taking and giving the lock itself.
|
|
1237
|
+
*
|
|
1238
|
+
* This is what the host half calls a moment after an edit. It is deliberately **not** a full run: a
|
|
1239
|
+
* price edit has nothing to do with shards, and a full run would drag the whole remote listing along
|
|
1240
|
+
* with it. The lock is taken because the edit can land while a full run is in flight, and two writers
|
|
1241
|
+
* on one data directory is the case the lock exists for.
|
|
1242
|
+
*
|
|
1243
|
+
* @param {{lock?:object, instanceId?:string}} [options]
|
|
1244
|
+
* @returns {{result:string, status:string, action?:string, message:string}}
|
|
1245
|
+
*/
|
|
1246
|
+
async pushPricing({ lock = null, instanceId = this.instanceId } = {}) {
|
|
1247
|
+
if (!this.config.enabled || !this.config.url || !this.config.ha1) {
|
|
1248
|
+
return { result: 'skipped', status: 'sync is off or unconfigured', message: 'sync is off or unconfigured' }
|
|
1249
|
+
}
|
|
1250
|
+
const held = lock ?? acquireLock(this.dataDir, { instanceId, log: this.log })
|
|
1251
|
+
if (!held.acquired) return { result: 'locked', status: held.reason, message: held.reason }
|
|
1252
|
+
let client
|
|
1253
|
+
try {
|
|
1254
|
+
client = this.connect()
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
const message = String(error?.message ?? error)
|
|
1257
|
+
return { result: 'failed', status: message, message }
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const pricing = await this.syncPricing(client)
|
|
1261
|
+
const message = `prices: ${pricing.status}`
|
|
1262
|
+
// `unreadable` is not a successful reconcile: the shared table could not be read, so whether this
|
|
1263
|
+
// device's edit is safe to publish is unknown — and it is deliberately not published. Recording it
|
|
1264
|
+
// as `ok` would tell the panel everything went fine at the exact moment nothing could be checked.
|
|
1265
|
+
const failed = pricing.status === 'unreadable' || pricing.action === 'failed'
|
|
1266
|
+
this.state.lastSyncAt = this.now()
|
|
1267
|
+
this.state.lastResult = failed ? 'failed' : 'ok'
|
|
1268
|
+
this.state.message = message
|
|
1269
|
+
writeSyncDocument(this.dataDir, { ...this.config, instanceId, state: this.state })
|
|
1270
|
+
return { result: failed ? 'failed' : 'ok', status: pricing.status, action: pricing.action, message }
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
const message = String(error?.message ?? error)
|
|
1273
|
+
this.log(`pricing push failed: ${message}`)
|
|
1274
|
+
this.state.lastResult = 'failed'
|
|
1275
|
+
this.state.message = `prices: ${message}`
|
|
1276
|
+
writeSyncDocument(this.dataDir, { ...this.config, instanceId, state: this.state })
|
|
1277
|
+
return { result: 'failed', status: message, message }
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* One sync: lock, discover, pull, push, price table, record.
|
|
1283
|
+
*
|
|
1284
|
+
* Every failure is caught and turned into `state.lastResult`, never into a thrown error the caller
|
|
1285
|
+
* has to interpret: the panel shows the sentence, and the local cache is untouched either way.
|
|
1286
|
+
*/
|
|
1287
|
+
async run({ lock = null } = {}) {
|
|
1288
|
+
const started = this.now()
|
|
1289
|
+
if (!this.config.enabled) {
|
|
1290
|
+
return this.finish('skipped', 'sync is turned off', { started })
|
|
1291
|
+
}
|
|
1292
|
+
this.lock = lock ?? acquireLock(this.dataDir, { instanceId: this.instanceId, log: this.log })
|
|
1293
|
+
this.ownsLock = lock === null
|
|
1294
|
+
if (!this.lock.acquired) {
|
|
1295
|
+
// `locked` is not a failure of the sync; it is this process not being the writer. The previous
|
|
1296
|
+
// run's timestamp is deliberately left alone, so the panel does not claim a sync just happened.
|
|
1297
|
+
this.state.lastResult = 'locked'
|
|
1298
|
+
this.state.message = this.lock.reason
|
|
1299
|
+
try {
|
|
1300
|
+
writeSyncDocument(this.dataDir, { ...this.config, instanceId: this.instanceId, state: this.state })
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.log(`sync state could not be saved: ${error?.message ?? error}`)
|
|
1303
|
+
}
|
|
1304
|
+
return { result: 'locked', message: this.lock.reason, ms: this.now() - started }
|
|
1305
|
+
}
|
|
1306
|
+
let client
|
|
1307
|
+
try {
|
|
1308
|
+
client = this.connect()
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
return this.finish('failed', String(error?.message ?? error), { started })
|
|
1311
|
+
}
|
|
1312
|
+
// The host can unload while this is in flight. Checked at every phase boundary rather than only at the
|
|
1313
|
+
// start, because the phases are where a run touches the store — and after `aggregator.close()` its
|
|
1314
|
+
// statements are finalized, so continuing is not harmless.
|
|
1315
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1316
|
+
|
|
1317
|
+
try {
|
|
1318
|
+
const { manifests } = await this.readManifests(client)
|
|
1319
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1320
|
+
// **Pull first, refuse only the push.** An installation whose device name is taken still wants the
|
|
1321
|
+
// history back, and that is the case the feature exists for (reinstall, another machine): the name
|
|
1322
|
+
// is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
|
|
1323
|
+
// take that directory's shards instead of skipping them as its own — and the refusal itself is
|
|
1324
|
+
// `push`'s, where the decision to write is actually made.
|
|
1325
|
+
const clash = manifests.find((manifest) => manifest.device === this.deviceId && manifest.instanceId && manifest.instanceId !== this.instanceId)
|
|
1326
|
+
if (clash) this.state.collision = { device: this.deviceId, owner: clash.instanceId, at: this.now() }
|
|
1327
|
+
else if (this.state.collision && this.state.collision.device !== this.deviceId) delete this.state.collision
|
|
1328
|
+
// Read the clashing directory only when the clash is a *name* clash. A directory that merely
|
|
1329
|
+
// disagrees with what we recorded is our own, and pulling it back would import a shard we are
|
|
1330
|
+
// refusing to publish — the one case where the two readings of `collision` differ.
|
|
1331
|
+
this.pullOwnDirectory = Boolean(clash)
|
|
1332
|
+
const pulled = await this.pull(client, manifests)
|
|
1333
|
+
this.pullOwnDirectory = false
|
|
1334
|
+
if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
|
|
1335
|
+
const pushed = await this.push(client)
|
|
1336
|
+
if (pushed.manifest) {
|
|
1337
|
+
this.state.published = pushed.manifest.chunks
|
|
1338
|
+
// The tombstones are state too: the next run has to keep carrying them, or a reader that has not
|
|
1339
|
+
// synced yet would never learn that a month is gone.
|
|
1340
|
+
this.state.retired = pushed.manifest.retired ?? {}
|
|
1341
|
+
}
|
|
1342
|
+
// The price table last: it is the only hand-written thing here, and it should not be touched by
|
|
1343
|
+
// a run that failed to move the usage it was about to move.
|
|
1344
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1345
|
+
const pricing = await this.syncPricing(client)
|
|
1346
|
+
const parts = [
|
|
1347
|
+
`pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
|
|
1348
|
+
`pushed ${pushed.uploaded} shard(s) / ${Math.round(pushed.bytes / 1024)} KB`,
|
|
1349
|
+
`${pushed.unchanged} month(s) unchanged`,
|
|
1350
|
+
]
|
|
1351
|
+
if (pushed.refused) {
|
|
1352
|
+
// Not `ok`: the months this device folded are not on the server. It is not `failed` either —
|
|
1353
|
+
// the pull worked and nothing was lost. Naming it is what lets the panel say what to do.
|
|
1354
|
+
parts.push(`nothing published: device name "${this.deviceId}" is being written by another installation`)
|
|
1355
|
+
}
|
|
1356
|
+
for (const error of pushed.withdrawErrors ?? []) parts.push(error)
|
|
1357
|
+
if (pushed.withdrawn) parts.push(`withdrew ${pushed.withdrawn} month(s) this device no longer holds`)
|
|
1358
|
+
if (pulled.dropped) parts.push(`dropped ${pulled.dropped} row(s) another device has withdrawn`)
|
|
1359
|
+
if (pricing.action !== 'none') parts.push(`prices: ${pricing.status}`)
|
|
1360
|
+
if (pulled.failed.length > 0) parts.push(`${pulled.failed.length} shard(s) failed`)
|
|
1361
|
+
const message = parts.join(', ')
|
|
1362
|
+
this.log(`sync: ${message}`)
|
|
1363
|
+
// Four outcomes, each meaning a different thing to the person reading the panel: `ok` moved
|
|
1364
|
+
// everything, `partial` moved almost everything, `clash` deliberately published nothing and needs a
|
|
1365
|
+
// decision from them, `failed` is a problem with the server or the network.
|
|
1366
|
+
const incomplete = pulled.failed.length > 0 || (pushed.withdrawErrors?.length ?? 0) > 0
|
|
1367
|
+
const result = pushed.refused ? 'clash' : incomplete ? 'partial' : 'ok'
|
|
1368
|
+
return this.finish(result, message, {
|
|
1369
|
+
started,
|
|
1370
|
+
extra: {
|
|
1371
|
+
pulled: pulled.fetched,
|
|
1372
|
+
rows: pulled.rows,
|
|
1373
|
+
pushed: pushed.uploaded,
|
|
1374
|
+
bytes: pushed.bytes,
|
|
1375
|
+
pricing: pricing.action,
|
|
1376
|
+
failed: pulled.failed,
|
|
1377
|
+
refused: pushed.refused ?? null,
|
|
1378
|
+
withdrawn: pushed.withdrawn ?? 0,
|
|
1379
|
+
dropped: pulled.dropped ?? 0,
|
|
1380
|
+
},
|
|
1381
|
+
})
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
const message = String(error?.message ?? error)
|
|
1384
|
+
this.log(`sync failed: ${message}`)
|
|
1385
|
+
return this.finish('failed', message, { started })
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Record the outcome, persist the state, and return it.
|
|
1391
|
+
*
|
|
1392
|
+
* The state object is **mutated, not replaced**: {@link pull} and {@link push} hold a reference to
|
|
1393
|
+
* it while they work, so assigning a fresh object here would silently drop everything they recorded
|
|
1394
|
+
* — which is exactly how a sync ends up re-importing every shard on every run.
|
|
1395
|
+
*/
|
|
1396
|
+
finish(result, message, { started, extra = {} } = {}) {
|
|
1397
|
+
// `skipped` (sync turned off between scheduling and running) deliberately leaves the timestamp alone:
|
|
1398
|
+
// nothing was synced, and stamping it would show "上次同步:刚刚" for a run that never happened. `locked`
|
|
1399
|
+
// returns before this, for the same reason.
|
|
1400
|
+
if (result !== 'skipped') this.state.lastSyncAt = this.now()
|
|
1401
|
+
this.state.lastResult = result
|
|
1402
|
+
this.state.message = message
|
|
1403
|
+
try {
|
|
1404
|
+
writeSyncDocument(this.dataDir, { ...this.config, instanceId: this.instanceId, state: this.state })
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
this.log(`sync state could not be saved: ${error?.message ?? error}`)
|
|
1407
|
+
}
|
|
1408
|
+
return { result, message, ms: this.now() - started, ...extra }
|
|
1409
|
+
}
|
|
1410
|
+
}
|