@summerscar/dsh-mnemon-sync 0.1.2 → 0.1.4

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/cordis.patch.yml CHANGED
@@ -1,3 +1,11 @@
1
+ # Host entry: HTTP API, scheduler and backup engine. Needs only webServer.
1
2
  - insert:
2
3
  - id: mnemon-sync
3
4
  name: '@summerscar/dsh-mnemon-sync'
5
+ # Memory Source entry, mounted exactly like the shipped dsh-mnemon-source-*
6
+ # packages: only id + name here, and the module itself exports
7
+ # `inject = ['mnemonMemory']` so the Loader waits for that service.
8
+ # Kept as a separate Entry so a profile without dsh-mnemon still gets a
9
+ # working backup engine (just no tab).
10
+ - id: mnemon-sync-source
11
+ name: '@summerscar/dsh-mnemon-sync/source'
package/index.js CHANGED
@@ -14,14 +14,13 @@
14
14
  * resident process, which is both simpler and compatible with the store.
15
15
  *
16
16
  * The browser half only ever sees JSON: credentials are masked on the way out
17
- * and are never echoed back by a read.
17
+ * and are never echoed back by a read. The memory Source that owns the page is
18
+ * a separate Cordis entry (./source.js), mounted by the bundle patch.
18
19
  */
19
20
 
20
21
  import { createHash } from 'node:crypto'
21
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
22
- import { createRequire } from 'node:module'
23
23
  import { dirname, join, resolve } from 'node:path'
24
- import { pathToFileURL } from 'node:url'
25
24
 
26
25
  import { SyncHistory, SyncScheduler } from './scheduler.js'
