@summerscar/dsh-mnemon-sync 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +53 -7
  2. package/package.json +1 -1
  3. package/targets.js +24 -2
package/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { createHash } from 'node:crypto'
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
23
+ import { hostname } from 'node:os'
23
24
  import { dirname, join, resolve } from 'node:path'
24
25
 
25
26
  import { SyncHistory, SyncScheduler } from './scheduler.js'
@@ -83,9 +84,44 @@ async function readJsonBody(req) {
83
84
  return JSON.parse(text)
84
85
  }
85
86
 
86
- /** `mnemon-sync-2026-11-09T041500Z.zip`: sorts lexicographically by time. */
87
- export function archiveKey(at = new Date()) {
88
- return `${ARCHIVE_PREFIX}${at.toISOString().replace(/:/gu, '-')}.zip`
87
+ /**
88
+ * The name this machine goes by in an archive name.
89
+ *
90
+ * Read from dsh-mnemon's own identity file (`state/machine.json`) so a backup
91
+ * uploaded from several machines stays tellable apart. Falls back to the
92
+ * hostname, then to a fixed word, so the key never ends up with an empty
93
+ * segment. Characters that would be awkward in a filename — slashes, spaces,
94
+ * anything a WebDAV path segment must escape — collapse to a single dash.
95
+ */
96
+ export function deviceName(home) {
97
+ let label = ''
98
+ try {
99
+ const identity = JSON.parse(readFileSync(join(home, 'state', 'machine.json'), 'utf8'))
100
+ if (typeof identity?.label === 'string') label = identity.label
101
+ } catch {
102
+ // No identity file yet: the hostname is the same fallback dsh-mnemon uses.
103
+ }
104
+ if (label.trim() === '') {
105
+ try {
106
+ label = hostname()
107
+ } catch {
108
+ label = ''
109
+ }
110
+ }
111
+ const cleaned = label.trim().replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/gu, '')
112
+ return cleaned === '' ? 'device' : cleaned.slice(0, 60)
113
+ }
114
+
115
+ /**
116
+ * `mnemon-sync-<device>-2026-11-09T00-53-57.903Z.zip`
117
+ *
118
+ * The device name sits after the prefix and before the timestamp, so archives
119
+ * from different machines sort together by time within one device and are
120
+ * still obvious at a glance in a shared folder.
121
+ */
122
+ export function archiveKey(at = new Date(), device = 'device') {
123
+ const stamp = at.toISOString().replace(/:/gu, '-')
124
+ return `${ARCHIVE_PREFIX}${device}-${stamp}.zip`
89
125
  }
90
126
 
91
127
  export function apply(ctx, config = {}) {
@@ -108,7 +144,7 @@ export function apply(ctx, config = {}) {
108
144
  try {
109
145
  const snapshot = await buildSnapshot(home, staging, { components: current.components })
110
146
  verifySnapshot(staging)
111
- const key = archiveKey()
147
+ const key = archiveKey(new Date(), deviceName(home))
112
148
  const archive = createZip(collect(staging), { level: 6 })
113
149
  const uploaded = await uploadArchive(target, key, archive, { signal })
114
150
  const removed = await applyRetention(history, target, current.retention.keep, { signal })
@@ -610,11 +646,21 @@ function collect(root, prefix = '') {
610
646
  * as removed.
611
647
  */
612
648
  async function applyRetention(history, target, keep, options) {
649
+ // Sort by when the run happened, not by key. With a device name in the key,
650
+ // lexical order groups by device first, so "keep the newest N" would delete
651
+ // this machine's newest archive before another machine's oldest one.
613
652
  const uploaded = history.read()
614
653
  .filter((run) => run.ok && typeof run.key === 'string')
615
- .map((run) => run.key)
616
- const unique = [...new Set(uploaded)].sort()
617
- const overflow = unique.slice(0, Math.max(0, unique.length - keep))
654
+ .map((run) => ({ key: run.key, at: Date.parse(run.finishedAt ?? run.startedAt ?? '') || 0 }))
655
+ const seen = new Set()
656
+ const unique = []
657
+ for (const entry of uploaded) {
658
+ if (seen.has(entry.key)) continue
659
+ seen.add(entry.key)
660
+ unique.push(entry)
661
+ }
662
+ unique.sort((left, right) => right.at - left.at)
663
+ const overflow = unique.slice(keep).map((entry) => entry.key)
618
664
  const removed = []
619
665
  for (const key of overflow) {
620
666
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@summerscar/dsh-mnemon-sync",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/targets.js CHANGED
@@ -266,7 +266,29 @@ function parseWebDavListing(xml, remotePath) {
266
266
  modifiedAt: textOf(/<[^:>]*:?getlastmodified[^>]*>([^<]+)</iu.exec(block)),
267
267
  })
268
268
  }
269
- return entries.sort((left, right) => String(right.modifiedAt ?? '').localeCompare(String(left.modifiedAt ?? '')))
269
+ return newestFirst(entries)
270
+ }
271
+
272
+ /**
273
+ * Sort archives newest first.
274
+ *
275
+ * `modifiedAt` is an RFC 1123 string (`Sun, 09 Nov 2026 08:00:00 GMT`) from
276
+ * WebDAV and an ISO 8601 string from S3. Comparing those as text is wrong: the
277
+ * weekday and day lead the WebDAV form, so "09 Nov 23:00" sorts before
278
+ * "10 Nov 01:00" even though it is older. Parse to an instant instead.
279
+ *
280
+ * An unparseable or missing stamp sorts last rather than corrupting the order:
281
+ * a listing that cannot say when something was written should not push a
282
+ * usable row off the top.
283
+ */
284
+ function newestFirst(entries) {
285
+ const stamp = (entry) => {
286
+ const value = entry?.modifiedAt
287
+ if (typeof value !== 'string') return Number.NEGATIVE_INFINITY
288
+ const parsed = Date.parse(value)
289
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed
290
+ }
291
+ return entries.sort((left, right) => stamp(right) - stamp(left))
270
292
  }
271
293
 
272
294
  function integerOf(match) {
@@ -465,7 +487,7 @@ async function listS3(target, signal) {
465
487
  modifiedAt: textOf(/<LastModified>([^<]+)<\/LastModified>/u.exec(block)),
466
488
  })
467
489
  }
468
- return entries.sort((left, right) => String(right.modifiedAt ?? '').localeCompare(String(left.modifiedAt ?? '')))
490
+ return newestFirst(entries)
469
491
  }
470
492
 
471
493
  /**