@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.
- package/index.js +53 -7
- package/package.json +1 -1
- 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
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
617
|
-
const
|
|
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
|
+
"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
|
|
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
|
|
490
|
+
return newestFirst(entries)
|
|
469
491
|
}
|
|
470
492
|
|
|
471
493
|
/**
|