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.
- package/README.md +70 -9
- package/bun.lock +5 -0
- package/package.json +2 -1
- package/src/cli/commands/chat.ts +37 -23
- package/src/cli/commands/projects.ts +25 -9
- package/src/cli/commands/sessions.ts +52 -27
- package/src/cli/commands/tokens.ts +28 -16
- package/src/cli/index.ts +41 -1
- package/src/cli/resolvers.ts +34 -9
- package/src/lib/opencode-data-provider.ts +685 -0
- package/src/lib/opencode-data-sqlite.ts +1973 -0
- package/tsconfig.json +1 -1
|
@@ -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
|
+
}
|