27
26
  import {
@@ -165,7 +164,6 @@ export function apply(ctx, config = {}) {
165
164
  // — not UI contributions. Standing up this minimal Source is what lets the
166
165
  // page use its own type instead of borrowing `memory-spaces`, which is the
167
166
  // only way its tab can highlight independently.
168
- ctx.effect(() => registerSyncSource(ctx, dataRoot), 'mnemon-sync: memory source')
169
167
 
170
168
  ctx.effect(() => ctx.webServer.register({
171
169
  kind: 'prefix',
@@ -582,104 +580,6 @@ function restoreMaskedSecrets(incoming, previous) {
582
580
  return incoming
583
581
  }
584
582
 
585
- /**
586
- * Resolve the dsh-mnemon extension SDK.
587
- *
588
- * A bare `import('dsh-mnemon/extension-sdk')` is not enough here. Installed
589
- * from npm the package sits at `<profile>/node_modules/@summerscar/...`, so
590
- * Node's own ancestor walk would find dsh-mnemon beside it — but a *linked*
591
- * checkout lives outside the profile with no `node_modules` of its own, and
592
- * then the walk finds nothing.
593
- *
594
- * Both install shapes are therefore covered, cheapest first:
595
- * 1. From this module's own location, which is right for an npm install.
596
- * 2. From `DSH_PROFILE_DIR`, which is right for a linked checkout and is set
597
- * in every shell and Host process a profile launches.
598
- * 3. From `DSH_HOME/profiles/web`, for a Host booted without that variable.
599
- *
600
- * Returns `undefined` when dsh-mnemon is not installed at all, which is a
601
- * supported configuration: the backup keeps working without its page.
602
- */
603
- async function loadExtensionSdk() {
604
- // 1. Ask Node, anchored at this file. Resolves for an npm install.
605
- try {
606
- const resolver = createRequire(import.meta.url)
607
- const resolved = resolver.resolve('dsh-mnemon/lib/extension-sdk.js')
608
- return await import(pathToFileURL(resolved).href)
609
- } catch {
610
- // Not resolvable from here: fall through to the profile-anchored paths.
611
- }
612
-
613
- const candidates = []
614
- const profileDir = process.env.DSH_PROFILE_DIR?.trim()
615
- if (profileDir !== undefined && profileDir !== '') {
616
- candidates.push(join(profileDir, 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
617
- }
618
- // The Host that loads this plugin lives in the installation's own tree.
619
- candidates.push(join(process.env.DSH_HOME ?? '', 'profiles', 'web', 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
620
- for (const candidate of candidates) {
621
- if (candidate === '' || !existsSync(candidate)) continue
622
- return import(pathToFileURL(candidate).href)
623
- }
624
- return undefined
625
- }
626
-
627
- /**
628
- * Install the sync Source into dsh-mnemon's composable-memory service.
629
- *
630
- * Returns a disposer, and does nothing at all when the Host runs without that
631
- * service (a profile with dsh-mnemon absent).
632
- */
633
- function registerSyncSource(ctx, dataRoot) {
634
- if (typeof ctx.get !== 'function') return () => {}
635
- const service = ctx.get('mnemonMemory')
636
- if (service === undefined || service === null || typeof service.installContributions !== 'function') {
637
- return () => {}
638
- }
639
- let disposed = false
640
- let uninstall
641
- void (async () => {
642
- try {
643
- const sdk = await loadExtensionSdk()
644
- if (sdk === undefined || typeof sdk.defineMemorySource !== 'function') {
645
- console.warn('mnemon-sync: dsh-mnemon extension SDK not found; the sync tab stays hidden')
646
- return
647
- }
648
- const { createSyncSource, syncSourceManifest } = await import('./source.js')
649
- if (disposed) return
650
- // The manifest goes through the SDK validator so a contract drift in
651
- // dsh-mnemon fails loudly here instead of at composition time.
652
- const definition = sdk.defineMemorySource(createSyncSource({ ...syncSourceManifest }))
653
- uninstall = service.installContributions({
654
- plugin: {
655
- apiVersion: 'dsh-mnemon/plugin/v1',
656
- packageName: '@summerscar/dsh-mnemon-sync',
657
- label: { en: 'Sync backup', 'zh-CN': '同步备份' },
658
- description: {
659
- en: 'Archive memory spaces to S3 or WebDAV.',
660
- 'zh-CN': '把记忆空间备份到 S3 或 WebDAV。',
661
- },
662
- roles: ['source'],
663
- provides: [{ id: 'source' }],
664
- },
665
- sources: [definition],
666
- }, { instanceId: 'mnemon-sync' })
667
- if (disposed) {
668
- uninstall?.()
669
- uninstall = undefined
670
- }
671
- } catch (error) {
672
- // A Host that cannot accept the Source must not take the backup down.
673
- console.warn('mnemon-sync: could not register the sync source', error)
674
- }
675
- })()
676
- return () => {
677
- disposed = true
678
- uninstall?.()
679
- uninstall = undefined
680
- }
681
- }
682
-
683
583
  /** Read a staging tree into the flat `{ path, bytes }` entries the ZIP writer takes. */
684
584
  function collect(root, prefix = '') {
685
585
  const entries = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@summerscar/dsh-mnemon-sync",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -8,7 +8,8 @@
8
8
  ".": "./index.js",
9
9
  "./client": "./client.js",
10
10
  "./locale/*.json": "./locale/*.json",
11
- "./icon": "./icon.svg"
11
+ "./icon": "./icon.svg",
12
+ "./source": "./source.js"
12
13
  },
13
14
  "files": [
14
15
  "index.js",
package/source.js CHANGED
@@ -1,34 +1,98 @@
1
1
  /**
2
2
  * Mnemon Sync — the Source that owns this page.
3
3
  *
4
+ * This module is a standard Cordis plugin entry, written the same way the
5
+ * shipped dsh-mnemon Source packages are (see dsh-mnemon-source-runtime):
6
+ * `inject = ['mnemonMemory']` and `installMemory(ctx, ...)` inside `apply`.
7
+ * The Loader waits for the service, calls apply when it appears, and disposes
8
+ * the registration when it goes away.
9
+ *
10
+ * One concession to being distributable: the SDK is loaded with `createRequire`
11
+ * instead of a static `import`. A static import resolves fine for an npm
12
+ * install (the package sits under `<profile>/node_modules/`, with dsh-mnemon
13
+ * as a sibling ancestor) but fails for a `link:` checkout that lives outside
14
+ * the profile and has no `node_modules` of its own. `createRequire` with an
15
+ * explicit `DSH_PROFILE_DIR` fallback covers both shapes.
16
+ *
4
17
  * Why a Source is required at all: dsh-mnemon renders a page tab only when its
5
- * `sourceTypeId` appears in the live source catalog, and the catalog lists
18
+ * `sourceTypeId` appears in the live source catalog, and that catalog lists
6
19
  * real, running Source implementations. A UI-only contribution can borrow
7
20
  * `memory-spaces` and be shown, but the tab bar highlights *every* tab sharing
8
21
  * the selected page's type prefix, so borrowing means clicking one lights up
9
22
  * the other. Declaring our own type is the only way to get an independent tab.
10
23
  *
11
24
  * This Source is deliberately honest about what it holds. It is not a memory
12
- * backend: it stores no insights, so it contributes an empty projection and
13
- * declares no read routes or write actions. Its role is to be the identity the
14
- * sync page hangs from, which is why every capability is false and the facts
15
- * report only what the backup configuration currently is.
16
- *
17
- * The alternative — faking routes that answer with nothing — would pollute the
18
- * model's memory view with an empty source and could make a composition look
19
- * incomplete. An empty, clearly-labelled source is the smaller lie.
25
+ * backend: it stores no insights, contributes an empty projection and declares
26
+ * no read routes or write actions. Its role is to be the identity the sync
27
+ * page hangs from.
20
28
  */
21
29
 
22
30
  import { createHash } from 'node:crypto'
31
+ import { existsSync } from 'node:fs'
32
+ import { createRequire } from 'node:module'
23
33
  import { join } from 'node:path'
24
34
 
25
35
  import { resolveDataRoot } from './index.js'
26
36
  import { inspectHome } from './snapshot.js'
27
37
  import { readSettings } from './store.js'
28
38
 
39
+ const require = createRequire(import.meta.url)
40
+
29
41
  export const SYNC_SOURCE_TYPE_ID = 'mnemon-sync'
30
42
  export const SYNC_PACKAGE_NAME = '@summerscar/dsh-mnemon-sync'
31
43
 
44
+ export const name = SYNC_PACKAGE_NAME
45
+ // The one dependency the Source needs. The Loader holds this Entry until
46
+ // dsh-mnemon publishes mnemonMemory, then mounts it.
47
+ export const inject = ['mnemonMemory']
48
+
49
+ /**
50
+ * Resolve the extension SDK synchronously so `apply` can stay synchronous.
51
+ * Tries normal package resolution first, then the profile-anchored absolute
52
+ * path for linked checkouts.
53
+ */
54
+ function loadSdk() {
55
+ try {
56
+ return require('dsh-mnemon/extension-sdk')
57
+ } catch {
58
+ // Fall through: linked checkout has no ancestor node_modules.
59
+ }
60
+ const profileDir = process.env.DSH_PROFILE_DIR?.trim()
61
+ const candidates = []
62
+ if (profileDir !== undefined && profileDir !== '') {
63
+ candidates.push(join(profileDir, 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
64
+ }
65
+ candidates.push(join(process.env.DSH_HOME ?? '', 'profiles', 'web', 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
66
+ for (const candidate of candidates) {
67
+ if (candidate === '' || !existsSync(candidate)) continue
68
+ return require(candidate)
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ const sdk = loadSdk()
74
+
75
+ /** The plugin descriptor, validated by the SDK when available. */
76
+ export const memoryPlugin = sdk === undefined ? {
77
+ packageName: SYNC_PACKAGE_NAME,
78
+ label: { en: 'Sync backup', 'zh-CN': '同步备份' },
79
+ description: {
80
+ en: 'Back up and restore dsh-mnemon memory components.',
81
+ 'zh-CN': '备份与恢复 dsh-mnemon 的记忆组件。',
82
+ },
83
+ roles: ['source'],
84
+ provides: [{ id: 'source' }],
85
+ } : sdk.defineMemoryPlugin({
86
+ packageName: SYNC_PACKAGE_NAME,
87
+ label: { en: 'Sync backup', 'zh-CN': '同步备份' },
88
+ description: {
89
+ en: 'Back up and restore dsh-mnemon memory components.',
90
+ 'zh-CN': '备份与恢复 dsh-mnemon 的记忆组件。',
91
+ },
92
+ roles: ['source'],
93
+ provides: [{ id: 'source' }],
94
+ })
95
+
32
96
  /** The manifest Core validates before this Source may take part in a View. */
33
97
  export const syncSourceManifest = {
34
98
  apiVersion: 'dsh-mnemon/v1',
@@ -43,30 +107,21 @@ export const syncSourceManifest = {
43
107
  consistency: 'exact-snapshot',
44
108
  routes: [],
45
109
  actions: [],
46
- // A management descriptor is not decoration. dsh-mnemon builds its Source
47
- // catalog from installed Sources that declare one, and the workbench decides
48
- // a layer is "running" by whether its type is in that catalog — so a Source
49
- // without `management` is installed but reported as "on yet not running".
50
- // Fields stay empty: this page does its own editing.
110
+ // A management descriptor is required for managementCatalog to list this
111
+ // Source; without it the Source is installed but reported "on yet not running".
51
112
  management: {
52
113
  label: 'Sync backup',
53
- description: 'Archive memory spaces to S3 or WebDAV.',
114
+ description: 'Back up and restore dsh-mnemon memory components.',
54
115
  },
55
116
  }
56
117
 
57
118
  /**
58
119
  * Build the Source definition.
59
120
  *
60
- * `facts()` must return the Host's *facts envelope* — `sourceInstanceKey`,
121
+ * `facts()` returns the Host's facts envelope — `sourceInstanceKey`,
61
122
  * `sourceTypeId`, `role`, `availability`, `revision`, `capabilities`,
62
- * `routeIds`, `actionIds` (plus optional `hints`). dsh-mnemon rejects anything
63
- * else: `normalizeFacts()` compares `facts.sourceInstanceKey` against the
64
- * registry key and throws `memory Source facts instance mismatch` when it is
65
- * missing, which is exactly what a `{ summary, details }` shape produced.
66
- *
67
- * `hints` is the only free-form channel that survives normalization, so the
68
- * backup configuration a human may want to see travels there. `project()`
69
- * returns `{ fragments: [] }`: there is no memory content here to contribute.
123
+ * `routeIds`, `actionIds` (plus optional `hints`). `project()` brings nothing
124
+ * into a prompt, because there is no memory content here to bring.
70
125
  */
71
126
  export function createSyncSource(manifest = syncSourceManifest) {
72
127
  return {
@@ -81,21 +136,18 @@ export function createSyncSource(manifest = syncSourceManifest) {
81
136
  return {
82
137
  facts() {
83
138
  const settings = safeSettings(home)
84
- const inspected = safeInspect(dataRoot)
139
+ const inspected = safeInspect(home)
85
140
  return {
86
141
  sourceInstanceKey: context.sourceInstanceKey,
87
142
  sourceTypeId: manifest.typeId,
88
143
  role: manifest.role,
89
- // This Source is a maintenance identity, not a memory backend: it
90
- // is always ready and holds nothing a View could select.
91
144
  availability: 'ready',
92
145
  revision: configurationRevision(settings, inspected),
93
146
  capabilities: [],
94
147
  routeIds: [],
95
148
  actionIds: [],
96
- // Facts are JSON only and must stay small: metadata the Host may
97
- // surface, never memory content. Only `hints` survives
98
- // normalization, so anything worth showing goes here.
149
+ // Only `hints` survives normalization, so the backup configuration
150
+ // a human may want to see travels there.
99
151
  hints: {
100
152
  summary: settings.enabled
101
153
  ? `Sync backup every ${String(settings.autoSyncMinutes)} min to ${describeTarget(settings.target)}`
@@ -114,7 +166,6 @@ export function createSyncSource(manifest = syncSourceManifest) {
114
166
  }
115
167
  },
116
168
  project() {
117
- // Nothing to inject: this Source holds no memories to contribute.
118
169
  return { fragments: [] }
119
170
  },
120
171
  dispose() {},
@@ -123,13 +174,23 @@ export function createSyncSource(manifest = syncSourceManifest) {
123
174
  }
124
175
  }
125
176
 
177
+ /** The standard Cordis entry point, identical in shape to the shipped Sources. */
178
+ export function apply(ctx, config = {}) {
179
+ if (sdk === undefined) {
180
+ // dsh-mnemon is not installed at all; the backup engine in index.js still
181
+ // works, only the page is unavailable. Do not fake a registration.
182
+ console.warn('mnemon-sync: dsh-mnemon extension SDK not found; the sync tab stays hidden')
183
+ return
184
+ }
185
+ sdk.installMemory(ctx, {
186
+ plugin: memoryPlugin,
187
+ sources: [sdk.defineMemorySource(createSyncSource({ ...syncSourceManifest }))],
188
+ }, config?.instanceId === undefined ? undefined : { instanceId: config.instanceId })
189
+ }
190
+
126
191
  /**
127
- * A stable revision for the current backup configuration.
128
- *
129
- * The Host requires a non-empty string and compares it across a turn, so this
130
- * must change when the configuration changes and stay equal otherwise. It is
131
- * derived from the facts themselves rather than a clock, which keeps
132
- * composition deterministic.
192
+ * A stable revision for the current backup configuration, derived from the
193
+ * facts themselves rather than a clock so composition stays deterministic.
133
194
  */
134
195
  function configurationRevision(settings, inspected) {
135
196
  const material = JSON.stringify({
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
  /**