dsh-usage-badge 0.1.5 → 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/lib/sync.js CHANGED
@@ -104,20 +104,27 @@ export const safeShardKey = (key) => {
104
104
  /**
105
105
  * One device's local rows grouped into month shards.
106
106
  *
107
- * Only `current` rows this device folded 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
109
- * its `sessionId` attached, because a shard has to be able to rebuild a session record on the other
110
- * side.
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.
111
117
  *
112
118
  * @param {object[]} records - records as `store.scan()` returns them, each with a `rows` array.
113
- * @param {{owns?:string}} [options]
119
+ * @param {{owns?:string|string[]}} [options]
114
120
  * @returns {Map<string, object[]>} shard key → rows, sorted by date then session.
115
121
  */
116
122
  export function chunkRows(records, { owns = LOCAL_SOURCE } = {}) {
123
+ const owners = new Set(Array.isArray(owns) ? owns : [owns])
117
124
  const shards = new Map()
118
125
  for (const record of records ?? []) {
119
126
  for (const row of record?.rows ?? []) {
120
- if (rowSource(row) !== owns) continue
127
+ if (!owners.has(rowSource(row))) continue
121
128
  if (rowKind(row) === FALLBACK) continue
122
129
  const key = monthOf(row.date)
123
130
  if (!/^\d{4}-\d{2}$/.test(key)) continue
@@ -160,14 +167,18 @@ export function chunkRows(records, { owns = LOCAL_SOURCE } = {}) {
160
167
  * mean "device `<id>` published it". Carrying `local` across would make the receiver treat
161
168
  * another machine's history as **its own** — it would then publish it onward under its own name,
162
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.
163
175
  */
164
176
  export function encodeShard(rows, { device }) {
165
177
  const id = safeDeviceId(device)
166
178
  const header = JSON.stringify({ v: SHARD_VERSION, device: id })
167
- const body = [
168
- header,
169
- ...rows.map((row) => JSON.stringify({ ...row, source: rowSource(row) === LOCAL_SOURCE ? id : rowSource(row) })),
170
- ].join('\n')
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')
171
182
  const compressed = zstdCompressSync(Buffer.from(body, 'utf8'))
172
183
  return { body: compressed, sha256: sha256(compressed), rows: rows.length }
173
184
  }
@@ -245,12 +256,17 @@ export function chunkEntry({ device, key, body, rows, at }) {
245
256
  }
246
257
  }
247
258
 
248
- /** A device's manifest: what this device has published. */
249
- export function buildManifest({ device, label = '', chunks = {}, at = Date.now(), instanceId = '' }) {
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 = {} }) {
250
261
  // `instanceId` is written so that the *other* installations can tell whether the name in this
251
262
  // directory is theirs. Two machines that both call themselves `office` would otherwise each overwrite
252
263
  // the other's month — the shard path is the same — and the rows would be gone with no warning at all.
253
- return { v: MANIFEST_VERSION, device, label, generatedAt: at, instanceId, chunks }
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 }
254
270
  }
255
271
 
256
272
  /** Parse a manifest, refusing a version this build does not write and a device id it cannot use. */
@@ -279,7 +295,14 @@ export function parseManifest(text) {
279
295
  at: Number(entry.at) || 0,
280
296
  }
281
297
  }
282
- return { version: raw.v, device, label: String(raw.label ?? ''), generatedAt: Number(raw.generatedAt) || 0, instanceId: String(raw.instanceId ?? ''), chunks }
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 }
283
306
  }
284
307
 
285
308
  /**
@@ -311,7 +334,11 @@ export function planUpload(shards, previous = {}, { device, label = '', at = Dat
311
334
  chunks[key] = entry
312
335
  pending.push({ key, body, entry, rows: rows.length })
313
336
  }
314
- return { pending, unchanged, manifest: buildManifest({ device: safeDevice, label, chunks, at, instanceId }) }
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 }) }
315
342
  }
316
343
 
317
344
  /**
@@ -323,8 +350,9 @@ export function planUpload(shards, previous = {}, { device, label = '', at = Dat
323
350
  * manifest still lists everything it has.
324
351
  *
325
352
  * @param {{manifests:object[], held?:object, now?:number, windowYears?:number, except?:string}} options
326
- * `held` maps `<device>/<key>` to the sha256 this reader already applied. `except` is the reader's
327
- * own device id: its own manifest lists what it published, which is already in its store.
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.
328
356
  */
