dsh-manage-session 0.5.7 → 2.0.4
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.ja.md +33 -229
- package/CHANGELOG.ko.md +33 -229
- package/CHANGELOG.md +33 -363
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +41 -46
- package/README.ja.md +33 -57
- package/README.ko.md +32 -55
- package/README.md +64 -64
- package/cordis.patch.yml +19 -16
- package/dsh.plugin.json +4 -4
- package/lib/client.js +661 -3082
- package/lib/index.d.ts +79 -970
- package/lib/index.js +249 -2669
- package/locale/de.json +2 -2
- package/locale/en.json +2 -2
- package/locale/es.json +2 -2
- package/locale/fr.json +2 -2
- package/locale/it.json +2 -2
- package/locale/ja.json +2 -2
- package/locale/ko.json +2 -2
- package/locale/ru.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +15 -25
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -213
- package/src/client/card.tsx +154 -154
- package/src/client/health-panel.tsx +49 -49
- package/src/client/host-api.ts +72 -6
- package/src/client/index.ts +594 -503
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -0
- package/src/client/{search/manage.tsx → manage.tsx} +430 -404
- package/src/client/panel.tsx +131 -129
- package/src/client/session-menu-legacy.ts +2 -3
- package/src/client/session-menu.tsx +5 -5
- package/src/config.ts +43 -99
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/repair.ts +2 -2
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/history/tombstones.ts +3 -3
- package/src/host/registry/archive-registry.ts +317 -317
- package/src/host/settings-bridge.ts +222 -264
- package/src/index.ts +66 -694
- package/src/client/search/card.tsx +0 -563
- package/src/client/search/host-api.ts +0 -213
- package/src/client/search/locales.ts +0 -861
- package/src/client/search/locate.ts +0 -330
- package/src/client/search/panel.tsx +0 -940
- package/src/client/search/platform.ts +0 -136
- package/src/host/index/archive-source.ts +0 -90
- package/src/host/index/drs-source.ts +0 -211
- package/src/host/index/engine.ts +0 -699
- package/src/host/index/extract.ts +0 -272
- package/src/host/index/peers.ts +0 -62
- package/src/host/index/rebuild.ts +0 -432
- package/src/host/index/schema.ts +0 -136
- package/src/host/index/snapshot.ts +0 -135
- package/src/host/index/sync.ts +0 -413
|
@@ -1,432 +0,0 @@
|
|
|
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
|
-
/**
|
|
145
|
-
* 主文件旁的 WAL sidecar(`-wal`/`-shm`)随主文件一并迁走;无目的地时
|
|
146
|
-
* (孤儿残留)直接 force rm。rename 失败降级删除——sidecar 清理永远不能
|
|
147
|
-
* 阻断 swap 本身。
|
|
148
|
-
*/
|
|
149
|
-
async function moveOrDeleteSidecars(basePath: string, destBasePath: string | null): Promise<void> {
|
|
150
|
-
for (const suffix of ['-wal', '-shm']) {
|
|
151
|
-
const src = basePath + suffix
|
|
152
|
-
if (!existsSync(src)) continue
|
|
153
|
-
if (destBasePath !== null) {
|
|
154
|
-
try {
|
|
155
|
-
await rename(src, destBasePath + suffix)
|
|
156
|
-
continue
|
|
157
|
-
} catch { /* 降级删除 */ }
|
|
158
|
-
}
|
|
159
|
-
await rm(src, { force: true }).catch(() => {})
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* The swap window: synchronous renames only; queries fail only inside it.
|
|
165
|
-
*
|
|
166
|
-
* issue #1 评论的损坏根因:只搬主文件时,旧库残留的 -wal/-shm 会被同名顶替
|
|
167
|
-
* 的新库当自己的 WAL 重放,页号错配 → malformed。因此 sidecar 必须随主文件
|
|
168
|
-
* 一并迁 archive(干净 close 本会 checkpoint 自删,这里是异常关闭的兜底)。
|
|
169
|
-
* swap 后跑 integrity_check:非 ok 则把损坏库改名 .corrupt、从刚落的 archive
|
|
170
|
-
* 回滚重开,并抛错让调用方置 error(把「索引损坏」降级为「回到上次快照」)。
|
|
171
|
-
* 导出供测试直接驱动 swap 窗口(多连接场景在 Windows 上 rename 必 EBUSY,
|
|
172
|
-
* 无法经 rebuildIndex 全链路确定性复现)。
|
|
173
|
-
*/
|
|
174
|
-
export async function swapShadowIntoActive(
|
|
175
|
-
activeEngine: SwitchIndexEngine,
|
|
176
|
-
layout: SwitchIndexLayout,
|
|
177
|
-
log?: (msg: string) => void,
|
|
178
|
-
): Promise<void> {
|
|
179
|
-
const activePath = join(layout.dir, layout.active)
|
|
180
|
-
const buildingPath = join(layout.dir, layout.building)
|
|
181
|
-
let archivePath: string | null = null
|
|
182
|
-
if (existsSync(activePath)) {
|
|
183
|
-
activeEngine.close()
|
|
184
|
-
archivePath = join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`)
|
|
185
|
-
await rename(activePath, archivePath)
|
|
186
|
-
await moveOrDeleteSidecars(activePath, archivePath)
|
|
187
|
-
} else {
|
|
188
|
-
// 主文件不在但 sidecar 残留(崩溃窗口):新库开在孤儿 WAL 上同样错配。
|
|
189
|
-
await moveOrDeleteSidecars(activePath, null)
|
|
190
|
-
}
|
|
191
|
-
await rename(buildingPath, activePath)
|
|
192
|
-
await activeEngine.open()
|
|
193
|
-
const integrity = activeEngine.integrityCheck()
|
|
194
|
-
if (integrity === 'ok') return
|
|
195
|
-
log?.(`post-swap integrity FAILED (${integrity}); rolling back to the newest archive`)
|
|
196
|
-
activeEngine.close()
|
|
197
|
-
const corruptBase = join(layout.dir, `index.corrupt-${Date.now()}.sqlite`)
|
|
198
|
-
try {
|
|
199
|
-
await rename(activePath, corruptBase)
|
|
200
|
-
await moveOrDeleteSidecars(activePath, corruptBase)
|
|
201
|
-
if (archivePath !== null) {
|
|
202
|
-
await rename(archivePath, activePath)
|
|
203
|
-
for (const suffix of ['-wal', '-shm']) {
|
|
204
|
-
if (existsSync(archivePath + suffix)) await rename(archivePath + suffix, activePath + suffix).catch(() => {})
|
|
205
|
-
}
|
|
206
|
-
await activeEngine.open()
|
|
207
|
-
}
|
|
208
|
-
} catch (rollbackErr) {
|
|
209
|
-
log?.(`swap rollback itself failed: ${String(rollbackErr instanceof Error ? rollbackErr.message : rollbackErr)}`)
|
|
210
|
-
}
|
|
211
|
-
throw new Error(`switch-search: post-swap integrity check failed: ${integrity}`)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export async function rebuildIndex(
|
|
215
|
-
activeEngine: SwitchIndexEngine,
|
|
216
|
-
layout: SwitchIndexLayout,
|
|
217
|
-
sessionQuery: SwitchRebuildSessionQuery,
|
|
218
|
-
keepArchives: number,
|
|
219
|
-
onProgress?: (done: number, total: number) => void,
|
|
220
|
-
archiveSource?: () => SwitchArchiveSource | undefined,
|
|
221
|
-
hooks?: SwitchRebuildHooks,
|
|
222
|
-
): Promise<SwitchRebuildState> {
|
|
223
|
-
const startedMs = Date.now()
|
|
224
|
-
let docsWritten = 0
|
|
225
|
-
const emit = (): void => { hooks?.onState?.({ ...state }) }
|
|
226
|
-
const rate = (): string => {
|
|
227
|
-
const secs = Math.max(0.001, (Date.now() - startedMs) / 1000)
|
|
228
|
-
return `${(state.done / secs).toFixed(1)} sess/s`
|
|
229
|
-
}
|
|
230
|
-
const eta = (): string => {
|
|
231
|
-
if (state.total <= 0 || state.done === 0) return '?'
|
|
232
|
-
const secs = (Date.now() - startedMs) / 1000
|
|
233
|
-
return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`
|
|
234
|
-
}
|
|
235
|
-
const state: SwitchRebuildState = {
|
|
236
|
-
state: 'building',
|
|
237
|
-
done: 0,
|
|
238
|
-
total: 0,
|
|
239
|
-
startedAt: Date.now(),
|
|
240
|
-
finishedAt: 0,
|
|
241
|
-
failures: [],
|
|
242
|
-
}
|
|
243
|
-
try {
|
|
244
|
-
hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`)
|
|
245
|
-
emit()
|
|
246
|
-
await mkdir(layout.dir, { recursive: true })
|
|
247
|
-
const buildingPath = join(layout.dir, layout.building)
|
|
248
|
-
// A stale shadow from a crashed run is discarded.
|
|
249
|
-
if (existsSync(buildingPath)) await unlink(buildingPath)
|
|
250
|
-
const shadow = new SwitchIndexEngine({ path: buildingPath })
|
|
251
|
-
await shadow.open()
|
|
252
|
-
try {
|
|
253
|
-
const records = await sessionQuery.listSessions()
|
|
254
|
-
state.total = records.length
|
|
255
|
-
hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`)
|
|
256
|
-
emit()
|
|
257
|
-
// R1 合表语义:归档会话**同样全量读内容**入影子索引(readSession 对归档是
|
|
258
|
-
// replay-validate 不激活);构建完成后按官方归档集合统一翻 flag。
|
|
259
|
-
const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? [])
|
|
260
|
-
const readLog = async (header: { id: string; version: number; createdAt?: number; cwd?: string }) => {
|
|
261
|
-
let log: { session: { id: string; version: number; createdAt?: number; cwd?: string }; events: readonly SwitchRawEvent[] }
|
|
262
|
-
try {
|
|
263
|
-
log = await sessionQuery.readSession(header.id)
|
|
264
|
-
} catch (serviceError) {
|
|
265
|
-
if (hooks?.readSessionFromFile === undefined) throw serviceError
|
|
266
|
-
const fromFile = await hooks.readSessionFromFile(header.id)
|
|
267
|
-
if (fromFile === undefined) throw serviceError
|
|
268
|
-
log = fromFile
|
|
269
|
-
}
|
|
270
|
-
return {
|
|
271
|
-
sessionId: header.id,
|
|
272
|
-
version: log.session.version,
|
|
273
|
-
// 标题基础层随重建落行(beta.5 教训:重建不抽标题 + 版本对齐后同步
|
|
274
|
-
// 永不重读 = 全库标题永久空缺)。
|
|
275
|
-
title: extractTitleFromEvents(log.events),
|
|
276
|
-
cwd: log.session.cwd ?? '',
|
|
277
|
-
updatedAt: log.session.createdAt ?? 0,
|
|
278
|
-
archived: archivedSet.has(header.id),
|
|
279
|
-
events: log.events,
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
|
|
283
|
-
const chunk = records.slice(i, i + REBUILD_CHUNK)
|
|
284
|
-
const chunkStart = Date.now()
|
|
285
|
-
const reads: Array<{ sessionId: string; version: number; cwd: string; updatedAt: number; archived: boolean; events: readonly SwitchRawEvent[] }> = []
|
|
286
|
-
for (const record of chunk) {
|
|
287
|
-
try {
|
|
288
|
-
reads.push(await readLog(record.header))
|
|
289
|
-
} catch (err) {
|
|
290
|
-
state.failures.push({ sessionId: record.header.id, error: String(err instanceof Error ? err.message : err) })
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
// One transaction per chunk = one checkpoint; a chunk-level failure
|
|
294
|
-
// replays session-by-session to keep the bad session isolated.
|
|
295
|
-
try {
|
|
296
|
-
shadow.runBatched(() => {
|
|
297
|
-
for (const item of reads) {
|
|
298
|
-
shadow.upsertSession(item)
|
|
299
|
-
docsWritten += item.events.length
|
|
300
|
-
}
|
|
301
|
-
})
|
|
302
|
-
} catch (err) {
|
|
303
|
-
hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`)
|
|
304
|
-
for (const item of reads) {
|
|
305
|
-
try { shadow.upsertSession(item); docsWritten += item.events.length }
|
|
306
|
-
catch (e2) { state.failures.push({ sessionId: item.sessionId, error: String(e2 instanceof Error ? e2.message : e2) }) }
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
state.done = Math.min(state.total, i + chunk.length)
|
|
310
|
-
onProgress?.(state.done, state.total)
|
|
311
|
-
emit()
|
|
312
|
-
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`)
|
|
313
|
-
}
|
|
314
|
-
// 归档标记统一翻转(upsertSession 不动已有行的 flag,此处是影子库的最终真值)。
|
|
315
|
-
shadow.setArchived(archivedSet)
|
|
316
|
-
shadow.close()
|
|
317
|
-
} catch (error) {
|
|
318
|
-
shadow.close()
|
|
319
|
-
await unlink(buildingPath).catch(() => {})
|
|
320
|
-
throw error
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
state.state = 'swapping'
|
|
324
|
-
emit()
|
|
325
|
-
hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`)
|
|
326
|
-
await swapShadowIntoActive(activeEngine, layout, hooks?.log)
|
|
327
|
-
await pruneArchives(layout, keepArchives)
|
|
328
|
-
state.finishedAt = Date.now()
|
|
329
|
-
state.state = 'idle'
|
|
330
|
-
emit()
|
|
331
|
-
hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1000).toFixed(1)}s, archives pruned to ${keepArchives}`)
|
|
332
|
-
} catch (err) {
|
|
333
|
-
state.state = 'error'
|
|
334
|
-
state.error = String(err instanceof Error ? err.message : err)
|
|
335
|
-
hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`)
|
|
336
|
-
emit()
|
|
337
|
-
// Leave the active engine usable if the swap itself failed before rename.
|
|
338
|
-
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
|
|
339
|
-
}
|
|
340
|
-
return state
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** One doc-level record the snapshot importer feeds in. */
|
|
344
|
-
export interface SwitchImportRecord {
|
|
345
|
-
sessionId: string
|
|
346
|
-
version: number
|
|
347
|
-
title?: string
|
|
348
|
-
/** 归档标记(旧快照缺省活跃)。 */
|
|
349
|
-
archived?: boolean
|
|
350
|
-
docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** Import doc-level records into the shadow file and swap it in (same swap path).
|
|
354
|
-
* @param log - 可选日志汇(空 docs 记录告警,issue #1 写入侧可观测)。 */
|
|
355
|
-
export async function importIntoIndex(
|
|
356
|
-
activeEngine: SwitchIndexEngine,
|
|
357
|
-
layout: SwitchIndexLayout,
|
|
358
|
-
records: readonly SwitchImportRecord[],
|
|
359
|
-
keepArchives: number,
|
|
360
|
-
log?: (msg: string) => void,
|
|
361
|
-
): Promise<SwitchRebuildState> {
|
|
362
|
-
const state: SwitchRebuildState = {
|
|
363
|
-
state: 'building',
|
|
364
|
-
done: 0,
|
|
365
|
-
total: records.length,
|
|
366
|
-
startedAt: Date.now(),
|
|
367
|
-
finishedAt: 0,
|
|
368
|
-
failures: [],
|
|
369
|
-
}
|
|
370
|
-
try {
|
|
371
|
-
await mkdir(layout.dir, { recursive: true })
|
|
372
|
-
const buildingPath = join(layout.dir, layout.building)
|
|
373
|
-
if (existsSync(buildingPath)) await unlink(buildingPath)
|
|
374
|
-
const shadow = new SwitchIndexEngine({ path: buildingPath })
|
|
375
|
-
await shadow.open()
|
|
376
|
-
try {
|
|
377
|
-
for (const record of records) {
|
|
378
|
-
try {
|
|
379
|
-
if (record.docs.length === 0) {
|
|
380
|
-
// 快照往返空正文是合法输入,不硬拒;修复后这样的行下一轮 poll 即补录。
|
|
381
|
-
log?.(`snapshot import: session ${record.sessionId} has zero docs (hollow header, sync will backfill)`)
|
|
382
|
-
}
|
|
383
|
-
shadow.importSessionDocs({
|
|
384
|
-
sessionId: record.sessionId,
|
|
385
|
-
version: record.version,
|
|
386
|
-
title: record.title ?? '',
|
|
387
|
-
archived: record.archived === true,
|
|
388
|
-
docs: record.docs,
|
|
389
|
-
})
|
|
390
|
-
} catch (err) {
|
|
391
|
-
state.failures.push({ sessionId: record.sessionId, error: String(err instanceof Error ? err.message : err) })
|
|
392
|
-
}
|
|
393
|
-
state.done += 1
|
|
394
|
-
}
|
|
395
|
-
shadow.close()
|
|
396
|
-
} catch (error) {
|
|
397
|
-
shadow.close()
|
|
398
|
-
await unlink(buildingPath).catch(() => {})
|
|
399
|
-
throw error
|
|
400
|
-
}
|
|
401
|
-
state.state = 'swapping'
|
|
402
|
-
await swapShadowIntoActive(activeEngine, layout, log)
|
|
403
|
-
await pruneArchives(layout, keepArchives)
|
|
404
|
-
state.finishedAt = Date.now()
|
|
405
|
-
state.state = 'idle'
|
|
406
|
-
} catch (err) {
|
|
407
|
-
state.state = 'error'
|
|
408
|
-
state.error = String(err instanceof Error ? err.message : err)
|
|
409
|
-
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
|
|
410
|
-
}
|
|
411
|
-
return state
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** Remove the oldest archives beyond the retention bound (sidecars too). */
|
|
415
|
-
async function pruneArchives(layout: SwitchIndexLayout, keep: number): Promise<void> {
|
|
416
|
-
const archives = await listArchives(layout)
|
|
417
|
-
const excess = archives.length - Math.max(0, keep)
|
|
418
|
-
for (let i = 0; i < excess; i += 1) {
|
|
419
|
-
await unlink(join(layout.dir, archives[i])).catch(() => {})
|
|
420
|
-
await moveOrDeleteSidecars(join(layout.dir, archives[i]), null)
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Resolve the index directory that hosts the independent index files:
|
|
426
|
-
* an explicit override wins, otherwise a plugin-owned directory under the
|
|
427
|
-
* user's home (never the official index path).
|
|
428
|
-
*/
|
|
429
|
-
export function resolveIndexDir(preferred?: string): string {
|
|
430
|
-
if (preferred !== undefined && preferred.trim() !== '') return preferred
|
|
431
|
-
return join(homedir(), '.dsh-switch-search')
|
|
432
|
-
}
|
package/src/host/index/schema.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|