prjct-cli 1.15.0 → 1.17.0

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,720 @@
1
+ /**
2
+ * JSON → SQLite Migration (PRJ-303)
3
+ *
4
+ * One-time migration: reads existing JSON/JSONL files and inserts into SQLite.
5
+ * Creates backup of original files before migration.
6
+ *
7
+ * Migration flow:
8
+ * 1. Check if prjct.db already exists → skip if so
9
+ * 2. Backup all JSON files to storage/backup/
10
+ * 3. Insert documents into kv_store (backward-compatible)
11
+ * 4. Populate normalized tables for indexed queries
12
+ * 5. Migrate events.jsonl → events table
13
+ * 6. Migrate index files → index tables
14
+ *
15
+ * Auto-runs on first StorageManager access when prjct.db doesn't exist.
16
+ *
17
+ * @version 1.0.0
18
+ */
19
+
20
+ import fs from 'node:fs/promises'
21
+ import path from 'node:path'
22
+ import pathManager from '../infrastructure/path-manager'
23
+ import { isNotFoundError } from '../types/fs'
24
+ import { prjctDb } from './database'
25
+
26
+ // =============================================================================
27
+ // Types
28
+ // =============================================================================
29
+
30
+ export interface MigrationResult {
31
+ success: boolean
32
+ migratedFiles: string[]
33
+ skippedFiles: string[]
34
+ errors: Array<{ file: string; error: string }>
35
+ backupDir: string | null
36
+ duration: number
37
+ }
38
+
39
+ // =============================================================================
40
+ // File Definitions
41
+ // =============================================================================
42
+
43
+ /** Storage JSON files → kv_store keys */
44
+ const STORAGE_FILES: Array<{ filename: string; key: string }> = [
45
+ { filename: 'state.json', key: 'state' },
46
+ { filename: 'queue.json', key: 'queue' },
47
+ { filename: 'ideas.json', key: 'ideas' },
48
+ { filename: 'shipped.json', key: 'shipped' },
49
+ { filename: 'metrics.json', key: 'metrics' },
50
+ { filename: 'velocity.json', key: 'velocity' },
51
+ { filename: 'analysis.json', key: 'analysis' },
52
+ { filename: 'roadmap.json', key: 'roadmap' },
53
+ { filename: 'session.json', key: 'session' },
54
+ { filename: 'issues.json', key: 'issues' },
55
+ ]
56
+
57
+ /** Index JSON files → index_meta keys */
58
+ const INDEX_FILES: Array<{ filename: string; key: string }> = [
59
+ { filename: 'project-index.json', key: 'project-index' },
60
+ { filename: 'domains.json', key: 'domains' },
61
+ { filename: 'categories-cache.json', key: 'categories-cache' },
62
+ ]
63
+
64
+ // =============================================================================
65
+ // Main Migration Function
66
+ // =============================================================================
67
+
68
+ /**
69
+ * Migrate all JSON files to SQLite for a project.
70
+ * Safe to call multiple times — skips if DB already has data.
71
+ */
72
+ export async function migrateJsonToSqlite(projectId: string): Promise<MigrationResult> {
73
+ const start = Date.now()
74
+ const result: MigrationResult = {
75
+ success: false,
76
+ migratedFiles: [],
77
+ skippedFiles: [],
78
+ errors: [],
79
+ backupDir: null,
80
+ duration: 0,
81
+ }
82
+
83
+ try {
84
+ // Check if already migrated (kv_store has data)
85
+ if (prjctDb.exists(projectId) && prjctDb.hasDoc(projectId, 'state')) {
86
+ result.success = true
87
+ result.duration = Date.now() - start
88
+ return result
89
+ }
90
+
91
+ const globalPath = pathManager.getGlobalProjectPath(projectId)
92
+ const storagePath = path.join(globalPath, 'storage')
93
+ const indexPath = path.join(globalPath, 'index')
94
+ const memoryPath = path.join(globalPath, 'memory')
95
+
96
+ // Step 1: Create backup
97
+ result.backupDir = await createBackup(storagePath, indexPath, memoryPath)
98
+
99
+ // Step 2: Ensure DB is initialized (creates tables)
100
+ prjctDb.getDb(projectId)
101
+
102
+ // Step 3: Migrate storage JSON files → kv_store + normalized tables
103
+ for (const { filename, key } of STORAGE_FILES) {
104
+ const filePath = path.join(storagePath, filename)
105
+ const data = await readJsonSafe(filePath)
106
+
107
+ if (data === null) {
108
+ result.skippedFiles.push(filename)
109
+ continue
110
+ }
111
+
112
+ try {
113
+ // Write to kv_store (document storage)
114
+ prjctDb.setDoc(projectId, key, data)
115
+
116
+ // Populate normalized tables
117
+ populateNormalized(projectId, key, data)
118
+
119
+ result.migratedFiles.push(filename)
120
+ } catch (err) {
121
+ result.errors.push({ file: filename, error: String(err) })
122
+ }
123
+ }
124
+
125
+ // Step 4: Migrate index files → index_meta + normalized index tables
126
+ for (const { filename, key } of INDEX_FILES) {
127
+ const filePath = path.join(indexPath, filename)
128
+ const data = await readJsonSafe(filePath)
129
+
130
+ if (data === null) {
131
+ result.skippedFiles.push(`index/${filename}`)
132
+ continue
133
+ }
134
+
135
+ try {
136
+ prjctDb.run(
137
+ projectId,
138
+ 'INSERT OR REPLACE INTO index_meta (key, data, updated_at) VALUES (?, ?, ?)',
139
+ key,
140
+ JSON.stringify(data),
141
+ new Date().toISOString()
142
+ )
143
+
144
+ // Populate normalized index tables
145
+ populateIndexTables(projectId, key, data)
146
+
147
+ result.migratedFiles.push(`index/${filename}`)
148
+ } catch (err) {
149
+ result.errors.push({ file: `index/${filename}`, error: String(err) })
150
+ }
151
+ }
152
+
153
+ // Step 5: Migrate checksums.json → index_checksums table
154
+ await migrateChecksums(projectId, indexPath, result)
155
+
156
+ // Step 6: Migrate file-scores.json → index_files table
157
+ await migrateFileScores(projectId, indexPath, result)
158
+
159
+ // Step 7: Migrate events.jsonl → events table
160
+ await migrateEventsJsonl(projectId, memoryPath, result)
161
+
162
+ // Step 8: Migrate learnings.jsonl → memory table
163
+ await migrateLearningsJsonl(projectId, memoryPath, result)
164
+
165
+ // Step 9: Clean up source JSON files (backup already exists)
166
+ if (result.errors.length === 0) {
167
+ await cleanupJsonFiles(storagePath, indexPath, memoryPath, result)
168
+ }
169
+
170
+ result.success = result.errors.length === 0
171
+ result.duration = Date.now() - start
172
+ return result
173
+ } catch (err) {
174
+ result.errors.push({ file: '<migration>', error: String(err) })
175
+ result.duration = Date.now() - start
176
+ return result
177
+ }
178
+ }
179
+
180
+ // =============================================================================
181
+ // Backup
182
+ // =============================================================================
183
+
184
+ async function createBackup(
185
+ storagePath: string,
186
+ indexPath: string,
187
+ memoryPath: string
188
+ ): Promise<string> {
189
+ const backupDir = path.join(storagePath, 'backup')
190
+ await fs.mkdir(backupDir, { recursive: true })
191
+ await fs.mkdir(path.join(backupDir, 'index'), { recursive: true })
192
+ await fs.mkdir(path.join(backupDir, 'memory'), { recursive: true })
193
+
194
+ // Backup storage files
195
+ await copyFiles(
196
+ storagePath,
197
+ backupDir,
198
+ (name) => name.endsWith('.json') || name.endsWith('.jsonl')
199
+ )
200
+
201
+ // Backup index files
202
+ await copyFiles(indexPath, path.join(backupDir, 'index'))
203
+
204
+ // Backup memory files
205
+ await copyFiles(memoryPath, path.join(backupDir, 'memory'))
206
+
207
+ return backupDir
208
+ }
209
+
210
+ async function copyFiles(
211
+ srcDir: string,
212
+ destDir: string,
213
+ filter?: (name: string) => boolean
214
+ ): Promise<void> {
215
+ try {
216
+ const entries = await fs.readdir(srcDir, { withFileTypes: true })
217
+ for (const entry of entries) {
218
+ if (!entry.isFile()) continue
219
+ if (filter && !filter(entry.name)) continue
220
+ const src = path.join(srcDir, entry.name)
221
+ const dest = path.join(destDir, entry.name)
222
+ await fs.copyFile(src, dest)
223
+ }
224
+ } catch (err) {
225
+ if (!isNotFoundError(err)) throw err
226
+ }
227
+ }
228
+
229
+ // =============================================================================
230
+ // Normalized Table Population
231
+ // =============================================================================
232
+
233
+ function populateNormalized(projectId: string, key: string, data: unknown): void {
234
+ switch (key) {
235
+ case 'state':
236
+ populateTasksFromState(projectId, data as Record<string, unknown>)
237
+ break
238
+ case 'queue':
239
+ populateQueueTasks(projectId, data as Record<string, unknown>)
240
+ break
241
+ case 'ideas':
242
+ populateIdeas(projectId, data as Record<string, unknown>)
243
+ break
244
+ case 'shipped':
245
+ populateShippedFeatures(projectId, data as Record<string, unknown>)
246
+ break
247
+ case 'metrics':
248
+ populateMetricsDaily(projectId, data as Record<string, unknown>)
249
+ break
250
+ case 'analysis':
251
+ populateAnalysis(projectId, data as Record<string, unknown>)
252
+ break
253
+ }
254
+ }
255
+
256
+ function populateTasksFromState(projectId: string, state: Record<string, unknown>): void {
257
+ const db = prjctDb.getDb(projectId)
258
+
259
+ const insertTask = db.prepare(`
260
+ INSERT OR REPLACE INTO tasks
261
+ (id, description, type, status, parent_description, branch, linear_id,
262
+ linear_uuid, session_id, feature_id, started_at, completed_at,
263
+ shipped_at, paused_at, pause_reason, pr_url, expected_value, data)
264
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
265
+ `)
266
+
267
+ const insertSubtask = db.prepare(`
268
+ INSERT OR REPLACE INTO subtasks
269
+ (id, task_id, description, status, domain, agent, sort_order,
270
+ depends_on, started_at, completed_at, output, summary)
271
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
272
+ `)
273
+
274
+ const migrateTask = (task: Record<string, unknown>, status?: string) => {
275
+ if (!task || !task.id) return
276
+
277
+ insertTask.run(
278
+ task.id as string,
279
+ (task.description ?? task.parentDescription ?? '') as string,
280
+ (task.type ?? null) as string | null,
281
+ (status ?? task.status ?? 'unknown') as string,
282
+ (task.parentDescription ?? null) as string | null,
283
+ (task.branch ?? null) as string | null,
284
+ (task.linearId ?? null) as string | null,
285
+ (task.linearUuid ?? null) as string | null,
286
+ (task.sessionId ?? null) as string | null,
287
+ (task.featureId ?? null) as string | null,
288
+ (task.startedAt ?? new Date().toISOString()) as string,
289
+ (task.completedAt ?? null) as string | null,
290
+ (task.shippedAt ?? null) as string | null,
291
+ (task.pausedAt ?? null) as string | null,
292
+ (task.pauseReason ?? null) as string | null,
293
+ (task.prUrl ?? null) as string | null,
294
+ task.expectedValue ? JSON.stringify(task.expectedValue) : null,
295
+ JSON.stringify(task)
296
+ )
297
+
298
+ // Migrate subtasks
299
+ const subtasks = task.subtasks as Array<Record<string, unknown>> | undefined
300
+ if (subtasks && Array.isArray(subtasks)) {
301
+ for (let i = 0; i < subtasks.length; i++) {
302
+ const st = subtasks[i]
303
+ insertSubtask.run(
304
+ (st.id ?? `subtask-${i}`) as string,
305
+ task.id as string,
306
+ (st.description ?? '') as string,
307
+ (st.status ?? 'pending') as string,
308
+ (st.domain ?? null) as string | null,
309
+ (st.agent ?? null) as string | null,
310
+ i,
311
+ st.dependsOn ? JSON.stringify(st.dependsOn) : null,
312
+ (st.startedAt ?? null) as string | null,
313
+ (st.completedAt ?? null) as string | null,
314
+ (st.output ?? null) as string | null,
315
+ st.summary ? JSON.stringify(st.summary) : null
316
+ )
317
+ }
318
+ }
319
+ }
320
+
321
+ if (state.currentTask) migrateTask(state.currentTask as Record<string, unknown>)
322
+ if (state.previousTask) migrateTask(state.previousTask as Record<string, unknown>)
323
+
324
+ const paused = state.pausedTasks as Array<Record<string, unknown>> | undefined
325
+ if (paused && Array.isArray(paused)) {
326
+ for (const task of paused) {
327
+ migrateTask(task, 'paused')
328
+ }
329
+ }
330
+ }
331
+
332
+ function populateQueueTasks(projectId: string, data: Record<string, unknown>): void {
333
+ const tasks = data.tasks as Array<Record<string, unknown>> | undefined
334
+ if (!tasks || !Array.isArray(tasks)) return
335
+
336
+ const db = prjctDb.getDb(projectId)
337
+ const stmt = db.prepare(`
338
+ INSERT OR REPLACE INTO queue_tasks
339
+ (id, description, type, priority, section, created_at, completed, completed_at,
340
+ feature_id, feature_name)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
342
+ `)
343
+
344
+ for (const t of tasks) {
345
+ stmt.run(
346
+ t.id as string,
347
+ (t.description ?? '') as string,
348
+ (t.type ?? null) as string | null,
349
+ (t.priority ?? null) as string | null,
350
+ (t.section ?? null) as string | null,
351
+ (t.createdAt ?? new Date().toISOString()) as string,
352
+ t.completed ? 1 : 0,
353
+ (t.completedAt ?? null) as string | null,
354
+ (t.featureId ?? null) as string | null,
355
+ (t.featureName ?? null) as string | null
356
+ )
357
+ }
358
+ }
359
+
360
+ function populateIdeas(projectId: string, data: Record<string, unknown>): void {
361
+ const ideas = data.ideas as Array<Record<string, unknown>> | undefined
362
+ if (!ideas || !Array.isArray(ideas)) return
363
+
364
+ const db = prjctDb.getDb(projectId)
365
+ const stmt = db.prepare(`
366
+ INSERT OR REPLACE INTO ideas
367
+ (id, text, status, priority, tags, added_at, converted_to, details, data)
368
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
369
+ `)
370
+
371
+ for (const idea of ideas) {
372
+ stmt.run(
373
+ idea.id as string,
374
+ (idea.text ?? '') as string,
375
+ (idea.status ?? 'pending') as string,
376
+ (idea.priority ?? 'medium') as string,
377
+ idea.tags ? JSON.stringify(idea.tags) : null,
378
+ (idea.addedAt ?? new Date().toISOString()) as string,
379
+ (idea.convertedTo ?? null) as string | null,
380
+ (idea.details ?? null) as string | null,
381
+ JSON.stringify(idea)
382
+ )
383
+ }
384
+ }
385
+
386
+ function populateShippedFeatures(projectId: string, data: Record<string, unknown>): void {
387
+ const shipped = data.shipped as Array<Record<string, unknown>> | undefined
388
+ if (!shipped || !Array.isArray(shipped)) return
389
+
390
+ const db = prjctDb.getDb(projectId)
391
+ const stmt = db.prepare(`
392
+ INSERT OR REPLACE INTO shipped_features
393
+ (id, name, shipped_at, version, description, type, duration, data)
394
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
395
+ `)
396
+
397
+ for (const feature of shipped) {
398
+ stmt.run(
399
+ feature.id as string,
400
+ (feature.name ?? '') as string,
401
+ (feature.shippedAt ?? new Date().toISOString()) as string,
402
+ (feature.version ?? '0.0.0') as string,
403
+ (feature.description ?? null) as string | null,
404
+ (feature.type ?? null) as string | null,
405
+ (feature.duration ?? null) as string | null,
406
+ JSON.stringify(feature)
407
+ )
408
+ }
409
+ }
410
+
411
+ function populateMetricsDaily(projectId: string, data: Record<string, unknown>): void {
412
+ const dailyStats = data.dailyStats as Array<Record<string, unknown>> | undefined
413
+ if (!dailyStats || !Array.isArray(dailyStats)) return
414
+
415
+ const db = prjctDb.getDb(projectId)
416
+ const stmt = db.prepare(`
417
+ INSERT OR REPLACE INTO metrics_daily
418
+ (date, tokens_saved, syncs, avg_compression_rate, total_duration)
419
+ VALUES (?, ?, ?, ?, ?)
420
+ `)
421
+
422
+ for (const day of dailyStats) {
423
+ stmt.run(
424
+ day.date as string,
425
+ (day.tokensSaved ?? 0) as number,
426
+ (day.syncs ?? 0) as number,
427
+ (day.avgCompressionRate ?? 0) as number,
428
+ (day.totalDuration ?? 0) as number
429
+ )
430
+ }
431
+ }
432
+
433
+ function populateAnalysis(projectId: string, data: Record<string, unknown>): void {
434
+ const db = prjctDb.getDb(projectId)
435
+ const stmt = db.prepare(`
436
+ INSERT OR REPLACE INTO analysis
437
+ (id, status, commit_hash, signature, sealed_at, analyzed_at, data)
438
+ VALUES (?, ?, ?, ?, ?, ?, ?)
439
+ `)
440
+
441
+ const migrate = (analysis: Record<string, unknown>, id: string) => {
442
+ if (!analysis) return
443
+ stmt.run(
444
+ id,
445
+ (analysis.status ?? 'unknown') as string,
446
+ (analysis.commitHash ?? null) as string | null,
447
+ (analysis.signature ?? null) as string | null,
448
+ (analysis.sealedAt ?? null) as string | null,
449
+ (analysis.analyzedAt ?? null) as string | null,
450
+ JSON.stringify(analysis)
451
+ )
452
+ }
453
+
454
+ if (data.draft) migrate(data.draft as Record<string, unknown>, 'draft')
455
+ if (data.sealed) migrate(data.sealed as Record<string, unknown>, 'sealed')
456
+ }
457
+
458
+ // =============================================================================
459
+ // Index Table Population
460
+ // =============================================================================
461
+
462
+ function populateIndexTables(projectId: string, key: string, data: unknown): void {
463
+ if (key === 'categories-cache') {
464
+ populateCategoriesIndex(projectId, data as Record<string, unknown>)
465
+ }
466
+ }
467
+
468
+ function populateCategoriesIndex(projectId: string, cache: Record<string, unknown>): void {
469
+ const fileCategories = cache.fileCategories as Array<Record<string, unknown>> | undefined
470
+ if (!fileCategories || !Array.isArray(fileCategories)) return
471
+
472
+ const db = prjctDb.getDb(projectId)
473
+ const stmt = db.prepare(`
474
+ INSERT OR REPLACE INTO index_files
475
+ (path, categories, domain, score, size, mtime, language)
476
+ VALUES (?, ?, ?, COALESCE((SELECT score FROM index_files WHERE path = ?), 0), NULL, NULL, NULL)
477
+ `)
478
+
479
+ for (const fc of fileCategories) {
480
+ stmt.run(
481
+ fc.path as string,
482
+ fc.categories ? JSON.stringify(fc.categories) : null,
483
+ (fc.primaryDomain ?? null) as string | null,
484
+ fc.path as string
485
+ )
486
+ }
487
+ }
488
+
489
+ // =============================================================================
490
+ // Specialized File Migrations
491
+ // =============================================================================
492
+
493
+ async function migrateChecksums(
494
+ projectId: string,
495
+ indexPath: string,
496
+ result: MigrationResult
497
+ ): Promise<void> {
498
+ const filePath = path.join(indexPath, 'checksums.json')
499
+ const data = await readJsonSafe(filePath)
500
+ if (data === null) {
501
+ result.skippedFiles.push('index/checksums.json')
502
+ return
503
+ }
504
+
505
+ try {
506
+ const checksums = (data as Record<string, unknown>).checksums as Record<string, string>
507
+ if (!checksums) return
508
+
509
+ const db = prjctDb.getDb(projectId)
510
+ const stmt = db.prepare('INSERT OR REPLACE INTO index_checksums (path, checksum) VALUES (?, ?)')
511
+
512
+ db.transaction(() => {
513
+ for (const [filePath, checksum] of Object.entries(checksums)) {
514
+ stmt.run(filePath, checksum)
515
+ }
516
+ })()
517
+
518
+ result.migratedFiles.push('index/checksums.json')
519
+ } catch (err) {
520
+ result.errors.push({ file: 'index/checksums.json', error: String(err) })
521
+ }
522
+ }
523
+
524
+ async function migrateFileScores(
525
+ projectId: string,
526
+ indexPath: string,
527
+ result: MigrationResult
528
+ ): Promise<void> {
529
+ const filePath = path.join(indexPath, 'file-scores.json')
530
+ const data = await readJsonSafe(filePath)
531
+ if (data === null) {
532
+ result.skippedFiles.push('index/file-scores.json')
533
+ return
534
+ }
535
+
536
+ try {
537
+ const scores = (data as Record<string, unknown>).scores as Array<Record<string, unknown>>
538
+ if (!scores || !Array.isArray(scores)) return
539
+
540
+ const db = prjctDb.getDb(projectId)
541
+ const stmt = db.prepare(`
542
+ INSERT OR REPLACE INTO index_files
543
+ (path, score, size, mtime, language, categories, domain)
544
+ VALUES (?, ?, ?, ?, NULL,
545
+ COALESCE((SELECT categories FROM index_files WHERE path = ?), NULL),
546
+ COALESCE((SELECT domain FROM index_files WHERE path = ?), NULL))
547
+ `)
548
+
549
+ db.transaction(() => {
550
+ for (const file of scores) {
551
+ stmt.run(
552
+ file.path as string,
553
+ (file.score ?? 0) as number,
554
+ (file.size ?? null) as number | null,
555
+ (file.mtime ?? null) as string | null,
556
+ file.path as string,
557
+ file.path as string
558
+ )
559
+ }
560
+ })()
561
+
562
+ result.migratedFiles.push('index/file-scores.json')
563
+ } catch (err) {
564
+ result.errors.push({ file: 'index/file-scores.json', error: String(err) })
565
+ }
566
+ }
567
+
568
+ async function migrateEventsJsonl(
569
+ projectId: string,
570
+ memoryPath: string,
571
+ result: MigrationResult
572
+ ): Promise<void> {
573
+ const filePath = path.join(memoryPath, 'events.jsonl')
574
+
575
+ try {
576
+ const content = await fs.readFile(filePath, 'utf-8')
577
+ const lines = content.split('\n').filter((line) => line.trim())
578
+
579
+ if (lines.length === 0) {
580
+ result.skippedFiles.push('memory/events.jsonl')
581
+ return
582
+ }
583
+
584
+ const db = prjctDb.getDb(projectId)
585
+ const stmt = db.prepare(
586
+ 'INSERT INTO events (type, task_id, data, timestamp) VALUES (?, ?, ?, ?)'
587
+ )
588
+
589
+ db.transaction(() => {
590
+ for (const line of lines) {
591
+ try {
592
+ const event = JSON.parse(line) as Record<string, unknown>
593
+ const type = (event.type ?? event.action ?? 'unknown') as string
594
+ const taskId = (event.taskId ?? event.task_id ?? null) as string | null
595
+ const timestamp = (event.timestamp ?? event.ts ?? new Date().toISOString()) as string
596
+ stmt.run(type, taskId, line, timestamp)
597
+ } catch {
598
+ // Skip malformed lines
599
+ }
600
+ }
601
+ })()
602
+
603
+ result.migratedFiles.push('memory/events.jsonl')
604
+ } catch (err) {
605
+ if (isNotFoundError(err)) {
606
+ result.skippedFiles.push('memory/events.jsonl')
607
+ } else {
608
+ result.errors.push({ file: 'memory/events.jsonl', error: String(err) })
609
+ }
610
+ }
611
+ }
612
+
613
+ async function migrateLearningsJsonl(
614
+ projectId: string,
615
+ memoryPath: string,
616
+ result: MigrationResult
617
+ ): Promise<void> {
618
+ const filePath = path.join(memoryPath, 'learnings.jsonl')
619
+
620
+ try {
621
+ const content = await fs.readFile(filePath, 'utf-8')
622
+ const lines = content.split('\n').filter((line) => line.trim())
623
+
624
+ if (lines.length === 0) {
625
+ result.skippedFiles.push('memory/learnings.jsonl')
626
+ return
627
+ }
628
+
629
+ const db = prjctDb.getDb(projectId)
630
+ const stmt = db.prepare(
631
+ 'INSERT OR REPLACE INTO memory (key, domain, value, confidence, updated_at) VALUES (?, ?, ?, ?, ?)'
632
+ )
633
+
634
+ db.transaction(() => {
635
+ for (const line of lines) {
636
+ try {
637
+ const entry = JSON.parse(line) as Record<string, unknown>
638
+ const key = `learning:${entry.taskId ?? entry.timestamp ?? Date.now()}`
639
+ const tags = entry.tags as string[] | undefined
640
+ const domain = tags && tags.length > 0 ? tags[0] : null
641
+ stmt.run(key, domain, line, 1.0, (entry.timestamp ?? new Date().toISOString()) as string)
642
+ } catch {
643
+ // Skip malformed lines
644
+ }
645
+ }
646
+ })()
647
+
648
+ result.migratedFiles.push('memory/learnings.jsonl')
649
+ } catch (err) {
650
+ if (isNotFoundError(err)) {
651
+ result.skippedFiles.push('memory/learnings.jsonl')
652
+ } else {
653
+ result.errors.push({ file: 'memory/learnings.jsonl', error: String(err) })
654
+ }
655
+ }
656
+ }
657
+
658
+ // =============================================================================
659
+ // JSON Cleanup (post-migration)
660
+ // =============================================================================
661
+
662
+ /**
663
+ * Delete source JSON/JSONL files after successful migration.
664
+ * Keeps backup/ directory and context/*.md files intact.
665
+ */
666
+ async function cleanupJsonFiles(
667
+ storagePath: string,
668
+ indexPath: string,
669
+ memoryPath: string,
670
+ result: MigrationResult
671
+ ): Promise<void> {
672
+ const deleteFile = async (filePath: string, label: string) => {
673
+ try {
674
+ await fs.unlink(filePath)
675
+ } catch (err) {
676
+ if (!isNotFoundError(err)) {
677
+ result.errors.push({ file: label, error: `cleanup: ${String(err)}` })
678
+ }
679
+ }
680
+ }
681
+
682
+ // Delete storage JSON files (keep backup/ directory)
683
+ for (const { filename } of STORAGE_FILES) {
684
+ await deleteFile(path.join(storagePath, filename), `cleanup:${filename}`)
685
+ }
686
+
687
+ // Delete index JSON files
688
+ const indexFiles = [
689
+ 'project-index.json',
690
+ 'domains.json',
691
+ 'categories-cache.json',
692
+ 'checksums.json',
693
+ 'file-scores.json',
694
+ ]
695
+ for (const filename of indexFiles) {
696
+ await deleteFile(path.join(indexPath, filename), `cleanup:index/${filename}`)
697
+ }
698
+
699
+ // Delete memory JSONL files
700
+ await deleteFile(path.join(memoryPath, 'events.jsonl'), 'cleanup:memory/events.jsonl')
701
+ await deleteFile(path.join(memoryPath, 'learnings.jsonl'), 'cleanup:memory/learnings.jsonl')
702
+ }
703
+
704
+ // =============================================================================
705
+ // Helpers
706
+ // =============================================================================
707
+
708
+ async function readJsonSafe(filePath: string): Promise<unknown | null> {
709
+ try {
710
+ const content = await fs.readFile(filePath, 'utf-8')
711
+ return JSON.parse(content)
712
+ } catch (err) {
713
+ if (isNotFoundError(err) || err instanceof SyntaxError) {
714
+ return null
715
+ }
716
+ throw err
717
+ }
718
+ }
719
+
720
+ export default migrateJsonToSqlite