@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Persistent deployment resource store.
3
+ *
4
+ * A resource is one service directory on one SSH account: where it lives, how
5
+ * to update/restart/verify it, and what was discovered about it. Resources hold
6
+ * no credentials; the public view adds `serverName` from the account store.
7
+ * The file is written atomically to `resources.json` with mode `0600`.
8
+ *
9
+ * @module host/resources
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+ import path from 'node:path'
14
+ import { createJsonFile } from './atomic.js'
15
+ import { validateRemotePath } from './shell.js'
16
+
17
+ /** File name inside the plugin data directory. */
18
+ const DATA_FILE = 'resources.json'
19
+ /** Maximum display name length. */
20
+ const NAME_MAX_LENGTH = 200
21
+ /** Maximum note length. */
22
+ const NOTE_MAX_LENGTH = 500
23
+ /** Maximum command length. */
24
+ const COMMAND_MAX_LENGTH = 8192
25
+ /** Maximum number of tags. */
26
+ const TAGS_MAX = 32
27
+ /** Default backup retention. */
28
+ const DEFAULT_BACKUP_KEEP = 5
29
+ /** Default step timeout in seconds. */
30
+ const DEFAULT_TIMEOUT_SECONDS = 600
31
+
32
+ /**
33
+ * Build one tagged error.
34
+ * @param {string} code - machine-readable code.
35
+ * @param {string} message - Chinese message.
36
+ * @param {unknown} [cause] - underlying failure.
37
+ * @returns {Error & { code: string }} tagged error.
38
+ */
39
+ function taggedError(code, message, cause) {
40
+ const error = new Error(message)
41
+ error.code = code
42
+ if (cause !== undefined) error.cause = cause
43
+ return error
44
+ }
45
+
46
+ /** @returns {Error} invalid-input error. */
47
+ function invalid(message) {
48
+ return taggedError('invalid-input', message)
49
+ }
50
+
51
+ /** @returns {Error} conflict error. */
52
+ function conflict(message) {
53
+ return taggedError('conflict', message)
54
+ }
55
+
56
+ /** @returns {Error} not-found error. */
57
+ function notFound(message) {
58
+ return taggedError('not-found', message)
59
+ }
60
+
61
+ /** @returns {Error} storage error. */
62
+ function storageError(cause) {
63
+ return taggedError('storage', '无法保存资源数据', cause)
64
+ }
65
+
66
+ /** @param {unknown} value @returns {boolean} whether the value is a plain object. */
67
+ function isObject(value) {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
69
+ }
70
+
71
+ /**
72
+ * Pick an incoming value, falling back to the stored value.
73
+ * @param {object} draft - incoming draft.
74
+ * @param {string} key - field name.
75
+ * @param {object | undefined} existing - stored record.
76
+ * @returns {unknown} effective value.
77
+ */
78
+ function pick(draft, key, existing) {
79
+ const value = draft[key]
80
+ if (value !== undefined) return value
81
+ if (existing === undefined) return undefined
82
+ return existing[key]
83
+ }
84
+
85
+ /**
86
+ * Read an optional, trimmed string field.
87
+ * @param {unknown} value - raw value.
88
+ * @param {string} label - field label.
89
+ * @param {number} maxLength - maximum length.
90
+ * @returns {string | undefined} trimmed value or undefined.
91
+ */
92
+ function readText(value, label, maxLength) {
93
+ if (value === undefined || value === null) return undefined
94
+ if (typeof value !== 'string') throw invalid(`${label}格式无效`)
95
+ const trimmed = value.trim()
96
+ if (trimmed === '') return undefined
97
+ if (trimmed.length > maxLength) throw invalid(`${label}最多 ${maxLength} 个字符`)
98
+ return trimmed
99
+ }
100
+
101
+ /**
102
+ * Read a required, non-empty string field.
103
+ * @param {unknown} value - raw value.
104
+ * @param {string} label - field label.
105
+ * @param {number} maxLength - maximum length.
106
+ * @returns {string} trimmed value.
107
+ */
108
+ function readRequiredText(value, label, maxLength) {
109
+ const text = readText(value, label, maxLength)
110
+ if (text === undefined) throw invalid(`${label}不能为空`)
111
+ return text
112
+ }
113
+
114
+ /**
115
+ * Read a boolean field with a fallback.
116
+ * @param {unknown} value - raw value.
117
+ * @param {string} label - field label.
118
+ * @param {boolean} fallback - default.
119
+ * @returns {boolean} effective value.
120
+ */
121
+ function readBoolean(value, label, fallback) {
122
+ if (value === undefined || value === null) return fallback
123
+ if (typeof value !== 'boolean') throw invalid(`${label}必须是布尔值`)
124
+ return value
125
+ }
126
+
127
+ /**
128
+ * Read a clamped integer field.
129
+ * @param {unknown} value - raw value.
130
+ * @param {string} label - field label.
131
+ * @param {number} min - lower bound.
132
+ * @param {number} max - upper bound.
133
+ * @param {number} fallback - default.
134
+ * @returns {number} clamped integer.
135
+ */
136
+ function readClampedInteger(value, label, min, max, fallback) {
137
+ if (value === undefined || value === null || value === '') return fallback
138
+ const numeric = typeof value === 'string' ? Number(value) : value
139
+ if (typeof numeric !== 'number' || !Number.isFinite(numeric)) throw invalid(`${label}必须是数字`)
140
+ return Math.min(max, Math.max(min, Math.round(numeric)))
141
+ }
142
+
143
+ /**
144
+ * Normalize the tag list.
145
+ * @param {unknown} value - raw tags.
146
+ * @param {string[]} [fallback] - values used when the field is absent.
147
+ * @returns {string[]} normalized tags.
148
+ */
149
+ function readTags(value, fallback = []) {
150
+ if (value === undefined || value === null) return fallback.slice()
151
+ if (!Array.isArray(value)) throw invalid('标签必须是字符串数组')
152
+ const tags = []
153
+ for (const entry of value) {
154
+ if (typeof entry !== 'string') throw invalid('标签必须是字符串数组')
155
+ const trimmed = entry.trim()
156
+ if (trimmed === '' || tags.includes(trimmed)) continue
157
+ tags.push(trimmed)
158
+ if (tags.length >= TAGS_MAX) break
159
+ }
160
+ return tags
161
+ }
162
+
163
+ /**
164
+ * Read an array of strings for a discovered fact.
165
+ * @param {unknown} value - raw value.
166
+ * @param {number} maxItems - maximum retained items.
167
+ * @returns {string[]} strings.
168
+ */
169
+ function readStringArray(value, maxItems) {
170
+ if (!Array.isArray(value)) return []
171
+ const items = []
172
+ for (const entry of value) {
173
+ if (typeof entry !== 'string') continue
174
+ const trimmed = entry.trim()
175
+ if (trimmed === '' || items.includes(trimmed)) continue
176
+ items.push(trimmed)
177
+ if (items.length >= maxItems) break
178
+ }
179
+ return items
180
+ }
181
+
182
+ /**
183
+ * Normalize the optional `discovered` snapshot from an inspect call.
184
+ * @param {unknown} value - raw discovered value.
185
+ * @param {object | undefined} existing - stored discovered value.
186
+ * @returns {object | null} sanitized snapshot or null.
187
+ */
188
+ function readDiscovered(value, existing) {
189
+ if (value === undefined) return existing === undefined ? null : existing
190
+ if (value === null) return null
191
+ if (!isObject(value)) throw invalid('采集信息格式无效')
192
+ const snapshot = {}
193
+ if (typeof value.os === 'string' && value.os.trim() !== '') snapshot.os = value.os.trim()
194
+ if (typeof value.user === 'string' && value.user.trim() !== '') snapshot.user = value.user.trim()
195
+ if (typeof value.sizeKB === 'number' && Number.isFinite(value.sizeKB) && value.sizeKB >= 0) snapshot.sizeKB = value.sizeKB
196
+ snapshot.markers = readStringArray(value.markers, 200)
197
+ if (typeof value.gitRemote === 'string') snapshot.gitRemote = value.gitRemote
198
+ if (typeof value.gitBranch === 'string') snapshot.gitBranch = value.gitBranch
199
+ snapshot.serviceUnits = readStringArray(value.serviceUnits, 200)
200
+ snapshot.inspectedAt =
201
+ typeof value.inspectedAt === 'string' && value.inspectedAt !== '' ? value.inspectedAt : new Date().toISOString()
202
+ return snapshot
203
+ }
204
+
205
+ /**
206
+ * Whether a backup directory lies inside the app directory.
207
+ * @param {string} appDir - normalized app directory.
208
+ * @param {string} backupDir - normalized backup directory.
209
+ * @returns {boolean} containment.
210
+ */
211
+ function isInside(appDir, backupDir) {
212
+ return backupDir === appDir || backupDir.startsWith(`${appDir}/`)
213
+ }
214
+
215
+ /**
216
+ * Build a complete internal resource record.
217
+ * @param {object} draft - create/update payload.
218
+ * @param {object | undefined} existing - stored record.
219
+ * @param {object} accounts - account store.
220
+ * @returns {object} complete record.
221
+ */
222
+ function buildRecord(draft, existing, accounts) {
223
+ if (!isObject(draft)) throw invalid('请求内容无效')
224
+
225
+ let name
226
+ const rawName = pick(draft, 'name', existing)
227
+ if (typeof rawName === 'string') {
228
+ const trimmed = rawName.trim()
229
+ name = trimmed === '' ? (existing && existing.name) : trimmed
230
+ } else if (rawName === undefined || rawName === null) {
231
+ name = existing && existing.name
232
+ } else {
233
+ throw invalid('资源名称格式无效')
234
+ }
235
+ if (typeof name !== 'string' || name === '') throw invalid('资源名称不能为空')
236
+ if (name.length > NAME_MAX_LENGTH) throw invalid(`资源名称最多 ${NAME_MAX_LENGTH} 个字符`)
237
+ // A resource name is a label shown in the approval prompt, never a script.
238
+ // Line breaks would let the name forge prompt chrome (`Backup: disabled.`),
239
+ // so they are rejected here rather than sanitised at every display site.
240
+ if (/[\r\n\u2028\u2029]/.test(name)) throw invalid('资源名称不能包含换行')
241
+
242
+ let serverId
243
+ if (typeof draft.serverId === 'string' && draft.serverId.trim() !== '') {
244
+ serverId = draft.serverId.trim()
245
+ } else if (typeof draft.server === 'string' && draft.server.trim() !== '') {
246
+ const account = accounts.find(draft.server.trim())
247
+ if (!account) throw invalid('服务器不存在')
248
+ serverId = account.id
249
+ } else if (existing && typeof existing.serverId === 'string' && existing.serverId !== '') {
250
+ serverId = existing.serverId
251
+ }
252
+ if (typeof serverId !== 'string' || serverId === '') throw invalid('请选择服务器')
253
+ if (!accounts.get(serverId)) throw invalid('服务器不存在')
254
+
255
+ const rawAppDir = pick(draft, 'appDir', existing)
256
+ if (rawAppDir === undefined || rawAppDir === null || rawAppDir === '') throw invalid('应用目录不能为空')
257
+ const appDir = validateRemotePath(rawAppDir)
258
+
259
+ const rawUpdate = pick(draft, 'updateCommand', existing)
260
+ const updateCommand = readRequiredText(rawUpdate, '更新命令', COMMAND_MAX_LENGTH)
261
+
262
+ const restartCommand = readText(pick(draft, 'restartCommand', existing), '重启命令', COMMAND_MAX_LENGTH)
263
+ const verifyCommand = readText(pick(draft, 'verifyCommand', existing), '校验命令', COMMAND_MAX_LENGTH)
264
+ const serviceName = readText(pick(draft, 'serviceName', existing), '服务名称', NAME_MAX_LENGTH)
265
+
266
+ const backupEnabled = readBoolean(pick(draft, 'backupEnabled', existing), '备份开关', true)
267
+ const rawBackupDir = readText(pick(draft, 'backupDir', existing), '备份目录', 1024)
268
+ const backupDir = rawBackupDir === undefined ? undefined : validateRemotePath(rawBackupDir)
269
+ if (backupDir !== undefined && isInside(appDir, backupDir)) throw invalid('备份目录不能位于应用目录内部')
270
+
271
+ const backupKeep = readClampedInteger(pick(draft, 'backupKeep', existing), '备份保留数量', 1, 50, DEFAULT_BACKUP_KEEP)
272
+ const timeoutSeconds = readClampedInteger(
273
+ pick(draft, 'timeoutSeconds', existing),
274
+ '超时时间',
275
+ 30,
276
+ 3600,
277
+ DEFAULT_TIMEOUT_SECONDS,
278
+ )
279
+
280
+ const rawNote = pick(draft, 'note', existing)
281
+ let note
282
+ if (rawNote !== undefined && rawNote !== null) {
283
+ if (typeof rawNote !== 'string') throw invalid('备注格式无效')
284
+ note = rawNote.trim()
285
+ if (note.length > NOTE_MAX_LENGTH) throw invalid(`备注最多 ${NOTE_MAX_LENGTH} 个字符`)
286
+ }
287
+
288
+ const tags = readTags(pick(draft, 'tags', existing), existing && Array.isArray(existing.tags) ? existing.tags : [])
289
+ const discovered = readDiscovered(pick(draft, 'discovered', existing), existing ? existing.discovered : undefined)
290
+
291
+ const now = new Date().toISOString()
292
+ const record = {
293
+ id: existing && typeof existing.id === 'string' ? existing.id : randomUUID(),
294
+ name,
295
+ serverId,
296
+ appDir,
297
+ updateCommand,
298
+ backupEnabled,
299
+ backupKeep,
300
+ timeoutSeconds,
301
+ tags,
302
+ discovered: discovered === undefined ? null : discovered,
303
+ createdAt: existing && typeof existing.createdAt === 'string' ? existing.createdAt : now,
304
+ updatedAt: now,
305
+ }
306
+ if (serviceName !== undefined) record.serviceName = serviceName
307
+ if (restartCommand !== undefined) record.restartCommand = restartCommand
308
+ if (verifyCommand !== undefined) record.verifyCommand = verifyCommand
309
+ if (backupDir !== undefined) record.backupDir = backupDir
310
+ if (note !== undefined && note !== '') record.note = note
311
+ if (existing && typeof existing.lastUpdateAt === 'string') record.lastUpdateAt = existing.lastUpdateAt
312
+ if (existing && typeof existing.lastUpdateStatus === 'string') record.lastUpdateStatus = existing.lastUpdateStatus
313
+ if (existing && typeof existing.lastBackupPath === 'string') record.lastBackupPath = existing.lastBackupPath
314
+ return record
315
+ }
316
+
317
+ /**
318
+ * Project one record to its public view, adding the account display name.
319
+ * @param {object} record - internal record.
320
+ * @param {object} accounts - account store.
321
+ * @returns {object} public view.
322
+ */
323
+ function toView(record, accounts) {
324
+ const account = accounts.get(record.serverId)
325
+ const view = {
326
+ id: String(record.id ?? ''),
327
+ name: typeof record.name === 'string' ? record.name : '',
328
+ serverId: typeof record.serverId === 'string' ? record.serverId : '',
329
+ serverName: account && typeof account.name === 'string' ? account.name : '',
330
+ appDir: typeof record.appDir === 'string' ? record.appDir : '',
331
+ updateCommand: typeof record.updateCommand === 'string' ? record.updateCommand : '',
332
+ backupEnabled: record.backupEnabled === true,
333
+ backupKeep: Number.isInteger(record.backupKeep) ? record.backupKeep : DEFAULT_BACKUP_KEEP,
334
+ timeoutSeconds: Number.isInteger(record.timeoutSeconds) ? record.timeoutSeconds : DEFAULT_TIMEOUT_SECONDS,
335
+ tags: Array.isArray(record.tags) ? record.tags.filter((entry) => typeof entry === 'string') : [],
336
+ discovered: record.discovered === null || record.discovered === undefined ? null : record.discovered,
337
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : '',
338
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : '',
339
+ }
340
+ if (typeof record.serviceName === 'string' && record.serviceName !== '') view.serviceName = record.serviceName
341
+ if (typeof record.restartCommand === 'string' && record.restartCommand !== '') view.restartCommand = record.restartCommand
342
+ if (typeof record.verifyCommand === 'string' && record.verifyCommand !== '') view.verifyCommand = record.verifyCommand
343
+ if (typeof record.backupDir === 'string' && record.backupDir !== '') view.backupDir = record.backupDir
344
+ if (typeof record.note === 'string' && record.note !== '') view.note = record.note
345
+ if (typeof record.lastUpdateAt === 'string' && record.lastUpdateAt !== '') view.lastUpdateAt = record.lastUpdateAt
346
+ if (typeof record.lastUpdateStatus === 'string' && record.lastUpdateStatus !== '') view.lastUpdateStatus = record.lastUpdateStatus
347
+ if (typeof record.lastBackupPath === 'string' && record.lastBackupPath !== '') view.lastBackupPath = record.lastBackupPath
348
+ return view
349
+ }
350
+
351
+ /**
352
+ * Whether one parsed entry is a usable persisted resource.
353
+ * @param {unknown} entry - parsed entry.
354
+ * @returns {boolean} validity.
355
+ */
356
+ function isPersistedRecord(entry) {
357
+ if (!isObject(entry)) return false
358
+ if (typeof entry.id !== 'string' || entry.id === '') return false
359
+ if (typeof entry.name !== 'string' || entry.name === '') return false
360
+ if (typeof entry.serverId !== 'string' || entry.serverId === '') return false
361
+ if (typeof entry.appDir !== 'string' || entry.appDir === '') return false
362
+ if (typeof entry.updateCommand !== 'string' || entry.updateCommand.trim() === '') return false
363
+ return true
364
+ }
365
+
366
+ /**
367
+ * Keep only known fields of a persisted resource.
368
+ * @param {object} entry - parsed record.
369
+ * @returns {object} sanitized record.
370
+ */
371
+ function sanitizePersisted(entry) {
372
+ const record = {
373
+ id: entry.id,
374
+ name: entry.name,
375
+ serverId: entry.serverId,
376
+ appDir: entry.appDir,
377
+ updateCommand: entry.updateCommand,
378
+ backupEnabled: entry.backupEnabled !== false,
379
+ backupKeep: Number.isInteger(entry.backupKeep) ? Math.min(50, Math.max(1, entry.backupKeep)) : DEFAULT_BACKUP_KEEP,
380
+ timeoutSeconds: Number.isInteger(entry.timeoutSeconds)
381
+ ? Math.min(3600, Math.max(30, entry.timeoutSeconds))
382
+ : DEFAULT_TIMEOUT_SECONDS,
383
+ tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === 'string') : [],
384
+ discovered: isObject(entry.discovered) || entry.discovered === null ? entry.discovered : null,
385
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(),
386
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : new Date().toISOString(),
387
+ }
388
+ for (const key of ['serviceName', 'restartCommand', 'verifyCommand', 'backupDir', 'note', 'lastUpdateAt', 'lastUpdateStatus', 'lastBackupPath']) {
389
+ if (typeof entry[key] === 'string' && entry[key] !== '') record[key] = entry[key]
390
+ }
391
+ return record
392
+ }
393
+
394
+ /**
395
+ * Create the resource store.
396
+ * @param {{ dir: string, accounts: object }} options - data directory and account store.
397
+ * @returns {{
398
+ * dataDir: () => string,
399
+ * load: () => void,
400
+ * list: (options?: { server?: string }) => object[],
401
+ * get: (id: string) => object | undefined,
402
+ * find: (query: string) => object | undefined,
403
+ * save: (draft: object) => Promise<object>,
404
+ * remove: (id: string) => Promise<boolean>,
405
+ * toView: (record: object) => object | undefined,
406
+ * markUpdated: (id: string, patch: { at?: string, status?: string, backupPath?: string }) => Promise<object | undefined>,
407
+ * }} store API.
408
+ */
409
+ export function createResourceStore({ dir, accounts } = {}) {
410
+ if (typeof dir !== 'string' || dir.trim() === '') throw new TypeError('createResourceStore: dir is required')
411
+ if (!accounts || typeof accounts.get !== 'function' || typeof accounts.find !== 'function') {
412
+ throw new TypeError('createResourceStore: accounts store is required')
413
+ }
414
+ const resolvedDir = path.resolve(dir)
415
+ const file = createJsonFile({ dir: resolvedDir, filename: DATA_FILE })
416
+
417
+ /** @type {object[]} internal records. */
418
+ let records = []
419
+ let loaded = false
420
+ /** @type {Promise<unknown>} tail of the serialized mutation chain. */
421
+ let mutationTail = Promise.resolve()
422
+
423
+ /** Read the data file once; never throws. */
424
+ function load() {
425
+ if (loaded) return
426
+ loaded = true
427
+ const parsed = file.read()
428
+ records = Array.isArray(parsed) ? parsed.filter(isPersistedRecord).map(sanitizePersisted) : []
429
+ }
430
+
431
+ /** Ensure the lazy read happened. */
432
+ function ensureLoaded() {
433
+ if (!loaded) load()
434
+ }
435
+
436
+ /**
437
+ * @param {string} id - record id.
438
+ * @returns {object | undefined} internal record.
439
+ */
440
+ function rawById(id) {
441
+ if (typeof id !== 'string' || id === '') return undefined
442
+ return records.find((record) => record.id === id)
443
+ }
444
+
445
+ /**
446
+ * Reject a display name used by another resource (case-insensitive).
447
+ * @param {string} name - candidate name.
448
+ * @param {string} selfId - id of the record being written.
449
+ * @throws {Error} conflict when another resource owns the name.
450
+ */
451
+ function assertNameAvailable(name, selfId) {
452
+ const wanted = name.toLowerCase()
453
+ const clash = records.find((record) => record.id !== selfId && typeof record.name === 'string' && record.name.toLowerCase() === wanted)
454
+ if (clash) throw conflict('资源名称已存在')
455
+ }
456
+
457
+ /**
458
+ * Serialize one mutation on top of earlier mutations.
459
+ * @template T
460
+ * @param {() => Promise<T>} task - mutation.
461
+ * @returns {Promise<T>} outcome.
462
+ */
463
+ function enqueue(task) {
464
+ const run = mutationTail.then(task, task)
465
+ mutationTail = run.then(
466
+ () => undefined,
467
+ () => undefined,
468
+ )
469
+ return run
470
+ }
471
+
472
+ /** @returns {string} resolved data directory. */
473
+ function dataDir() {
474
+ return resolvedDir
475
+ }
476
+
477
+ /**
478
+ * List resources, optionally filtered by account id/name.
479
+ * @param {{ server?: string }} [options] - filter.
480
+ * @returns {object[]} public views sorted by name.
481
+ */
482
+ function list({ server } = {}) {
483
+ ensureLoaded()
484
+ /** @type {string | undefined} */
485
+ let filterId
486
+ if (typeof server === 'string' && server.trim() !== '') {
487
+ const found = accounts.find(server.trim())
488
+ filterId = found ? found.id : server.trim()
489
+ }
490
+ const selected = filterId === undefined ? records : records.filter((record) => record.serverId === filterId)
491
+ return selected
492
+ .slice()
493
+ .sort((left, right) => String(left.name).localeCompare(String(right.name)))
494
+ .map((record) => toView(record, accounts))
495
+ }
496
+
497
+ /**
498
+ * @param {string} id - record id.
499
+ * @returns {object | undefined} public view.
500
+ */
501
+ function get(id) {
502
+ ensureLoaded()
503
+ const record = rawById(id)
504
+ return record === undefined ? undefined : toView(record, accounts)
505
+ }
506
+
507
+ /**
508
+ * Resolve a resource by id, exact name (case-insensitive), then a unique name
509
+ * prefix.
510
+ * @param {string} query - id or name text.
511
+ * @returns {object | undefined} public view.
512
+ */
513
+ function find(query) {
514
+ ensureLoaded()
515
+ if (typeof query !== 'string') return undefined
516
+ const text = query.trim()
517
+ if (text === '') return undefined
518
+ const byId = records.find((record) => record.id === text)
519
+ if (byId) return toView(byId, accounts)
520
+ const lower = text.toLowerCase()
521
+ const byName = records.find((record) => typeof record.name === 'string' && record.name.toLowerCase() === lower)
522
+ if (byName) return toView(byName, accounts)
523
+ const byPrefix = records.filter((record) => typeof record.name === 'string' && record.name.toLowerCase().startsWith(lower))
524
+ if (byPrefix.length === 1) return toView(byPrefix[0], accounts)
525
+ return undefined
526
+ }
527
+
528
+ /**
529
+ * Create or update one resource.
530
+ * @param {object} draft - flat payload; `id` updates an existing resource.
531
+ * @returns {Promise<object>} public view.
532
+ */
533
+ function save(draft) {
534
+ ensureLoaded()
535
+ return enqueue(async () => {
536
+ if (!isObject(draft)) throw invalid('请求内容无效')
537
+ const id = typeof draft.id === 'string' && draft.id.trim() !== '' ? draft.id.trim() : ''
538
+ const existing = id === '' ? undefined : rawById(id)
539
+ if (id !== '' && existing === undefined) throw notFound('资源不存在')
540
+ const record = buildRecord(draft, existing, accounts)
541
+ assertNameAvailable(record.name, record.id)
542
+ const previous = records
543
+ records = existing === undefined ? [...records, record] : records.map((entry) => (entry.id === record.id ? record : entry))
544
+ try {
545
+ await file.write(records)
546
+ } catch (error) {
547
+ records = previous
548
+ throw storageError(error)
549
+ }
550
+ return toView(record, accounts)
551
+ })
552
+ }
553
+
554
+ /**
555
+ * Remove one resource.
556
+ * @param {string} id - record id.
557
+ * @returns {Promise<boolean>} whether a record was removed.
558
+ */
559
+ function remove(id) {
560
+ ensureLoaded()
561
+ return enqueue(async () => {
562
+ if (!rawById(id)) return false
563
+ const previous = records
564
+ records = records.filter((record) => record.id !== id)
565
+ try {
566
+ await file.write(records)
567
+ } catch (error) {
568
+ records = previous
569
+ throw storageError(error)
570
+ }
571
+ return true
572
+ })
573
+ }
574
+
575
+ /**
576
+ * Record the outcome of an update attempt on one resource.
577
+ * @param {string} id - record id.
578
+ * @param {{ at?: string, status?: string, backupPath?: string }} patch - outcome fields.
579
+ * @returns {Promise<object | undefined>} public view, or undefined when absent.
580
+ */
581
+ function markUpdated(id, patch = {}) {
582
+ ensureLoaded()
583
+ return enqueue(async () => {
584
+ const existing = rawById(id)
585
+ if (!existing) return undefined
586
+ const now = new Date().toISOString()
587
+ const updated = { ...existing, updatedAt: now }
588
+ updated.lastUpdateAt = typeof patch.at === 'string' && patch.at !== '' ? patch.at : now
589
+ if (typeof patch.status === 'string' && patch.status !== '') updated.lastUpdateStatus = patch.status
590
+ if (typeof patch.backupPath === 'string') updated.lastBackupPath = patch.backupPath
591
+ const previous = records
592
+ records = records.map((record) => (record.id === id ? updated : record))
593
+ try {
594
+ await file.write(records)
595
+ } catch (error) {
596
+ records = previous
597
+ throw storageError(error)
598
+ }
599
+ return toView(updated, accounts)
600
+ })
601
+ }
602
+
603
+ return { dataDir, load, list, get, find, save, remove, toView: (record) => toView(record, accounts), markUpdated }
604
+ }