dsh-manage-session 0.4.8 → 0.5.3
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 +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-destructive index rebuild ("整理索引") for the independent index.
|
|
3
|
+
*
|
|
4
|
+
* A shadow index file is built from scratch beside the active one; the active
|
|
5
|
+
* engine keeps serving queries the whole time. When the shadow is complete the
|
|
6
|
+
* swap is three synchronous renames (active → archive, shadow → active), then
|
|
7
|
+
* the engine reopens. Old archives are kept (bounded) and stay readable.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { mkdir, readdir, rename, rm, unlink } from 'node:fs/promises'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { SwitchIndexEngine } from './engine.ts'
|
|
14
|
+
import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
|
|
15
|
+
import type { SwitchArchiveSource } from './sync.ts'
|
|
16
|
+
|
|
17
|
+
/** The corpus reader a rebuild needs (same faces as the syncer). */
|
|
18
|
+
export interface SwitchRebuildSessionQuery {
|
|
19
|
+
listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
|
|
20
|
+
readSession(sessionId: string): Promise<{
|
|
21
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
22
|
+
events: readonly SwitchRawEvent[]
|
|
23
|
+
}>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Live rebuild progress reported to the status endpoint. */
|
|
27
|
+
export interface SwitchRebuildState {
|
|
28
|
+
state: 'idle' | 'building' | 'swapping' | 'error'
|
|
29
|
+
/** Sessions written into the shadow index so far. */
|
|
30
|
+
done: number
|
|
31
|
+
/** Sessions seen in the corpus when the build started. */
|
|
32
|
+
total: number
|
|
33
|
+
/** Epoch ms when the build started. */
|
|
34
|
+
startedAt: number
|
|
35
|
+
/** Epoch ms when the last build finished. */
|
|
36
|
+
finishedAt: number
|
|
37
|
+
/** Per-session failures during the last build. */
|
|
38
|
+
failures: { sessionId: string; error: string }[]
|
|
39
|
+
error?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Filesystem layout of one index directory. */
|
|
43
|
+
export interface SwitchIndexLayout {
|
|
44
|
+
/** Directory holding every index file. */
|
|
45
|
+
dir: string
|
|
46
|
+
/** Active index file name. */
|
|
47
|
+
active: string
|
|
48
|
+
/** Shadow file name used while building. */
|
|
49
|
+
building: string
|
|
50
|
+
/** Archive file name prefix. */
|
|
51
|
+
archivePrefix: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Default layout names. */
|
|
55
|
+
export const DEFAULT_INDEX_LAYOUT: SwitchIndexLayout = {
|
|
56
|
+
dir: '.',
|
|
57
|
+
active: 'index.sqlite',
|
|
58
|
+
building: 'index.building.sqlite',
|
|
59
|
+
archivePrefix: 'index.archive-',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Inspect the index directory for half-built leftovers from an abnormally
|
|
64
|
+
* terminated rebuild and recover:
|
|
65
|
+
* - shadow present + active present: the build never finished — the shadow
|
|
66
|
+
* is garbage (the active index kept serving) and is discarded.
|
|
67
|
+
* - shadow present + active missing: the crash hit the rename window — the
|
|
68
|
+
* newest archive is restored as the active index, the shadow discarded.
|
|
69
|
+
* Runs at host activation, before the engine opens (opening would create a
|
|
70
|
+
* fresh empty active file and mask the swap-window case).
|
|
71
|
+
*/
|
|
72
|
+
export async function recoverIndex(
|
|
73
|
+
layout: SwitchIndexLayout,
|
|
74
|
+
log?: (msg: string) => void,
|
|
75
|
+
): Promise<string[]> {
|
|
76
|
+
const actions: string[] = []
|
|
77
|
+
await mkdir(layout.dir, { recursive: true })
|
|
78
|
+
const activePath = join(layout.dir, layout.active)
|
|
79
|
+
const buildingPath = join(layout.dir, layout.building)
|
|
80
|
+
const activeExists = existsSync(activePath)
|
|
81
|
+
if (!existsSync(buildingPath)) return actions
|
|
82
|
+
const discardShadow = async (): Promise<void> => {
|
|
83
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
84
|
+
await rm(`${buildingPath}${suffix}`, { force: true })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (activeExists) {
|
|
88
|
+
await discardShadow()
|
|
89
|
+
actions.push(`discarded stale shadow index (a previous rebuild did not finish; the active index was never at risk)`)
|
|
90
|
+
} else {
|
|
91
|
+
const archives = await listArchives(layout)
|
|
92
|
+
if (archives.length > 0) {
|
|
93
|
+
const newest = archives[archives.length - 1]
|
|
94
|
+
await rename(join(layout.dir, newest), activePath)
|
|
95
|
+
actions.push(`active index was missing (crash during the swap window); restored "${newest}" as the active index`)
|
|
96
|
+
} else {
|
|
97
|
+
actions.push('no active index and no archive: the first build crashed mid-way; starting from a fresh index')
|
|
98
|
+
}
|
|
99
|
+
await discardShadow()
|
|
100
|
+
}
|
|
101
|
+
for (const action of actions) log?.(`index recovery: ${action}`)
|
|
102
|
+
return actions
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** List existing archive files, oldest first. */
|
|
106
|
+
export async function listArchives(layout: SwitchIndexLayout): Promise<string[]> {
|
|
107
|
+
if (!existsSync(layout.dir)) return []
|
|
108
|
+
const entries = await readdir(layout.dir)
|
|
109
|
+
return entries.filter(name => name.startsWith(layout.archivePrefix) && name.endsWith('.sqlite')).sort()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build a fresh index into the shadow file, then swap it in atomically.
|
|
114
|
+
*
|
|
115
|
+
* During the build the caller's active engine stays open and queryable; only
|
|
116
|
+
* the final swap briefly reopens the handle.
|
|
117
|
+
* @param activeEngine - the currently-serving engine (its file is replaced).
|
|
118
|
+
* @param layout - filesystem layout of the index directory.
|
|
119
|
+
* @param sessionQuery - corpus reader for the full rebuild.
|
|
120
|
+
* @param keepArchives - how many archive files to retain (oldest pruned).
|
|
121
|
+
* @param onProgress - optional progress callback after each session.
|
|
122
|
+
* @returns the rebuild state snapshot after completion.
|
|
123
|
+
*/
|
|
124
|
+
/** Optional observability callbacks for rebuildIndex. */
|
|
125
|
+
export interface SwitchRebuildHooks {
|
|
126
|
+
/** Progress log line sink (cordis logger bridge). */
|
|
127
|
+
log?: (msg: string) => void
|
|
128
|
+
/** State-mutation sink: called after every change so index-status sees
|
|
129
|
+
* live progress (the "0/?" bug was state cloned only at completion). */
|
|
130
|
+
onState?: (state: SwitchRebuildState) => void
|
|
131
|
+
/**
|
|
132
|
+
* 文件级兜底读取器(与同步器同语义):readSession 对归档/服务面退化会话
|
|
133
|
+
* 失败时改读转录文件;返回 undefined 表示文件也不可用(记失败)。
|
|
134
|
+
*/
|
|
135
|
+
readSessionFromFile?: (sessionId: string) => Promise<{
|
|
136
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
137
|
+
events: readonly SwitchRawEvent[]
|
|
138
|
+
} | undefined>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Sessions per batched transaction (one fsync checkpoint per chunk). */
|
|
142
|
+
const REBUILD_CHUNK = 50
|
|
143
|
+
|
|
144
|
+
export async function rebuildIndex(
|
|
145
|
+
activeEngine: SwitchIndexEngine,
|
|
146
|
+
layout: SwitchIndexLayout,
|
|
147
|
+
sessionQuery: SwitchRebuildSessionQuery,
|
|
148
|
+
keepArchives: number,
|
|
149
|
+
onProgress?: (done: number, total: number) => void,
|
|
150
|
+
archiveSource?: () => SwitchArchiveSource | undefined,
|
|
151
|
+
hooks?: SwitchRebuildHooks,
|
|
152
|
+
): Promise<SwitchRebuildState> {
|
|
153
|
+
const startedMs = Date.now()
|
|
154
|
+
let docsWritten = 0
|
|
155
|
+
const emit = (): void => { hooks?.onState?.({ ...state }) }
|
|
156
|
+
const rate = (): string => {
|
|
157
|
+
const secs = Math.max(0.001, (Date.now() - startedMs) / 1000)
|
|
158
|
+
return `${(state.done / secs).toFixed(1)} sess/s`
|
|
159
|
+
}
|
|
160
|
+
const eta = (): string => {
|
|
161
|
+
if (state.total <= 0 || state.done === 0) return '?'
|
|
162
|
+
const secs = (Date.now() - startedMs) / 1000
|
|
163
|
+
return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`
|
|
164
|
+
}
|
|
165
|
+
const state: SwitchRebuildState = {
|
|
166
|
+
state: 'building',
|
|
167
|
+
done: 0,
|
|
168
|
+
total: 0,
|
|
169
|
+
startedAt: Date.now(),
|
|
170
|
+
finishedAt: 0,
|
|
171
|
+
failures: [],
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`)
|
|
175
|
+
emit()
|
|
176
|
+
await mkdir(layout.dir, { recursive: true })
|
|
177
|
+
const buildingPath = join(layout.dir, layout.building)
|
|
178
|
+
// A stale shadow from a crashed run is discarded.
|
|
179
|
+
if (existsSync(buildingPath)) await unlink(buildingPath)
|
|
180
|
+
const shadow = new SwitchIndexEngine({ path: buildingPath })
|
|
181
|
+
await shadow.open()
|
|
182
|
+
try {
|
|
183
|
+
const records = await sessionQuery.listSessions()
|
|
184
|
+
state.total = records.length
|
|
185
|
+
hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`)
|
|
186
|
+
emit()
|
|
187
|
+
// R1 合表语义:归档会话**同样全量读内容**入影子索引(readSession 对归档是
|
|
188
|
+
// replay-validate 不激活);构建完成后按官方归档集合统一翻 flag。
|
|
189
|
+
const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? [])
|
|
190
|
+
const readLog = async (header: { id: string; version: number; createdAt?: number; cwd?: string }) => {
|
|
191
|
+
let log: { session: { id: string; version: number; createdAt?: number; cwd?: string }; events: readonly SwitchRawEvent[] }
|
|
192
|
+
try {
|
|
193
|
+
log = await sessionQuery.readSession(header.id)
|
|
194
|
+
} catch (serviceError) {
|
|
195
|
+
if (hooks?.readSessionFromFile === undefined) throw serviceError
|
|
196
|
+
const fromFile = await hooks.readSessionFromFile(header.id)
|
|
197
|
+
if (fromFile === undefined) throw serviceError
|
|
198
|
+
log = fromFile
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
sessionId: header.id,
|
|
202
|
+
version: log.session.version,
|
|
203
|
+
// 标题基础层随重建落行(beta.5 教训:重建不抽标题 + 版本对齐后同步
|
|
204
|
+
// 永不重读 = 全库标题永久空缺)。
|
|
205
|
+
title: extractTitleFromEvents(log.events),
|
|
206
|
+
cwd: log.session.cwd ?? '',
|
|
207
|
+
updatedAt: log.session.createdAt ?? 0,
|
|
208
|
+
archived: archivedSet.has(header.id),
|
|
209
|
+
events: log.events,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
|
|
213
|
+
const chunk = records.slice(i, i + REBUILD_CHUNK)
|
|
214
|
+
const chunkStart = Date.now()
|
|
215
|
+
const reads: Array<{ sessionId: string; version: number; cwd: string; updatedAt: number; archived: boolean; events: readonly SwitchRawEvent[] }> = []
|
|
216
|
+
for (const record of chunk) {
|
|
217
|
+
try {
|
|
218
|
+
reads.push(await readLog(record.header))
|
|
219
|
+
} catch (err) {
|
|
220
|
+
state.failures.push({ sessionId: record.header.id, error: String(err instanceof Error ? err.message : err) })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// One transaction per chunk = one checkpoint; a chunk-level failure
|
|
224
|
+
// replays session-by-session to keep the bad session isolated.
|
|
225
|
+
try {
|
|
226
|
+
shadow.runBatched(() => {
|
|
227
|
+
for (const item of reads) {
|
|
228
|
+
shadow.upsertSession(item)
|
|
229
|
+
docsWritten += item.events.length
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
} catch (err) {
|
|
233
|
+
hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`)
|
|
234
|
+
for (const item of reads) {
|
|
235
|
+
try { shadow.upsertSession(item); docsWritten += item.events.length }
|
|
236
|
+
catch (e2) { state.failures.push({ sessionId: item.sessionId, error: String(e2 instanceof Error ? e2.message : e2) }) }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
state.done = Math.min(state.total, i + chunk.length)
|
|
240
|
+
onProgress?.(state.done, state.total)
|
|
241
|
+
emit()
|
|
242
|
+
hooks?.log?.(`rebuild ${state.done}/${state.total} (${Math.round((state.done / Math.max(1, state.total)) * 100)}%) ${rate()} elapsed ${Math.round((Date.now() - startedMs) / 1000)}s eta ${eta()} chunk ${Date.now() - chunkStart}ms`)
|
|
243
|
+
}
|
|
244
|
+
// 归档标记统一翻转(upsertSession 不动已有行的 flag,此处是影子库的最终真值)。
|
|
245
|
+
shadow.setArchived(archivedSet)
|
|
246
|
+
shadow.close()
|
|
247
|
+
} catch (error) {
|
|
248
|
+
shadow.close()
|
|
249
|
+
await unlink(buildingPath).catch(() => {})
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
state.state = 'swapping'
|
|
254
|
+
emit()
|
|
255
|
+
hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`)
|
|
256
|
+
// The swap window is three synchronous renames; queries fail only inside it.
|
|
257
|
+
const activePath = join(layout.dir, layout.active)
|
|
258
|
+
if (existsSync(activePath)) {
|
|
259
|
+
activeEngine.close()
|
|
260
|
+
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`))
|
|
261
|
+
}
|
|
262
|
+
await rename(buildingPath, activePath)
|
|
263
|
+
await activeEngine.open()
|
|
264
|
+
await pruneArchives(layout, keepArchives)
|
|
265
|
+
state.finishedAt = Date.now()
|
|
266
|
+
state.state = 'idle'
|
|
267
|
+
emit()
|
|
268
|
+
hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1000).toFixed(1)}s, archives pruned to ${keepArchives}`)
|
|
269
|
+
} catch (err) {
|
|
270
|
+
state.state = 'error'
|
|
271
|
+
state.error = String(err instanceof Error ? err.message : err)
|
|
272
|
+
hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`)
|
|
273
|
+
emit()
|
|
274
|
+
// Leave the active engine usable if the swap itself failed before rename.
|
|
275
|
+
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
|
|
276
|
+
}
|
|
277
|
+
return state
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One doc-level record the snapshot importer feeds in. */
|
|
281
|
+
export interface SwitchImportRecord {
|
|
282
|
+
sessionId: string
|
|
283
|
+
version: number
|
|
284
|
+
title?: string
|
|
285
|
+
/** 归档标记(旧快照缺省活跃)。 */
|
|
286
|
+
archived?: boolean
|
|
287
|
+
docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Import doc-level records into the shadow file and swap it in (same swap path). */
|
|
291
|
+
export async function importIntoIndex(
|
|
292
|
+
activeEngine: SwitchIndexEngine,
|
|
293
|
+
layout: SwitchIndexLayout,
|
|
294
|
+
records: readonly SwitchImportRecord[],
|
|
295
|
+
keepArchives: number,
|
|
296
|
+
): Promise<SwitchRebuildState> {
|
|
297
|
+
const state: SwitchRebuildState = {
|
|
298
|
+
state: 'building',
|
|
299
|
+
done: 0,
|
|
300
|
+
total: records.length,
|
|
301
|
+
startedAt: Date.now(),
|
|
302
|
+
finishedAt: 0,
|
|
303
|
+
failures: [],
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await mkdir(layout.dir, { recursive: true })
|
|
307
|
+
const buildingPath = join(layout.dir, layout.building)
|
|
308
|
+
if (existsSync(buildingPath)) await unlink(buildingPath)
|
|
309
|
+
const shadow = new SwitchIndexEngine({ path: buildingPath })
|
|
310
|
+
await shadow.open()
|
|
311
|
+
try {
|
|
312
|
+
for (const record of records) {
|
|
313
|
+
try {
|
|
314
|
+
shadow.importSessionDocs({
|
|
315
|
+
sessionId: record.sessionId,
|
|
316
|
+
version: record.version,
|
|
317
|
+
title: record.title ?? '',
|
|
318
|
+
archived: record.archived === true,
|
|
319
|
+
docs: record.docs,
|
|
320
|
+
})
|
|
321
|
+
} catch (err) {
|
|
322
|
+
state.failures.push({ sessionId: record.sessionId, error: String(err instanceof Error ? err.message : err) })
|
|
323
|
+
}
|
|
324
|
+
state.done += 1
|
|
325
|
+
}
|
|
326
|
+
shadow.close()
|
|
327
|
+
} catch (error) {
|
|
328
|
+
shadow.close()
|
|
329
|
+
await unlink(buildingPath).catch(() => {})
|
|
330
|
+
throw error
|
|
331
|
+
}
|
|
332
|
+
state.state = 'swapping'
|
|
333
|
+
const activePath = join(layout.dir, layout.active)
|
|
334
|
+
if (existsSync(activePath)) {
|
|
335
|
+
activeEngine.close()
|
|
336
|
+
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`))
|
|
337
|
+
}
|
|
338
|
+
await rename(buildingPath, activePath)
|
|
339
|
+
await activeEngine.open()
|
|
340
|
+
await pruneArchives(layout, keepArchives)
|
|
341
|
+
state.finishedAt = Date.now()
|
|
342
|
+
state.state = 'idle'
|
|
343
|
+
} catch (err) {
|
|
344
|
+
state.state = 'error'
|
|
345
|
+
state.error = String(err instanceof Error ? err.message : err)
|
|
346
|
+
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
|
|
347
|
+
}
|
|
348
|
+
return state
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Remove the oldest archives beyond the retention bound. */
|
|
352
|
+
async function pruneArchives(layout: SwitchIndexLayout, keep: number): Promise<void> {
|
|
353
|
+
const archives = await listArchives(layout)
|
|
354
|
+
const excess = archives.length - Math.max(0, keep)
|
|
355
|
+
for (let i = 0; i < excess; i += 1) {
|
|
356
|
+
await unlink(join(layout.dir, archives[i])).catch(() => {})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Resolve the index directory that hosts the independent index files:
|
|
362
|
+
* an explicit override wins, otherwise a plugin-owned directory under the
|
|
363
|
+
* user's home (never the official index path).
|
|
364
|
+
*/
|
|
365
|
+
export function resolveIndexDir(preferred?: string): string {
|
|
366
|
+
if (preferred !== undefined && preferred.trim() !== '') return preferred
|
|
367
|
+
return join(homedir(), '.dsh-switch-search')
|
|
368
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite schema for the independent switch-search full-text index.
|
|
3
|
+
*
|
|
4
|
+
* Fully separate from the official session-query derived index: a different
|
|
5
|
+
* application id, a different schema version lineage, and a plugin-owned file
|
|
6
|
+
* path. The official file (application id 0x44534851) is never opened here.
|
|
7
|
+
*/
|
|
8
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
9
|
+
import { mkdir } from 'node:fs/promises'
|
|
10
|
+
import { dirname, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** Current switch-search index schema version. Incompatible versions reset in place.
|
|
13
|
+
* v5: 归档会话正文入索引(v4 库里归档行是 header-only,语义不兼容 → 就地重置重建)。 */
|
|
14
|
+
export const SWITCH_SEARCH_SCHEMA_VERSION = 5
|
|
15
|
+
|
|
16
|
+
/** Application id marking files owned by this plugin's index (ASCII "SWIS"). */
|
|
17
|
+
export const SWITCH_SEARCH_APPLICATION_ID = 0x53574954
|
|
18
|
+
|
|
19
|
+
/** The official derived-index application id this plugin must never touch. */
|
|
20
|
+
export const OFFICIAL_SESSION_QUERY_APPLICATION_ID = 0x44534851
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which SQLite driver served the handle (better-sqlite3 when the optional
|
|
24
|
+
* dependency installed, node:sqlite otherwise). Surfaced in logs and
|
|
25
|
+
* index-status so rebuild speed can be compared across drivers.
|
|
26
|
+
*/
|
|
27
|
+
export type SwitchSqliteDriver = 'better-sqlite3' | 'node:sqlite'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open the raw handle: better-sqlite3 (synchronous, faster statement
|
|
31
|
+
* dispatch) when the optional dependency is present, node:sqlite otherwise.
|
|
32
|
+
* Both expose the same prepare/exec/close shape this plugin uses.
|
|
33
|
+
*/
|
|
34
|
+
async function openRawHandle(actual: string): Promise<{ db: DatabaseSync; driver: SwitchSqliteDriver }> {
|
|
35
|
+
try {
|
|
36
|
+
// Non-literal specifier keeps typecheck green when the optional native
|
|
37
|
+
// dependency is absent; tsdown externalizes it via neverBundle.
|
|
38
|
+
const spec = 'better-sqlite3'
|
|
39
|
+
const mod = (await import(spec)) as unknown as { default?: new (p: string) => DatabaseSync }
|
|
40
|
+
const Ctor = mod.default ?? (mod as unknown as new (p: string) => DatabaseSync)
|
|
41
|
+
return { db: new Ctor(actual), driver: 'better-sqlite3' }
|
|
42
|
+
} catch {
|
|
43
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
44
|
+
return { db: new DatabaseSync(actual), driver: 'node:sqlite' }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open, validate, and initialize one switch-search index file.
|
|
50
|
+
* Missing directories and files are created; a file that belongs to another
|
|
51
|
+
* application (including the official session-query index) is refused.
|
|
52
|
+
* @param path - absolute path to the index file.
|
|
53
|
+
* @returns initialized database handle owned by the caller, plus the driver.
|
|
54
|
+
*/
|
|
55
|
+
export async function openIndexDatabase(path: string): Promise<{ db: DatabaseSync; driver: SwitchSqliteDriver }> {
|
|
56
|
+
const actual = resolve(path)
|
|
57
|
+
await mkdir(dirname(actual), { recursive: true })
|
|
58
|
+
const { db, driver } = await openRawHandle(actual)
|
|
59
|
+
try {
|
|
60
|
+
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
|
|
61
|
+
const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
|
|
62
|
+
if (applicationId === OFFICIAL_SESSION_QUERY_APPLICATION_ID) {
|
|
63
|
+
throw new Error(`switch-search: "${actual}" is the official session-query index, refusing to open it`)
|
|
64
|
+
}
|
|
65
|
+
if (applicationId !== 0 && applicationId !== SWITCH_SEARCH_APPLICATION_ID) {
|
|
66
|
+
throw new Error(`switch-search: database at "${actual}" belongs to another application`)
|
|
67
|
+
}
|
|
68
|
+
if (applicationId === SWITCH_SEARCH_APPLICATION_ID && version !== SWITCH_SEARCH_SCHEMA_VERSION) {
|
|
69
|
+
resetSchema(db)
|
|
70
|
+
}
|
|
71
|
+
db.exec(`PRAGMA journal_mode = wal`)
|
|
72
|
+
ensureSchema(db)
|
|
73
|
+
// Write-throughput pragmas: NORMAL is crash-safe under WAL (fsync moves
|
|
74
|
+
// to checkpoints), MEMORY temp + a 64MB page cache mostly serve rebuilds.
|
|
75
|
+
db.exec(`PRAGMA synchronous = NORMAL`)
|
|
76
|
+
db.exec(`PRAGMA temp_store = MEMORY`)
|
|
77
|
+
db.exec(`PRAGMA cache_size = -65536`)
|
|
78
|
+
return { db, driver }
|
|
79
|
+
} catch (error: unknown) {
|
|
80
|
+
db.close()
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Reset an incompatible database in place, preserving the owning application id. */
|
|
86
|
+
function resetSchema(db: DatabaseSync): void {
|
|
87
|
+
db.exec(`
|
|
88
|
+
PRAGMA writable_schema = OFF;
|
|
89
|
+
PRAGMA journal_mode = delete;
|
|
90
|
+
`)
|
|
91
|
+
for (const row of db.prepare(`
|
|
92
|
+
SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
93
|
+
`).all() as { name: string }[]) {
|
|
94
|
+
db.exec(`DROP TABLE IF EXISTS "${row.name.replace(/"/g, '""')}"`)
|
|
95
|
+
}
|
|
96
|
+
db.exec(`PRAGMA user_version = 0`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create the persistent schema when absent. */
|
|
100
|
+
function ensureSchema(db: DatabaseSync): void {
|
|
101
|
+
db.exec(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
103
|
+
key TEXT PRIMARY KEY,
|
|
104
|
+
value TEXT NOT NULL
|
|
105
|
+
);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
107
|
+
session_id TEXT PRIMARY KEY,
|
|
108
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
title TEXT NOT NULL DEFAULT '',
|
|
110
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
111
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
indexed_at INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
archived INTEGER NOT NULL DEFAULT 0
|
|
114
|
+
);
|
|
115
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
116
|
+
doc_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
117
|
+
session_id TEXT NOT NULL,
|
|
118
|
+
seq INTEGER NOT NULL,
|
|
119
|
+
type TEXT NOT NULL,
|
|
120
|
+
surface TEXT NOT NULL,
|
|
121
|
+
time INTEGER NOT NULL,
|
|
122
|
+
text TEXT NOT NULL,
|
|
123
|
+
index_text TEXT NOT NULL DEFAULT ''
|
|
124
|
+
);
|
|
125
|
+
CREATE UNIQUE INDEX IF NOT EXISTS docs_session_seq ON docs(session_id, seq);
|
|
126
|
+
CREATE INDEX IF NOT EXISTS docs_session ON docs(session_id);
|
|
127
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
128
|
+
index_text,
|
|
129
|
+
content = 'docs',
|
|
130
|
+
content_rowid = 'doc_id',
|
|
131
|
+
tokenize = 'unicode61'
|
|
132
|
+
);
|
|
133
|
+
`)
|
|
134
|
+
db.exec(`PRAGMA application_id = ${SWITCH_SEARCH_APPLICATION_ID}`)
|
|
135
|
+
db.exec(`PRAGMA user_version = ${SWITCH_SEARCH_SCHEMA_VERSION}`)
|
|
136
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Lines snapshot migration for the independent switch-search index.
|
|
3
|
+
*
|
|
4
|
+
* The snapshot is the "translation" seam: one JSON object per line, carrying a
|
|
5
|
+
* session's indexed documents in a self-contained shape. Export writes the
|
|
6
|
+
* active index out; import parses the lines and rebuilds them into a fresh
|
|
7
|
+
* shadow index that swaps in atomically (same swap path as 整理).
|
|
8
|
+
*/
|
|
9
|
+
import type { SwitchIndexEngine } from './engine.ts'
|
|
10
|
+
import type { SwitchImportRecord } from './rebuild.ts'
|
|
11
|
+
|
|
12
|
+
/** Snapshot format header line. */
|
|
13
|
+
export interface SwitchSnapshotHeader {
|
|
14
|
+
v: 1
|
|
15
|
+
kind: 'dsh-switch-search-snapshot'
|
|
16
|
+
exportedAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One snapshot line: a session and its indexed documents. */
|
|
20
|
+
export interface SwitchSnapshotRecord {
|
|
21
|
+
v: 1
|
|
22
|
+
sessionId: string
|
|
23
|
+
version: number
|
|
24
|
+
title: string
|
|
25
|
+
/** 归档标记随快照往返;旧快照缺省按活跃导入。 */
|
|
26
|
+
archived?: boolean
|
|
27
|
+
docs: {
|
|
28
|
+
seq: number
|
|
29
|
+
type: string
|
|
30
|
+
surface: string
|
|
31
|
+
time: number
|
|
32
|
+
text: string
|
|
33
|
+
}[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Render the snapshot header line. */
|
|
37
|
+
export function snapshotHeader(): string {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
v: 1,
|
|
40
|
+
kind: 'dsh-switch-search-snapshot',
|
|
41
|
+
exportedAt: Date.now(),
|
|
42
|
+
} satisfies SwitchSnapshotHeader)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Export the whole index (active AND archived) as a JSON Lines string.
|
|
47
|
+
* 归档行带 archived 标记一起导出——快照是完整备份,不是活跃子集。
|
|
48
|
+
* @param engine - the open active engine.
|
|
49
|
+
* @returns the complete snapshot text (header line first).
|
|
50
|
+
*/
|
|
51
|
+
export function exportSnapshot(engine: SwitchIndexEngine): string {
|
|
52
|
+
const lines: string[] = [snapshotHeader()]
|
|
53
|
+
for (const session of engine.listIndexedSessions()) {
|
|
54
|
+
lines.push(JSON.stringify({
|
|
55
|
+
v: 1,
|
|
56
|
+
sessionId: session.sessionId,
|
|
57
|
+
version: session.version,
|
|
58
|
+
title: session.title,
|
|
59
|
+
...(session.archived ? { archived: true } : {}),
|
|
60
|
+
docs: engine.exportSessionDocs(session.sessionId),
|
|
61
|
+
} satisfies SwitchSnapshotRecord))
|
|
62
|
+
}
|
|
63
|
+
return `${lines.join('\n')}\n`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One parsed snapshot: importable records plus skipped-line count. */
|
|
67
|
+
export interface SwitchParsedSnapshot {
|
|
68
|
+
records: SwitchImportRecord[]
|
|
69
|
+
skipped: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse a snapshot's JSON Lines text into importable records.
|
|
74
|
+
* The header line and any malformed line are skipped, not fatal.
|
|
75
|
+
* @param text - raw snapshot text.
|
|
76
|
+
* @returns importable records and how many lines were skipped.
|
|
77
|
+
*/
|
|
78
|
+
export function parseSnapshot(text: string): SwitchParsedSnapshot {
|
|
79
|
+
const records: SwitchImportRecord[] = []
|
|
80
|
+
let skipped = 0
|
|
81
|
+
for (const line of text.split(/\r?\n/)) {
|
|
82
|
+
if (line.trim() === '') continue
|
|
83
|
+
let value: unknown
|
|
84
|
+
try {
|
|
85
|
+
value = JSON.parse(line)
|
|
86
|
+
} catch {
|
|
87
|
+
skipped += 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
91
|
+
skipped += 1
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const record = value as Record<string, unknown>
|
|
95
|
+
// The snapshot header line is recognized and skipped silently.
|
|
96
|
+
if (record['kind'] === 'dsh-switch-search-snapshot') continue
|
|
97
|
+
const sessionId = record['sessionId']
|
|
98
|
+
const rawDocs = record['docs']
|
|
99
|
+
if (typeof sessionId !== 'string' || sessionId === '' || !Array.isArray(rawDocs)) {
|
|
100
|
+
skipped += 1
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const docs: { seq: number; type: string; surface: string; time: number; text: string }[] = []
|
|
104
|
+
let docsValid = true
|
|
105
|
+
for (const rawDoc of rawDocs) {
|
|
106
|
+
if (typeof rawDoc !== 'object' || rawDoc === null
|
|
107
|
+
|| typeof (rawDoc as { seq?: unknown }).seq !== 'number'
|
|
108
|
+
|| typeof (rawDoc as { type?: unknown }).type !== 'string'
|
|
109
|
+
|| typeof (rawDoc as { text?: unknown }).text !== 'string') {
|
|
110
|
+
docsValid = false
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
const doc = rawDoc as { seq: number; type: string; surface?: unknown; time?: unknown; text: string }
|
|
114
|
+
docs.push({
|
|
115
|
+
seq: doc.seq,
|
|
116
|
+
type: doc.type,
|
|
117
|
+
surface: typeof doc.surface === 'string' ? doc.surface : 'current',
|
|
118
|
+
time: typeof doc.time === 'number' ? doc.time : 0,
|
|
119
|
+
text: doc.text,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
if (!docsValid) {
|
|
123
|
+
skipped += 1
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
records.push({
|
|
127
|
+
sessionId,
|
|
128
|
+
version: typeof record['version'] === 'number' ? record['version'] : 0,
|
|
129
|
+
title: typeof record['title'] === 'string' ? record['title'] : undefined,
|
|
130
|
+
archived: record['archived'] === true,
|
|
131
|
+
docs,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return { records, skipped }
|
|
135
|
+
}
|