/ internal / session / index_test.go
index_test.go
  1  package session
  2  
  3  import (
  4  	"context"
  5  	"database/sql"
  6  	"fmt"
  7  	"path/filepath"
  8  	"strings"
  9  	"testing"
 10  	"time"
 11  
 12  	"github.com/Kocoro-lab/ShanClaw/internal/client"
 13  )
 14  
 15  // mustUpsert wraps UpsertSession with t.Fatal on error, used by tests that
 16  // only care about seeding rows (not about exercising the upsert path itself).
 17  func mustUpsert(t *testing.T, idx *Index, s *Session) {
 18  	t.Helper()
 19  	if err := idx.UpsertSession(s); err != nil {
 20  		t.Fatalf("UpsertSession(%s): %v", s.ID, err)
 21  	}
 22  }
 23  
 24  func TestIndex_OpenClose(t *testing.T) {
 25  	dir := t.TempDir()
 26  	idx, err := OpenIndex(dir)
 27  	if err != nil {
 28  		t.Fatalf("OpenIndex: %v", err)
 29  	}
 30  	if err := idx.Close(); err != nil {
 31  		t.Fatalf("Close: %v", err)
 32  	}
 33  }
 34  
 35  func TestIndex_UpsertAndList(t *testing.T) {
 36  	dir := t.TempDir()
 37  	idx, err := OpenIndex(dir)
 38  	if err != nil {
 39  		t.Fatalf("OpenIndex: %v", err)
 40  	}
 41  	defer idx.Close()
 42  
 43  	now := time.Now().Truncate(time.Second)
 44  	sess := &Session{
 45  		ID:        "sess-1",
 46  		Title:     "First session",
 47  		CWD:       "/tmp/test",
 48  		CreatedAt: now,
 49  		UpdatedAt: now,
 50  		Messages: []client.Message{
 51  			{Role: "user", Content: client.NewTextContent("hello world")},
 52  			{Role: "assistant", Content: client.NewTextContent("hi there")},
 53  		},
 54  	}
 55  
 56  	if err := idx.UpsertSession(sess); err != nil {
 57  		t.Fatalf("UpsertSession: %v", err)
 58  	}
 59  
 60  	summaries, err := idx.ListSessions()
 61  	if err != nil {
 62  		t.Fatalf("ListSessions: %v", err)
 63  	}
 64  	if len(summaries) != 1 {
 65  		t.Fatalf("expected 1 session, got %d", len(summaries))
 66  	}
 67  	if summaries[0].ID != "sess-1" {
 68  		t.Errorf("expected ID 'sess-1', got %q", summaries[0].ID)
 69  	}
 70  	if summaries[0].Title != "First session" {
 71  		t.Errorf("expected title 'First session', got %q", summaries[0].Title)
 72  	}
 73  	if summaries[0].MsgCount != 2 {
 74  		t.Errorf("expected 2 messages, got %d", summaries[0].MsgCount)
 75  	}
 76  }
 77  
 78  func TestIndex_ListOrder(t *testing.T) {
 79  	dir := t.TempDir()
 80  	idx, err := OpenIndex(dir)
 81  	if err != nil {
 82  		t.Fatalf("OpenIndex: %v", err)
 83  	}
 84  	defer idx.Close()
 85  
 86  	older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
 87  	newer := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
 88  
 89  	if err := idx.UpsertSession(&Session{
 90  		ID: "old", Title: "Old session", CreatedAt: older, UpdatedAt: older,
 91  	}); err != nil {
 92  		t.Fatal(err)
 93  	}
 94  	if err := idx.UpsertSession(&Session{
 95  		ID: "new", Title: "New session", CreatedAt: newer, UpdatedAt: newer,
 96  	}); err != nil {
 97  		t.Fatal(err)
 98  	}
 99  
100  	summaries, err := idx.ListSessions()
101  	if err != nil {
102  		t.Fatalf("ListSessions: %v", err)
103  	}
104  	if len(summaries) != 2 {
105  		t.Fatalf("expected 2, got %d", len(summaries))
106  	}
107  	if summaries[0].ID != "new" {
108  		t.Errorf("expected newest first, got %q", summaries[0].ID)
109  	}
110  	if summaries[1].ID != "old" {
111  		t.Errorf("expected oldest second, got %q", summaries[1].ID)
112  	}
113  }
114  
115  func TestIndex_Search(t *testing.T) {
116  	dir := t.TempDir()
117  	idx, err := OpenIndex(dir)
118  	if err != nil {
119  		t.Fatalf("OpenIndex: %v", err)
120  	}
121  	defer idx.Close()
122  
123  	now := time.Now().Truncate(time.Second)
124  	sess := &Session{
125  		ID: "search-1", Title: "Search test", CreatedAt: now, UpdatedAt: now,
126  		Messages: []client.Message{
127  			{Role: "user", Content: client.NewTextContent("tell me about websocket reconnect logic")},
128  			{Role: "assistant", Content: client.NewTextContent("the reconnect uses exponential backoff")},
129  		},
130  	}
131  	if err := idx.UpsertSession(sess); err != nil {
132  		t.Fatal(err)
133  	}
134  
135  	results, err := idx.Search("websocket", 20)
136  	if err != nil {
137  		t.Fatalf("Search: %v", err)
138  	}
139  	if len(results) == 0 {
140  		t.Fatal("expected at least one result")
141  	}
142  	if results[0].SessionID != "search-1" {
143  		t.Errorf("expected session 'search-1', got %q", results[0].SessionID)
144  	}
145  	if results[0].SessionTitle != "Search test" {
146  		t.Errorf("expected title 'Search test', got %q", results[0].SessionTitle)
147  	}
148  	if results[0].Role != "user" {
149  		t.Errorf("expected role 'user', got %q", results[0].Role)
150  	}
151  	if !strings.Contains(results[0].Snippet, "websocket") {
152  		t.Errorf("snippet should contain 'websocket', got %q", results[0].Snippet)
153  	}
154  }
155  
156  func TestIndex_SearchStemming(t *testing.T) {
157  	dir := t.TempDir()
158  	idx, err := OpenIndex(dir)
159  	if err != nil {
160  		t.Fatalf("OpenIndex: %v", err)
161  	}
162  	defer idx.Close()
163  
164  	now := time.Now().Truncate(time.Second)
165  	sess := &Session{
166  		ID: "stem-1", Title: "Stemming", CreatedAt: now, UpdatedAt: now,
167  		Messages: []client.Message{
168  			{Role: "user", Content: client.NewTextContent("the server is running on port 8080")},
169  		},
170  	}
171  	if err := idx.UpsertSession(sess); err != nil {
172  		t.Fatal(err)
173  	}
174  
175  	results, err := idx.Search("run", 20)
176  	if err != nil {
177  		t.Fatalf("Search: %v", err)
178  	}
179  	if len(results) == 0 {
180  		t.Fatal("expected stemmed match for 'run' -> 'running'")
181  	}
182  }
183  
184  func TestIndex_SearchNoResults(t *testing.T) {
185  	dir := t.TempDir()
186  	idx, err := OpenIndex(dir)
187  	if err != nil {
188  		t.Fatalf("OpenIndex: %v", err)
189  	}
190  	defer idx.Close()
191  
192  	now := time.Now().Truncate(time.Second)
193  	if err := idx.UpsertSession(&Session{
194  		ID: "no-match", Title: "No match", CreatedAt: now, UpdatedAt: now,
195  		Messages: []client.Message{
196  			{Role: "user", Content: client.NewTextContent("hello world")},
197  		},
198  	}); err != nil {
199  		t.Fatal(err)
200  	}
201  
202  	results, err := idx.Search("xyzzynonexistent", 20)
203  	if err != nil {
204  		t.Fatalf("Search: %v", err)
205  	}
206  	if len(results) != 0 {
207  		t.Errorf("expected 0 results, got %d", len(results))
208  	}
209  }
210  
211  func TestIndex_SearchPhraseQuery(t *testing.T) {
212  	dir := t.TempDir()
213  	idx, err := OpenIndex(dir)
214  	if err != nil {
215  		t.Fatalf("OpenIndex: %v", err)
216  	}
217  	defer idx.Close()
218  
219  	now := time.Now().Truncate(time.Second)
220  	if err := idx.UpsertSession(&Session{
221  		ID: "phrase-1", Title: "Phrase test", CreatedAt: now, UpdatedAt: now,
222  		Messages: []client.Message{
223  			{Role: "user", Content: client.NewTextContent("fix the websocket reconnect issue")},
224  			{Role: "user", Content: client.NewTextContent("websocket is fine but reconnect is broken elsewhere")},
225  		},
226  	}); err != nil {
227  		t.Fatal(err)
228  	}
229  
230  	results, err := idx.Search(`"websocket reconnect"`, 20)
231  	if err != nil {
232  		t.Fatalf("Search: %v", err)
233  	}
234  	if len(results) == 0 {
235  		t.Fatal("expected phrase match")
236  	}
237  	// The phrase "websocket reconnect" only appears adjacent in msg_index 0
238  	if results[0].MsgIndex != 0 {
239  		t.Errorf("expected msg_index 0 for phrase match, got %d", results[0].MsgIndex)
240  	}
241  }
242  
243  func TestIndex_SearchMalformed(t *testing.T) {
244  	dir := t.TempDir()
245  	idx, err := OpenIndex(dir)
246  	if err != nil {
247  		t.Fatalf("OpenIndex: %v", err)
248  	}
249  	defer idx.Close()
250  
251  	// Insert data so FTS table is non-empty (empty FTS skips MATCH evaluation)
252  	now := time.Now().Truncate(time.Second)
253  	if err := idx.UpsertSession(&Session{
254  		ID: "mal-1", Title: "Malformed", CreatedAt: now, UpdatedAt: now,
255  		Messages: []client.Message{
256  			{Role: "user", Content: client.NewTextContent("some content")},
257  		},
258  	}); err != nil {
259  		t.Fatal(err)
260  	}
261  
262  	_, err = idx.Search(`"unbalanced`, 20)
263  	if err == nil {
264  		t.Fatal("expected error for malformed query")
265  	}
266  	if !strings.Contains(err.Error(), "invalid search query") {
267  		t.Errorf("expected clean error message, got: %v", err)
268  	}
269  }
270  
271  func TestIndex_Delete(t *testing.T) {
272  	dir := t.TempDir()
273  	idx, err := OpenIndex(dir)
274  	if err != nil {
275  		t.Fatalf("OpenIndex: %v", err)
276  	}
277  	defer idx.Close()
278  
279  	now := time.Now().Truncate(time.Second)
280  	sess := &Session{
281  		ID: "del-1", Title: "Delete me", CreatedAt: now, UpdatedAt: now,
282  		Messages: []client.Message{
283  			{Role: "user", Content: client.NewTextContent("unique deletable content")},
284  		},
285  	}
286  	if err := idx.UpsertSession(sess); err != nil {
287  		t.Fatal(err)
288  	}
289  
290  	if err := idx.DeleteSession("del-1"); err != nil {
291  		t.Fatalf("DeleteSession: %v", err)
292  	}
293  
294  	// Verify gone from list
295  	summaries, err := idx.ListSessions()
296  	if err != nil {
297  		t.Fatal(err)
298  	}
299  	if len(summaries) != 0 {
300  		t.Errorf("expected 0 sessions after delete, got %d", len(summaries))
301  	}
302  
303  	// Verify gone from FTS
304  	results, err := idx.Search("deletable", 20)
305  	if err != nil {
306  		t.Fatal(err)
307  	}
308  	if len(results) != 0 {
309  		t.Errorf("expected 0 FTS results after delete, got %d", len(results))
310  	}
311  }
312  
313  func TestIndex_UpsertUpdatesExisting(t *testing.T) {
314  	dir := t.TempDir()
315  	idx, err := OpenIndex(dir)
316  	if err != nil {
317  		t.Fatalf("OpenIndex: %v", err)
318  	}
319  	defer idx.Close()
320  
321  	now := time.Now().Truncate(time.Second)
322  
323  	// First upsert
324  	if err := idx.UpsertSession(&Session{
325  		ID: "upd-1", Title: "Original", CreatedAt: now, UpdatedAt: now,
326  		Messages: []client.Message{
327  			{Role: "user", Content: client.NewTextContent("original content alpha")},
328  		},
329  	}); err != nil {
330  		t.Fatal(err)
331  	}
332  
333  	// Second upsert with updated title and new messages
334  	if err := idx.UpsertSession(&Session{
335  		ID: "upd-1", Title: "Updated", CreatedAt: now, UpdatedAt: now.Add(time.Minute),
336  		Messages: []client.Message{
337  			{Role: "user", Content: client.NewTextContent("original content alpha")},
338  			{Role: "assistant", Content: client.NewTextContent("new content bravo")},
339  		},
340  	}); err != nil {
341  		t.Fatal(err)
342  	}
343  
344  	summaries, err := idx.ListSessions()
345  	if err != nil {
346  		t.Fatal(err)
347  	}
348  	if len(summaries) != 1 {
349  		t.Fatalf("expected 1 session, got %d", len(summaries))
350  	}
351  	if summaries[0].Title != "Updated" {
352  		t.Errorf("expected title 'Updated', got %q", summaries[0].Title)
353  	}
354  	if summaries[0].MsgCount != 2 {
355  		t.Errorf("expected 2 messages, got %d", summaries[0].MsgCount)
356  	}
357  
358  	// Both terms should be searchable
359  	r1, err := idx.Search("alpha", 20)
360  	if err != nil {
361  		t.Fatal(err)
362  	}
363  	if len(r1) == 0 {
364  		t.Error("expected 'alpha' to still be searchable")
365  	}
366  
367  	r2, err := idx.Search("bravo", 20)
368  	if err != nil {
369  		t.Fatal(err)
370  	}
371  	if len(r2) == 0 {
372  		t.Error("expected 'bravo' to be searchable after upsert")
373  	}
374  }
375  
376  func TestIndex_Rebuild(t *testing.T) {
377  	dir := t.TempDir()
378  	store := &Store{dir: dir}
379  
380  	// Create sessions via Store (JSON files)
381  	now := time.Now().Truncate(time.Second)
382  	s1 := &Session{
383  		ID: "rb-1", Title: "Rebuild one", CreatedAt: now, UpdatedAt: now,
384  		Messages: []client.Message{
385  			{Role: "user", Content: client.NewTextContent("rebuild test alpha")},
386  		},
387  	}
388  	s2 := &Session{
389  		ID: "rb-2", Title: "Rebuild two", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second),
390  		Messages: []client.Message{
391  			{Role: "user", Content: client.NewTextContent("rebuild test bravo")},
392  		},
393  	}
394  	if err := store.Save(s1); err != nil {
395  		t.Fatal(err)
396  	}
397  	if err := store.Save(s2); err != nil {
398  		t.Fatal(err)
399  	}
400  
401  	idx, err := OpenIndex(dir)
402  	if err != nil {
403  		t.Fatal(err)
404  	}
405  	defer idx.Close()
406  
407  	if err := idx.Rebuild(store); err != nil {
408  		t.Fatalf("Rebuild: %v", err)
409  	}
410  
411  	summaries, err := idx.ListSessions()
412  	if err != nil {
413  		t.Fatal(err)
414  	}
415  	if len(summaries) != 2 {
416  		t.Fatalf("expected 2 sessions after rebuild, got %d", len(summaries))
417  	}
418  
419  	// Verify FTS works
420  	results, err := idx.Search("bravo", 20)
421  	if err != nil {
422  		t.Fatal(err)
423  	}
424  	if len(results) == 0 {
425  		t.Error("expected FTS results after rebuild")
426  	}
427  }
428  
429  func TestIndex_LatestUpdated(t *testing.T) {
430  	dir := t.TempDir()
431  	idx, err := OpenIndex(dir)
432  	if err != nil {
433  		t.Fatal(err)
434  	}
435  	defer idx.Close()
436  
437  	t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
438  	t2 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
439  
440  	if err := idx.UpsertSession(&Session{
441  		ID: "old", Title: "Old", CreatedAt: t1, UpdatedAt: t1,
442  	}); err != nil {
443  		t.Fatal(err)
444  	}
445  	if err := idx.UpsertSession(&Session{
446  		ID: "new", Title: "New", CreatedAt: t1, UpdatedAt: t2,
447  	}); err != nil {
448  		t.Fatal(err)
449  	}
450  
451  	id, err := idx.LatestUpdatedID()
452  	if err != nil {
453  		t.Fatalf("LatestUpdatedID: %v", err)
454  	}
455  	if id != "new" {
456  		t.Errorf("expected 'new', got %q", id)
457  	}
458  }
459  
460  func TestIndex_SearchFTSSyntaxError(t *testing.T) {
461  	dir := t.TempDir()
462  	idx, err := OpenIndex(dir)
463  	if err != nil {
464  		t.Fatalf("OpenIndex: %v", err)
465  	}
466  	defer idx.Close()
467  
468  	// Insert data so FTS actually runs the query
469  	idx.UpsertSession(&Session{
470  		ID: "data", Title: "Data", CreatedAt: time.Now(), UpdatedAt: time.Now(),
471  		Messages: []client.Message{
472  			{Role: "user", Content: client.NewTextContent("some data")},
473  		},
474  	})
475  
476  	// Various malformed queries
477  	badQueries := []string{
478  		`"unbalanced`,
479  		`AND`,
480  		`OR OR`,
481  		`NOT`,
482  	}
483  	for _, q := range badQueries {
484  		_, err := idx.Search(q, 10)
485  		if err == nil {
486  			continue // some "bad" queries may be valid in FTS5, that's OK
487  		}
488  		// If it errors, the message should be clean (not raw sqlite)
489  		if !strings.Contains(err.Error(), "invalid search query") {
490  			t.Errorf("query %q: expected clean error, got: %v", q, err)
491  		}
492  	}
493  }
494  
495  func TestIndex_UpsertSkipsSystemInjected(t *testing.T) {
496  	dir := t.TempDir()
497  	idx, err := OpenIndex(dir)
498  	if err != nil {
499  		t.Fatalf("OpenIndex: %v", err)
500  	}
501  	defer idx.Close()
502  
503  	now := time.Now().Truncate(time.Second)
504  
505  	tests := []struct {
506  		name           string
507  		messages       []client.Message
508  		meta           []MessageMeta
509  		wantMsgCount   int
510  		searchHit      string
511  		searchMiss     string
512  	}{
513  		{
514  			name: "no meta (legacy session) indexes all",
515  			messages: []client.Message{
516  				{Role: "user", Content: client.NewTextContent("legacy alpha")},
517  				{Role: "assistant", Content: client.NewTextContent("legacy bravo")},
518  			},
519  			meta:         nil,
520  			wantMsgCount: 2,
521  			searchHit:    "alpha",
522  			searchMiss:   "",
523  		},
524  		{
525  			// msg_count reflects total messages (len(sess.Messages)), not
526  			// indexed rows, so the desktop sidebar's "used session" filter
527  			// stays consistent with what the user sees in the conversation.
528  			name: "injected messages excluded from index",
529  			messages: []client.Message{
530  				{Role: "user", Content: client.NewTextContent("visible unicorn")},
531  				{Role: "assistant", Content: client.NewTextContent("injected giraffe")},
532  				{Role: "assistant", Content: client.NewTextContent("visible elephant")},
533  			},
534  			meta: []MessageMeta{
535  				{},
536  				{SystemInjected: true},
537  				{},
538  			},
539  			wantMsgCount: 3,
540  			searchHit:    "unicorn",
541  			searchMiss:   "giraffe",
542  		},
543  		{
544  			name: "short meta (fewer than messages) indexes unmatched positions",
545  			messages: []client.Message{
546  				{Role: "user", Content: client.NewTextContent("first penguin")},
547  				{Role: "assistant", Content: client.NewTextContent("second dolphin")},
548  				{Role: "user", Content: client.NewTextContent("third falcon")},
549  			},
550  			meta: []MessageMeta{
551  				{SystemInjected: true},
552  			},
553  			wantMsgCount: 3,
554  			searchHit:    "dolphin",
555  			searchMiss:   "penguin",
556  		},
557  	}
558  
559  	for i, tt := range tests {
560  		t.Run(tt.name, func(t *testing.T) {
561  			sessID := fmt.Sprintf("injected-%d", i)
562  			sess := &Session{
563  				ID: sessID, Title: tt.name, CreatedAt: now, UpdatedAt: now,
564  				Messages:    tt.messages,
565  				MessageMeta: tt.meta,
566  			}
567  			if err := idx.UpsertSession(sess); err != nil {
568  				t.Fatalf("UpsertSession: %v", err)
569  			}
570  
571  			summaries, err := idx.ListSessions()
572  			if err != nil {
573  				t.Fatalf("ListSessions: %v", err)
574  			}
575  			var found bool
576  			for _, s := range summaries {
577  				if s.ID == sessID {
578  					found = true
579  					if s.MsgCount != tt.wantMsgCount {
580  						t.Errorf("msg_count = %d, want %d", s.MsgCount, tt.wantMsgCount)
581  					}
582  				}
583  			}
584  			if !found {
585  				t.Fatalf("session %q not found in list", sessID)
586  			}
587  
588  			if tt.searchHit != "" {
589  				results, err := idx.Search(tt.searchHit, 20)
590  				if err != nil {
591  					t.Fatalf("Search(%q): %v", tt.searchHit, err)
592  				}
593  				if len(results) == 0 {
594  					t.Errorf("expected hit for %q", tt.searchHit)
595  				}
596  			}
597  			if tt.searchMiss != "" {
598  				results, err := idx.Search(tt.searchMiss, 20)
599  				if err != nil {
600  					t.Fatalf("Search(%q): %v", tt.searchMiss, err)
601  				}
602  				for _, r := range results {
603  					if r.SessionID == sessID {
604  						t.Errorf("expected no hit for %q in session %q, but got one", tt.searchMiss, sessID)
605  					}
606  				}
607  			}
608  		})
609  	}
610  }
611  
612  func TestIndex_IsEmpty(t *testing.T) {
613  	dir := t.TempDir()
614  	idx, err := OpenIndex(dir)
615  	if err != nil {
616  		t.Fatal(err)
617  	}
618  	defer idx.Close()
619  
620  	empty, err := idx.IsEmpty()
621  	if err != nil {
622  		t.Fatal(err)
623  	}
624  	if !empty {
625  		t.Error("expected empty index")
626  	}
627  
628  	now := time.Now().Truncate(time.Second)
629  	if err := idx.UpsertSession(&Session{
630  		ID: "x", Title: "X", CreatedAt: now, UpdatedAt: now,
631  	}); err != nil {
632  		t.Fatal(err)
633  	}
634  
635  	empty, err = idx.IsEmpty()
636  	if err != nil {
637  		t.Fatal(err)
638  	}
639  	if empty {
640  		t.Error("expected non-empty index after insert")
641  	}
642  }
643  
644  // TestIndex_V2ToV3MigrationRebuildsFromJSON verifies that an existing v2
645  // sessions.db (no `source` column, PRAGMA user_version = 2) can be opened by
646  // the v3 schema without error: the rebuild path drops messages tables, the
647  // ALTER TABLE backfills the new column, and subsequent UpsertSession calls
648  // populate `source` correctly.
649  func TestIndex_V2ToV3MigrationRebuildsFromJSON(t *testing.T) {
650  	dir := t.TempDir()
651  	dbPath := filepath.Join(dir, "sessions.db")
652  	{
653  		raw, err := sql.Open("sqlite", dbPath)
654  		if err != nil {
655  			t.Fatalf("open raw sqlite: %v", err)
656  		}
657  		// Exact v2 CREATE TABLE — no source column.
658  		if _, err := raw.Exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', cwd TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, msg_count INTEGER NOT NULL DEFAULT 0)`); err != nil {
659  			t.Fatalf("create v2 sessions table: %v", err)
660  		}
661  		if _, err := raw.Exec(`PRAGMA user_version = 2`); err != nil {
662  			t.Fatalf("stamp v2 user_version: %v", err)
663  		}
664  		now := time.Now().UTC()
665  		if _, err := raw.Exec(
666  			`INSERT INTO sessions (id, title, created_at, updated_at, msg_count) VALUES (?, ?, ?, ?, ?)`,
667  			"s1", "old session", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), 1,
668  		); err != nil {
669  			t.Fatalf("seed v2 row: %v", err)
670  		}
671  		if err := raw.Close(); err != nil {
672  			t.Fatalf("close raw sqlite: %v", err)
673  		}
674  	}
675  
676  	// Open via the actual API. The version mismatch (2 != 3) MUST trigger
677  	// the drop-and-rebuild path AND backfill the `source` column.
678  	idx, err := OpenIndex(dir)
679  	if err != nil {
680  		t.Fatalf("OpenIndex (v2->v3): %v", err)
681  	}
682  	defer idx.Close()
683  
684  	// Confirm new column is queryable. Rebuild reads JSON files on disk
685  	// (none seeded here), so an empty result set is expected — the assertion
686  	// is that the query SUCCEEDS against the migrated schema.
687  	rows, err := idx.ListUpdatedSince(context.Background(), time.Time{})
688  	if err != nil {
689  		t.Fatalf("ListUpdatedSince after migration: %v", err)
690  	}
691  	_ = rows
692  
693  	// Confirm a fresh Upsert populates the source column.
694  	if err := idx.UpsertSession(&Session{
695  		ID: "s2", Source: "slack",
696  		CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
697  	}); err != nil {
698  		t.Fatalf("UpsertSession with source: %v", err)
699  	}
700  	rows2, err := idx.ListUpdatedSince(context.Background(), time.Time{})
701  	if err != nil {
702  		t.Fatalf("ListUpdatedSince after upsert: %v", err)
703  	}
704  	found := false
705  	for _, r := range rows2 {
706  		if r.ID == "s2" && r.Source == "slack" {
707  			found = true
708  		}
709  	}
710  	if !found {
711  		t.Errorf("expected s2 with Source=slack after migration; got %+v", rows2)
712  	}
713  }
714  
715  func TestIndex_ListUpdatedSince(t *testing.T) {
716  	dir := t.TempDir()
717  	idx, err := OpenIndex(dir)
718  	if err != nil {
719  		t.Fatalf("OpenIndex: %v", err)
720  	}
721  	defer idx.Close()
722  
723  	now := time.Now().UTC().Truncate(time.Second)
724  	older := now.Add(-2 * time.Hour)
725  	newer := now.Add(-30 * time.Minute)
726  	newest := now.Add(-1 * time.Minute)
727  
728  	mustUpsert(t, idx, &Session{ID: "old", CreatedAt: older, UpdatedAt: older})
729  	mustUpsert(t, idx, &Session{ID: "mid", CreatedAt: newer, UpdatedAt: newer})
730  	mustUpsert(t, idx, &Session{ID: "new", CreatedAt: newest, UpdatedAt: newest})
731  
732  	cutoff := now.Add(-1 * time.Hour)
733  	rows, err := idx.ListUpdatedSince(context.Background(), cutoff)
734  	if err != nil {
735  		t.Fatalf("ListUpdatedSince: %v", err)
736  	}
737  
738  	gotIDs := map[string]bool{}
739  	for _, r := range rows {
740  		gotIDs[r.ID] = true
741  	}
742  	if gotIDs["old"] {
743  		t.Errorf("ListUpdatedSince should exclude sessions with updated_at <= cutoff")
744  	}
745  	if !gotIDs["mid"] || !gotIDs["new"] {
746  		t.Errorf("ListUpdatedSince should include sessions with updated_at > cutoff; got %v", gotIDs)
747  	}
748  	if len(rows) != 2 {
749  		t.Errorf("expected 2 rows, got %d: %+v", len(rows), rows)
750  	}
751  }