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.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.en.md +4 -0
  3. package/README.md +4 -0
  4. package/cordis.patch.yml +6 -1
  5. package/dsh.plugin.json +22 -22
  6. package/lib/client.js +3848 -202
  7. package/lib/index.d.ts +759 -29
  8. package/lib/index.js +2604 -93
  9. package/locale/de.json +6 -0
  10. package/locale/en.json +6 -0
  11. package/locale/es.json +6 -0
  12. package/locale/fr.json +6 -0
  13. package/locale/it.json +6 -0
  14. package/locale/ja.json +6 -0
  15. package/locale/ko.json +6 -0
  16. package/locale/ru.json +6 -0
  17. package/locale/zh.json +6 -0
  18. package/package.json +43 -32
  19. package/src/client/bridge-scope.ts +213 -0
  20. package/src/client/index.ts +154 -26
  21. package/src/client/locales.ts +857 -223
  22. package/src/client/panel.tsx +139 -118
  23. package/src/client/search/card.tsx +563 -0
  24. package/src/client/search/host-api.ts +208 -0
  25. package/src/client/search/locales.ts +780 -0
  26. package/src/client/search/manage.tsx +400 -0
  27. package/src/client/search/panel.tsx +815 -0
  28. package/src/client/search/platform.ts +136 -0
  29. package/src/config.ts +54 -5
  30. package/src/host/history/archive-source.ts +48 -0
  31. package/src/host/history/archive.ts +56 -1
  32. package/src/host/history/favorites.ts +71 -0
  33. package/src/host/index/archive-source.ts +90 -0
  34. package/src/host/index/engine.ts +571 -0
  35. package/src/host/index/extract.ts +272 -0
  36. package/src/host/index/peers.ts +62 -0
  37. package/src/host/index/rebuild.ts +368 -0
  38. package/src/host/index/schema.ts +136 -0
  39. package/src/host/index/snapshot.ts +135 -0
  40. package/src/host/index/sync.ts +286 -0
  41. package/src/host/registry/archive-registry.ts +317 -0
  42. package/src/host/settings-bridge.ts +237 -0
  43. 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
+ }