dsh-sessions-manager 3.4.0 → 3.4.2

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,137 @@
1
+ // title-persist-index.js — 会话标题/元数据的**磁盘**小索引(P4,issue #1)。
2
+ //
3
+ // metaCache(session-meta-cache.js)解决的是「进程内重复解码」;本模块解决
4
+ // 的是冷启动:插件重启后内存缓存为空,第一次列表构建仍要全库解码一次。
5
+ // 把解码出的元数据连同文件指纹原子写进一个 JSON 索引,重启后列表只需
6
+ // 一次索引读 + 指纹比对,指纹没变的会话零解码。
7
+ //
8
+ // 结构仿 trash 的原子写索引:{ schemaVersion, entries: { [sessionId]: entry } }
9
+ // entry = { title, cwd, createdAt, fingerprint, updatedAt }
10
+ // fingerprint 即 session-meta-cache.js 的 fingerprintOf(stat) 产出
11
+ // ("<mtimeMs>:<size>"),比对一致即可信任条目内容。
12
+ //
13
+ // 写入时机由调用方决定(列表构建收尾批量回写、purge 时清理),本模块只
14
+ // 提供:读取缓存、合并写入(串行化 + 原子替换)、按 id 删除。任何文件
15
+ // 损坏都按空索引处理,绝不阻塞列表构建。
16
+ //
17
+ // 单实例假设:一个进程内只 apply 一个插件实例(生产即如此),实例间的
18
+ // 内存副本不互相同步——跨「重启」以落盘内容为准(测试亦按此断言)。
19
+
20
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ export const TITLE_INDEX_SCHEMA_VERSION = 1
24
+
25
+ const MAX_ENTRIES = 20000
26
+
27
+ // 条目只保留可序列化且对列表有用的字段;指纹缺失的条目无法校验,直接丢弃——
28
+ // 宁可下次重解码,也不能把无法失效的数据当真。
29
+ export function normalizeEntry(raw) {
30
+ if (!raw || typeof raw !== 'object') return null
31
+ const title = typeof raw.title === 'string' ? raw.title : null
32
+ const cwd = typeof raw.cwd === 'string' ? raw.cwd : null
33
+ const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
34
+ const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
35
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
36
+ if (!fingerprint || (!title && !cwd)) return null
37
+ return { title, cwd, createdAt, fingerprint, updatedAt }
38
+ }
39
+
40
+ export function normalizeTitleIndex(raw) {
41
+ const entries = {}
42
+ if (raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object') {
43
+ for (const [id, entry] of Object.entries(raw.entries)) {
44
+ if (typeof id !== 'string' || !id || id.length > 200) continue
45
+ const normalized = normalizeEntry(entry)
46
+ if (normalized) entries[id] = normalized
47
+ }
48
+ }
49
+ return { schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries }
50
+ }
51
+
52
+ // 纯合并:right 覆盖 left 同 id 条目;截断到 MAX_ENTRIES(保留 updatedAt 新的)。
53
+ export function mergeEntries(left, right) {
54
+ const merged = { ...left }
55
+ for (const [id, entry] of Object.entries(right)) merged[id] = entry
56
+ const ids = Object.keys(merged)
57
+ if (ids.length > MAX_ENTRIES) {
58
+ ids.sort((a, b) => (merged[a].updatedAt || 0) - (merged[b].updatedAt || 0))
59
+ for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete merged[id]
60
+ }
61
+ return merged
62
+ }
63
+
64
+ export function createTitleIndexStore({ dir, file }) {
65
+ let cache = null
66
+ let chain = Promise.resolve()
67
+ const path = file || join(dir, 'title-index.json')
68
+
69
+ async function readRaw() {
70
+ try {
71
+ return normalizeTitleIndex(JSON.parse(await readFile(path, 'utf8')))
72
+ } catch (e) {
73
+ return normalizeTitleIndex(null)
74
+ }
75
+ }
76
+
77
+ // 所有写操作串行化(仿 trash 的 mutate 队列),避免并发 merge 互相覆盖。
78
+ function enqueue(mutator) {
79
+ const operation = chain.then(async () => {
80
+ const store = cache || (cache = (await readRaw()).entries)
81
+ await mutator(store)
82
+ return store
83
+ })
84
+ chain = operation.catch(() => {})
85
+ return operation
86
+ }
87
+
88
+ return {
89
+ // 只读:内存优先,未加载过才落盘一次。绝不抛错。
90
+ async entries() {
91
+ if (cache) return cache
92
+ cache = (await readRaw()).entries
93
+ return cache
94
+ },
95
+ // 批量合并写入(原子替换)。失败静默:索引只是加速器,坏了下次重解码。
96
+ async merge(batch) {
97
+ const right = {}
98
+ for (const [id, entry] of Object.entries(batch || {})) {
99
+ const normalized = normalizeEntry(entry)
100
+ if (normalized) right[String(id)] = normalized
101
+ }
102
+ if (!Object.keys(right).length) return false
103
+ await enqueue(async (store) => {
104
+ const next = mergeEntries(store, right)
105
+ await mkdir(dirname(path), { recursive: true })
106
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
107
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: next }), { encoding: 'utf8', mode: 0o600 })
108
+ await rename(tmp, path)
109
+ cache = next
110
+ })
111
+ return true
112
+ },
113
+ async remove(ids) {
114
+ const wanted = new Set((ids || []).map(String))
115
+ if (!wanted.size) return false
116
+ await enqueue(async (store) => {
117
+ let changed = false
118
+ for (const id of wanted) {
119
+ if (id in store) { delete store[id]; changed = true }
120
+ }
121
+ if (!changed) return
122
+ await mkdir(dirname(path), { recursive: true })
123
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
124
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: store }), { encoding: 'utf8', mode: 0o600 })
125
+ await rename(tmp, path)
126
+ })
127
+ return true
128
+ },
129
+ }
130
+ }
131
+
132
+ // 判定持久条目能否当作当前日志的解码结果:指纹一致即可(与内存缓存同一标准)。
133
+ export function persistEntryUsable(entry, stat) {
134
+ const normalized = normalizeEntry(entry)
135
+ if (!normalized || !stat) return null
136
+ return normalized.fingerprint === stat.fingerprint ? normalized : null
137
+ }