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,1016 @@
1
+ /**
2
+ * SQLite Migration & Integration Tests (PRJ-303)
3
+ *
4
+ * Tests for:
5
+ * - Migration correctness (JSON → SQLite)
6
+ * - Concurrent access (WAL mode)
7
+ * - Query performance (SQLite vs JSON)
8
+ * - Graceful degradation
9
+ * - StorageManager + IndexStorage SQLite integration
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
13
+ import fs from 'node:fs/promises'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import pathManager from '../../infrastructure/path-manager'
17
+ import { prjctDb } from '../../storage/database'
18
+ import { indexStorage } from '../../storage/index-storage'
19
+ import { migrateJsonToSqlite } from '../../storage/migrate-json'
20
+ import { StorageManager } from '../../storage/storage-manager'
21
+
22
+ // =============================================================================
23
+ // Test Setup
24
+ // =============================================================================
25
+
26
+ let tmpRoot: string | null = null
27
+ let testProjectId: string
28
+
29
+ const originalGetGlobalProjectPath = pathManager.getGlobalProjectPath.bind(pathManager)
30
+ const originalGetStoragePath = pathManager.getStoragePath.bind(pathManager)
31
+ const originalGetFilePath = pathManager.getFilePath.bind(pathManager)
32
+
33
+ // Concrete StorageManager for testing
34
+ interface TestData {
35
+ value: string
36
+ count: number
37
+ items: string[]
38
+ }
39
+
40
+ class TestStorageManager extends StorageManager<TestData> {
41
+ constructor() {
42
+ super('test-data.json')
43
+ }
44
+
45
+ protected getLayer(): string {
46
+ return 'context'
47
+ }
48
+
49
+ protected getDefault(): TestData {
50
+ return { value: '', count: 0, items: [] }
51
+ }
52
+
53
+ protected toMarkdown(data: TestData): string {
54
+ return `# Test\nValue: ${data.value}`
55
+ }
56
+
57
+ protected getMdFilename(): string {
58
+ return 'test-data.md'
59
+ }
60
+
61
+ protected getEventType(action: 'update' | 'create' | 'delete'): string {
62
+ return `test.${action}`
63
+ }
64
+ }
65
+
66
+ function mockPaths() {
67
+ pathManager.getGlobalProjectPath = (projectId: string) => {
68
+ return path.join(tmpRoot!, projectId)
69
+ }
70
+
71
+ pathManager.getStoragePath = (projectId: string, filename: string) => {
72
+ return path.join(tmpRoot!, projectId, 'storage', filename)
73
+ }
74
+
75
+ pathManager.getFilePath = (projectId: string, layer: string, filename: string) => {
76
+ return path.join(tmpRoot!, projectId, layer, filename)
77
+ }
78
+ }
79
+
80
+ function restorePaths() {
81
+ pathManager.getGlobalProjectPath = originalGetGlobalProjectPath
82
+ pathManager.getStoragePath = originalGetStoragePath
83
+ pathManager.getFilePath = originalGetFilePath
84
+ }
85
+
86
+ // =============================================================================
87
+ // Migration Correctness Tests
88
+ // =============================================================================
89
+
90
+ describe('SQLite Migration', () => {
91
+ beforeEach(async () => {
92
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-sqlite-test-'))
93
+ testProjectId = 'test-project-migration'
94
+ mockPaths()
95
+ })
96
+
97
+ afterEach(async () => {
98
+ prjctDb.close()
99
+ restorePaths()
100
+ if (tmpRoot) {
101
+ await fs.rm(tmpRoot, { recursive: true, force: true })
102
+ tmpRoot = null
103
+ }
104
+ })
105
+
106
+ describe('migration correctness', () => {
107
+ it('should migrate state.json to kv_store', async () => {
108
+ // Create source JSON
109
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
110
+ await fs.mkdir(storagePath, { recursive: true })
111
+
112
+ const state = {
113
+ currentTask: {
114
+ id: 'task-1',
115
+ description: 'Test task',
116
+ type: 'feature',
117
+ status: 'active',
118
+ startedAt: '2026-01-01T00:00:00.000Z',
119
+ subtasks: [
120
+ { id: 'st-1', description: 'Sub 1', status: 'completed' },
121
+ { id: 'st-2', description: 'Sub 2', status: 'active' },
122
+ ],
123
+ currentSubtaskIndex: 1,
124
+ branch: 'feature/test',
125
+ linearId: 'PRJ-100',
126
+ },
127
+ previousTask: null,
128
+ pausedTasks: [],
129
+ }
130
+
131
+ await fs.writeFile(
132
+ path.join(storagePath, 'state.json'),
133
+ JSON.stringify(state, null, 2),
134
+ 'utf-8'
135
+ )
136
+
137
+ const result = await migrateJsonToSqlite(testProjectId)
138
+
139
+ expect(result.success).toBe(true)
140
+ expect(result.migratedFiles).toContain('state.json')
141
+
142
+ // Verify kv_store has the data
143
+ const doc = prjctDb.getDoc(testProjectId, 'state')
144
+ expect(doc).not.toBeNull()
145
+ expect((doc as typeof state).currentTask.id).toBe('task-1')
146
+ expect((doc as typeof state).currentTask.description).toBe('Test task')
147
+ })
148
+
149
+ it('should migrate state.json to normalized tasks table', async () => {
150
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
151
+ await fs.mkdir(storagePath, { recursive: true })
152
+
153
+ const state = {
154
+ currentTask: {
155
+ id: 'task-abc',
156
+ description: 'Normalized test',
157
+ type: 'bug',
158
+ status: 'active',
159
+ startedAt: '2026-01-01T00:00:00.000Z',
160
+ subtasks: [
161
+ { id: 'st-1', description: 'First sub', status: 'completed', domain: 'backend' },
162
+ ],
163
+ branch: 'fix/test',
164
+ linearId: 'PRJ-200',
165
+ },
166
+ previousTask: null,
167
+ pausedTasks: [],
168
+ }
169
+
170
+ await fs.writeFile(
171
+ path.join(storagePath, 'state.json'),
172
+ JSON.stringify(state, null, 2),
173
+ 'utf-8'
174
+ )
175
+
176
+ await migrateJsonToSqlite(testProjectId)
177
+
178
+ // Verify normalized tasks table
179
+ const task = prjctDb.get<{ id: string; description: string; type: string; status: string }>(
180
+ testProjectId,
181
+ 'SELECT id, description, type, status FROM tasks WHERE id = ?',
182
+ 'task-abc'
183
+ )
184
+ expect(task).not.toBeNull()
185
+ expect(task!.type).toBe('bug')
186
+ expect(task!.status).toBe('active')
187
+
188
+ // Verify subtasks table
189
+ const subtask = prjctDb.get<{ id: string; task_id: string; domain: string }>(
190
+ testProjectId,
191
+ 'SELECT id, task_id, domain FROM subtasks WHERE id = ?',
192
+ 'st-1'
193
+ )
194
+ expect(subtask).not.toBeNull()
195
+ expect(subtask!.task_id).toBe('task-abc')
196
+ expect(subtask!.domain).toBe('backend')
197
+ })
198
+
199
+ it('should migrate ideas.json to kv_store and ideas table', async () => {
200
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
201
+ await fs.mkdir(storagePath, { recursive: true })
202
+
203
+ const ideas = {
204
+ ideas: [
205
+ {
206
+ id: 'idea-1',
207
+ text: 'Add dark mode',
208
+ status: 'pending',
209
+ priority: 'high',
210
+ tags: ['ui', 'frontend'],
211
+ addedAt: '2026-01-15T00:00:00.000Z',
212
+ },
213
+ {
214
+ id: 'idea-2',
215
+ text: 'Improve caching',
216
+ status: 'converted',
217
+ priority: 'medium',
218
+ tags: ['performance'],
219
+ addedAt: '2026-01-16T00:00:00.000Z',
220
+ convertedTo: 'task-xyz',
221
+ },
222
+ ],
223
+ }
224
+
225
+ await fs.writeFile(
226
+ path.join(storagePath, 'ideas.json'),
227
+ JSON.stringify(ideas, null, 2),
228
+ 'utf-8'
229
+ )
230
+
231
+ await migrateJsonToSqlite(testProjectId)
232
+
233
+ // Verify kv_store
234
+ const doc = prjctDb.getDoc(testProjectId, 'ideas')
235
+ expect(doc).not.toBeNull()
236
+
237
+ // Verify normalized ideas table
238
+ const rows = prjctDb.query<{ id: string; text: string; priority: string }>(
239
+ testProjectId,
240
+ 'SELECT id, text, priority FROM ideas ORDER BY id'
241
+ )
242
+ expect(rows).toHaveLength(2)
243
+ expect(rows[0].text).toBe('Add dark mode')
244
+ expect(rows[0].priority).toBe('high')
245
+ expect(rows[1].text).toBe('Improve caching')
246
+ })
247
+
248
+ it('should migrate index files to index_meta', async () => {
249
+ const indexPath = path.join(tmpRoot!, testProjectId, 'index')
250
+ await fs.mkdir(indexPath, { recursive: true })
251
+
252
+ const projectIndex = {
253
+ version: '1.0.0',
254
+ projectPath: '/test/project',
255
+ lastFullScan: '2026-01-01T00:00:00.000Z',
256
+ lastIncrementalUpdate: '',
257
+ languages: { TypeScript: { count: 10, totalLines: 500, totalSize: 25000 } },
258
+ configFiles: [],
259
+ directories: [],
260
+ relevantFiles: [],
261
+ patterns: [],
262
+ detectedStack: {
263
+ ecosystem: 'JavaScript',
264
+ frameworks: [],
265
+ hasTests: true,
266
+ hasDocker: false,
267
+ hasCi: false,
268
+ buildTool: 'bun',
269
+ },
270
+ totalFiles: 50,
271
+ totalSize: 100000,
272
+ totalLines: 5000,
273
+ scanDuration: 30,
274
+ }
275
+
276
+ await fs.writeFile(
277
+ path.join(indexPath, 'project-index.json'),
278
+ JSON.stringify(projectIndex, null, 2),
279
+ 'utf-8'
280
+ )
281
+
282
+ // Need a state.json so migration doesn't short-circuit
283
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
284
+ await fs.mkdir(storagePath, { recursive: true })
285
+ await fs.writeFile(
286
+ path.join(storagePath, 'state.json'),
287
+ JSON.stringify({
288
+ currentTask: {
289
+ id: 'x',
290
+ description: 'x',
291
+ status: 'active',
292
+ startedAt: new Date().toISOString(),
293
+ },
294
+ previousTask: null,
295
+ pausedTasks: [],
296
+ }),
297
+ 'utf-8'
298
+ )
299
+
300
+ await migrateJsonToSqlite(testProjectId)
301
+
302
+ // Verify index_meta
303
+ const row = prjctDb.get<{ data: string }>(
304
+ testProjectId,
305
+ 'SELECT data FROM index_meta WHERE key = ?',
306
+ 'project-index'
307
+ )
308
+ expect(row).not.toBeNull()
309
+ const parsed = JSON.parse(row!.data)
310
+ expect(parsed.totalFiles).toBe(50)
311
+ expect(parsed.languages.TypeScript.count).toBe(10)
312
+ })
313
+
314
+ it('should migrate checksums to index_checksums table', async () => {
315
+ const indexPath = path.join(tmpRoot!, testProjectId, 'index')
316
+ await fs.mkdir(indexPath, { recursive: true })
317
+
318
+ const checksums = {
319
+ version: '1.0.0',
320
+ lastUpdated: '2026-01-01T00:00:00.000Z',
321
+ checksums: {
322
+ 'src/index.ts': 'abc123',
323
+ 'src/utils.ts': 'def456',
324
+ 'package.json': 'ghi789',
325
+ },
326
+ }
327
+
328
+ await fs.writeFile(
329
+ path.join(indexPath, 'checksums.json'),
330
+ JSON.stringify(checksums, null, 2),
331
+ 'utf-8'
332
+ )
333
+
334
+ // Need state.json
335
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
336
+ await fs.mkdir(storagePath, { recursive: true })
337
+ await fs.writeFile(
338
+ path.join(storagePath, 'state.json'),
339
+ JSON.stringify({
340
+ currentTask: {
341
+ id: 'x',
342
+ description: 'x',
343
+ status: 'active',
344
+ startedAt: new Date().toISOString(),
345
+ },
346
+ previousTask: null,
347
+ pausedTasks: [],
348
+ }),
349
+ 'utf-8'
350
+ )
351
+
352
+ await migrateJsonToSqlite(testProjectId)
353
+
354
+ // Verify index_checksums table
355
+ const rows = prjctDb.query<{ path: string; checksum: string }>(
356
+ testProjectId,
357
+ 'SELECT path, checksum FROM index_checksums ORDER BY path'
358
+ )
359
+ expect(rows).toHaveLength(3)
360
+ expect(rows[0].path).toBe('package.json')
361
+ expect(rows[0].checksum).toBe('ghi789')
362
+ })
363
+
364
+ it('should migrate events.jsonl to events table', async () => {
365
+ const memoryPath = path.join(tmpRoot!, testProjectId, 'memory')
366
+ await fs.mkdir(memoryPath, { recursive: true })
367
+
368
+ const events = [
369
+ '{"type":"task_started","taskId":"t1","timestamp":"2026-01-01T00:00:00.000Z"}',
370
+ '{"type":"subtask_completed","taskId":"t1","timestamp":"2026-01-01T01:00:00.000Z"}',
371
+ '{"type":"task_completed","taskId":"t1","timestamp":"2026-01-01T02:00:00.000Z"}',
372
+ ]
373
+ await fs.writeFile(path.join(memoryPath, 'events.jsonl'), events.join('\n'), 'utf-8')
374
+
375
+ // Need state.json
376
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
377
+ await fs.mkdir(storagePath, { recursive: true })
378
+ await fs.writeFile(
379
+ path.join(storagePath, 'state.json'),
380
+ JSON.stringify({
381
+ currentTask: {
382
+ id: 'x',
383
+ description: 'x',
384
+ status: 'active',
385
+ startedAt: new Date().toISOString(),
386
+ },
387
+ previousTask: null,
388
+ pausedTasks: [],
389
+ }),
390
+ 'utf-8'
391
+ )
392
+
393
+ await migrateJsonToSqlite(testProjectId)
394
+
395
+ const rows = prjctDb.query<{ type: string; task_id: string }>(
396
+ testProjectId,
397
+ 'SELECT type, task_id FROM events ORDER BY id'
398
+ )
399
+ expect(rows).toHaveLength(3)
400
+ expect(rows[0].type).toBe('task_started')
401
+ expect(rows[2].type).toBe('task_completed')
402
+ })
403
+
404
+ it('should be idempotent (skip if already migrated)', async () => {
405
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
406
+ await fs.mkdir(storagePath, { recursive: true })
407
+
408
+ await fs.writeFile(
409
+ path.join(storagePath, 'state.json'),
410
+ JSON.stringify({
411
+ currentTask: {
412
+ id: 't1',
413
+ description: 'x',
414
+ status: 'active',
415
+ startedAt: new Date().toISOString(),
416
+ },
417
+ previousTask: null,
418
+ pausedTasks: [],
419
+ }),
420
+ 'utf-8'
421
+ )
422
+
423
+ // First migration
424
+ const result1 = await migrateJsonToSqlite(testProjectId)
425
+ expect(result1.success).toBe(true)
426
+ expect(result1.migratedFiles.length).toBeGreaterThan(0)
427
+
428
+ // Second migration should short-circuit
429
+ const result2 = await migrateJsonToSqlite(testProjectId)
430
+ expect(result2.success).toBe(true)
431
+ expect(result2.migratedFiles).toHaveLength(0)
432
+ })
433
+
434
+ it('should create backup of original files', async () => {
435
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
436
+ await fs.mkdir(storagePath, { recursive: true })
437
+
438
+ await fs.writeFile(
439
+ path.join(storagePath, 'state.json'),
440
+ JSON.stringify({
441
+ currentTask: {
442
+ id: 'bk',
443
+ description: 'backup test',
444
+ status: 'active',
445
+ startedAt: new Date().toISOString(),
446
+ },
447
+ previousTask: null,
448
+ pausedTasks: [],
449
+ }),
450
+ 'utf-8'
451
+ )
452
+
453
+ const result = await migrateJsonToSqlite(testProjectId)
454
+
455
+ expect(result.backupDir).not.toBeNull()
456
+ // Verify backup file exists
457
+ const backupFile = path.join(result.backupDir!, 'state.json')
458
+ const stat = await fs.stat(backupFile)
459
+ expect(stat.isFile()).toBe(true)
460
+ })
461
+
462
+ it('should delete JSON files after successful migration', async () => {
463
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
464
+ const indexPath = path.join(tmpRoot!, testProjectId, 'index')
465
+ const memoryPath = path.join(tmpRoot!, testProjectId, 'memory')
466
+ await fs.mkdir(storagePath, { recursive: true })
467
+ await fs.mkdir(indexPath, { recursive: true })
468
+ await fs.mkdir(memoryPath, { recursive: true })
469
+
470
+ // Create various JSON files
471
+ await fs.writeFile(
472
+ path.join(storagePath, 'state.json'),
473
+ JSON.stringify({
474
+ currentTask: {
475
+ id: 'del',
476
+ description: 'del',
477
+ status: 'active',
478
+ startedAt: new Date().toISOString(),
479
+ },
480
+ previousTask: null,
481
+ pausedTasks: [],
482
+ }),
483
+ 'utf-8'
484
+ )
485
+ await fs.writeFile(
486
+ path.join(storagePath, 'queue.json'),
487
+ JSON.stringify({ tasks: [] }),
488
+ 'utf-8'
489
+ )
490
+ await fs.writeFile(
491
+ path.join(indexPath, 'project-index.json'),
492
+ JSON.stringify({ version: '1.0.0', totalFiles: 1 }),
493
+ 'utf-8'
494
+ )
495
+ await fs.writeFile(
496
+ path.join(memoryPath, 'events.jsonl'),
497
+ '{"type":"test","timestamp":"2026-01-01T00:00:00.000Z"}\n',
498
+ 'utf-8'
499
+ )
500
+
501
+ const result = await migrateJsonToSqlite(testProjectId)
502
+ expect(result.success).toBe(true)
503
+
504
+ // Verify JSON files were deleted
505
+ await expect(fs.access(path.join(storagePath, 'state.json'))).rejects.toThrow()
506
+ await expect(fs.access(path.join(storagePath, 'queue.json'))).rejects.toThrow()
507
+ await expect(fs.access(path.join(indexPath, 'project-index.json'))).rejects.toThrow()
508
+ await expect(fs.access(path.join(memoryPath, 'events.jsonl'))).rejects.toThrow()
509
+
510
+ // Verify backup still exists
511
+ expect(result.backupDir).not.toBeNull()
512
+ const backupFile = path.join(result.backupDir!, 'state.json')
513
+ const stat = await fs.stat(backupFile)
514
+ expect(stat.isFile()).toBe(true)
515
+
516
+ // Verify data is accessible from SQLite
517
+ const doc = prjctDb.getDoc(testProjectId, 'state')
518
+ expect(doc).not.toBeNull()
519
+ })
520
+
521
+ it('should handle missing files gracefully', async () => {
522
+ // Create only storage dir, no files
523
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
524
+ await fs.mkdir(storagePath, { recursive: true })
525
+
526
+ const result = await migrateJsonToSqlite(testProjectId)
527
+
528
+ // Should succeed with skipped files
529
+ expect(result.errors).toHaveLength(0)
530
+ expect(result.skippedFiles.length).toBeGreaterThan(0)
531
+ })
532
+ })
533
+
534
+ // ===========================================================================
535
+ // Concurrent Access Tests (WAL Mode)
536
+ // ===========================================================================
537
+
538
+ describe('concurrent access', () => {
539
+ it('should handle multiple concurrent reads', async () => {
540
+ const manager = new TestStorageManager()
541
+ const data: TestData = { value: 'concurrent', count: 42, items: ['a', 'b'] }
542
+ await manager.write(testProjectId, data)
543
+ manager.clearCache()
544
+
545
+ // Fire off multiple concurrent reads
546
+ const reads = Array.from({ length: 10 }, () => manager.read(testProjectId))
547
+ const results = await Promise.all(reads)
548
+
549
+ for (const result of results) {
550
+ expect(result).toEqual(data)
551
+ }
552
+ })
553
+
554
+ it('should handle concurrent writes to different projects', async () => {
555
+ const manager = new TestStorageManager()
556
+ const projects = ['proj-a', 'proj-b', 'proj-c']
557
+
558
+ // Write to different projects concurrently
559
+ const writes = projects.map((id, i) =>
560
+ manager.write(id, { value: `project-${i}`, count: i, items: [] })
561
+ )
562
+ await Promise.all(writes)
563
+
564
+ // Verify each project has correct data
565
+ for (let i = 0; i < projects.length; i++) {
566
+ manager.clearCache(projects[i])
567
+ const result = await manager.read(projects[i])
568
+ expect(result.value).toBe(`project-${i}`)
569
+ expect(result.count).toBe(i)
570
+ }
571
+ })
572
+
573
+ it('should handle sequential updates consistently', async () => {
574
+ const manager = new TestStorageManager()
575
+ await manager.write(testProjectId, { value: 'start', count: 0, items: [] })
576
+
577
+ // Run sequential updates
578
+ for (let i = 1; i <= 10; i++) {
579
+ await manager.update(testProjectId, (current) => ({
580
+ ...current,
581
+ count: current.count + 1,
582
+ items: [...current.items, `item-${i}`],
583
+ }))
584
+ }
585
+
586
+ const result = await manager.read(testProjectId)
587
+ expect(result.count).toBe(10)
588
+ expect(result.items).toHaveLength(10)
589
+ })
590
+ })
591
+
592
+ // ===========================================================================
593
+ // Query Performance Tests
594
+ // ===========================================================================
595
+
596
+ describe('query performance', () => {
597
+ it('should perform SQLite reads efficiently', async () => {
598
+ const manager = new TestStorageManager()
599
+ const data: TestData = {
600
+ value: 'perf-test',
601
+ count: 100,
602
+ items: Array.from({ length: 50 }, (_, i) => `item-${i}`),
603
+ }
604
+ await manager.write(testProjectId, data)
605
+
606
+ // Benchmark SQLite read (direct)
607
+ const sqliteStart = performance.now()
608
+ for (let i = 0; i < 100; i++) {
609
+ prjctDb.getDoc(testProjectId, 'test-data')
610
+ }
611
+ const sqliteTime = performance.now() - sqliteStart
612
+
613
+ // Verify data is correct
614
+ const sqliteResult = prjctDb.getDoc<TestData>(testProjectId, 'test-data')
615
+ expect(sqliteResult).toEqual(data)
616
+
617
+ // Log for informational purposes
618
+ console.log(` SQLite: ${sqliteTime.toFixed(2)}ms (100 reads)`)
619
+ })
620
+
621
+ it('should handle indexed queries efficiently', async () => {
622
+ // Populate normalized tasks table
623
+ const storagePath = path.join(tmpRoot!, testProjectId, 'storage')
624
+ await fs.mkdir(storagePath, { recursive: true })
625
+
626
+ const state = {
627
+ currentTask: {
628
+ id: 'perf-task',
629
+ description: 'Performance test',
630
+ type: 'feature',
631
+ status: 'active',
632
+ startedAt: '2026-01-01T00:00:00.000Z',
633
+ subtasks: Array.from({ length: 20 }, (_, i) => ({
634
+ id: `st-${i}`,
635
+ description: `Subtask ${i}`,
636
+ status: i < 10 ? 'completed' : 'pending',
637
+ domain: i % 2 === 0 ? 'backend' : 'frontend',
638
+ })),
639
+ },
640
+ previousTask: null,
641
+ pausedTasks: [],
642
+ }
643
+
644
+ await fs.writeFile(
645
+ path.join(storagePath, 'state.json'),
646
+ JSON.stringify(state, null, 2),
647
+ 'utf-8'
648
+ )
649
+
650
+ await migrateJsonToSqlite(testProjectId)
651
+
652
+ // Indexed query: find completed subtasks
653
+ const start = performance.now()
654
+ const completed = prjctDb.query<{ id: string }>(
655
+ testProjectId,
656
+ 'SELECT id FROM subtasks WHERE status = ?',
657
+ 'completed'
658
+ )
659
+ const queryTime = performance.now() - start
660
+
661
+ expect(completed).toHaveLength(10)
662
+ // Indexed query should be sub-millisecond
663
+ expect(queryTime).toBeLessThan(10)
664
+ })
665
+ })
666
+
667
+ // ===========================================================================
668
+ // StorageManager SQLite Integration
669
+ // ===========================================================================
670
+
671
+ describe('StorageManager SQLite integration', () => {
672
+ it('should write to SQLite only (no JSON file)', async () => {
673
+ const manager = new TestStorageManager()
674
+ const data: TestData = { value: 'sqlite-write', count: 7, items: ['x'] }
675
+
676
+ await manager.write(testProjectId, data)
677
+
678
+ // Verify SQLite has it
679
+ const sqliteData = prjctDb.getDoc<TestData>(testProjectId, 'test-data')
680
+ expect(sqliteData).toEqual(data)
681
+
682
+ // Verify JSON file does NOT exist
683
+ const jsonPath = pathManager.getStoragePath(testProjectId, 'test-data.json')
684
+ await expect(fs.access(jsonPath)).rejects.toThrow()
685
+ })
686
+
687
+ it('should read from SQLite', async () => {
688
+ const manager = new TestStorageManager()
689
+ const data: TestData = { value: 'sqlite-only', count: 3, items: [] }
690
+
691
+ await manager.write(testProjectId, data)
692
+ manager.clearCache()
693
+
694
+ const result = await manager.read(testProjectId)
695
+ expect(result).toEqual(data)
696
+ })
697
+
698
+ it('should return default when SQLite has no data', async () => {
699
+ const manager = new TestStorageManager()
700
+ const result = await manager.read('nonexistent-project')
701
+
702
+ expect(result).toEqual({ value: '', count: 0, items: [] })
703
+ })
704
+ })
705
+
706
+ // ===========================================================================
707
+ // IndexStorage SQLite Integration
708
+ // ===========================================================================
709
+
710
+ describe('IndexStorage SQLite integration', () => {
711
+ it('should write index to SQLite only (no JSON file)', async () => {
712
+ // Ensure project directory exists for DB creation
713
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
714
+
715
+ const projectIndex = {
716
+ version: '1.0.0',
717
+ projectPath: '/test',
718
+ lastFullScan: '2026-01-01T00:00:00.000Z',
719
+ lastIncrementalUpdate: '',
720
+ languages: {},
721
+ configFiles: [],
722
+ directories: [],
723
+ relevantFiles: [],
724
+ patterns: [],
725
+ detectedStack: {
726
+ ecosystem: 'JavaScript',
727
+ frameworks: [],
728
+ hasTests: false,
729
+ hasDocker: false,
730
+ hasCi: false,
731
+ buildTool: null,
732
+ },
733
+ totalFiles: 10,
734
+ totalSize: 1000,
735
+ totalLines: 100,
736
+ scanDuration: 5,
737
+ }
738
+
739
+ await indexStorage.writeIndex(testProjectId, projectIndex)
740
+
741
+ // Verify SQLite index_meta
742
+ const row = prjctDb.get<{ data: string }>(
743
+ testProjectId,
744
+ 'SELECT data FROM index_meta WHERE key = ?',
745
+ 'project-index'
746
+ )
747
+ expect(row).not.toBeNull()
748
+ const parsed = JSON.parse(row!.data)
749
+ expect(parsed.totalFiles).toBe(10)
750
+
751
+ // Verify JSON file does NOT exist
752
+ const jsonPath = path.join(indexStorage.getIndexPath(testProjectId), 'project-index.json')
753
+ await expect(fs.access(jsonPath)).rejects.toThrow()
754
+ })
755
+
756
+ it('should read index from SQLite', async () => {
757
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
758
+
759
+ const projectIndex = {
760
+ version: '1.0.0',
761
+ projectPath: '/test',
762
+ lastFullScan: '2026-01-01T00:00:00.000Z',
763
+ lastIncrementalUpdate: '',
764
+ languages: {},
765
+ configFiles: [],
766
+ directories: [],
767
+ relevantFiles: [],
768
+ patterns: [],
769
+ detectedStack: {
770
+ ecosystem: 'JavaScript',
771
+ frameworks: [],
772
+ hasTests: false,
773
+ hasDocker: false,
774
+ hasCi: false,
775
+ buildTool: null,
776
+ },
777
+ totalFiles: 20,
778
+ totalSize: 2000,
779
+ totalLines: 200,
780
+ scanDuration: 10,
781
+ }
782
+
783
+ await indexStorage.writeIndex(testProjectId, projectIndex)
784
+
785
+ const result = await indexStorage.readIndex(testProjectId)
786
+ expect(result).not.toBeNull()
787
+ expect(result!.totalFiles).toBe(20)
788
+ })
789
+
790
+ it('should write and read checksums via SQLite', async () => {
791
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
792
+
793
+ const checksums = {
794
+ version: '1.0.0',
795
+ lastUpdated: '2026-01-01T00:00:00.000Z',
796
+ checksums: { 'a.ts': 'hash1', 'b.ts': 'hash2' },
797
+ }
798
+
799
+ await indexStorage.writeChecksums(testProjectId, checksums)
800
+
801
+ const result = await indexStorage.readChecksums(testProjectId)
802
+ expect(result.checksums['a.ts']).toBe('hash1')
803
+ expect(result.checksums['b.ts']).toBe('hash2')
804
+ })
805
+
806
+ it('should write and read file scores via SQLite', async () => {
807
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
808
+
809
+ const scores = [
810
+ { path: 'src/main.ts', score: 0.95, size: 1000, mtime: '2026-01-01T00:00:00.000Z' },
811
+ { path: 'src/utils.ts', score: 0.7, size: 500, mtime: '2026-01-01T00:00:00.000Z' },
812
+ ]
813
+
814
+ await indexStorage.writeScores(testProjectId, scores)
815
+
816
+ const result = await indexStorage.readScores(testProjectId)
817
+ expect(result).toHaveLength(2)
818
+ expect(result[0].path).toBe('src/main.ts')
819
+ expect(result[0].score).toBe(0.95)
820
+ })
821
+
822
+ it('should write and read domains via SQLite', async () => {
823
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
824
+
825
+ const domains = {
826
+ version: '1.0.0',
827
+ projectId: testProjectId,
828
+ domains: [
829
+ {
830
+ name: 'api',
831
+ description: 'API layer',
832
+ keywords: ['api'],
833
+ filePatterns: ['**/api/**'],
834
+ fileCount: 5,
835
+ },
836
+ ],
837
+ discoveredAt: '2026-01-01T00:00:00.000Z',
838
+ }
839
+
840
+ await indexStorage.writeDomains(testProjectId, domains)
841
+
842
+ const result = await indexStorage.readDomains(testProjectId)
843
+ expect(result).not.toBeNull()
844
+ expect(result!.domains[0].name).toBe('api')
845
+ })
846
+
847
+ it('should write and read categories via SQLite', async () => {
848
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
849
+
850
+ const cache = {
851
+ version: '1.0.0',
852
+ lastUpdate: '2026-01-01T00:00:00.000Z',
853
+ fileCategories: [
854
+ {
855
+ path: 'src/api.ts',
856
+ categories: ['api', 'backend'],
857
+ primaryDomain: 'api',
858
+ confidence: 0.9,
859
+ categorizedAt: '2026-01-01T00:00:00.000Z',
860
+ method: 'heuristic' as const,
861
+ },
862
+ ],
863
+ domainIndex: { api: ['src/api.ts'] },
864
+ }
865
+
866
+ await indexStorage.writeCategories(testProjectId, cache)
867
+
868
+ const result = await indexStorage.readCategories(testProjectId)
869
+ expect(result).not.toBeNull()
870
+ expect(result!.fileCategories[0].path).toBe('src/api.ts')
871
+ })
872
+
873
+ it('should clear SQLite on clearIndex', async () => {
874
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
875
+
876
+ await indexStorage.writeIndex(testProjectId, {
877
+ version: '1.0.0',
878
+ projectPath: '/test',
879
+ lastFullScan: '2026-01-01T00:00:00.000Z',
880
+ lastIncrementalUpdate: '',
881
+ languages: {},
882
+ configFiles: [],
883
+ directories: [],
884
+ relevantFiles: [],
885
+ patterns: [],
886
+ detectedStack: {
887
+ ecosystem: 'JavaScript',
888
+ frameworks: [],
889
+ hasTests: false,
890
+ hasDocker: false,
891
+ hasCi: false,
892
+ buildTool: null,
893
+ },
894
+ totalFiles: 1,
895
+ totalSize: 1,
896
+ totalLines: 1,
897
+ scanDuration: 1,
898
+ })
899
+
900
+ await indexStorage.clearIndex(testProjectId)
901
+
902
+ const sqliteRow = prjctDb.get<{ data: string }>(
903
+ testProjectId,
904
+ 'SELECT data FROM index_meta WHERE key = ?',
905
+ 'project-index'
906
+ )
907
+ expect(sqliteRow).toBeNull()
908
+
909
+ const result = await indexStorage.readIndex(testProjectId)
910
+ expect(result).toBeNull()
911
+ })
912
+
913
+ it('should return null for outdated index version', async () => {
914
+ // Ensure project directory exists for DB creation
915
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
916
+ // Write directly to SQLite with wrong version
917
+ const db = prjctDb.getDb(testProjectId)
918
+ db.prepare('INSERT OR REPLACE INTO index_meta (key, data, updated_at) VALUES (?, ?, ?)').run(
919
+ 'project-index',
920
+ JSON.stringify({ version: '0.0.1', totalFiles: 5 }),
921
+ new Date().toISOString()
922
+ )
923
+
924
+ const result = await indexStorage.readIndex(testProjectId)
925
+ expect(result).toBeNull()
926
+ })
927
+ })
928
+
929
+ // ===========================================================================
930
+ // Database Manager Tests
931
+ // ===========================================================================
932
+
933
+ describe('database manager', () => {
934
+ it('should create tables on first access', async () => {
935
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
936
+ const db = prjctDb.getDb(testProjectId)
937
+ const tables = db
938
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
939
+ .all() as Array<{ name: string }>
940
+
941
+ const tableNames = tables.map((t) => t.name)
942
+ expect(tableNames).toContain('kv_store')
943
+ expect(tableNames).toContain('tasks')
944
+ expect(tableNames).toContain('subtasks')
945
+ expect(tableNames).toContain('events')
946
+ expect(tableNames).toContain('index_meta')
947
+ expect(tableNames).toContain('index_files')
948
+ expect(tableNames).toContain('index_checksums')
949
+ expect(tableNames).toContain('memory')
950
+ })
951
+
952
+ it('should track migrations', async () => {
953
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
954
+ prjctDb.getDb(testProjectId) // Ensure DB is initialized
955
+ const migrations = prjctDb.getMigrations(testProjectId)
956
+ expect(migrations.length).toBeGreaterThan(0)
957
+ expect(migrations[0].name).toBe('initial-schema')
958
+ })
959
+
960
+ it('should support document CRUD operations', async () => {
961
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
962
+ // Create
963
+ prjctDb.setDoc(testProjectId, 'test-key', { hello: 'world' })
964
+ expect(prjctDb.hasDoc(testProjectId, 'test-key')).toBe(true)
965
+
966
+ // Read
967
+ const doc = prjctDb.getDoc<{ hello: string }>(testProjectId, 'test-key')
968
+ expect(doc).not.toBeNull()
969
+ expect(doc!.hello).toBe('world')
970
+
971
+ // Update
972
+ prjctDb.setDoc(testProjectId, 'test-key', { hello: 'updated' })
973
+ const updated = prjctDb.getDoc<{ hello: string }>(testProjectId, 'test-key')
974
+ expect(updated!.hello).toBe('updated')
975
+
976
+ // Delete
977
+ prjctDb.deleteDoc(testProjectId, 'test-key')
978
+ expect(prjctDb.hasDoc(testProjectId, 'test-key')).toBe(false)
979
+ expect(prjctDb.getDoc(testProjectId, 'test-key')).toBeNull()
980
+ })
981
+
982
+ it('should support event log operations', async () => {
983
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
984
+ prjctDb.appendEvent(testProjectId, 'test.event', { key: 'value' }, 'task-1')
985
+ prjctDb.appendEvent(testProjectId, 'test.event', { key: 'value2' }, 'task-1')
986
+ prjctDb.appendEvent(testProjectId, 'other.event', { key: 'value3' })
987
+
988
+ const allEvents = prjctDb.getEvents(testProjectId)
989
+ expect(allEvents).toHaveLength(3)
990
+
991
+ const testEvents = prjctDb.getEvents(testProjectId, 'test.event')
992
+ expect(testEvents).toHaveLength(2)
993
+ })
994
+
995
+ it('should support transactions', async () => {
996
+ await fs.mkdir(path.join(tmpRoot!, testProjectId), { recursive: true })
997
+ const result = prjctDb.transaction(testProjectId, (db) => {
998
+ db.prepare('INSERT INTO kv_store (key, data, updated_at) VALUES (?, ?, ?)').run(
999
+ 'tx-key-1',
1000
+ '"value1"',
1001
+ new Date().toISOString()
1002
+ )
1003
+ db.prepare('INSERT INTO kv_store (key, data, updated_at) VALUES (?, ?, ?)').run(
1004
+ 'tx-key-2',
1005
+ '"value2"',
1006
+ new Date().toISOString()
1007
+ )
1008
+ return 'committed'
1009
+ })
1010
+
1011
+ expect(result).toBe('committed')
1012
+ expect(prjctDb.hasDoc(testProjectId, 'tx-key-1')).toBe(true)
1013
+ expect(prjctDb.hasDoc(testProjectId, 'tx-key-2')).toBe(true)
1014
+ })
1015
+ })
1016
+ })