opencode-manager 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1973 @@
1
+ /**
2
+ * SQLite backend for opencode data access.
3
+ *
4
+ * This module provides functions for reading opencode session/project data
5
+ * from SQLite databases (as an alternative to the default JSONL file-based storage).
6
+ *
7
+ * @experimental This module is experimental and may change.
8
+ */
9
+ import { Database } from "bun:sqlite"
10
+ import { homedir } from "node:os"
11
+ import { join, resolve } from "node:path"
12
+ import { promises as fs, constants } from "node:fs"
13
+ import type {
14
+ ProjectRecord,
15
+ ProjectState,
16
+ SessionRecord,
17
+ ChatMessage,
18
+ ChatRole,
19
+ TokenBreakdown,
20
+ ChatPart,
21
+ PartType,
22
+ DeleteResult,
23
+ DeleteOptions,
24
+ } from "./opencode-data"
25
+
26
+ // ========================
27
+ // Constants
28
+ // ========================
29
+
30
+ /**
31
+ * Default path to the opencode SQLite database.
32
+ */
33
+ export const DEFAULT_SQLITE_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
34
+
35
+ /**
36
+ * Busy timeout for force-write operations (milliseconds).
37
+ */
38
+ const SQLITE_BUSY_TIMEOUT_MS = 5000
39
+
40
+ /**
41
+ * Required SQLite schema for OpenCode data.
42
+ * These table/column names are assumed by the loader and writer functions.
43
+ *
44
+ * Tables:
45
+ * - project(id, data)
46
+ * - session(id, project_id, parent_id, created_at, updated_at, data)
47
+ * - message(id, session_id, created_at, data)
48
+ * - part(id, message_id, session_id, data)
49
+ */
50
+ const SQLITE_REQUIRED_COLUMNS = {
51
+ project: ["id", "data"],
52
+ session: ["id", "project_id", "parent_id", "created_at", "updated_at", "data"],
53
+ message: ["id", "session_id", "created_at", "data"],
54
+ part: ["id", "message_id", "session_id", "data"],
55
+ } as const
56
+
57
+ // ========================
58
+ // Types
59
+ // ========================
60
+
61
+ /**
62
+ * Options for SQLite-based data loading functions.
63
+ *
64
+ * Accepts either a path string (which will be opened as a new Database connection)
65
+ * or an existing Database instance (which will be used directly).
66
+ */
67
+ export interface SqliteLoadOptions {
68
+ /**
69
+ * Database connection or path to SQLite file.
70
+ * - If a string, opens a new readonly Database connection.
71
+ * - If a Database instance, uses it directly (caller manages lifecycle).
72
+ */
73
+ db: Database | string
74
+ /**
75
+ * If true, fail fast on any SQLite error or malformed data.
76
+ * Default behavior is to warn and continue when possible.
77
+ */
78
+ strict?: boolean
79
+ /**
80
+ * Optional warning sink for recoverable issues (schema gaps, malformed rows).
81
+ * Defaults to console.warn when not provided.
82
+ */
83
+ onWarning?: (warning: string) => void
84
+ /**
85
+ * If true, wait for SQLite write locks to clear before failing.
86
+ * Only applies to write operations (readonly: false).
87
+ */
88
+ forceWrite?: boolean
89
+ }
90
+
91
+ // ========================
92
+ // Database Helpers
93
+ // ========================
94
+
95
+ /**
96
+ * Opens a SQLite database from a path or returns an existing Database instance.
97
+ *
98
+ * @param pathOrDb - Either a file path to open, or an existing Database instance.
99
+ * @param options - Optional configuration for opening the database.
100
+ * @returns A Database instance ready for queries.
101
+ * @throws Error if the path does not exist or cannot be opened.
102
+ */
103
+ export function openDatabase(
104
+ pathOrDb: Database | string,
105
+ options: { readonly?: boolean; forceWrite?: boolean } = {}
106
+ ): Database {
107
+ // If already a Database instance, return as-is
108
+ if (pathOrDb instanceof Database) {
109
+ return pathOrDb
110
+ }
111
+
112
+ // Open database from path
113
+ // Note: bun:sqlite only accepts { readonly: true } for readonly mode
114
+ // Omit the option entirely for read-write mode (the default)
115
+ const readonly = options.readonly ?? true
116
+ try {
117
+ if (readonly) {
118
+ return new Database(pathOrDb, { readonly: true })
119
+ }
120
+ const db = new Database(pathOrDb)
121
+ if (options.forceWrite) {
122
+ // Wait briefly for write locks to clear when force-write is enabled.
123
+ db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
124
+ }
125
+ return db
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error)
128
+ if (isSqliteBusyError(error)) {
129
+ const busyMessage = formatBusyErrorMessage(
130
+ `SQLite database at "${pathOrDb}" is locked`,
131
+ { forceWrite: options.forceWrite, allowForceWrite: !readonly }
132
+ )
133
+ throw new Error(busyMessage)
134
+ }
135
+ throw new Error(`Failed to open SQLite database at "${pathOrDb}": ${message}`)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Closes the database if it was opened from a path string.
141
+ *
142
+ * This is a helper to manage database lifecycle correctly:
143
+ * - If the original input was a path string, the database was opened by us and should be closed.
144
+ * - If the original input was a Database instance, the caller owns it and we should not close it.
145
+ *
146
+ * @param db - The Database instance to potentially close.
147
+ * @param originalInput - The original input that was passed to openDatabase.
148
+ */
149
+ export function closeIfOwned(db: Database, originalInput: Database | string): void {
150
+ // Only close if we opened it (i.e., originalInput was a string path)
151
+ if (typeof originalInput === "string") {
152
+ db.close()
153
+ }
154
+ }
155
+
156
+ // ========================
157
+ // Internal Helpers
158
+ // ========================
159
+
160
+ /**
161
+ * Expand ~ to home directory in paths.
162
+ */
163
+ function expandUserPath(rawPath?: string | null): string | null {
164
+ if (!rawPath) {
165
+ return null
166
+ }
167
+ if (rawPath === "~") {
168
+ return homedir()
169
+ }
170
+ if (rawPath.startsWith("~/")) {
171
+ return join(homedir(), rawPath.slice(2))
172
+ }
173
+ return resolve(rawPath)
174
+ }
175
+
176
+ /**
177
+ * Convert milliseconds timestamp to Date, or null if invalid.
178
+ */
179
+ function msToDate(ms?: number | null): Date | null {
180
+ if (typeof ms !== "number" || Number.isNaN(ms)) {
181
+ return null
182
+ }
183
+ return new Date(ms)
184
+ }
185
+
186
+ /**
187
+ * Check if a path exists and is a directory.
188
+ */
189
+ async function computeState(worktree: string | null): Promise<ProjectState> {
190
+ if (!worktree) {
191
+ return "unknown"
192
+ }
193
+ try {
194
+ const stat = await fs.stat(worktree)
195
+ return stat.isDirectory() ? "present" : "missing"
196
+ } catch {
197
+ return "missing"
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Compare dates for sorting (descending, most recent first).
203
+ */
204
+ function compareDates(a: Date | null, b: Date | null): number {
205
+ const aTime = a?.getTime() ?? 0
206
+ const bTime = b?.getTime() ?? 0
207
+ return bTime - aTime
208
+ }
209
+
210
+ /**
211
+ * Add 1-based index to records.
212
+ */
213
+ function withIndex<T extends { index: number }>(records: T[]): T[] {
214
+ return records.map((record, idx) => ({ ...record, index: idx + 1 }))
215
+ }
216
+
217
+ /**
218
+ * Emit a warning for recoverable SQLite issues.
219
+ */
220
+ function warnSqlite(options: { onWarning?: (warning: string) => void } | undefined, message: string): void {
221
+ if (options?.onWarning) {
222
+ options.onWarning(message)
223
+ return
224
+ }
225
+ console.warn(`Warning: ${message}`)
226
+ }
227
+
228
+ /**
229
+ * Detect SQLITE_BUSY errors from bun:sqlite.
230
+ */
231
+ function isSqliteBusyError(error: unknown): boolean {
232
+ const message = error instanceof Error ? error.message : String(error)
233
+ return /SQLITE_BUSY|database is locked/i.test(message)
234
+ }
235
+
236
+ /**
237
+ * Build a friendly error message for SQLite lock contention.
238
+ */
239
+ function formatBusyErrorMessage(
240
+ context: string,
241
+ options?: { forceWrite?: boolean; allowForceWrite?: boolean }
242
+ ): string {
243
+ const allowForceWrite = options?.allowForceWrite ?? true
244
+ const hint = allowForceWrite
245
+ ? options?.forceWrite
246
+ ? "Close OpenCode or wait for the lock to clear."
247
+ : "Close OpenCode and retry, or pass --force-write to wait for the lock."
248
+ : "Close OpenCode and retry."
249
+ return `${context}. ${hint}`
250
+ }
251
+
252
+ /**
253
+ * Format a SQLite error with context, handling SQLITE_BUSY specially.
254
+ */
255
+ function formatSqliteErrorMessage(
256
+ error: unknown,
257
+ context: string,
258
+ options?: { forceWrite?: boolean; allowForceWrite?: boolean }
259
+ ): string {
260
+ if (isSqliteBusyError(error)) {
261
+ return formatBusyErrorMessage("SQLite database is locked", options)
262
+ }
263
+ const message = error instanceof Error ? error.message : String(error)
264
+ return `${context}: ${message}`
265
+ }
266
+
267
+ interface SchemaValidationResult {
268
+ ok: boolean
269
+ missingTables: string[]
270
+ missingColumns: Record<string, string[]>
271
+ }
272
+
273
+ type SchemaRequirements = Record<string, readonly string[]>
274
+
275
+ function buildSchemaRequirements(tables: (keyof typeof SQLITE_REQUIRED_COLUMNS)[]): SchemaRequirements {
276
+ const requirements: SchemaRequirements = {}
277
+ for (const table of tables) {
278
+ requirements[table] = SQLITE_REQUIRED_COLUMNS[table]
279
+ }
280
+ return requirements
281
+ }
282
+
283
+ function validateSchemaForTables(db: Database, requirements: SchemaRequirements): SchemaValidationResult {
284
+ const tableRows = db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as {
285
+ name: string
286
+ }[]
287
+ const existingTables = new Set(tableRows.map((row) => row.name))
288
+
289
+ const missingTables: string[] = []
290
+ const missingColumns: Record<string, string[]> = {}
291
+
292
+ for (const [table, columns] of Object.entries(requirements)) {
293
+ if (!existingTables.has(table)) {
294
+ missingTables.push(table)
295
+ continue
296
+ }
297
+
298
+ const columnRows = db.query(`PRAGMA table_info(${table})`).all() as { name: string }[]
299
+ const existingColumns = new Set(columnRows.map((row) => row.name))
300
+ const missing = columns.filter((column) => !existingColumns.has(column))
301
+
302
+ if (missing.length > 0) {
303
+ missingColumns[table] = missing
304
+ }
305
+ }
306
+
307
+ return {
308
+ ok: missingTables.length === 0 && Object.keys(missingColumns).length === 0,
309
+ missingTables,
310
+ missingColumns,
311
+ }
312
+ }
313
+
314
+ function formatSchemaIssues(result: SchemaValidationResult, context?: string): string {
315
+ const parts: string[] = []
316
+ if (result.missingTables.length > 0) {
317
+ parts.push(`missing tables: ${result.missingTables.join(", ")}`)
318
+ }
319
+ const columnEntries = Object.entries(result.missingColumns)
320
+ if (columnEntries.length > 0) {
321
+ const missingCols = columnEntries
322
+ .flatMap(([table, columns]) => columns.map((column) => `${table}.${column}`))
323
+ .join(", ")
324
+ parts.push(`missing columns: ${missingCols}`)
325
+ }
326
+
327
+ const detail = parts.join("; ")
328
+ const prefix = context ? `${context}: ` : ""
329
+ return `${prefix}SQLite schema is invalid (${detail}).`
330
+ }
331
+
332
+ function ensureSchema(
333
+ db: Database,
334
+ requirements: SchemaRequirements,
335
+ options?: SqliteLoadOptions,
336
+ context?: string
337
+ ): boolean {
338
+ let result: SchemaValidationResult
339
+ try {
340
+ result = validateSchemaForTables(db, requirements)
341
+ } catch (error) {
342
+ const message = formatSqliteErrorMessage(error, "Failed to read SQLite schema", {
343
+ forceWrite: options?.forceWrite,
344
+ allowForceWrite: false,
345
+ })
346
+ if (isSqliteBusyError(error) || options?.strict) {
347
+ throw new Error(message)
348
+ }
349
+ warnSqlite(options, message)
350
+ return false
351
+ }
352
+ if (result.ok) {
353
+ return true
354
+ }
355
+
356
+ const message = formatSchemaIssues(result, context)
357
+ if (options?.strict) {
358
+ throw new Error(message)
359
+ }
360
+ warnSqlite(options, message)
361
+ return false
362
+ }
363
+
364
+ function getSchemaIssueMessage(
365
+ db: Database,
366
+ requirements: SchemaRequirements,
367
+ context?: string
368
+ ): string | null {
369
+ const result = validateSchemaForTables(db, requirements)
370
+ if (result.ok) {
371
+ return null
372
+ }
373
+ return formatSchemaIssues(result, context)
374
+ }
375
+
376
+ /**
377
+ * Validate the OpenCode SQLite schema.
378
+ *
379
+ * Returns true when all required tables and columns are present.
380
+ * When strict is enabled, throws an error on invalid schema.
381
+ */
382
+ export function validateSchema(
383
+ db: Database,
384
+ options: { strict?: boolean; onWarning?: (warning: string) => void } = {}
385
+ ): boolean {
386
+ const requirements = buildSchemaRequirements([
387
+ "project",
388
+ "session",
389
+ "message",
390
+ "part",
391
+ ])
392
+ let result: SchemaValidationResult
393
+ try {
394
+ result = validateSchemaForTables(db, requirements)
395
+ } catch (error) {
396
+ const message = formatSqliteErrorMessage(error, "Failed to read SQLite schema", {
397
+ allowForceWrite: false,
398
+ })
399
+ if (isSqliteBusyError(error) || options.strict) {
400
+ throw new Error(message)
401
+ }
402
+ warnSqlite(options, message)
403
+ return false
404
+ }
405
+ if (!result.ok) {
406
+ const message = formatSchemaIssues(result)
407
+ if (options.strict) {
408
+ throw new Error(message)
409
+ }
410
+ warnSqlite(options, message)
411
+ }
412
+ return result.ok
413
+ }
414
+
415
+ // ========================
416
+ // Project Loading
417
+ // ========================
418
+
419
+ /**
420
+ * Raw row structure from the SQLite project table.
421
+ */
422
+ interface ProjectRow {
423
+ id: string
424
+ data: string
425
+ }
426
+
427
+ /**
428
+ * Parsed JSON structure from the project data column.
429
+ */
430
+ interface ProjectData {
431
+ id?: string
432
+ worktree?: string
433
+ vcs?: string
434
+ time?: {
435
+ created?: number
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Load project records from SQLite database.
441
+ *
442
+ * Queries the `project` table and parses the JSON `data` column.
443
+ * Returns an array of ProjectRecord objects compatible with the JSONL loader.
444
+ *
445
+ * @param options - Database connection options.
446
+ * @returns Array of ProjectRecord objects, sorted by createdAt (descending).
447
+ */
448
+ export async function loadProjectRecordsSqlite(
449
+ options: SqliteLoadOptions
450
+ ): Promise<ProjectRecord[]> {
451
+ const db = openDatabase(options.db)
452
+ const records: ProjectRecord[] = []
453
+
454
+ try {
455
+ if (!ensureSchema(db, buildSchemaRequirements(["project"]), options, "loadProjectRecords")) {
456
+ return []
457
+ }
458
+
459
+ // Query all projects from the database
460
+ let rows: ProjectRow[] = []
461
+ try {
462
+ rows = db.query("SELECT id, data FROM project").all() as ProjectRow[]
463
+ } catch (error) {
464
+ const message = formatSqliteErrorMessage(error, "Failed to query project table", {
465
+ forceWrite: options.forceWrite,
466
+ allowForceWrite: false,
467
+ })
468
+ if (isSqliteBusyError(error)) {
469
+ throw new Error(message)
470
+ }
471
+ if (options.strict) {
472
+ throw new Error(message)
473
+ }
474
+ warnSqlite(options, message)
475
+ return []
476
+ }
477
+
478
+ for (const row of rows) {
479
+ let data: ProjectData = {}
480
+
481
+ // Parse JSON data column, skip malformed entries
482
+ try {
483
+ data = JSON.parse(row.data) as ProjectData
484
+ } catch (error) {
485
+ const message = formatSqliteErrorMessage(
486
+ error,
487
+ `Malformed JSON in project row "${row.id}"`,
488
+ options
489
+ )
490
+ if (options.strict) {
491
+ throw new Error(message)
492
+ }
493
+ warnSqlite(options, message)
494
+ continue
495
+ }
496
+
497
+ const createdAt = msToDate(data.time?.created)
498
+ const worktree = expandUserPath(data.worktree)
499
+ const state = await computeState(worktree)
500
+
501
+ records.push({
502
+ index: 0, // Will be set by withIndex
503
+ bucket: "project", // SQLite projects are always in the "project" bucket
504
+ filePath: `sqlite:project:${row.id}`, // Virtual path for SQLite records
505
+ projectId: row.id,
506
+ worktree: worktree ?? "",
507
+ vcs: typeof data.vcs === "string" ? data.vcs : null,
508
+ createdAt,
509
+ state,
510
+ })
511
+ }
512
+ } finally {
513
+ closeIfOwned(db, options.db)
514
+ }
515
+
516
+ // Sort by createdAt descending, then by projectId for stability
517
+ records.sort((a, b) => {
518
+ const dateDelta = compareDates(a.createdAt, b.createdAt)
519
+ if (dateDelta !== 0) {
520
+ return dateDelta
521
+ }
522
+ return a.projectId.localeCompare(b.projectId)
523
+ })
524
+
525
+ return withIndex(records)
526
+ }
527
+
528
+ // ========================
529
+ // Session Loading
530
+ // ========================
531
+
532
+ /**
533
+ * Options for session loading from SQLite.
534
+ */
535
+ export interface SqliteSessionLoadOptions extends SqliteLoadOptions {
536
+ /**
537
+ * Filter sessions by project ID.
538
+ */
539
+ projectId?: string
540
+ }
541
+
542
+ /**
543
+ * Raw row structure from the SQLite session table.
544
+ */
545
+ interface SessionRow {
546
+ id: string
547
+ project_id: string
548
+ parent_id: string | null
549
+ created_at: number | null
550
+ updated_at: number | null
551
+ data: string
552
+ }
553
+
554
+ /**
555
+ * Parsed JSON structure from the session data column.
556
+ */
557
+ interface SessionData {
558
+ id?: string
559
+ projectID?: string
560
+ parentID?: string
561
+ directory?: string
562
+ title?: string
563
+ version?: string
564
+ time?: {
565
+ created?: number
566
+ updated?: number
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Load session records from SQLite database.
572
+ *
573
+ * Queries the `session` table and parses the JSON `data` column.
574
+ * Returns an array of SessionRecord objects compatible with the JSONL loader.
575
+ *
576
+ * @param options - Database connection options with optional projectId filter.
577
+ * @returns Array of SessionRecord objects, sorted by updatedAt/createdAt (descending).
578
+ */
579
+ export async function loadSessionRecordsSqlite(
580
+ options: SqliteSessionLoadOptions
581
+ ): Promise<SessionRecord[]> {
582
+ const db = openDatabase(options.db)
583
+ const records: SessionRecord[] = []
584
+
585
+ try {
586
+ if (!ensureSchema(db, buildSchemaRequirements(["session"]), options, "loadSessionRecords")) {
587
+ return []
588
+ }
589
+
590
+ // Build query with optional project_id filter
591
+ let query = "SELECT id, project_id, parent_id, created_at, updated_at, data FROM session"
592
+ const params: string[] = []
593
+
594
+ if (options.projectId) {
595
+ query += " WHERE project_id = ?"
596
+ params.push(options.projectId)
597
+ }
598
+
599
+ let rows: SessionRow[] = []
600
+ try {
601
+ rows = params.length > 0
602
+ ? db.query(query).all(params[0]) as SessionRow[]
603
+ : db.query(query).all() as SessionRow[]
604
+ } catch (error) {
605
+ const message = formatSqliteErrorMessage(error, "Failed to query session table", {
606
+ forceWrite: options.forceWrite,
607
+ allowForceWrite: false,
608
+ })
609
+ if (isSqliteBusyError(error)) {
610
+ throw new Error(message)
611
+ }
612
+ if (options.strict) {
613
+ throw new Error(message)
614
+ }
615
+ warnSqlite(options, message)
616
+ return []
617
+ }
618
+
619
+ for (const row of rows) {
620
+ let data: SessionData = {}
621
+
622
+ // Parse JSON data column, skip malformed entries
623
+ try {
624
+ data = JSON.parse(row.data) as SessionData
625
+ } catch (error) {
626
+ const message = formatSqliteErrorMessage(
627
+ error,
628
+ `Malformed JSON in session row "${row.id}"`,
629
+ options
630
+ )
631
+ if (options.strict) {
632
+ throw new Error(message)
633
+ }
634
+ warnSqlite(options, message)
635
+ continue
636
+ }
637
+
638
+ // Use column values first, fall back to data JSON
639
+ // created_at/updated_at columns are epoch milliseconds
640
+ const createdAt = msToDate(row.created_at) ?? msToDate(data.time?.created)
641
+ const updatedAt = msToDate(row.updated_at) ?? msToDate(data.time?.updated)
642
+ const directory = expandUserPath(data.directory)
643
+
644
+ records.push({
645
+ index: 0, // Will be set by withIndex
646
+ filePath: `sqlite:session:${row.id}`, // Virtual path for SQLite records
647
+ sessionId: row.id,
648
+ projectId: row.project_id ?? data.projectID ?? "",
649
+ directory: directory ?? "",
650
+ title: typeof data.title === "string" ? data.title : "",
651
+ version: typeof data.version === "string" ? data.version : "",
652
+ createdAt,
653
+ updatedAt,
654
+ })
655
+ }
656
+ } finally {
657
+ closeIfOwned(db, options.db)
658
+ }
659
+
660
+ // Sort by updatedAt (or createdAt) descending, then by sessionId for stability
661
+ records.sort((a, b) => {
662
+ const aSortDate = a.updatedAt ?? a.createdAt
663
+ const bSortDate = b.updatedAt ?? b.createdAt
664
+ const dateDelta = compareDates(aSortDate, bSortDate)
665
+ if (dateDelta !== 0) {
666
+ return dateDelta
667
+ }
668
+ return a.sessionId.localeCompare(b.sessionId)
669
+ })
670
+
671
+ return withIndex(records)
672
+ }
673
+
674
+ // ========================
675
+ // Chat Message Loading
676
+ // ========================
677
+
678
+ /**
679
+ * Options for chat message loading from SQLite.
680
+ */
681
+ export interface SqliteChatLoadOptions extends SqliteLoadOptions {
682
+ /**
683
+ * Session ID to load messages for.
684
+ */
685
+ sessionId: string
686
+ }
687
+
688
+ /**
689
+ * Raw row structure from the SQLite message table.
690
+ */
691
+ interface MessageRow {
692
+ id: string
693
+ session_id: string
694
+ created_at: number | null
695
+ data: string
696
+ }
697
+
698
+ /**
699
+ * Parsed JSON structure from the message data column.
700
+ */
701
+ interface MessageData {
702
+ id?: string
703
+ sessionID?: string
704
+ role?: string
705
+ time?: {
706
+ created?: number
707
+ }
708
+ parentID?: string
709
+ tokens?: {
710
+ input?: number
711
+ output?: number
712
+ reasoning?: number
713
+ cache?: {
714
+ read?: number
715
+ write?: number
716
+ }
717
+ } | null
718
+ }
719
+
720
+ /**
721
+ * Safely convert a value to a non-negative number, or null if invalid.
722
+ */
723
+ function asTokenNumber(value: unknown): number | null {
724
+ if (typeof value !== "number" || !Number.isFinite(value)) {
725
+ return null
726
+ }
727
+ if (value < 0) {
728
+ return null
729
+ }
730
+ return value
731
+ }
732
+
733
+ /**
734
+ * Parse token breakdown from message data.
735
+ */
736
+ function parseMessageTokens(tokens: MessageData["tokens"]): TokenBreakdown | null {
737
+ if (!tokens || typeof tokens !== "object") {
738
+ return null
739
+ }
740
+
741
+ const input = asTokenNumber(tokens.input)
742
+ const output = asTokenNumber(tokens.output)
743
+ const reasoning = asTokenNumber(tokens.reasoning)
744
+ const cacheRead = asTokenNumber(tokens.cache?.read)
745
+ const cacheWrite = asTokenNumber(tokens.cache?.write)
746
+
747
+ const hasAny = input !== null || output !== null || reasoning !== null || cacheRead !== null || cacheWrite !== null
748
+ if (!hasAny) {
749
+ return null
750
+ }
751
+
752
+ const breakdown: TokenBreakdown = {
753
+ input: input ?? 0,
754
+ output: output ?? 0,
755
+ reasoning: reasoning ?? 0,
756
+ cacheRead: cacheRead ?? 0,
757
+ cacheWrite: cacheWrite ?? 0,
758
+ total: 0,
759
+ }
760
+ breakdown.total = breakdown.input + breakdown.output + breakdown.reasoning + breakdown.cacheRead + breakdown.cacheWrite
761
+ return breakdown
762
+ }
763
+
764
+ /**
765
+ * Load chat message index for a session from SQLite (metadata only, no parts).
766
+ * Returns an array of ChatMessage stubs with parts set to null.
767
+ *
768
+ * @param options - Database connection options with sessionId.
769
+ * @returns Array of ChatMessage objects, sorted by createdAt (ascending, oldest first).
770
+ */
771
+ export async function loadSessionChatIndexSqlite(
772
+ options: SqliteChatLoadOptions
773
+ ): Promise<ChatMessage[]> {
774
+ const db = openDatabase(options.db)
775
+ const messages: ChatMessage[] = []
776
+
777
+ try {
778
+ if (!ensureSchema(db, buildSchemaRequirements(["message"]), options, "loadSessionChatIndex")) {
779
+ return []
780
+ }
781
+
782
+ // Query messages for the given session, ordered by created_at ascending
783
+ let rows: MessageRow[] = []
784
+ try {
785
+ rows = db.query(
786
+ "SELECT id, session_id, created_at, data FROM message WHERE session_id = ? ORDER BY created_at ASC"
787
+ ).all(options.sessionId) as MessageRow[]
788
+ } catch (error) {
789
+ const message = formatSqliteErrorMessage(error, "Failed to query message table", {
790
+ forceWrite: options.forceWrite,
791
+ allowForceWrite: false,
792
+ })
793
+ if (isSqliteBusyError(error)) {
794
+ throw new Error(message)
795
+ }
796
+ if (options.strict) {
797
+ throw new Error(message)
798
+ }
799
+ warnSqlite(options, message)
800
+ return []
801
+ }
802
+
803
+ for (const row of rows) {
804
+ let data: MessageData = {}
805
+
806
+ // Parse JSON data column, skip malformed entries
807
+ try {
808
+ data = JSON.parse(row.data) as MessageData
809
+ } catch (error) {
810
+ const message = formatSqliteErrorMessage(
811
+ error,
812
+ `Malformed JSON in message row "${row.id}"`,
813
+ options
814
+ )
815
+ if (options.strict) {
816
+ throw new Error(message)
817
+ }
818
+ warnSqlite(options, message)
819
+ continue
820
+ }
821
+
822
+ // Determine role
823
+ const role: ChatRole =
824
+ data.role === "user" ? "user" :
825
+ data.role === "assistant" ? "assistant" :
826
+ "unknown"
827
+
828
+ // Use column timestamp first, fall back to data JSON
829
+ const createdAt = msToDate(row.created_at) ?? msToDate(data.time?.created)
830
+
831
+ // Parse tokens for assistant messages
832
+ let tokens: TokenBreakdown | undefined
833
+ if (role === "assistant" && data.tokens) {
834
+ const parsed = parseMessageTokens(data.tokens)
835
+ if (parsed) {
836
+ tokens = parsed
837
+ }
838
+ }
839
+
840
+ messages.push({
841
+ sessionId: row.session_id,
842
+ messageId: row.id,
843
+ role,
844
+ createdAt,
845
+ parentId: data.parentID,
846
+ tokens,
847
+ parts: null,
848
+ previewText: "[loading...]",
849
+ totalChars: null,
850
+ })
851
+ }
852
+ } finally {
853
+ closeIfOwned(db, options.db)
854
+ }
855
+
856
+ // Messages are already sorted by created_at ASC from the query,
857
+ // but apply stable sort for any ties using messageId
858
+ messages.sort((a, b) => {
859
+ const aTime = a.createdAt?.getTime() ?? 0
860
+ const bTime = b.createdAt?.getTime() ?? 0
861
+ if (aTime !== bTime) {
862
+ return aTime - bTime // ascending (oldest first)
863
+ }
864
+ return a.messageId.localeCompare(b.messageId)
865
+ })
866
+
867
+ return messages
868
+ }
869
+
870
+ // ========================
871
+ // Message Parts Loading
872
+ // ========================
873
+
874
+ /**
875
+ * Options for message parts loading from SQLite.
876
+ */
877
+ export interface SqlitePartsLoadOptions extends SqliteLoadOptions {
878
+ /**
879
+ * Message ID to load parts for.
880
+ */
881
+ messageId: string
882
+ }
883
+
884
+ /**
885
+ * Raw row structure from the SQLite part table.
886
+ */
887
+ interface PartRow {
888
+ id: string
889
+ message_id: string
890
+ session_id: string
891
+ data: string
892
+ }
893
+
894
+ /**
895
+ * Parsed JSON structure from the part data column.
896
+ */
897
+ interface PartData {
898
+ id?: string
899
+ messageID?: string
900
+ sessionID?: string
901
+ type?: string
902
+ text?: unknown
903
+ prompt?: unknown
904
+ description?: unknown
905
+ tool?: string
906
+ state?: {
907
+ status?: string
908
+ input?: Record<string, unknown>
909
+ output?: unknown
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Convert a value to a display-safe string.
915
+ */
916
+ function toDisplayText(value: unknown): string {
917
+ if (typeof value === "string") {
918
+ return value
919
+ }
920
+ if (value === null || value === undefined) {
921
+ return ""
922
+ }
923
+ try {
924
+ return JSON.stringify(value)
925
+ } catch {
926
+ return String(value)
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Extract human-readable content from a part data object.
932
+ */
933
+ function extractPartContent(data: PartData): { text: string; toolName?: string; toolStatus?: string } {
934
+ const type = data.type ?? "unknown"
935
+
936
+ switch (type) {
937
+ case "text":
938
+ return { text: toDisplayText(data.text) }
939
+
940
+ case "subtask":
941
+ return { text: toDisplayText(data.prompt ?? data.description ?? "") }
942
+
943
+ case "tool": {
944
+ const state = data.state ?? {}
945
+ const toolName = typeof data.tool === "string" ? data.tool : "unknown"
946
+ const status = typeof state.status === "string" ? state.status : "unknown"
947
+
948
+ // Prefer output when present; otherwise show a prompt-like input summary.
949
+ if (state.output !== undefined) {
950
+ return { text: toDisplayText(state.output), toolName, toolStatus: status }
951
+ }
952
+
953
+ const input = state.input ?? {}
954
+ const prompt = input.prompt ?? `[tool:${toolName}]`
955
+ return { text: toDisplayText(prompt), toolName, toolStatus: status }
956
+ }
957
+
958
+ default:
959
+ // Unknown part type: attempt a safe JSON preview, then fall back to a label.
960
+ return { text: toDisplayText(data) || `[${type} part]` }
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Load message parts from SQLite database.
966
+ *
967
+ * Queries the `part` table for parts belonging to a specific message.
968
+ * Returns an array of ChatPart objects compatible with the JSONL loader.
969
+ *
970
+ * @param options - Database connection options with messageId.
971
+ * @returns Array of ChatPart objects, sorted by partId for deterministic order.
972
+ */
973
+ export async function loadMessagePartsSqlite(
974
+ options: SqlitePartsLoadOptions
975
+ ): Promise<ChatPart[]> {
976
+ const db = openDatabase(options.db)
977
+ const parts: ChatPart[] = []
978
+
979
+ try {
980
+ if (!ensureSchema(db, buildSchemaRequirements(["part"]), options, "loadMessageParts")) {
981
+ return []
982
+ }
983
+
984
+ // Query parts for the given message
985
+ let rows: PartRow[] = []
986
+ try {
987
+ rows = db.query(
988
+ "SELECT id, message_id, session_id, data FROM part WHERE message_id = ?"
989
+ ).all(options.messageId) as PartRow[]
990
+ } catch (error) {
991
+ const message = formatSqliteErrorMessage(error, "Failed to query part table", {
992
+ forceWrite: options.forceWrite,
993
+ allowForceWrite: false,
994
+ })
995
+ if (isSqliteBusyError(error)) {
996
+ throw new Error(message)
997
+ }
998
+ if (options.strict) {
999
+ throw new Error(message)
1000
+ }
1001
+ warnSqlite(options, message)
1002
+ return []
1003
+ }
1004
+
1005
+ for (const row of rows) {
1006
+ let data: PartData = {}
1007
+
1008
+ // Parse JSON data column, skip malformed entries
1009
+ try {
1010
+ data = JSON.parse(row.data) as PartData
1011
+ } catch (error) {
1012
+ const message = formatSqliteErrorMessage(
1013
+ error,
1014
+ `Malformed JSON in part row "${row.id}"`,
1015
+ options
1016
+ )
1017
+ if (options.strict) {
1018
+ throw new Error(message)
1019
+ }
1020
+ warnSqlite(options, message)
1021
+ continue
1022
+ }
1023
+
1024
+ // Determine part type
1025
+ const typeRaw = typeof data.type === "string" ? data.type : "unknown"
1026
+ const type: PartType =
1027
+ typeRaw === "text" ? "text" :
1028
+ typeRaw === "subtask" ? "subtask" :
1029
+ typeRaw === "tool" ? "tool" :
1030
+ "unknown"
1031
+
1032
+ const extracted = extractPartContent(data)
1033
+
1034
+ parts.push({
1035
+ partId: row.id,
1036
+ messageId: row.message_id,
1037
+ type,
1038
+ text: extracted.text,
1039
+ toolName: extracted.toolName,
1040
+ toolStatus: extracted.toolStatus,
1041
+ })
1042
+ }
1043
+ } finally {
1044
+ closeIfOwned(db, options.db)
1045
+ }
1046
+
1047
+ // Sort by partId for deterministic order (consistent with JSONL loader's filename sort)
1048
+ parts.sort((a, b) => a.partId.localeCompare(b.partId))
1049
+
1050
+ return parts
1051
+ }
1052
+
1053
+ // ========================
1054
+ // Session Delete Operations
1055
+ // ========================
1056
+
1057
+ /**
1058
+ * Options for deleting session metadata from SQLite.
1059
+ */
1060
+ export interface SqliteDeleteSessionOptions extends SqliteLoadOptions {
1061
+ /**
1062
+ * If true, report what would be deleted without actually deleting.
1063
+ */
1064
+ dryRun?: boolean
1065
+ }
1066
+
1067
+ /**
1068
+ * Delete session metadata and all related data from SQLite database.
1069
+ *
1070
+ * This function deletes sessions and their associated data (messages, parts) in a
1071
+ * transaction for atomicity. If any part of the deletion fails, the entire operation
1072
+ * is rolled back.
1073
+ *
1074
+ * Deletion order (to satisfy foreign key constraints if enabled):
1075
+ * 1. Delete parts where session_id IN (sessionIds)
1076
+ * 2. Delete messages where session_id IN (sessionIds)
1077
+ * 3. Delete sessions where id IN (sessionIds)
1078
+ *
1079
+ * @param sessionIds - Array of session IDs to delete.
1080
+ * @param options - Database connection options and dry-run flag.
1081
+ * @returns DeleteResult with removed session IDs and any failures.
1082
+ */
1083
+ export async function deleteSessionMetadataSqlite(
1084
+ sessionIds: string[],
1085
+ options: SqliteDeleteSessionOptions
1086
+ ): Promise<DeleteResult> {
1087
+ // Handle empty input
1088
+ if (sessionIds.length === 0) {
1089
+ return { removed: [], failed: [] }
1090
+ }
1091
+
1092
+ // For dry-run, we don't need write access
1093
+ const removed: string[] = []
1094
+ const failed: { path: string; error?: string }[] = []
1095
+ const needsWrite = !options.dryRun
1096
+ let db: Database | undefined
1097
+
1098
+ try {
1099
+ try {
1100
+ db = openDatabase(options.db, { readonly: !needsWrite, forceWrite: options.forceWrite })
1101
+ } catch (error) {
1102
+ const message = formatSqliteErrorMessage(error, "Failed to open SQLite database", options)
1103
+ if (options.strict) {
1104
+ throw new Error(message)
1105
+ }
1106
+ for (const sessionId of sessionIds) {
1107
+ failed.push({ path: `sqlite:session:${sessionId}`, error: message })
1108
+ }
1109
+ return { removed, failed }
1110
+ }
1111
+
1112
+ if (!db) {
1113
+ return { removed, failed }
1114
+ }
1115
+
1116
+ let schemaMessage: string | null
1117
+ try {
1118
+ schemaMessage = getSchemaIssueMessage(
1119
+ db,
1120
+ buildSchemaRequirements(["session", "message", "part"]),
1121
+ "deleteSessionMetadata"
1122
+ )
1123
+ } catch (error) {
1124
+ const message = formatSqliteErrorMessage(error, "Failed to read SQLite schema", options)
1125
+ if (options.strict) {
1126
+ throw new Error(message)
1127
+ }
1128
+ for (const sessionId of sessionIds) {
1129
+ failed.push({ path: `sqlite:session:${sessionId}`, error: message })
1130
+ }
1131
+ return { removed, failed }
1132
+ }
1133
+ if (schemaMessage) {
1134
+ if (options.strict) {
1135
+ throw new Error(schemaMessage)
1136
+ }
1137
+ warnSqlite(options, schemaMessage)
1138
+ for (const sessionId of sessionIds) {
1139
+ failed.push({ path: `sqlite:session:${sessionId}`, error: schemaMessage })
1140
+ }
1141
+ return { removed, failed }
1142
+ }
1143
+
1144
+ if (options.dryRun) {
1145
+ // Dry run: just check which sessions exist and would be deleted
1146
+ const placeholders = sessionIds.map(() => "?").join(", ")
1147
+ const selectStmt = db.prepare(
1148
+ `SELECT id FROM session WHERE id IN (${placeholders})`
1149
+ )
1150
+ const existingRows = selectStmt.all(...sessionIds) as { id: string }[]
1151
+
1152
+ const existingIds = new Set(existingRows.map(r => r.id))
1153
+
1154
+ for (const sessionId of sessionIds) {
1155
+ if (existingIds.has(sessionId)) {
1156
+ removed.push(`sqlite:session:${sessionId}`)
1157
+ } else {
1158
+ // Session doesn't exist - not a failure, just not removed
1159
+ // (matching JSONL behavior where non-existent files report error)
1160
+ failed.push({
1161
+ path: `sqlite:session:${sessionId}`,
1162
+ error: "Session not found"
1163
+ })
1164
+ }
1165
+ }
1166
+
1167
+ return { removed, failed }
1168
+ }
1169
+
1170
+ // Actual deletion: use transaction for atomicity
1171
+ try {
1172
+ db.run(options.forceWrite ? "BEGIN IMMEDIATE" : "BEGIN TRANSACTION")
1173
+ } catch (error) {
1174
+ const message = formatSqliteErrorMessage(error, "Failed to start SQLite transaction", options)
1175
+ if (options.strict) {
1176
+ throw new Error(message)
1177
+ }
1178
+ for (const sessionId of sessionIds) {
1179
+ failed.push({ path: `sqlite:session:${sessionId}`, error: message })
1180
+ }
1181
+ return { removed, failed }
1182
+ }
1183
+
1184
+ try {
1185
+ // Build parameterized query with placeholders
1186
+ const placeholders = sessionIds.map(() => "?").join(", ")
1187
+
1188
+ // Delete parts first (child of message, also references session_id directly)
1189
+ const deleteParts = db.prepare(
1190
+ `DELETE FROM part WHERE session_id IN (${placeholders})`
1191
+ )
1192
+ deleteParts.run(...sessionIds)
1193
+
1194
+ // Delete messages next (child of session)
1195
+ const deleteMessages = db.prepare(
1196
+ `DELETE FROM message WHERE session_id IN (${placeholders})`
1197
+ )
1198
+ deleteMessages.run(...sessionIds)
1199
+
1200
+ // Finally delete sessions
1201
+ // Get the list of actually deleted sessions for accurate reporting
1202
+ const selectSessions = db.prepare(
1203
+ `SELECT id FROM session WHERE id IN (${placeholders})`
1204
+ )
1205
+ const existingRows = selectSessions.all(...sessionIds) as { id: string }[]
1206
+
1207
+ const existingIds = new Set(existingRows.map(r => r.id))
1208
+
1209
+ const deleteSessions = db.prepare(
1210
+ `DELETE FROM session WHERE id IN (${placeholders})`
1211
+ )
1212
+ deleteSessions.run(...sessionIds)
1213
+
1214
+ db.run("COMMIT")
1215
+
1216
+ // Report results
1217
+ for (const sessionId of sessionIds) {
1218
+ if (existingIds.has(sessionId)) {
1219
+ removed.push(`sqlite:session:${sessionId}`)
1220
+ } else {
1221
+ failed.push({
1222
+ path: `sqlite:session:${sessionId}`,
1223
+ error: "Session not found"
1224
+ })
1225
+ }
1226
+ }
1227
+
1228
+ } catch (error) {
1229
+ // Rollback on any error
1230
+ try {
1231
+ db.run("ROLLBACK")
1232
+ } catch {
1233
+ // Ignore rollback errors
1234
+ }
1235
+
1236
+ // Report all sessions as failed
1237
+ const message = formatSqliteErrorMessage(error, "SQLite delete failed", options)
1238
+ if (options.strict) {
1239
+ throw new Error(message)
1240
+ }
1241
+ for (const sessionId of sessionIds) {
1242
+ failed.push({
1243
+ path: `sqlite:session:${sessionId}`,
1244
+ error: message
1245
+ })
1246
+ }
1247
+ }
1248
+
1249
+ } finally {
1250
+ if (db) {
1251
+ closeIfOwned(db, options.db)
1252
+ }
1253
+ }
1254
+
1255
+ return { removed, failed }
1256
+ }
1257
+
1258
+ /**
1259
+ * Options for SQLite-based project deletion.
1260
+ */
1261
+ export interface SqliteDeleteProjectOptions extends SqliteLoadOptions {
1262
+ /**
1263
+ * If true, report what would be deleted without actually deleting.
1264
+ */
1265
+ dryRun?: boolean
1266
+ }
1267
+
1268
+ /**
1269
+ * Delete project metadata and all related data from SQLite database.
1270
+ *
1271
+ * This function deletes projects and their associated data (sessions, messages, parts)
1272
+ * in a transaction for atomicity. If any part of the deletion fails, the entire operation
1273
+ * is rolled back.
1274
+ *
1275
+ * Deletion order (to satisfy foreign key constraints if enabled):
1276
+ * 1. Get all session IDs for the projects
1277
+ * 2. Delete parts where session_id IN (sessionIds)
1278
+ * 3. Delete messages where session_id IN (sessionIds)
1279
+ * 4. Delete sessions where project_id IN (projectIds)
1280
+ * 5. Delete projects where id IN (projectIds)
1281
+ *
1282
+ * @param projectIds - Array of project IDs to delete.
1283
+ * @param options - Database connection options and dry-run flag.
1284
+ * @returns DeleteResult with removed project IDs and any failures.
1285
+ */
1286
+ export async function deleteProjectMetadataSqlite(
1287
+ projectIds: string[],
1288
+ options: SqliteDeleteProjectOptions
1289
+ ): Promise<DeleteResult> {
1290
+ // Handle empty input
1291
+ if (projectIds.length === 0) {
1292
+ return { removed: [], failed: [] }
1293
+ }
1294
+
1295
+ // For dry-run, we don't need write access
1296
+ const removed: string[] = []
1297
+ const failed: { path: string; error?: string }[] = []
1298
+ const needsWrite = !options.dryRun
1299
+ let db: Database | undefined
1300
+
1301
+ try {
1302
+ try {
1303
+ db = openDatabase(options.db, { readonly: !needsWrite, forceWrite: options.forceWrite })
1304
+ } catch (error) {
1305
+ const message = formatSqliteErrorMessage(error, "Failed to open SQLite database", options)
1306
+ if (options.strict) {
1307
+ throw new Error(message)
1308
+ }
1309
+ for (const projectId of projectIds) {
1310
+ failed.push({ path: `sqlite:project:${projectId}`, error: message })
1311
+ }
1312
+ return { removed, failed }
1313
+ }
1314
+
1315
+ if (!db) {
1316
+ return { removed, failed }
1317
+ }
1318
+
1319
+ let schemaMessage: string | null
1320
+ try {
1321
+ schemaMessage = getSchemaIssueMessage(
1322
+ db,
1323
+ buildSchemaRequirements(["project", "session", "message", "part"]),
1324
+ "deleteProjectMetadata"
1325
+ )
1326
+ } catch (error) {
1327
+ const message = formatSqliteErrorMessage(error, "Failed to read SQLite schema", options)
1328
+ if (options.strict) {
1329
+ throw new Error(message)
1330
+ }
1331
+ for (const projectId of projectIds) {
1332
+ failed.push({ path: `sqlite:project:${projectId}`, error: message })
1333
+ }
1334
+ return { removed, failed }
1335
+ }
1336
+ if (schemaMessage) {
1337
+ if (options.strict) {
1338
+ throw new Error(schemaMessage)
1339
+ }
1340
+ warnSqlite(options, schemaMessage)
1341
+ for (const projectId of projectIds) {
1342
+ failed.push({ path: `sqlite:project:${projectId}`, error: schemaMessage })
1343
+ }
1344
+ return { removed, failed }
1345
+ }
1346
+
1347
+ // Build parameterized query with placeholders
1348
+ const placeholders = projectIds.map(() => "?").join(", ")
1349
+
1350
+ // First, get all session IDs for these projects
1351
+ const selectSessions = db.prepare(
1352
+ `SELECT id FROM session WHERE project_id IN (${placeholders})`
1353
+ )
1354
+ const sessionRows = selectSessions.all(...projectIds) as { id: string }[]
1355
+ const sessionIds = sessionRows.map(r => r.id)
1356
+ const sessionPlaceholders = sessionIds.length > 0 ? sessionIds.map(() => "?").join(", ") : ""
1357
+
1358
+ if (options.dryRun) {
1359
+ // Dry run: just check which projects exist and would be deleted
1360
+ const selectProjects = db.prepare(
1361
+ `SELECT id FROM project WHERE id IN (${placeholders})`
1362
+ )
1363
+ const existingRows = selectProjects.all(...projectIds) as { id: string }[]
1364
+
1365
+ const existingIds = new Set(existingRows.map(r => r.id))
1366
+
1367
+ for (const projectId of projectIds) {
1368
+ if (existingIds.has(projectId)) {
1369
+ removed.push(`sqlite:project:${projectId}`)
1370
+ } else {
1371
+ failed.push({
1372
+ path: `sqlite:project:${projectId}`,
1373
+ error: "Project not found"
1374
+ })
1375
+ }
1376
+ }
1377
+
1378
+ return { removed, failed }
1379
+ }
1380
+
1381
+ // Actual deletion: use transaction for atomicity
1382
+ try {
1383
+ db.run(options.forceWrite ? "BEGIN IMMEDIATE" : "BEGIN TRANSACTION")
1384
+ } catch (error) {
1385
+ const message = formatSqliteErrorMessage(error, "Failed to start SQLite transaction", options)
1386
+ if (options.strict) {
1387
+ throw new Error(message)
1388
+ }
1389
+ for (const projectId of projectIds) {
1390
+ failed.push({ path: `sqlite:project:${projectId}`, error: message })
1391
+ }
1392
+ return { removed, failed }
1393
+ }
1394
+
1395
+ try {
1396
+ // Delete parts first (child of message, also references session_id directly)
1397
+ if (sessionIds.length > 0) {
1398
+ const deleteParts = db.prepare(
1399
+ `DELETE FROM part WHERE session_id IN (${sessionPlaceholders})`
1400
+ )
1401
+ deleteParts.run(...sessionIds)
1402
+
1403
+ // Delete messages next (child of session)
1404
+ const deleteMessages = db.prepare(
1405
+ `DELETE FROM message WHERE session_id IN (${sessionPlaceholders})`
1406
+ )
1407
+ deleteMessages.run(...sessionIds)
1408
+ }
1409
+
1410
+ // Delete sessions (child of project)
1411
+ const deleteSessions = db.prepare(
1412
+ `DELETE FROM session WHERE project_id IN (${placeholders})`
1413
+ )
1414
+ deleteSessions.run(...projectIds)
1415
+
1416
+ // Get the list of actually existing projects for accurate reporting
1417
+ const selectProjects = db.prepare(
1418
+ `SELECT id FROM project WHERE id IN (${placeholders})`
1419
+ )
1420
+ const existingRows = selectProjects.all(...projectIds) as { id: string }[]
1421
+
1422
+ const existingIds = new Set(existingRows.map(r => r.id))
1423
+
1424
+ // Finally delete projects
1425
+ const deleteProjects = db.prepare(
1426
+ `DELETE FROM project WHERE id IN (${placeholders})`
1427
+ )
1428
+ deleteProjects.run(...projectIds)
1429
+
1430
+ db.run("COMMIT")
1431
+
1432
+ // Report results
1433
+ for (const projectId of projectIds) {
1434
+ if (existingIds.has(projectId)) {
1435
+ removed.push(`sqlite:project:${projectId}`)
1436
+ } else {
1437
+ failed.push({
1438
+ path: `sqlite:project:${projectId}`,
1439
+ error: "Project not found"
1440
+ })
1441
+ }
1442
+ }
1443
+
1444
+ } catch (error) {
1445
+ // Rollback on any error
1446
+ try {
1447
+ db.run("ROLLBACK")
1448
+ } catch {
1449
+ // Ignore rollback errors
1450
+ }
1451
+
1452
+ // Report all projects as failed
1453
+ const message = formatSqliteErrorMessage(error, "SQLite delete failed", options)
1454
+ if (options.strict) {
1455
+ throw new Error(message)
1456
+ }
1457
+ for (const projectId of projectIds) {
1458
+ failed.push({
1459
+ path: `sqlite:project:${projectId}`,
1460
+ error: message
1461
+ })
1462
+ }
1463
+ }
1464
+
1465
+ } finally {
1466
+ if (db) {
1467
+ closeIfOwned(db, options.db)
1468
+ }
1469
+ }
1470
+
1471
+ return { removed, failed }
1472
+ }
1473
+
1474
+ // ========================
1475
+ // Session Update Operations
1476
+ // ========================
1477
+
1478
+ /**
1479
+ * Options for updating session title in SQLite.
1480
+ */
1481
+ export interface SqliteUpdateTitleOptions extends SqliteLoadOptions {
1482
+ /**
1483
+ * The session ID to update.
1484
+ */
1485
+ sessionId: string
1486
+
1487
+ /**
1488
+ * The new title to set.
1489
+ */
1490
+ newTitle: string
1491
+ }
1492
+
1493
+ /**
1494
+ * Update the title of a session in SQLite database.
1495
+ *
1496
+ * This function:
1497
+ * 1. Loads the existing session data from the database
1498
+ * 2. Updates the title field in the JSON data
1499
+ * 3. Updates the updated_at timestamp in both the column and JSON data
1500
+ * 4. Writes the updated data back to the database
1501
+ *
1502
+ * @param options - Database connection options with sessionId and newTitle.
1503
+ * @throws Error if the session is not found.
1504
+ */
1505
+ export async function updateSessionTitleSqlite(
1506
+ options: SqliteUpdateTitleOptions
1507
+ ): Promise<void> {
1508
+ const db = openDatabase(options.db, { readonly: false, forceWrite: options.forceWrite })
1509
+
1510
+ try {
1511
+ const schemaMessage = getSchemaIssueMessage(
1512
+ db,
1513
+ buildSchemaRequirements(["session"]),
1514
+ "updateSessionTitle"
1515
+ )
1516
+ if (schemaMessage) {
1517
+ if (options.strict) {
1518
+ throw new Error(schemaMessage)
1519
+ }
1520
+ warnSqlite(options, schemaMessage)
1521
+ throw new Error(schemaMessage)
1522
+ }
1523
+
1524
+ // Load existing session data
1525
+ let row: { id: string; data: string } | null = null
1526
+ try {
1527
+ row = db.query(
1528
+ "SELECT id, data FROM session WHERE id = ?"
1529
+ ).get(options.sessionId) as { id: string; data: string } | null
1530
+ } catch (error) {
1531
+ throw new Error(formatSqliteErrorMessage(error, "Failed to query session table", options))
1532
+ }
1533
+
1534
+ if (!row) {
1535
+ throw new Error(`Session not found: ${options.sessionId}`)
1536
+ }
1537
+
1538
+ // Parse existing JSON data
1539
+ let data: SessionData
1540
+ try {
1541
+ data = JSON.parse(row.data) as SessionData
1542
+ } catch (error) {
1543
+ throw new Error(
1544
+ formatSqliteErrorMessage(error, `Failed to parse session data for: ${options.sessionId}`, options)
1545
+ )
1546
+ }
1547
+
1548
+ // Update title and timestamp
1549
+ data.title = options.newTitle
1550
+ data.time = data.time ?? {}
1551
+ const now = Date.now()
1552
+ data.time.updated = now
1553
+
1554
+ // Update the database
1555
+ try {
1556
+ const stmt = db.prepare(
1557
+ "UPDATE session SET data = ?, updated_at = ? WHERE id = ?"
1558
+ )
1559
+ stmt.run(JSON.stringify(data), now, options.sessionId)
1560
+ } catch (error) {
1561
+ throw new Error(formatSqliteErrorMessage(error, "Failed to update session title", options))
1562
+ }
1563
+ } catch (error) {
1564
+ if (isSqliteBusyError(error)) {
1565
+ throw new Error(
1566
+ formatBusyErrorMessage("SQLite database is locked", { forceWrite: options.forceWrite })
1567
+ )
1568
+ }
1569
+ throw error instanceof Error ? error : new Error(String(error))
1570
+ } finally {
1571
+ closeIfOwned(db, options.db)
1572
+ }
1573
+ }
1574
+
1575
+ // ========================
1576
+ // Session Move Operations
1577
+ // ========================
1578
+
1579
+ /**
1580
+ * Options for moving a session to a different project in SQLite.
1581
+ */
1582
+ export interface SqliteMoveSessionOptions extends SqliteLoadOptions {
1583
+ /**
1584
+ * The session ID to move.
1585
+ */
1586
+ sessionId: string
1587
+
1588
+ /**
1589
+ * The target project ID to move the session to.
1590
+ */
1591
+ targetProjectId: string
1592
+ }
1593
+
1594
+ /**
1595
+ * Move a session to a different project in SQLite database.
1596
+ *
1597
+ * This function:
1598
+ * 1. Loads the existing session data from the database
1599
+ * 2. Verifies the target project exists (optional - see notes)
1600
+ * 3. Updates the project_id column in the session row
1601
+ * 4. Updates the projectID field in the JSON data
1602
+ * 5. Updates the updated_at timestamp in both column and JSON
1603
+ * 6. Returns the updated session record
1604
+ *
1605
+ * Note: Unlike JSONL which moves files between directories, SQLite just updates
1606
+ * the project_id column. There's no file system operation.
1607
+ *
1608
+ * @param options - Database connection options with sessionId and targetProjectId.
1609
+ * @returns The updated SessionRecord with new projectId.
1610
+ * @throws Error if the session is not found.
1611
+ */
1612
+ export async function moveSessionSqlite(
1613
+ options: SqliteMoveSessionOptions
1614
+ ): Promise<SessionRecord> {
1615
+ const db = openDatabase(options.db, { readonly: false, forceWrite: options.forceWrite })
1616
+
1617
+ try {
1618
+ const schemaMessage = getSchemaIssueMessage(
1619
+ db,
1620
+ buildSchemaRequirements(["session"]),
1621
+ "moveSession"
1622
+ )
1623
+ if (schemaMessage) {
1624
+ if (options.strict) {
1625
+ throw new Error(schemaMessage)
1626
+ }
1627
+ warnSqlite(options, schemaMessage)
1628
+ throw new Error(schemaMessage)
1629
+ }
1630
+
1631
+ // Load existing session data
1632
+ let row: SessionRow | null = null
1633
+ try {
1634
+ row = db.query(
1635
+ "SELECT id, project_id, parent_id, created_at, updated_at, data FROM session WHERE id = ?"
1636
+ ).get(options.sessionId) as SessionRow | null
1637
+ } catch (error) {
1638
+ throw new Error(formatSqliteErrorMessage(error, "Failed to query session table", options))
1639
+ }
1640
+
1641
+ if (!row) {
1642
+ throw new Error(`Session not found: ${options.sessionId}`)
1643
+ }
1644
+
1645
+ // Parse existing JSON data
1646
+ let data: SessionData
1647
+ try {
1648
+ data = JSON.parse(row.data) as SessionData
1649
+ } catch (error) {
1650
+ throw new Error(
1651
+ formatSqliteErrorMessage(error, `Failed to parse session data for: ${options.sessionId}`, options)
1652
+ )
1653
+ }
1654
+
1655
+ // Update project ID and timestamp
1656
+ const now = Date.now()
1657
+ data.projectID = options.targetProjectId
1658
+ data.time = data.time ?? {}
1659
+ data.time.updated = now
1660
+
1661
+ // Update the database - both project_id column and data JSON
1662
+ try {
1663
+ const stmt = db.prepare(
1664
+ "UPDATE session SET project_id = ?, data = ?, updated_at = ? WHERE id = ?"
1665
+ )
1666
+ stmt.run(options.targetProjectId, JSON.stringify(data), now, options.sessionId)
1667
+ } catch (error) {
1668
+ throw new Error(formatSqliteErrorMessage(error, "Failed to move session", options))
1669
+ }
1670
+
1671
+ // Build and return the updated session record
1672
+ const createdAt = msToDate(row.created_at) ?? msToDate(data.time?.created)
1673
+ const directory = expandUserPath(data.directory)
1674
+
1675
+ return {
1676
+ index: 1, // Single result, so index is 1
1677
+ filePath: `sqlite:session:${row.id}`,
1678
+ sessionId: row.id,
1679
+ projectId: options.targetProjectId,
1680
+ directory: directory ?? "",
1681
+ title: typeof data.title === "string" ? data.title : "",
1682
+ version: typeof data.version === "string" ? data.version : "",
1683
+ createdAt,
1684
+ updatedAt: new Date(now),
1685
+ }
1686
+ } catch (error) {
1687
+ if (isSqliteBusyError(error)) {
1688
+ throw new Error(
1689
+ formatBusyErrorMessage("SQLite database is locked", { forceWrite: options.forceWrite })
1690
+ )
1691
+ }
1692
+ throw error instanceof Error ? error : new Error(String(error))
1693
+ } finally {
1694
+ closeIfOwned(db, options.db)
1695
+ }
1696
+ }
1697
+
1698
+ // ========================
1699
+ // Session Copy Operations
1700
+ // ========================
1701
+
1702
+ /**
1703
+ * Options for copying a session to a different project in SQLite.
1704
+ */
1705
+ export interface SqliteCopySessionOptions extends SqliteLoadOptions {
1706
+ /**
1707
+ * The session ID to copy.
1708
+ */
1709
+ sessionId: string
1710
+
1711
+ /**
1712
+ * The target project ID to copy the session to.
1713
+ */
1714
+ targetProjectId: string
1715
+ }
1716
+
1717
+ /**
1718
+ * Generate a new unique ID with a given prefix.
1719
+ * Format: {prefix}_{timestamp}_{random}
1720
+ *
1721
+ * @param prefix - Prefix for the ID (e.g., "session", "msg", "part")
1722
+ * @returns A unique ID string.
1723
+ */
1724
+ function generateId(prefix: string): string {
1725
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
1726
+ }
1727
+
1728
+ /**
1729
+ * Copy a session to a different project in SQLite database.
1730
+ *
1731
+ * This function:
1732
+ * 1. Generates new IDs for the session, all messages, and all parts
1733
+ * 2. Copies the session row with new ID and target project_id
1734
+ * 3. Copies all messages with new IDs, pointing to the new session
1735
+ * 4. Copies all parts with new IDs, pointing to the new messages
1736
+ * 5. Uses a transaction for atomicity
1737
+ * 6. Returns the new session record
1738
+ *
1739
+ * Note: Unlike JSONL which copies files, SQLite duplicates rows with new IDs.
1740
+ * All relationships (session->messages->parts) are preserved via ID remapping.
1741
+ *
1742
+ * @param options - Database connection options with sessionId and targetProjectId.
1743
+ * @returns The new SessionRecord with new sessionId and targetProjectId.
1744
+ * @throws Error if the source session is not found.
1745
+ */
1746
+ export async function copySessionSqlite(
1747
+ options: SqliteCopySessionOptions
1748
+ ): Promise<SessionRecord> {
1749
+ const db = openDatabase(options.db, { readonly: false, forceWrite: options.forceWrite })
1750
+
1751
+ try {
1752
+ const schemaMessage = getSchemaIssueMessage(
1753
+ db,
1754
+ buildSchemaRequirements(["session", "message", "part"]),
1755
+ "copySession"
1756
+ )
1757
+ if (schemaMessage) {
1758
+ if (options.strict) {
1759
+ throw new Error(schemaMessage)
1760
+ }
1761
+ warnSqlite(options, schemaMessage)
1762
+ throw new Error(schemaMessage)
1763
+ }
1764
+
1765
+ // Load existing session data
1766
+ let sessionRow: SessionRow | null = null
1767
+ try {
1768
+ sessionRow = db.query(
1769
+ "SELECT id, project_id, parent_id, created_at, updated_at, data FROM session WHERE id = ?"
1770
+ ).get(options.sessionId) as SessionRow | null
1771
+ } catch (error) {
1772
+ throw new Error(formatSqliteErrorMessage(error, "Failed to query session table", options))
1773
+ }
1774
+
1775
+ if (!sessionRow) {
1776
+ throw new Error(`Session not found: ${options.sessionId}`)
1777
+ }
1778
+
1779
+ // Parse existing session JSON data
1780
+ let sessionData: SessionData
1781
+ try {
1782
+ sessionData = JSON.parse(sessionRow.data) as SessionData
1783
+ } catch (error) {
1784
+ throw new Error(
1785
+ formatSqliteErrorMessage(error, `Failed to parse session data for: ${options.sessionId}`, options)
1786
+ )
1787
+ }
1788
+
1789
+ // Generate new session ID
1790
+ const newSessionId = generateId("session")
1791
+ const now = Date.now()
1792
+
1793
+ // Update session data for the copy
1794
+ const newSessionData: SessionData = {
1795
+ ...sessionData,
1796
+ id: newSessionId,
1797
+ projectID: options.targetProjectId,
1798
+ time: {
1799
+ ...sessionData.time,
1800
+ created: now,
1801
+ updated: now,
1802
+ },
1803
+ }
1804
+
1805
+ // Load all messages for this session
1806
+ let messageRows: MessageRow[] = []
1807
+ try {
1808
+ messageRows = db.query(
1809
+ "SELECT id, session_id, created_at, data FROM message WHERE session_id = ? ORDER BY created_at ASC"
1810
+ ).all(options.sessionId) as MessageRow[]
1811
+ } catch (error) {
1812
+ throw new Error(formatSqliteErrorMessage(error, "Failed to query message table", options))
1813
+ }
1814
+
1815
+ // Load all parts for this session
1816
+ let partRows: PartRow[] = []
1817
+ try {
1818
+ partRows = db.query(
1819
+ "SELECT id, message_id, session_id, data FROM part WHERE session_id = ?"
1820
+ ).all(options.sessionId) as PartRow[]
1821
+ } catch (error) {
1822
+ throw new Error(formatSqliteErrorMessage(error, "Failed to query part table", options))
1823
+ }
1824
+
1825
+ // Create ID mapping for messages (old ID -> new ID)
1826
+ const messageIdMap = new Map<string, string>()
1827
+ for (const msg of messageRows) {
1828
+ messageIdMap.set(msg.id, generateId("msg"))
1829
+ }
1830
+
1831
+ // Begin transaction for atomicity
1832
+ try {
1833
+ db.run(options.forceWrite ? "BEGIN IMMEDIATE" : "BEGIN TRANSACTION")
1834
+ } catch (error) {
1835
+ throw new Error(formatSqliteErrorMessage(error, "Failed to start SQLite transaction", options))
1836
+ }
1837
+
1838
+ try {
1839
+ // Insert new session
1840
+ const insertSessionStmt = db.prepare(
1841
+ "INSERT INTO session (id, project_id, parent_id, created_at, updated_at, data) VALUES (?, ?, ?, ?, ?, ?)"
1842
+ )
1843
+ insertSessionStmt.run(
1844
+ newSessionId,
1845
+ options.targetProjectId,
1846
+ null, // Copied sessions have no parent_id
1847
+ now,
1848
+ now,
1849
+ JSON.stringify(newSessionData)
1850
+ )
1851
+
1852
+ // Insert copied messages
1853
+ if (messageRows.length > 0) {
1854
+ const insertMessageStmt = db.prepare(
1855
+ "INSERT INTO message (id, session_id, created_at, data) VALUES (?, ?, ?, ?)"
1856
+ )
1857
+
1858
+ for (const msgRow of messageRows) {
1859
+ const newMessageId = messageIdMap.get(msgRow.id)!
1860
+
1861
+ // Parse and update message data
1862
+ let msgData: MessageData
1863
+ try {
1864
+ msgData = JSON.parse(msgRow.data) as MessageData
1865
+ } catch (error) {
1866
+ const message = formatSqliteErrorMessage(
1867
+ error,
1868
+ `Malformed JSON in message row "${msgRow.id}"`,
1869
+ options
1870
+ )
1871
+ if (options.strict) {
1872
+ throw new Error(message)
1873
+ }
1874
+ warnSqlite(options, message)
1875
+ continue
1876
+ }
1877
+
1878
+ const newMsgData: MessageData = {
1879
+ ...msgData,
1880
+ id: newMessageId,
1881
+ sessionID: newSessionId,
1882
+ }
1883
+
1884
+ insertMessageStmt.run(
1885
+ newMessageId,
1886
+ newSessionId,
1887
+ msgRow.created_at,
1888
+ JSON.stringify(newMsgData)
1889
+ )
1890
+ }
1891
+ }
1892
+
1893
+ // Insert copied parts
1894
+ if (partRows.length > 0) {
1895
+ const insertPartStmt = db.prepare(
1896
+ "INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)"
1897
+ )
1898
+
1899
+ for (const partRow of partRows) {
1900
+ const newMessageId = messageIdMap.get(partRow.message_id)
1901
+ if (!newMessageId) {
1902
+ // Skip orphaned parts (message was skipped due to malformed data)
1903
+ continue
1904
+ }
1905
+
1906
+ const newPartId = generateId("part")
1907
+
1908
+ // Parse and update part data
1909
+ let partData: PartData
1910
+ try {
1911
+ partData = JSON.parse(partRow.data) as PartData
1912
+ } catch (error) {
1913
+ const message = formatSqliteErrorMessage(
1914
+ error,
1915
+ `Malformed JSON in part row "${partRow.id}"`,
1916
+ options
1917
+ )
1918
+ if (options.strict) {
1919
+ throw new Error(message)
1920
+ }
1921
+ warnSqlite(options, message)
1922
+ continue
1923
+ }
1924
+
1925
+ const newPartData: PartData = {
1926
+ ...partData,
1927
+ id: newPartId,
1928
+ messageID: newMessageId,
1929
+ sessionID: newSessionId,
1930
+ }
1931
+
1932
+ insertPartStmt.run(
1933
+ newPartId,
1934
+ newMessageId,
1935
+ newSessionId,
1936
+ JSON.stringify(newPartData)
1937
+ )
1938
+ }
1939
+ }
1940
+
1941
+ // Commit transaction
1942
+ db.run("COMMIT")
1943
+ } catch (err) {
1944
+ // Rollback on error
1945
+ db.run("ROLLBACK")
1946
+ throw err
1947
+ }
1948
+
1949
+ // Build and return the new session record
1950
+ const directory = expandUserPath(newSessionData.directory)
1951
+
1952
+ return {
1953
+ index: 1, // Single result, so index is 1
1954
+ filePath: `sqlite:session:${newSessionId}`,
1955
+ sessionId: newSessionId,
1956
+ projectId: options.targetProjectId,
1957
+ directory: directory ?? "",
1958
+ title: typeof newSessionData.title === "string" ? newSessionData.title : "",
1959
+ version: typeof newSessionData.version === "string" ? newSessionData.version : "",
1960
+ createdAt: new Date(now),
1961
+ updatedAt: new Date(now),
1962
+ }
1963
+ } catch (error) {
1964
+ if (isSqliteBusyError(error)) {
1965
+ throw new Error(
1966
+ formatBusyErrorMessage("SQLite database is locked", { forceWrite: options.forceWrite })
1967
+ )
1968
+ }
1969
+ throw error instanceof Error ? error : new Error(String(error))
1970
+ } finally {
1971
+ closeIfOwned(db, options.db)
1972
+ }
1973
+ }