@wangxiaocuo/dsh-session-trash 0.6.3 → 0.6.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/CHANGELOG.md +36 -0
- package/README.en.md +25 -8
- package/README.md +25 -8
- package/client.js +117 -6
- package/index.js +2 -2
- package/lib/cleanup.js +23 -16
- package/lib/eligibility.js +5 -5
- package/lib/errors.js +9 -6
- package/lib/fsops.js +8 -6
- package/lib/http.js +45 -19
- package/lib/inventory.js +40 -16
- package/lib/members.js +6 -6
- package/lib/state.js +11 -9
- package/lib/store.js +45 -16
- package/lib/trash-service.js +103 -66
- package/package.json +10 -3
package/lib/errors.js
CHANGED
|
@@ -43,7 +43,7 @@ export class TrashError extends Error {
|
|
|
43
43
|
/**
|
|
44
44
|
* @param {string} code stable error code.
|
|
45
45
|
* @param {string} message owner-facing explanation.
|
|
46
|
-
* @param {
|
|
46
|
+
* @param {Record<string, unknown>} [details] optional structured context.
|
|
47
47
|
*/
|
|
48
48
|
constructor(code, message, details) {
|
|
49
49
|
super(message)
|
|
@@ -87,25 +87,28 @@ const SAFE_DETAIL_KEYS = new Set([
|
|
|
87
87
|
* only their numeric counters so no path can ride along.
|
|
88
88
|
*
|
|
89
89
|
* @param {unknown} value candidate metrics object.
|
|
90
|
-
* @returns {
|
|
90
|
+
* @returns {{ sizeBytes?: number, fileCount?: number, symlinkCount?: number } | undefined} the numeric metrics.
|
|
91
91
|
*/
|
|
92
92
|
function sanitizeMetrics(value) {
|
|
93
93
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
94
|
+
const record = /** @type {Record<string, unknown>} */ (value)
|
|
95
|
+
/** @type {Record<string, unknown>} */
|
|
94
96
|
const safe = {}
|
|
95
97
|
for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
|
|
96
|
-
if (typeof
|
|
98
|
+
if (typeof record[key] === 'number') safe[key] = record[key]
|
|
97
99
|
}
|
|
98
100
|
return Object.keys(safe).length === 0 ? undefined : safe
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* @param {unknown} details arbitrary structured context.
|
|
103
|
-
* @returns {
|
|
105
|
+
* @returns {Record<string, unknown> | undefined} the same context restricted to whitelisted keys.
|
|
104
106
|
*/
|
|
105
107
|
export function sanitizeDetails(details) {
|
|
106
108
|
if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
|
|
109
|
+
/** @type {Record<string, unknown>} */
|
|
107
110
|
const safe = {}
|
|
108
|
-
for (const [key, value] of Object.entries(details)) {
|
|
111
|
+
for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (details))) {
|
|
109
112
|
if (!SAFE_DETAIL_KEYS.has(key)) continue
|
|
110
113
|
if (key === 'expected' || key === 'measured') {
|
|
111
114
|
const metrics = sanitizeMetrics(value)
|
|
@@ -123,7 +126,7 @@ export function sanitizeDetails(details) {
|
|
|
123
126
|
|
|
124
127
|
/**
|
|
125
128
|
* @param {unknown} error arbitrary thrown value.
|
|
126
|
-
* @returns {{ code: string, message: string, details?:
|
|
129
|
+
* @returns {{ code: string, message: string, details?: Record<string, unknown> }} the wire error shape.
|
|
127
130
|
*/
|
|
128
131
|
export function toWireError(error) {
|
|
129
132
|
if (error instanceof TrashError) {
|
package/lib/fsops.js
CHANGED
|
@@ -40,7 +40,9 @@ const COPY_OPTIONS = {
|
|
|
40
40
|
* @returns {string | undefined} the Node error code.
|
|
41
41
|
*/
|
|
42
42
|
export function errorCode(error) {
|
|
43
|
-
|
|
43
|
+
if (error === null || typeof error !== 'object') return undefined
|
|
44
|
+
const code = /** @type {Record<string, unknown>} */ (error).code
|
|
45
|
+
return typeof code === 'string' ? code : undefined
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -286,8 +288,8 @@ export async function measureDirectory(directory) {
|
|
|
286
288
|
}
|
|
287
289
|
|
|
288
290
|
/**
|
|
289
|
-
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
290
|
-
* @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
|
|
291
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
|
|
292
|
+
* @param {{ sizeBytes: number, fileCount: number, symlinkCount?: number }} measured current metrics.
|
|
291
293
|
* @returns {boolean} whether the recorded metrics are absent or match exactly.
|
|
292
294
|
*/
|
|
293
295
|
export function metricsMatch(entry, measured) {
|
|
@@ -303,7 +305,7 @@ export function metricsMatch(entry, measured) {
|
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
/**
|
|
306
|
-
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
308
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
|
|
307
309
|
* @returns {boolean} whether the entry carries a complete move snapshot.
|
|
308
310
|
*/
|
|
309
311
|
export function hasMetrics(entry) {
|
|
@@ -339,7 +341,7 @@ export function createTrashId() {
|
|
|
339
341
|
* @param {string} destination final directory path.
|
|
340
342
|
* @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
|
|
341
343
|
* @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
|
|
342
|
-
* @param {(source: string, destination: string, options:
|
|
344
|
+
* @param {(source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>} copy copy implementation for tests.
|
|
343
345
|
* @returns {Promise<void>} resolution after the destination is published.
|
|
344
346
|
*/
|
|
345
347
|
async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
@@ -382,7 +384,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
|
382
384
|
* @param {{
|
|
383
385
|
* logger?: { warn: Function },
|
|
384
386
|
* rename?: (from: string, to: string) => Promise<void>,
|
|
385
|
-
* copy?: (source: string, destination: string, options:
|
|
387
|
+
* copy?: (source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>,
|
|
386
388
|
* }} [options] diagnostics sink and test seams.
|
|
387
389
|
* @returns {Promise<void>} resolution after the directory lives at `destination`.
|
|
388
390
|
*/
|
package/lib/http.js
CHANGED
|
@@ -45,7 +45,7 @@ function headerBag(req) {
|
|
|
45
45
|
/**
|
|
46
46
|
* @param {import('node:http').ServerResponse} res response to complete.
|
|
47
47
|
* @param {number} status HTTP status.
|
|
48
|
-
* @param {
|
|
48
|
+
* @param {any} body JSON-serializable body.
|
|
49
49
|
* @returns {void}
|
|
50
50
|
*/
|
|
51
51
|
function sendJson(res, status, body) {
|
|
@@ -62,7 +62,7 @@ function sendJson(res, status, body) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* @param {import('node:http').ServerResponse} res response to complete.
|
|
65
|
-
* @param {
|
|
65
|
+
* @param {any} error thrown failure.
|
|
66
66
|
* @returns {void}
|
|
67
67
|
*/
|
|
68
68
|
function sendError(res, error) {
|
|
@@ -151,20 +151,25 @@ function requireSessionId(body) {
|
|
|
151
151
|
* @param {{
|
|
152
152
|
* ctx: import('@deepseek-ai/cordis').Context,
|
|
153
153
|
* eligibility: { evaluate: Function, invalidate: Function },
|
|
154
|
-
* stateService: { snapshot: () => Promise<
|
|
154
|
+
* stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
|
|
155
155
|
* trashService: {
|
|
156
|
-
* trash: (sessionId: string) => Promise<
|
|
157
|
-
* restore: (sessionId: string, mode?: string) => Promise<
|
|
158
|
-
* purge: (sessionId: string) => Promise<
|
|
159
|
-
* purgeAll: () => Promise<
|
|
160
|
-
* forget: (sessionId: string) => Promise<
|
|
161
|
-
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<
|
|
162
|
-
* rebuild: () => Promise<
|
|
156
|
+
* trash: (sessionId: string) => Promise<any>,
|
|
157
|
+
* restore: (sessionId: string, mode?: string) => Promise<any>,
|
|
158
|
+
* purge: (sessionId: string) => Promise<any>,
|
|
159
|
+
* purgeAll: () => Promise<any>,
|
|
160
|
+
* forget: (sessionId: string) => Promise<any>,
|
|
161
|
+
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
|
|
162
|
+
* rebuild: () => Promise<any>,
|
|
163
|
+
* purgeEmptyOrphans: () => Promise<{ removed: number }>,
|
|
164
|
+
* },
|
|
165
|
+
* cleanupService?: {
|
|
166
|
+
* runNow: (options?: any) => Promise<any>,
|
|
167
|
+
* sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: any }>,
|
|
168
|
+
* diagnostics?: () => any,
|
|
163
169
|
* },
|
|
164
|
-
* cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
|
|
165
170
|
* ready: Promise<unknown>,
|
|
166
|
-
* config:
|
|
167
|
-
* logger:
|
|
171
|
+
* config: any,
|
|
172
|
+
* logger: any,
|
|
168
173
|
* }} deps host services.
|
|
169
174
|
* @returns {void}
|
|
170
175
|
*/
|
|
@@ -260,11 +265,28 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
|
|
|
260
265
|
|
|
261
266
|
route('state', 'GET', async (_req, res) => {
|
|
262
267
|
sendJson(res, 200, await stateService.snapshot())
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
route('sweep', 'POST', async (_req, res) => {
|
|
271
|
+
const outcome = await cleanupService.sweepOnPageOpen()
|
|
272
|
+
const result = outcome.result !== null && outcome.result !== undefined ? outcome.result : null
|
|
273
|
+
sendJson(res, 200, {
|
|
274
|
+
ok: true,
|
|
275
|
+
ran: outcome.ran === true,
|
|
276
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
277
|
+
...(result === null
|
|
278
|
+
? {}
|
|
279
|
+
: {
|
|
280
|
+
purged: result.purged ?? 0,
|
|
281
|
+
failed: result.failed ?? 0,
|
|
282
|
+
settled: result.changed ?? 0,
|
|
283
|
+
}),
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
route('orphans/forget', 'POST', async (_req, res) => {
|
|
288
|
+
const result = await trashService.purgeEmptyOrphans()
|
|
289
|
+
sendJson(res, 200, { ok: true, removed: result.removed ?? 0 })
|
|
268
290
|
})
|
|
269
291
|
|
|
270
292
|
route('preflight', 'POST', async (req, res) => {
|
|
@@ -292,7 +314,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
|
|
|
292
314
|
route('restore', 'POST', async (req, res) => {
|
|
293
315
|
const body = await readJsonRequest(req)
|
|
294
316
|
const sessionId = requireSessionId(body)
|
|
295
|
-
const
|
|
317
|
+
const rawMode = body.mode
|
|
318
|
+
if (rawMode !== undefined && typeof rawMode !== 'string') {
|
|
319
|
+
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
320
|
+
}
|
|
321
|
+
const mode = typeof rawMode === 'string' ? rawMode : undefined
|
|
296
322
|
if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
|
|
297
323
|
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
298
324
|
}
|
package/lib/inventory.js
CHANGED
|
@@ -17,7 +17,7 @@ const META_VERSION = 2
|
|
|
17
17
|
const MAX_MEMBERS = 64
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* @param {
|
|
20
|
+
* @param {any} value candidate timestamp.
|
|
21
21
|
* @returns {boolean} whether the value is a finite number.
|
|
22
22
|
*/
|
|
23
23
|
function isFiniteNumber(value) {
|
|
@@ -25,7 +25,7 @@ function isFiniteNumber(value) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* @param {
|
|
28
|
+
* @param {any} value candidate metric.
|
|
29
29
|
* @returns {boolean} whether the value is a usable number or absent.
|
|
30
30
|
*/
|
|
31
31
|
function isMetric(value) {
|
|
@@ -36,7 +36,7 @@ function isMetric(value) {
|
|
|
36
36
|
* Validate one v2 member's absolute paths. The parent's sidecar is validated
|
|
37
37
|
* field by field so a hand-edited file is rejected instead of guessed at.
|
|
38
38
|
*
|
|
39
|
-
* @param {
|
|
39
|
+
* @param {any} member candidate member.
|
|
40
40
|
* @returns {boolean} whether the member names safe, coherent paths.
|
|
41
41
|
*/
|
|
42
42
|
function isValidMemberPaths(member) {
|
|
@@ -72,7 +72,7 @@ function isValidMemberPaths(member) {
|
|
|
72
72
|
/**
|
|
73
73
|
* Validate the v2 member list of one sidecar.
|
|
74
74
|
*
|
|
75
|
-
* @param {
|
|
75
|
+
* @param {any} meta candidate sidecar.
|
|
76
76
|
* @returns {boolean} whether the list is coherent with its parent fields.
|
|
77
77
|
*/
|
|
78
78
|
function isValidMetaMembers(meta) {
|
|
@@ -100,7 +100,7 @@ function isValidMetaMembers(meta) {
|
|
|
100
100
|
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
101
101
|
* being guessed at.
|
|
102
102
|
*
|
|
103
|
-
* @param {
|
|
103
|
+
* @param {any} meta parsed `meta.json`.
|
|
104
104
|
* @param {string} trashId object directory identity.
|
|
105
105
|
* @returns {boolean} whether the sidecar can rebuild one entry.
|
|
106
106
|
*/
|
|
@@ -132,9 +132,9 @@ export function isValidMeta(meta, trashId) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* @param {
|
|
135
|
+
* @param {any} member raw member fields.
|
|
136
136
|
* @param {string} fallbackTitle title used when the member carries none.
|
|
137
|
-
* @returns {
|
|
137
|
+
* @returns {any} one normalized v2 member.
|
|
138
138
|
*/
|
|
139
139
|
function toMember(member, fallbackTitle) {
|
|
140
140
|
return {
|
|
@@ -151,8 +151,8 @@ function toMember(member, fallbackTitle) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
|
-
* @param {
|
|
155
|
-
* @returns {
|
|
154
|
+
* @param {any} meta valid sidecar.
|
|
155
|
+
* @returns {any} a clean index entry with defaulted optional fields.
|
|
156
156
|
*/
|
|
157
157
|
export function toEntry(meta) {
|
|
158
158
|
const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
|
|
@@ -201,7 +201,7 @@ export function toEntry(meta) {
|
|
|
201
201
|
* Which of one entry's member directories currently exist inside its object.
|
|
202
202
|
*
|
|
203
203
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
204
|
-
* @param {
|
|
204
|
+
* @param {any} entry index entry, v1 or v2 shaped.
|
|
205
205
|
* @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
|
|
206
206
|
*/
|
|
207
207
|
export async function memberPresence(store, entry) {
|
|
@@ -229,8 +229,8 @@ export async function memberPresence(store, entry) {
|
|
|
229
229
|
* Whether two member lists name the same directories in the same order, so a
|
|
230
230
|
* presence probe taken for one can be reused for the other.
|
|
231
231
|
*
|
|
232
|
-
* @param {readonly
|
|
233
|
-
* @param {readonly
|
|
232
|
+
* @param {readonly any[]} left first member list.
|
|
233
|
+
* @param {readonly any[]} right second member list.
|
|
234
234
|
* @returns {boolean} whether both lists describe the same members.
|
|
235
235
|
*/
|
|
236
236
|
function sameMembers(left, right) {
|
|
@@ -244,10 +244,10 @@ function sameMembers(left, right) {
|
|
|
244
244
|
/**
|
|
245
245
|
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
246
246
|
*
|
|
247
|
-
* @param {
|
|
247
|
+
* @param {any} entry index-shaped entry.
|
|
248
248
|
* @param {{ total: number, present: number, missing: string[] }} presence member presence.
|
|
249
249
|
* @param {number} now current timestamp.
|
|
250
|
-
* @returns {
|
|
250
|
+
* @returns {any} the entry whose state matches the filesystem.
|
|
251
251
|
*/
|
|
252
252
|
export function deriveEntry(entry, presence, now) {
|
|
253
253
|
const complete = presence.total > 0 && presence.present === presence.total
|
|
@@ -282,7 +282,7 @@ export function deriveEntry(entry, presence, now) {
|
|
|
282
282
|
*
|
|
283
283
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
284
284
|
* @param {{ warn: Function }} logger diagnostics sink.
|
|
285
|
-
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta:
|
|
285
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: any | undefined, metaValid: boolean, entry?: any, presence?: object }[], unindexed: number }>} the inventory.
|
|
286
286
|
*/
|
|
287
287
|
export async function scanObjects(store, logger) {
|
|
288
288
|
let entries
|
|
@@ -336,13 +336,37 @@ export async function scanObjects(store, logger) {
|
|
|
336
336
|
return { objects, unindexed }
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Plugin-owned sidecar names that may remain inside an otherwise empty object
|
|
341
|
+
* directory. Everything else (session/, children/, stray files) is payload.
|
|
342
|
+
*/
|
|
343
|
+
const PLUGIN_OWNED_OBJECT_NAMES = new Set(['meta.json', 'meta.json.tmp'])
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Whether an object directory holds no session payload, only plugin metadata
|
|
347
|
+
* that can be recreated. Used to offer a safe cleanup for unindexed leftovers.
|
|
348
|
+
*
|
|
349
|
+
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
350
|
+
* @param {string} trashId object identity.
|
|
351
|
+
* @returns {Promise<boolean>} whether the directory is safe to remove.
|
|
352
|
+
*/
|
|
353
|
+
export async function isEmptyObjectDir(store, trashId) {
|
|
354
|
+
if (!isSafeTrashId(trashId)) return false
|
|
355
|
+
try {
|
|
356
|
+
const names = await fs.readdir(store.objectDir(trashId))
|
|
357
|
+
return names.every((name) => PLUGIN_OWNED_OBJECT_NAMES.has(name))
|
|
358
|
+
} catch {
|
|
359
|
+
return false
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
339
363
|
/**
|
|
340
364
|
* Import object directories the index does not know about and mark entries
|
|
341
365
|
* whose object directory vanished. Never deletes a session file or an object
|
|
342
366
|
* directory.
|
|
343
367
|
*
|
|
344
368
|
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
345
|
-
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string,
|
|
369
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, any> }>} reconcile summary plus the presence probe per object.
|
|
346
370
|
*/
|
|
347
371
|
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
348
372
|
if (store.isProtected()) {
|
package/lib/members.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* @param {
|
|
11
|
+
* @param {any} entry index entry (v1 or v2 shape).
|
|
12
12
|
* @returns {object|null} the parent member, or null when the entry has no directory data.
|
|
13
13
|
*/
|
|
14
14
|
export function parentMember(entry) {
|
|
@@ -28,8 +28,8 @@ export function parentMember(entry) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* @param {
|
|
32
|
-
* @returns {
|
|
31
|
+
* @param {any} entry index entry (v1 or v2 shape).
|
|
32
|
+
* @returns {any[]} every owned directory, parent first.
|
|
33
33
|
*/
|
|
34
34
|
export function memberList(entry) {
|
|
35
35
|
if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
|
|
@@ -40,7 +40,7 @@ export function memberList(entry) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* @param {readonly
|
|
43
|
+
* @param {readonly any[]} members member list.
|
|
44
44
|
* @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
|
|
45
45
|
*/
|
|
46
46
|
export function aggregateMetrics(members) {
|
|
@@ -57,7 +57,7 @@ export function aggregateMetrics(members) {
|
|
|
57
57
|
* Shape-level validation shared by the durable index and object sidecars.
|
|
58
58
|
* Path strictness belongs to the sidecar validator.
|
|
59
59
|
*
|
|
60
|
-
* @param {
|
|
60
|
+
* @param {any} member candidate member.
|
|
61
61
|
* @returns {boolean} whether the member carries the required v2 identity.
|
|
62
62
|
*/
|
|
63
63
|
export function isValidMemberShape(member) {
|
|
@@ -79,7 +79,7 @@ export function isValidMemberShape(member) {
|
|
|
79
79
|
/**
|
|
80
80
|
* Whether the v2 member list is internally consistent with its parent entry.
|
|
81
81
|
*
|
|
82
|
-
* @param {
|
|
82
|
+
* @param {any} entry index entry carrying `members`.
|
|
83
83
|
* @returns {boolean} whether the list is acceptable.
|
|
84
84
|
*/
|
|
85
85
|
export function isValidMemberList(entry) {
|
package/lib/state.js
CHANGED
|
@@ -10,6 +10,7 @@ const PROBE_CONCURRENCY = 3
|
|
|
10
10
|
* stays an English diagnostic for logs and tooltips; the code is what the
|
|
11
11
|
* Client localizes.
|
|
12
12
|
*/
|
|
13
|
+
/** @type {[RegExp, string][]} */
|
|
13
14
|
const LAST_ERROR_KINDS = [
|
|
14
15
|
[/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
|
|
15
16
|
[/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
|
|
@@ -22,7 +23,7 @@ const LAST_ERROR_KINDS = [
|
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
* @param {
|
|
26
|
+
* @param {any} message raw `lastError` text.
|
|
26
27
|
* @returns {string | null} a stable failure family, or null when none was recorded.
|
|
27
28
|
*/
|
|
28
29
|
export function lastErrorCode(message) {
|
|
@@ -55,9 +56,10 @@ async function directoryExists(directory) {
|
|
|
55
56
|
* config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
|
|
56
57
|
* trashRootDisplay: string,
|
|
57
58
|
* nextSweepAt?: () => number | null,
|
|
58
|
-
* cleanup?: () =>
|
|
59
|
+
* cleanup?: () => any,
|
|
60
|
+
* directoryExists?: (directory?: any) => Promise<boolean>,
|
|
59
61
|
* }} deps state inputs.
|
|
60
|
-
* @returns {{ snapshot: () => Promise<
|
|
62
|
+
* @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
|
|
61
63
|
*/
|
|
62
64
|
export function createStateService({
|
|
63
65
|
store,
|
|
@@ -68,7 +70,7 @@ export function createStateService({
|
|
|
68
70
|
directoryExists: probeDirectory = directoryExists,
|
|
69
71
|
}) {
|
|
70
72
|
/**
|
|
71
|
-
* @returns {
|
|
73
|
+
* @returns {any} cleanup diagnostics, or a neutral fallback.
|
|
72
74
|
*/
|
|
73
75
|
function cleanupDiagnostics() {
|
|
74
76
|
if (typeof cleanup === 'function') {
|
|
@@ -92,10 +94,10 @@ export function createStateService({
|
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
|
-
* @param {
|
|
97
|
+
* @param {any} entry committed index entry.
|
|
96
98
|
* @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
|
|
97
99
|
* started for this snapshot, so entries sharing a cwd probe it once.
|
|
98
|
-
* @returns {Promise<
|
|
100
|
+
* @returns {Promise<any>} the wire entry.
|
|
99
101
|
*/
|
|
100
102
|
async function describeEntry(entry, probeCache = new Map()) {
|
|
101
103
|
const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
|
|
@@ -138,12 +140,12 @@ export function createStateService({
|
|
|
138
140
|
|
|
139
141
|
return {
|
|
140
142
|
/**
|
|
141
|
-
* @param {
|
|
142
|
-
* @returns {Promise<
|
|
143
|
+
* @param {any} entry committed index entry.
|
|
144
|
+
* @returns {Promise<any>} the wire entry.
|
|
143
145
|
*/
|
|
144
146
|
describe: describeEntry,
|
|
145
147
|
/**
|
|
146
|
-
* @returns {Promise<
|
|
148
|
+
* @returns {Promise<any>} the complete `/state` response body.
|
|
147
149
|
*/
|
|
148
150
|
async snapshot() {
|
|
149
151
|
const probeCache = new Map()
|
package/lib/store.js
CHANGED
|
@@ -14,9 +14,25 @@ export const LOCK_STALE_MS = 10 * 60 * 1000
|
|
|
14
14
|
/** How often a long-running operation refreshes its lock heartbeat. */
|
|
15
15
|
export const LOCK_REFRESH_MS = 60 * 1000
|
|
16
16
|
|
|
17
|
+
/** How many times a caller retries a lock held by another live process. */
|
|
18
|
+
export const LOCK_WAIT_ATTEMPTS = 3
|
|
19
|
+
|
|
20
|
+
/** Base delay between live-lock retries. */
|
|
21
|
+
export const LOCK_WAIT_DELAY_MS = 200
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {number} milliseconds delay length.
|
|
25
|
+
* @returns {Promise<void>} resolution after the delay.
|
|
26
|
+
*/
|
|
27
|
+
function sleep(milliseconds) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
setTimeout(resolve, milliseconds)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* @param {number} [now] timestamp for the new index.
|
|
19
|
-
* @returns {{ version: number, updatedAt: number, entries:
|
|
35
|
+
* @returns {{ version: number, updatedAt: number, entries: any[] }} an empty index document.
|
|
20
36
|
*/
|
|
21
37
|
export function emptyIndex(now = Date.now()) {
|
|
22
38
|
return { version: INDEX_VERSION, updatedAt: now, entries: [] }
|
|
@@ -26,8 +42,8 @@ export function emptyIndex(now = Date.now()) {
|
|
|
26
42
|
* Coerce one durable entry to the v2 shape. A v1 entry names a single
|
|
27
43
|
* directory, which becomes the parent member of a one-member tree.
|
|
28
44
|
*
|
|
29
|
-
* @param {
|
|
30
|
-
* @returns {
|
|
45
|
+
* @param {any} entry entry from either durable shape.
|
|
46
|
+
* @returns {any} the v2 entry.
|
|
31
47
|
*/
|
|
32
48
|
function normalizeEntry(entry) {
|
|
33
49
|
if (entry === null || typeof entry !== 'object') return entry
|
|
@@ -40,7 +56,7 @@ function normalizeEntry(entry) {
|
|
|
40
56
|
* Validate the persisted index shape strictly enough that a truncated or
|
|
41
57
|
* hand-edited file enters protection mode instead of silently vanishing.
|
|
42
58
|
*
|
|
43
|
-
* @param {
|
|
59
|
+
* @param {any} value parsed JSON document.
|
|
44
60
|
* @returns {boolean} whether the document is a supported index.
|
|
45
61
|
*/
|
|
46
62
|
export function isValidIndex(value) {
|
|
@@ -141,7 +157,7 @@ export class TrashStore {
|
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
/**
|
|
144
|
-
* @returns {readonly
|
|
160
|
+
* @returns {readonly any[]} the committed entries.
|
|
145
161
|
*/
|
|
146
162
|
entries() {
|
|
147
163
|
return this.index.entries
|
|
@@ -149,7 +165,7 @@ export class TrashStore {
|
|
|
149
165
|
|
|
150
166
|
/**
|
|
151
167
|
* @param {string} sessionId session to look up.
|
|
152
|
-
* @returns {
|
|
168
|
+
* @returns {any | undefined} the index entry for this session.
|
|
153
169
|
*/
|
|
154
170
|
findEntry(sessionId) {
|
|
155
171
|
return this.index.entries.find((entry) => entry.sessionId === sessionId)
|
|
@@ -235,7 +251,7 @@ export class TrashStore {
|
|
|
235
251
|
|
|
236
252
|
/**
|
|
237
253
|
* @param {string} trashId object identity.
|
|
238
|
-
* @returns {Promise<
|
|
254
|
+
* @returns {Promise<any | undefined>} the parsed sidecar metadata, when present.
|
|
239
255
|
*/
|
|
240
256
|
async readMeta(trashId) {
|
|
241
257
|
try {
|
|
@@ -279,7 +295,7 @@ export class TrashStore {
|
|
|
279
295
|
* Atomically write one object sidecar. Metadata is written before the session
|
|
280
296
|
* directory moves, so a damaged index can rebuild from it later.
|
|
281
297
|
*
|
|
282
|
-
* @param {
|
|
298
|
+
* @param {any} entry index-shaped entry carrying `trashId`.
|
|
283
299
|
* @returns {Promise<void>} resolution once the sidecar is durable.
|
|
284
300
|
*/
|
|
285
301
|
async writeMeta(entry) {
|
|
@@ -331,7 +347,7 @@ export class TrashStore {
|
|
|
331
347
|
/**
|
|
332
348
|
* Commit a complete entry list and refresh the in-memory projection.
|
|
333
349
|
*
|
|
334
|
-
* @param {readonly
|
|
350
|
+
* @param {readonly any[]} entries complete new entry list.
|
|
335
351
|
* @returns {Promise<void>} resolution once committed.
|
|
336
352
|
*/
|
|
337
353
|
async commit(entries) {
|
|
@@ -346,7 +362,7 @@ export class TrashStore {
|
|
|
346
362
|
|
|
347
363
|
/**
|
|
348
364
|
* @param {string} file index file to read.
|
|
349
|
-
* @returns {Promise<
|
|
365
|
+
* @returns {Promise<any>} parsed JSON.
|
|
350
366
|
*/
|
|
351
367
|
async readJson(file) {
|
|
352
368
|
return JSON.parse(await fs.readFile(file, 'utf8'))
|
|
@@ -392,8 +408,8 @@ export class TrashStore {
|
|
|
392
408
|
}
|
|
393
409
|
|
|
394
410
|
/**
|
|
395
|
-
* @param {{ version: number, updatedAt: number, entries: readonly
|
|
396
|
-
* @returns {{ version: number, updatedAt: number, entries:
|
|
411
|
+
* @param {{ version: number, updatedAt: number, entries: readonly any[] }} data parsed index document.
|
|
412
|
+
* @returns {{ version: number, updatedAt: number, entries: any[] }} the v2 document.
|
|
397
413
|
*/
|
|
398
414
|
normalizeIndex(data) {
|
|
399
415
|
if (data.version !== INDEX_VERSION) this.needsUpgrade = true
|
|
@@ -408,8 +424,8 @@ export class TrashStore {
|
|
|
408
424
|
* Atomically commit the index: write a temporary file, preserve the previous
|
|
409
425
|
* commit as `.bak`, then rename over the live index.
|
|
410
426
|
*
|
|
411
|
-
* @param {readonly
|
|
412
|
-
* @returns {Promise<{ version: number, updatedAt: number, entries: readonly
|
|
427
|
+
* @param {readonly any[]} entries the complete entry list to persist.
|
|
428
|
+
* @returns {Promise<{ version: number, updatedAt: number, entries: readonly any[] }>} the committed document.
|
|
413
429
|
*/
|
|
414
430
|
async writeIndex(entries) {
|
|
415
431
|
const normalized = entries.map((entry) => normalizeEntry(entry))
|
|
@@ -558,10 +574,15 @@ export class TrashStore {
|
|
|
558
574
|
}
|
|
559
575
|
|
|
560
576
|
/**
|
|
577
|
+
* Acquire the cross-process lock. A lock held by another live process is
|
|
578
|
+
* retried for a short window first, so two windows clicking at the same time
|
|
579
|
+
* do not surface a spurious LOCKED error.
|
|
580
|
+
*
|
|
561
581
|
* @returns {Promise<void>} resolution once this process owns the lock.
|
|
562
582
|
*/
|
|
563
583
|
async acquireLock() {
|
|
564
|
-
|
|
584
|
+
let waits = 0
|
|
585
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
565
586
|
try {
|
|
566
587
|
const handle = await fs.open(this.lockPath, 'wx', 0o600)
|
|
567
588
|
try {
|
|
@@ -576,13 +597,21 @@ export class TrashStore {
|
|
|
576
597
|
throw new TrashError('IO_ERROR', `Cannot create the trash lock: ${error instanceof Error ? error.message : String(error)}`)
|
|
577
598
|
}
|
|
578
599
|
if (!(await this.lockIsStale())) {
|
|
600
|
+
if (waits < LOCK_WAIT_ATTEMPTS - 1) {
|
|
601
|
+
waits += 1
|
|
602
|
+
await sleep(LOCK_WAIT_DELAY_MS * waits)
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
579
605
|
throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
|
|
580
606
|
}
|
|
581
607
|
try {
|
|
582
608
|
await removePathWithRetry(this.lockPath, { recursive: false, force: true, attempts: 5, delayMs: 20 })
|
|
583
609
|
} catch (removeError) {
|
|
584
610
|
throw new TrashError('LOCKED', 'A stale trash lock could not be removed. Try again in a moment.', {
|
|
585
|
-
code:
|
|
611
|
+
code:
|
|
612
|
+
removeError instanceof Error && typeof /** @type {any} */ (removeError).code === 'string'
|
|
613
|
+
? /** @type {any} */ (removeError).code
|
|
614
|
+
: null,
|
|
586
615
|
})
|
|
587
616
|
}
|
|
588
617
|
}
|