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.
Files changed (67) hide show
  1. package/CHANGELOG.ja.md +33 -229
  2. package/CHANGELOG.ko.md +33 -229
  3. package/CHANGELOG.md +33 -363
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +41 -46
  10. package/README.ja.md +33 -57
  11. package/README.ko.md +32 -55
  12. package/README.md +64 -64
  13. package/cordis.patch.yml +19 -16
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +661 -3082
  16. package/lib/index.d.ts +79 -970
  17. package/lib/index.js +249 -2669
  18. package/locale/de.json +2 -2
  19. package/locale/en.json +2 -2
  20. package/locale/es.json +2 -2
  21. package/locale/fr.json +2 -2
  22. package/locale/it.json +2 -2
  23. package/locale/ja.json +2 -2
  24. package/locale/ko.json +2 -2
  25. package/locale/ru.json +2 -2
  26. package/locale/zh.json +2 -2
  27. package/package.json +15 -25
  28. package/screenshots.json +3 -3
  29. package/src/client/bridge-scope.ts +202 -213
  30. package/src/client/card.tsx +154 -154
  31. package/src/client/health-panel.tsx +49 -49
  32. package/src/client/host-api.ts +72 -6
  33. package/src/client/index.ts +594 -503
  34. package/src/client/locales.ts +893 -893
  35. package/src/client/manage-locales.ts +178 -0
  36. package/src/client/{search/manage.tsx → manage.tsx} +430 -404
  37. package/src/client/panel.tsx +131 -129
  38. package/src/client/session-menu-legacy.ts +2 -3
  39. package/src/client/session-menu.tsx +5 -5
  40. package/src/config.ts +43 -99
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/repair.ts +2 -2
  44. package/src/host/health/source-kind.ts +350 -350
  45. package/src/host/history/archive-source.ts +250 -250
  46. package/src/host/history/archive.ts +249 -249
  47. package/src/host/history/favorites.ts +71 -71
  48. package/src/host/history/purge.ts +370 -370
  49. package/src/host/history/tombstones.ts +3 -3
  50. package/src/host/registry/archive-registry.ts +317 -317
  51. package/src/host/settings-bridge.ts +222 -264
  52. package/src/index.ts +66 -694
  53. package/src/client/search/card.tsx +0 -563
  54. package/src/client/search/host-api.ts +0 -213
  55. package/src/client/search/locales.ts +0 -861
  56. package/src/client/search/locate.ts +0 -330
  57. package/src/client/search/panel.tsx +0 -940
  58. package/src/client/search/platform.ts +0 -136
  59. package/src/host/index/archive-source.ts +0 -90
  60. package/src/host/index/drs-source.ts +0 -211
  61. package/src/host/index/engine.ts +0 -699
  62. package/src/host/index/extract.ts +0 -272
  63. package/src/host/index/peers.ts +0 -62
  64. package/src/host/index/rebuild.ts +0 -432
  65. package/src/host/index/schema.ts +0 -136
  66. package/src/host/index/snapshot.ts +0 -135
  67. 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
- }
@@ -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
- }