dsh-sessions-manager 3.6.2 → 3.7.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.
- package/README.en.md +21 -10
- package/README.md +23 -12
- package/lib/client.js +990 -32
- package/lib/client.js.map +2 -2
- package/lib/index.js +921 -151
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +816 -33
- package/src/client/logic.js +285 -0
- package/src/empty-scan-index.js +118 -0
- package/src/index.js +340 -79
- package/src/lineage.js +18 -14
- package/src/move-notices.js +129 -0
- package/src/saved-filters.js +208 -0
- package/src/tag-index.js +308 -0
package/src/client/logic.js
CHANGED
|
@@ -177,3 +177,288 @@ export function toastDurationFor(text, kind) {
|
|
|
177
177
|
const scaled = kind === 'err' ? Math.round(readingMs * 1.25) : readingMs
|
|
178
178
|
return Math.min(TOAST_MAX_MS, Math.max(TOAST_MIN_MS, scaled))
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
// —— T2(3.7.0):排队移动终局通知的文案与投递计划(纯函数,可 node --test)——
|
|
182
|
+
// host 把每次后台终局(成功/放弃)落成通知经 sidebar-state 带出;client 用本地
|
|
183
|
+
// 已见集合过滤后弹一次 toast,并 ack 已展示项。投递策略全部收敛在这里,
|
|
184
|
+
// index.jsx 只做接线。
|
|
185
|
+
|
|
186
|
+
export function pathTail(p) {
|
|
187
|
+
const parts = String(p == null ? '' : p).split(/[\\/]+/).filter(Boolean)
|
|
188
|
+
return parts.length ? parts[parts.length - 1] : ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function moveNoticeText(n) {
|
|
192
|
+
const id = shortId(n && n.sessionId)
|
|
193
|
+
if (n && n.kind === 'moved') {
|
|
194
|
+
const where = pathTail(n.targetPath)
|
|
195
|
+
return `排队中的移动已完成:${id}${where ? ` → 「${where}」` : ''}`
|
|
196
|
+
}
|
|
197
|
+
const reason = String((n && n.reason) || '').split('\n')[0].slice(0, 120)
|
|
198
|
+
return `排队中的移动多次失败已放弃:${id}${reason ? `(${reason})` : ''},可在 设置 → 会话管理 → 待移动队列 重新发起移动`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 一次侧栏拉取里的通知投递计划。
|
|
203
|
+
* @param raw sidebar-state 的 moveNotices 数组(可能缺失/畸形——一律宽容)
|
|
204
|
+
* @param seenIds 本浏览器已展示过的通知 id 集合(多 tab 即时去重)
|
|
205
|
+
* @param max 单次最多展示几条真实通知,超出并入省略句
|
|
206
|
+
* @returns {{ text: string|null, kind: 'ok'|'err', ackIds: string[] }}
|
|
207
|
+
* ackIds 只含**已展示**项——未展示的留在服务端,下一拍继续(TTL 兜底)。
|
|
208
|
+
*/
|
|
209
|
+
export function noticeToastPlan(raw, seenIds, max = 2) {
|
|
210
|
+
const list = (Array.isArray(raw) ? raw : [])
|
|
211
|
+
.filter((n) => n && typeof n.id === 'string' && typeof n.sessionId === 'string' && (n.kind === 'moved' || n.kind === 'abandoned') && !seenIds.has(String(n.id)))
|
|
212
|
+
.sort((a, b) => (a.at || 0) - (b.at || 0))
|
|
213
|
+
if (!list.length) return { text: null, kind: 'ok', ackIds: [] }
|
|
214
|
+
const shown = list.slice(Math.max(0, list.length - Math.max(1, max)))
|
|
215
|
+
const parts = shown.map(moveNoticeText)
|
|
216
|
+
if (list.length > shown.length) parts.push(`另有 ${list.length - shown.length} 条排队移动的结果,见 设置 → 会话管理 → 待移动队列`)
|
|
217
|
+
return {
|
|
218
|
+
text: parts.join(';'),
|
|
219
|
+
kind: shown.some((n) => n.kind === 'abandoned') ? 'err' : 'ok',
|
|
220
|
+
ackIds: shown.map((n) => String(n.id)),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// —— T3(3.7.0):分支聚拢 ——
|
|
225
|
+
// 把「同一父会话派生出的普通分支会话」归组,收拢到组锚(真实父行或合成头)
|
|
226
|
+
// 之下。子代理已由 foldSubagents 先行摘除,本函数只处理非子代理的分支血缘。
|
|
227
|
+
// 判定全部收敛为纯函数,供列表管线在 foldSubagents 之后调用;UI 接线不在此处。
|
|
228
|
+
|
|
229
|
+
// 组内时间键:createdAt(>0)优先;缺失/非正回退 updatedAt——它是 host 后加的
|
|
230
|
+
// 字段,老快照或尚未同步的行可能根本没有,必须宽容;两者都不可用按 0,交给
|
|
231
|
+
// sessionId 决胜。
|
|
232
|
+
export function branchTimeKey(item) {
|
|
233
|
+
const c = Number(item && item.createdAt)
|
|
234
|
+
if (Number.isFinite(c) && c > 0) return c
|
|
235
|
+
const u = Number(item && item.updatedAt)
|
|
236
|
+
if (Number.isFinite(u) && u > 0) return u
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 组内升序比较:时间差优先,决胜恒为 sessionId 字典序——即使宿主哪天给所有行
|
|
241
|
+
// 都补上 createdAt,排序仍全序稳定、可复现。同毫秒/同零值也靠决胜定序。
|
|
242
|
+
export function ascBranchTime(a, b) {
|
|
243
|
+
const ta = String((a && a.sessionId) ?? '')
|
|
244
|
+
const tb = String((b && b.sessionId) ?? '')
|
|
245
|
+
return branchTimeKey(a) - branchTimeKey(b) || ta.localeCompare(tb)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 分支血缘边:lineage[id] 存在、origin 非 subagent、parentSession 非空且不
|
|
249
|
+
// 自指,才返回父 id;否则 null(该行不是分支,不参与聚拢)。
|
|
250
|
+
// 子代理即便漏进输入(上游本该是 foldSubagents 之后的 topList)也被 origin
|
|
251
|
+
// 挡下——链上链下两层都锁,绝不入组。
|
|
252
|
+
function branchParentOf(id, table) {
|
|
253
|
+
const info = table[id]
|
|
254
|
+
if (!info || typeof info !== 'object') return null
|
|
255
|
+
if (info.origin === 'subagent') return null
|
|
256
|
+
const p = info.parentSession ? String(info.parentSession) : null
|
|
257
|
+
if (!p || p === id) return null
|
|
258
|
+
return p
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 分支聚拢:把普通分支会话(lineage 有 parentSession、非子代理、不自指)移入
|
|
263
|
+
* 组锚的分支组。
|
|
264
|
+
*
|
|
265
|
+
* 输入契约:items 应为 foldSubagents 之后的 topList(子代理已摘除);lineage
|
|
266
|
+
* 形状 {id:{origin,parentSession,delegationDepth,empty}}。任何畸形输入(items
|
|
267
|
+
* 或 lineage 为 null、数组含洞/非对象行、lineage 非对象)都不得抛错——认不出
|
|
268
|
+
* 血缘的行一律原样留在顶层。
|
|
269
|
+
*
|
|
270
|
+
* 组锚解析(memo 化):沿 parentSession 上溯,锚 = 链上第一个「在 items 中且
|
|
271
|
+
* 自身非分支」的行;父在 items 但也是分支 → 继续上溯(整条链拍平进同一组);
|
|
272
|
+
* 父不在 items → 合成锚 = 链尾那个缺失父 id。visited Set + 步数上限防环;成
|
|
273
|
+
* 环的整条链不聚拢(行不丢,原地保 chip);自指不算分支。
|
|
274
|
+
*
|
|
275
|
+
* 真实锚父行在 topList 中的位置纹丝不动;成员行从 topList 移出进 branchGroupsOf。
|
|
276
|
+
* 仅当锚是合成的(缺失父)才产出头行 {syntheticRoot:rootId, sessionId:'dsm-src:'+rootId},
|
|
277
|
+
* 插入位置 = 组内最早成员(ascBranchTime 排序后的第一个)在原列表中的下标;
|
|
278
|
+
* 成员 ≥1 就出合成头(单孤儿与多孤儿统一规则)。组内恒按 ascBranchTime 升序
|
|
279
|
+
* (与调用方主排序方向无关)。
|
|
280
|
+
*
|
|
281
|
+
* 守恒不变量:topList 中非合成行数 + foldedCount === items.length(行不丢)。
|
|
282
|
+
*
|
|
283
|
+
* @param items 任意(应为 foldSubagents 之后的 topList 数组;畸形宽容)
|
|
284
|
+
* @param lineage 任意(血缘表 {id:{origin,parentSession,...}};畸形宽容)
|
|
285
|
+
* @returns {{ topList: any[], branchGroupsOf: Map<string, any[]>, foldedCount: number, groupCount: number }}
|
|
286
|
+
* topList 真实锚原位保留(或插入合成头行),branchGroupsOf 为 rootId → 升序
|
|
287
|
+
* 成员数组,foldedCount 为移出顶层的成员行数,groupCount 为组数。
|
|
288
|
+
*/
|
|
289
|
+
export function foldBranches(items, lineage) {
|
|
290
|
+
const list = Array.isArray(items) ? items : []
|
|
291
|
+
const table = lineage && typeof lineage === 'object' ? lineage : {}
|
|
292
|
+
|
|
293
|
+
const byId = new Map()
|
|
294
|
+
for (const it of list) {
|
|
295
|
+
if (it == null || it.sessionId == null) continue
|
|
296
|
+
byId.set(String(it.sessionId), it)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 1) 组锚解析:向上走 parent 链。防环 = 访问集 + 步数上限(链上每个 id 至多
|
|
300
|
+
// 出现一次,上限只是兜底);结果按起点 id memo。
|
|
301
|
+
const maxWalk = Object.keys(table).length + list.length + 2
|
|
302
|
+
const anchorMemo = new Map()
|
|
303
|
+
function resolveAnchor(id) {
|
|
304
|
+
if (anchorMemo.has(id)) return anchorMemo.get(id)
|
|
305
|
+
const seen = new Set([id])
|
|
306
|
+
let cur = id
|
|
307
|
+
let steps = 0
|
|
308
|
+
let anchor = null // null = 环 / 起点不是分支,整链不聚
|
|
309
|
+
while (true) {
|
|
310
|
+
const p = branchParentOf(cur, table)
|
|
311
|
+
if (!p) {
|
|
312
|
+
// cur 不是分支:起点若不是分支则不聚;否则停在祖先上,cur 即锚
|
|
313
|
+
// (循环不变量:除首轮外 cur 恒为分支,此分支只在起点命中)。
|
|
314
|
+
anchor = steps === 0 ? null : cur
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
if (seen.has(p)) break // 成环:anchor 保持 null
|
|
318
|
+
seen.add(p)
|
|
319
|
+
steps++
|
|
320
|
+
if (byId.has(p)) {
|
|
321
|
+
if (!branchParentOf(p, table)) {
|
|
322
|
+
anchor = p // p 在列表且自身非分支 → 真实锚
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
cur = p // p 在列表但也是分支 → 继续上溯(链拍平)
|
|
326
|
+
if (steps > maxWalk) break // 环:保持 null
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
// p 不在列表:还是分支 → 继续上溯找更高祖先;否则合成锚 = p
|
|
330
|
+
if (!branchParentOf(p, table)) {
|
|
331
|
+
anchor = p
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
cur = p
|
|
335
|
+
if (steps > maxWalk) break // 环:保持 null
|
|
336
|
+
}
|
|
337
|
+
anchorMemo.set(id, anchor)
|
|
338
|
+
return anchor
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 2) 收集分组。anchorOfRow 按行对象记录锚(重复 id 的畸形行也各算各的),
|
|
342
|
+
// foldedCount 按行计数——保证守恒律在含洞/重复 id 的输入下仍成立。
|
|
343
|
+
const groupsOf = new Map()
|
|
344
|
+
const anchorOfRow = new Map()
|
|
345
|
+
let foldedCount = 0
|
|
346
|
+
for (const it of list) {
|
|
347
|
+
if (it == null || it.sessionId == null) continue
|
|
348
|
+
const id = String(it.sessionId)
|
|
349
|
+
if (!branchParentOf(id, table)) continue // 非分支 / 子代理 / 自指 → 不动
|
|
350
|
+
const anchor = resolveAnchor(id)
|
|
351
|
+
if (!anchor || anchor === id) continue // 环 / 自锚异常 → 保持顶层可见
|
|
352
|
+
if (!groupsOf.has(anchor)) groupsOf.set(anchor, [])
|
|
353
|
+
groupsOf.get(anchor).push(it)
|
|
354
|
+
anchorOfRow.set(it, anchor)
|
|
355
|
+
foldedCount++
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 3) 组内升序(与主排序方向无关);合成锚的组记下最早成员(升序第一个),
|
|
359
|
+
// 合成头将在它原来的位置插一次。
|
|
360
|
+
const earliest = new Set()
|
|
361
|
+
for (const [anchor, members] of groupsOf) {
|
|
362
|
+
members.sort(ascBranchTime)
|
|
363
|
+
if (!byId.has(anchor)) earliest.add(members[0])
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 4) topList 重建:非成员行(含畸形行)原样保序;成员行移出,合成组在最早
|
|
367
|
+
// 成员的原位置放一次合成头行。
|
|
368
|
+
const topList = []
|
|
369
|
+
const emitted = new Set()
|
|
370
|
+
for (const it of list) {
|
|
371
|
+
const anchor = it == null ? undefined : anchorOfRow.get(it)
|
|
372
|
+
if (anchor === undefined) {
|
|
373
|
+
topList.push(it)
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if (!byId.has(anchor) && !emitted.has(anchor) && earliest.has(it)) {
|
|
377
|
+
emitted.add(anchor)
|
|
378
|
+
topList.push({ syntheticRoot: anchor, sessionId: 'dsm-src:' + anchor })
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 空组防御(正常路径每组成员 ≥1,不产出空组)。
|
|
383
|
+
for (const [anchor, members] of groupsOf) {
|
|
384
|
+
if (!members.length) groupsOf.delete(anchor)
|
|
385
|
+
}
|
|
386
|
+
return { topList, branchGroupsOf: groupsOf, foldedCount, groupCount: groupsOf.size }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// —— T4(3.7.0):标签筛选 + 已存筛选 ——
|
|
390
|
+
// 标签与已存筛选是纯用户元数据:host 已把每会话的 tagId 列表挂到 /sessions 的
|
|
391
|
+
// item.tags 上,筛选谓词只认这个字段。UI 接线全部在 index.jsx;这里收敛可测的
|
|
392
|
+
// 判定与文案。
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 标签筛选(v1 单选):tagId 为空串 / null / undefined 时原样返回输入列表
|
|
396
|
+
* (同一数组,不做拷贝);否则返回 tags 数组含该 id 的子集。
|
|
397
|
+
* 为什么只做单选:多标签 AND 需要「已选标签集」这份额外界面状态,收益不抵
|
|
398
|
+
* 复杂度和与已存筛选快照格式的纠缠;v1 明确不追多选 AND,管理页的「合并」
|
|
399
|
+
* 已能覆盖主要的归并诉求。
|
|
400
|
+
* 宽容性:list 非数组按空表;条目缺 tags / tags 非数组 / id 是数字都不得抛错,
|
|
401
|
+
* 统一按「不匹配」处理(id 比较走字符串化,防 host 与本地 id 类型漂移)。
|
|
402
|
+
* @param list any(应为 /sessions 返回的条目数组,条目带 tags:[tagId])
|
|
403
|
+
* @param tagId string|null|undefined ''/null 表示不按标签筛选
|
|
404
|
+
* @returns any[]
|
|
405
|
+
*/
|
|
406
|
+
export function applyTagFilter(list, tagId) {
|
|
407
|
+
const items = Array.isArray(list) ? list : []
|
|
408
|
+
if (!tagId) return items
|
|
409
|
+
const want = String(tagId)
|
|
410
|
+
return items.filter((item) => {
|
|
411
|
+
const tags = item && item.tags
|
|
412
|
+
return Array.isArray(tags) && tags.some((t) => String(t) === want)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 删除标签的确认文案。红线:必须明说「只删标签,不会删除会话」——本插件一切
|
|
417
|
+
// 标签操作都是纯元数据,但用户在「删除」字样上默认会联想到丢会话,含糊一次
|
|
418
|
+
// 就再也骗不回来(见 star/标签路由注释的同一纪律)。单测锁关键词(copy-guard
|
|
419
|
+
// 的替身:文案生成收敛为纯函数,防回归锁在 client-logic 测试里)。
|
|
420
|
+
export function tagDeleteConfirm(name) {
|
|
421
|
+
return `删除标签「${String(name == null ? '' : name) || '(未命名)'}」?只删标签,不会删除会话;打过这个标签的会话只是失去它。`
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 已存筛选快照的字段域:视图与排序必须落在已知枚举里,否则应用一个不存在的
|
|
425
|
+
// 视图会把面板带进没有 tab 的状态。workspace 只验「非空字符串」——工作区可能
|
|
426
|
+
// 已删,删了也允许恢复(筛选结果为 0 行,用户可理解);tag 允许 ''(=全部)。
|
|
427
|
+
const SAVED_VIEWS = ['all', 'active', 'archived', 'starred', 'empty', 'trash']
|
|
428
|
+
const SAVED_SORTS = ['newest', 'oldest', 'title']
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 把面板当前筛选态抓成可持久化的快照(/filters/save 的 filters 载荷)。
|
|
432
|
+
* 载荷字段名固定 {view, workspace, sort, tag};host 侧对本体不透明(opaque
|
|
433
|
+
* payload),跨版本兼容全靠这里 + filterShapeFromSaved 两端夹逼。
|
|
434
|
+
* 入参任何字段非法都先回落默认再落盘,保存的永远是干净快照。
|
|
435
|
+
*/
|
|
436
|
+
export function filterSnapshotOf({ filter = 'all', workspaceFilter = 'all', sortBy = 'newest', tagFilter = '' } = {}) {
|
|
437
|
+
return {
|
|
438
|
+
view: SAVED_VIEWS.includes(filter) ? filter : 'all',
|
|
439
|
+
workspace: typeof workspaceFilter === 'string' && workspaceFilter ? workspaceFilter : 'all',
|
|
440
|
+
sort: SAVED_SORTS.includes(sortBy) ? sortBy : 'newest',
|
|
441
|
+
tag: typeof tagFilter === 'string' ? tagFilter : '',
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 把一条已存筛选(/filters/list 的 item)还原成面板筛选形状;任何非法字段
|
|
447
|
+
* 回落默认,并在 degraded 数组里标注该字段名——UI 据此提示「部分条件已失效,
|
|
448
|
+
* 已按默认处理」,绝不静默吞掉。脏数据宽容:item/filters 缺失、为数组、字段
|
|
449
|
+
* 类型不对,统统不抛错。
|
|
450
|
+
* @returns {{view:string, workspace:string, sort:string, tag:string, degraded:string[]}}
|
|
451
|
+
*/
|
|
452
|
+
export function filterShapeFromSaved(item) {
|
|
453
|
+
const f = item && item.filters && typeof item.filters === 'object' && !Array.isArray(item.filters) ? item.filters : {}
|
|
454
|
+
const degraded = []
|
|
455
|
+
let view = typeof f.view === 'string' && SAVED_VIEWS.includes(f.view) ? f.view : 'all'
|
|
456
|
+
if (view === 'all' && f.view !== 'all') degraded.push('view')
|
|
457
|
+
let workspace = typeof f.workspace === 'string' && f.workspace ? f.workspace : 'all'
|
|
458
|
+
if (workspace === 'all' && f.workspace !== 'all') degraded.push('workspace')
|
|
459
|
+
let sort = typeof f.sort === 'string' && SAVED_SORTS.includes(f.sort) ? f.sort : 'newest'
|
|
460
|
+
if (sort === 'newest' && f.sort !== 'newest') degraded.push('sort')
|
|
461
|
+
let tag = typeof f.tag === 'string' ? f.tag : ''
|
|
462
|
+
if (tag === '' && f.tag !== '') degraded.push('tag')
|
|
463
|
+
return { view, workspace, sort, tag, degraded }
|
|
464
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// empty-scan-index.js — 空白会话精判结论的**磁盘**缓存(3.7.0 / T1 refine 解绑)。
|
|
2
|
+
//
|
|
3
|
+
// 背景:精判(对 ≤8KB 小日志按事件类型判空白)原先只在 sidebar-state 的请求路径里
|
|
4
|
+
// 同步跑,进程内 Map 缓存重启即全冷——分支标签延迟审计的根因之一。T1 把精判挪进
|
|
5
|
+
// 后台队列后,本模块让「已判定」跨重启复用:指纹未变的会话直接沿用结论,零解码。
|
|
6
|
+
//
|
|
7
|
+
// 纪律(全部沿用 title-persist-index / session-meta-cache 的既定红线):
|
|
8
|
+
// 1. 只有**真正解码成功**的结论才允许落盘;解码失败按「非空」兜底只进进程内,
|
|
9
|
+
// 绝不固化成跨重启的判定(v3.6.1 #1 的教训:一次失败被永久记下)。
|
|
10
|
+
// 2. rev: 指纹(实例内 opaque token)在 normalizeEntry 处硬性拦截,与
|
|
11
|
+
// title-persist-index.js 同一道防线。
|
|
12
|
+
// 3. 任何文件损坏 → 当空索引,绝不阻塞侧栏数据;坏 db 的代价是重判,不是错数据。
|
|
13
|
+
//
|
|
14
|
+
// 条目形状 { empty: 0|1, fingerprint: "<mtime:size>" | "sz:<n>", updatedAt }
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
export const EMPTY_SCAN_SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
const MAX_ENTRIES = 20000
|
|
22
|
+
|
|
23
|
+
export function normalizeEntry(raw) {
|
|
24
|
+
if (!raw || typeof raw !== 'object') return null
|
|
25
|
+
const empty = raw.empty === 1 || raw.empty === true ? 1 : raw.empty === 0 || raw.empty === false ? 0 : null
|
|
26
|
+
const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
|
|
27
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
28
|
+
if (empty === null || !fingerprint || fingerprint.startsWith('rev:')) return null
|
|
29
|
+
return { empty, fingerprint, updatedAt }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeEmptyScanIndex(raw) {
|
|
33
|
+
const entries = {}
|
|
34
|
+
if (raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object') {
|
|
35
|
+
for (const [id, entry] of Object.entries(raw.entries)) {
|
|
36
|
+
if (typeof id !== 'string' || !id || id.length > 200) continue
|
|
37
|
+
const normalized = normalizeEntry(entry)
|
|
38
|
+
if (normalized) entries[id] = normalized
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { schemaVersion: EMPTY_SCAN_SCHEMA_VERSION, entries }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mergeEntries(left, right) {
|
|
45
|
+
const merged = { ...left }
|
|
46
|
+
for (const [id, entry] of Object.entries(right)) merged[id] = entry
|
|
47
|
+
const ids = Object.keys(merged)
|
|
48
|
+
if (ids.length > MAX_ENTRIES) {
|
|
49
|
+
ids.sort((a, b) => (merged[a].updatedAt || 0) - (merged[b].updatedAt || 0))
|
|
50
|
+
for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete merged[id]
|
|
51
|
+
}
|
|
52
|
+
return merged
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createEmptyScanStore({ dir, file }) {
|
|
56
|
+
let cache = null
|
|
57
|
+
let chain = Promise.resolve()
|
|
58
|
+
const path = file || join(dir, 'empty-scan.json')
|
|
59
|
+
|
|
60
|
+
async function readRaw() {
|
|
61
|
+
try {
|
|
62
|
+
return normalizeEmptyScanIndex(JSON.parse(await readFile(path, 'utf8')))
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return normalizeEmptyScanIndex(null)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function enqueue(mutator) {
|
|
69
|
+
const operation = chain.then(async () => {
|
|
70
|
+
const store = cache || (cache = (await readRaw()).entries)
|
|
71
|
+
await mutator(store)
|
|
72
|
+
return store
|
|
73
|
+
})
|
|
74
|
+
chain = operation.catch(() => {})
|
|
75
|
+
return operation
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async entries() {
|
|
80
|
+
if (cache) return cache
|
|
81
|
+
cache = (await readRaw()).entries
|
|
82
|
+
return cache
|
|
83
|
+
},
|
|
84
|
+
async merge(batch) {
|
|
85
|
+
const right = {}
|
|
86
|
+
for (const [id, entry] of Object.entries(batch || {})) {
|
|
87
|
+
const normalized = normalizeEntry(entry)
|
|
88
|
+
if (normalized) right[String(id)] = normalized
|
|
89
|
+
}
|
|
90
|
+
if (!Object.keys(right).length) return false
|
|
91
|
+
await enqueue(async (store) => {
|
|
92
|
+
const next = mergeEntries(store, right)
|
|
93
|
+
await mkdir(dirname(path), { recursive: true })
|
|
94
|
+
const tmp = join(dirname(path), `.empty-scan-${process.pid}-${Date.now()}.tmp`)
|
|
95
|
+
await writeFile(tmp, JSON.stringify({ schemaVersion: EMPTY_SCAN_SCHEMA_VERSION, entries: next }), { encoding: 'utf8', mode: 0o600 })
|
|
96
|
+
await rename(tmp, path)
|
|
97
|
+
cache = next
|
|
98
|
+
})
|
|
99
|
+
return true
|
|
100
|
+
},
|
|
101
|
+
async remove(ids) {
|
|
102
|
+
const wanted = new Set((ids || []).map(String))
|
|
103
|
+
if (!wanted.size) return false
|
|
104
|
+
await enqueue(async (store) => {
|
|
105
|
+
let changed = false
|
|
106
|
+
for (const id of wanted) {
|
|
107
|
+
if (id in store) { delete store[id]; changed = true }
|
|
108
|
+
}
|
|
109
|
+
if (!changed) return
|
|
110
|
+
await mkdir(dirname(path), { recursive: true })
|
|
111
|
+
const tmp = join(dirname(path), `.empty-scan-${process.pid}-${Date.now()}.tmp`)
|
|
112
|
+
await writeFile(tmp, JSON.stringify({ schemaVersion: EMPTY_SCAN_SCHEMA_VERSION, entries: store }), { encoding: 'utf8', mode: 0o600 })
|
|
113
|
+
await rename(tmp, path)
|
|
114
|
+
})
|
|
115
|
+
return true
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|