329
357
  export function planDownload({ manifests, held = {}, now = Date.now(), windowYears = 5, except = '' }) {
330
358
  const cutoff = `${new Date(now).getFullYear() - Math.max(0, windowYears) + 1}-01`
@@ -334,7 +362,7 @@ export function planDownload({ manifests, held = {}, now = Date.now(), windowYea
334
362
  for (const [key, entry] of Object.entries(manifest?.chunks ?? {})) {
335
363
  if (!entry?.path || !entry?.sha256) continue
336
364
  if (windowYears > 0 && key < cutoff) continue
337
- if (held[`${manifest.device}/${key}`] === entry.sha256) continue
365
+ if (heldSha(held[`${manifest.device}/${key}`]) === entry.sha256) continue
338
366
  wanted.push({ device: manifest.device, key, entry })
339
367
  }
340
368
  }
@@ -342,6 +370,21 @@ export function planDownload({ manifests, held = {}, now = Date.now(), windowYea
342
370
  return wanted
343
371
  }
344
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
+
345
388
  /**
346
389
  * Turn decoded shard rows into the records the store's import takes.
347
390
  *
@@ -506,6 +549,28 @@ export function acquireLock(dir, { instanceId, log = () => {} } = {}) {
506
549
  return { acquired: false, owner: null, reason: `the data directory cannot be created: ${error?.message ?? error}` }
507
550
  }
508
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
+
509
574
  const existing = readLock(dir)
510
575
  if (existing && !lockIsStale(existing)) {
511
576
  return {
@@ -514,14 +579,23 @@ export function acquireLock(dir, { instanceId, log = () => {} } = {}) {
514
579
  reason: `this data directory is open by another DSH instance (pid ${existing.pid} on ${existing.hostname || 'this host'})`,
515
580
  }
516
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.
517
585
  if (existing) log(`taking over a stale ${LOCK_FILE} left by pid ${existing.pid}`)
518
- const body = { pid: process.pid, startedAt: PROCESS_STARTED_AT, instanceId, hostname: hostname(), at: Date.now() }
519
586
  try {
520
- writeFileSync(path, JSON.stringify(body))
521
- } catch (error) {
522
- return { acquired: false, owner: null, reason: `could not write ${LOCK_FILE}: ${error?.message ?? error}` }
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`,
523
598
  }
524
- return { acquired: true, owner: body }
525
599
  }
526
600
 
527
601
  /** Release the lock, but only if this process is the one holding it. */
@@ -646,6 +720,17 @@ export class SyncEngine {
646
720
  return released
647
721
  }
648
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
+
649
734
  get deviceId() {
650
735
  return this.config.deviceId
651
736
  }
@@ -718,7 +803,13 @@ export class SyncEngine {
718
803
  }
719
804
  }
720
805
 
721
- /** Everything the remote knows, as manifests. */
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
+ */
722
813
  async readManifests(client) {
723
814
  const devices = await this.discover(client)
724
815
  const manifests = []
@@ -726,7 +817,7 @@ export class SyncEngine {
726
817
  const manifest = await this.readManifest(client, device)
727
818
  if (manifest) manifests.push(manifest)
728
819
  }
729
- return manifests
820
+ return { manifests, devices }
730
821
  }
731
822
 
732
823
  /**
@@ -758,6 +849,9 @@ export class SyncEngine {
758
849
  let fetched = 0
759
850
  const failed = []
760
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
761
855
  let body
762
856
  try {
763
857
  body = await client.get(item.entry.path)
@@ -782,16 +876,64 @@ export class SyncEngine {
782
876
  failed.push({ path: item.entry.path, error: 'the shard does not match the hash its manifest advertises' })
783
877
  continue
784
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
+ }
785
900
  if (this.store) {
786
- this.store.intakeRows(decoded.rows)
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 } })
787
904
  // `held` is only advanced once the rows are in the store: if the import throws, the shard
788
905
  // must be fetched again rather than remembered as applied.
789
- this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: item.entry.sha256 }
906
+ this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
790
907
  }
791
908
  rows += decoded.rows.length
792
909
  fetched++
793
910
  }
794
- return { fetched, rows, failed }
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 }
795
937
  }
796
938
 
797
939
  /**
@@ -810,7 +952,38 @@ export class SyncEngine {
810
952
  // The owner is recorded in the directory's own manifest. A directory with no recorded owner is one
811
953
  // published by a build that did not write the field (or the first publish of anything), and adopting
812
954
  // it is what makes an upgrade work; an owner that is a *different* installation is a real clash.
813
- const remote = (await this.readManifests(client)).find((manifest) => manifest.device === this.deviceId)
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) : []
814
987
  if (remote && remote.instanceId && remote.instanceId !== this.instanceId) {
815
988
  this.state.collision = { device: this.deviceId, owner: remote.instanceId, at: this.now() }
816
989
  this.log(
@@ -830,11 +1003,12 @@ export class SyncEngine {
830
1003
  }
831
1004
  delete this.state.collision
832
1005
 
833
- const rows = chunkRows(this.store.scan())
1006
+ const rows = chunkRows(this.store.scan(), { owns: [LOCAL_SOURCE, this.deviceId, ...mine.map((manifest) => manifest.device)] })
834
1007
  // `state.published` is the manifest's **chunks map**, so it is wrapped back into a manifest shape
835
1008
  // here. Passing the map itself would leave `previous.chunks` empty, and every run would re-upload
836
- // every month while looking perfectly healthy.
837
- const { pending, unchanged, manifest } = planUpload(rows, { chunks: this.state.published ?? {} }, {
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 ?? {} }, {
838
1012
  device: this.deviceId,
839
1013
  label: this.config.deviceLabel ?? '',
840
1014
  at: this.now(),
@@ -861,13 +1035,6 @@ export class SyncEngine {
861
1035
  return { uploaded: 0, unchanged: 0, bytes: 0, shards: [], refused: 'diverged' }
862
1036
  }
863
1037
 
864
- // A month this device published and no longer has any rows for: a session folder was deleted, or a
865
- // log version that no longer covers that day superseded the rows. The manifest is republished from
866
- // what is actually held, so **new** devices must stop being told about it — otherwise they import
867
- // usage its own machine no longer has, which is precisely the "the numbers came back" surprise. The
868
- // bytes stay on the server; only the claim that this device still holds them is withdrawn.
869
- const retired = Object.keys(this.state.published ?? {}).filter((key) => !rows.has(key))
870
-
871
1038
  // The collection comes **before** anything is written into it. `mod_dav_fs` refuses a write whose
872
1039
  // parent does not exist (403/409, not the 404 an HTTP client might expect), so creating it on the
873
1040
  // way past would fail the first publish of a device — and mkcol is idempotent anyway, so it costs
@@ -876,6 +1043,9 @@ export class SyncEngine {
876
1043
  let bytes = 0
877
1044
  const shards = []
878
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
879
1049
  // Upload under a temporary name and rename: a crash mid-upload leaves a name nobody reads,
880
1050
  // instead of a shard that looks complete and is half a month.
881
1051
  const temp = `${item.entry.path}.upload-${process.pid}-${Math.random().toString(16).slice(2, 8)}`
@@ -894,27 +1064,70 @@ export class SyncEngine {
894
1064
  // **last** on purpose: a manifest naming a shard that has not landed yet would send every other
895
1065
  // device after a 404 on their next run.
896
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.
897
1103
  let withdrawn = 0
898
1104
  const withdrawnKeys = []
899
- for (const key of retired) {
1105
+ const withdrawErrors = []
1106
+ for (const item of withdrawals) {
900
1107
  try {
901
1108
  // Gone from the manifest *and* from the server: leaving the bytes behind while dropping the claim
902
- // would be tidier for a manual rescue and worse for everything else — the next reader that finds
903
- // the manifest is authoritative would see a shard nobody lists. The shard is *this device's own*,
904
- // which is the one direction where deleting is not the remote deciding anything.
905
- await client.delete(shardPath(this.deviceId, key))
906
- delete manifest.chunks[key]
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 ?? '') } }
907
1118
  withdrawn++
908
- withdrawnKeys.push(key)
909
- this.log(`withdrew ${key}: this device no longer holds any rows for it`)
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`)
910
1121
  } catch (error) {
911
- // The month could not be withdrawn, so the manifest is not written at all: half-recording it
912
- // would leave the claim in place while the shard is gone. Reported, and retried next run.
913
- return { uploaded: pending.length, unchanged, bytes, shards, manifest: null, withdrawn: 0, refused: null, withdrawError: `could not withdraw ${key}: ${error?.message ?? error}` }
1122
+ withdrawErrors.push(`could not withdraw ${item.key} from ${item.device}/: ${error?.message ?? error}`)
914
1123
  }
915
1124
  }
916
- await client.put(manifestPath(this.deviceId), Buffer.from(JSON.stringify(manifest, null, 2)), { contentType: 'application/json' })
917
- return { uploaded: pending.length, unchanged, bytes, shards, manifest, withdrawn, withdrawnKeys }
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 }
918
1131
  }
919
1132
 
920
1133
  /**
@@ -1096,9 +1309,14 @@ export class SyncEngine {
1096
1309
  } catch (error) {
1097
1310
  return this.finish('failed', String(error?.message ?? error), { started })
1098
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 })
1099
1316
 
1100
1317
  try {
1101
- const manifests = await this.readManifests(client)
1318
+ const { manifests } = await this.readManifests(client)
1319
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1102
1320
  // **Pull first, refuse only the push.** An installation whose device name is taken still wants the
1103
1321
  // history back, and that is the case the feature exists for (reinstall, another machine): the name
1104
1322
  // is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
@@ -1113,10 +1331,17 @@ export class SyncEngine {
1113
1331
  this.pullOwnDirectory = Boolean(clash)
1114
1332
  const pulled = await this.pull(client, manifests)
1115
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 })
1116
1335
  const pushed = await this.push(client)
1117
- if (pushed.manifest) this.state.published = pushed.manifest.chunks
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
+ }
1118
1342
  // The price table last: it is the only hand-written thing here, and it should not be touched by
1119
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 })
1120
1345
  const pricing = await this.syncPricing(client)
1121
1346
  const parts = [
1122
1347
  `pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
@@ -1128,8 +1353,9 @@ export class SyncEngine {
1128
1353
  // the pull worked and nothing was lost. Naming it is what lets the panel say what to do.
1129
1354
  parts.push(`nothing published: device name "${this.deviceId}" is being written by another installation`)
1130
1355
  }
1131
- if (pushed.withdrawError) parts.push(pushed.withdrawError)
1356
+ for (const error of pushed.withdrawErrors ?? []) parts.push(error)
1132
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`)
1133
1359
  if (pricing.action !== 'none') parts.push(`prices: ${pricing.status}`)
1134
1360
  if (pulled.failed.length > 0) parts.push(`${pulled.failed.length} shard(s) failed`)
1135
1361
  const message = parts.join(', ')
@@ -1137,7 +1363,8 @@ export class SyncEngine {
1137
1363
  // Four outcomes, each meaning a different thing to the person reading the panel: `ok` moved
1138
1364
  // everything, `partial` moved almost everything, `clash` deliberately published nothing and needs a
1139
1365
  // decision from them, `failed` is a problem with the server or the network.
1140
- const result = pushed.refused ? 'clash' : pulled.failed.length > 0 || pushed.withdrawError ? 'partial' : 'ok'
1366
+ const incomplete = pulled.failed.length > 0 || (pushed.withdrawErrors?.length ?? 0) > 0
1367
+ const result = pushed.refused ? 'clash' : incomplete ? 'partial' : 'ok'
1141
1368
  return this.finish(result, message, {
1142
1369
  started,
1143
1370
  extra: {
@@ -1149,6 +1376,7 @@ export class SyncEngine {
1149
1376
  failed: pulled.failed,
1150
1377
  refused: pushed.refused ?? null,
1151
1378
  withdrawn: pushed.withdrawn ?? 0,
1379
+ dropped: pulled.dropped ?? 0,
1152
1380
  },
1153
1381
  })
1154
1382
  } catch (error) {
@@ -1166,7 +1394,10 @@ export class SyncEngine {
1166
1394
  * — which is exactly how a sync ends up re-importing every shard on every run.
1167
1395
  */
1168
1396
  finish(result, message, { started, extra = {} } = {}) {
1169
- this.state.lastSyncAt = this.now()
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()
1170
1401
  this.state.lastResult = result
1171
1402
  this.state.message = message
1172
1403
  try {