Skip to content

Commit a48d064

Browse files
fix(migration): rebuild legacy observations table with valid ids
1 parent 1eb6ef5 commit a48d064

File tree

2 files changed

+235
-0
lines changed

2 files changed

+235
-0
lines changed

internal/store/store.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ func (s *Store) migrate() error {
307307
}
308308
}
309309

310+
if err := s.migrateLegacyObservationsTable(); err != nil {
311+
return err
312+
}
313+
310314
if _, err := s.db.Exec(`
311315
CREATE INDEX IF NOT EXISTS idx_obs_scope ON observations(scope);
312316
CREATE INDEX IF NOT EXISTS idx_obs_topic ON observations(topic_key, project, scope, updated_at DESC);
@@ -1357,6 +1361,138 @@ func (s *Store) addColumnIfNotExists(tableName, columnName, definition string) e
13571361
return err
13581362
}
13591363

1364+
func (s *Store) migrateLegacyObservationsTable() error {
1365+
rows, err := s.db.Query("PRAGMA table_info(observations)")
1366+
if err != nil {
1367+
return err
1368+
}
1369+
defer rows.Close()
1370+
1371+
var hasID bool
1372+
var idIsPrimaryKey bool
1373+
for rows.Next() {
1374+
var cid int
1375+
var name, typ string
1376+
var notNull int
1377+
var defaultValue any
1378+
var pk int
1379+
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
1380+
return err
1381+
}
1382+
if name == "id" {
1383+
hasID = true
1384+
idIsPrimaryKey = pk == 1
1385+
break
1386+
}
1387+
}
1388+
if err := rows.Err(); err != nil {
1389+
return err
1390+
}
1391+
1392+
if !hasID || idIsPrimaryKey {
1393+
return nil
1394+
}
1395+
1396+
tx, err := s.db.Begin()
1397+
if err != nil {
1398+
return fmt.Errorf("migrate legacy observations: begin tx: %w", err)
1399+
}
1400+
defer tx.Rollback()
1401+
1402+
if _, err := tx.Exec(`
1403+
CREATE TABLE observations_migrated (
1404+
id INTEGER PRIMARY KEY AUTOINCREMENT,
1405+
session_id TEXT NOT NULL,
1406+
type TEXT NOT NULL,
1407+
title TEXT NOT NULL,
1408+
content TEXT NOT NULL,
1409+
tool_name TEXT,
1410+
project TEXT,
1411+
scope TEXT NOT NULL DEFAULT 'project',
1412+
topic_key TEXT,
1413+
normalized_hash TEXT,
1414+
revision_count INTEGER NOT NULL DEFAULT 1,
1415+
duplicate_count INTEGER NOT NULL DEFAULT 1,
1416+
last_seen_at TEXT,
1417+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
1418+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1419+
deleted_at TEXT,
1420+
FOREIGN KEY (session_id) REFERENCES sessions(id)
1421+
);
1422+
`); err != nil {
1423+
return fmt.Errorf("migrate legacy observations: create table: %w", err)
1424+
}
1425+
1426+
if _, err := tx.Exec(`
1427+
INSERT INTO observations_migrated (
1428+
id, session_id, type, title, content, tool_name, project,
1429+
scope, topic_key, normalized_hash, revision_count, duplicate_count,
1430+
last_seen_at, created_at, updated_at, deleted_at
1431+
)
1432+
SELECT
1433+
CASE
1434+
WHEN id IS NULL THEN NULL
1435+
WHEN ROW_NUMBER() OVER (PARTITION BY id ORDER BY rowid) = 1 THEN CAST(id AS INTEGER)
1436+
ELSE NULL
1437+
END,
1438+
session_id,
1439+
COALESCE(NULLIF(type, ''), 'manual'),
1440+
COALESCE(NULLIF(title, ''), 'Untitled observation'),
1441+
COALESCE(content, ''),
1442+
tool_name,
1443+
project,
1444+
CASE WHEN scope IS NULL OR scope = '' THEN 'project' ELSE scope END,
1445+
NULLIF(topic_key, ''),
1446+
normalized_hash,
1447+
CASE WHEN revision_count IS NULL OR revision_count < 1 THEN 1 ELSE revision_count END,
1448+
CASE WHEN duplicate_count IS NULL OR duplicate_count < 1 THEN 1 ELSE duplicate_count END,
1449+
last_seen_at,
1450+
COALESCE(NULLIF(created_at, ''), datetime('now')),
1451+
COALESCE(NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')),
1452+
deleted_at
1453+
FROM observations
1454+
ORDER BY rowid;
1455+
`); err != nil {
1456+
return fmt.Errorf("migrate legacy observations: copy rows: %w", err)
1457+
}
1458+
1459+
if _, err := tx.Exec("DROP TABLE observations"); err != nil {
1460+
return fmt.Errorf("migrate legacy observations: drop old table: %w", err)
1461+
}
1462+
1463+
if _, err := tx.Exec("ALTER TABLE observations_migrated RENAME TO observations"); err != nil {
1464+
return fmt.Errorf("migrate legacy observations: rename table: %w", err)
1465+
}
1466+
1467+
if _, err := tx.Exec(`
1468+
DROP TRIGGER IF EXISTS obs_fts_insert;
1469+
DROP TRIGGER IF EXISTS obs_fts_update;
1470+
DROP TRIGGER IF EXISTS obs_fts_delete;
1471+
DROP TABLE IF EXISTS observations_fts;
1472+
CREATE VIRTUAL TABLE observations_fts USING fts5(
1473+
title,
1474+
content,
1475+
tool_name,
1476+
type,
1477+
project,
1478+
content='observations',
1479+
content_rowid='id'
1480+
);
1481+
INSERT INTO observations_fts(rowid, title, content, tool_name, type, project)
1482+
SELECT id, title, content, tool_name, type, project
1483+
FROM observations
1484+
WHERE deleted_at IS NULL;
1485+
`); err != nil {
1486+
return fmt.Errorf("migrate legacy observations: rebuild fts: %w", err)
1487+
}
1488+
1489+
if err := tx.Commit(); err != nil {
1490+
return fmt.Errorf("migrate legacy observations: commit: %w", err)
1491+
}
1492+
1493+
return nil
1494+
}
1495+
13601496
func nullableString(s string) *string {
13611497
if s == "" {
13621498
return nil

internal/store/store_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package store
22

33
import (
4+
"database/sql"
5+
"path/filepath"
46
"strings"
57
"testing"
68
"time"
9+
10+
_ "modernc.org/sqlite"
711
)
812

913
func newTestStore(t *testing.T) *Store {
@@ -313,6 +317,101 @@ func TestDifferentTopicsDoNotReplaceEachOther(t *testing.T) {
313317
}
314318
}
315319

320+
func TestNewMigratesLegacyObservationIDSchema(t *testing.T) {
321+
dataDir := t.TempDir()
322+
dbPath := filepath.Join(dataDir, "engram.db")
323+
324+
db, err := sql.Open("sqlite", dbPath)
325+
if err != nil {
326+
t.Fatalf("open legacy db: %v", err)
327+
}
328+
329+
_, err = db.Exec(`
330+
CREATE TABLE sessions (
331+
id TEXT PRIMARY KEY,
332+
project TEXT NOT NULL,
333+
directory TEXT NOT NULL,
334+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
335+
ended_at TEXT,
336+
summary TEXT
337+
);
338+
CREATE TABLE observations (
339+
id INT,
340+
session_id TEXT,
341+
type TEXT,
342+
title TEXT,
343+
content TEXT,
344+
tool_name TEXT,
345+
project TEXT,
346+
created_at TEXT
347+
);
348+
INSERT INTO sessions (id, project, directory) VALUES ('s1', 'engram', '/tmp/engram');
349+
INSERT INTO observations (id, session_id, type, title, content, project, created_at)
350+
VALUES
351+
(NULL, 's1', 'bugfix', 'legacy null', 'legacy null content', 'engram', datetime('now')),
352+
(7, 's1', 'bugfix', 'legacy fixed', 'legacy fixed content', 'engram', datetime('now')),
353+
(7, 's1', 'bugfix', 'legacy duplicate', 'legacy duplicate content', 'engram', datetime('now'));
354+
`)
355+
if err != nil {
356+
_ = db.Close()
357+
t.Fatalf("seed legacy db: %v", err)
358+
}
359+
if err := db.Close(); err != nil {
360+
t.Fatalf("close legacy db: %v", err)
361+
}
362+
363+
cfg := DefaultConfig()
364+
cfg.DataDir = dataDir
365+
366+
s, err := New(cfg)
367+
if err != nil {
368+
t.Fatalf("new store after legacy schema: %v", err)
369+
}
370+
t.Cleanup(func() { _ = s.Close() })
371+
372+
obs, err := s.AllObservations("engram", "", 20)
373+
if err != nil {
374+
t.Fatalf("all observations after migration: %v", err)
375+
}
376+
if len(obs) != 3 {
377+
t.Fatalf("expected 3 migrated observations, got %d", len(obs))
378+
}
379+
380+
seen := make(map[int64]bool)
381+
for _, o := range obs {
382+
if o.ID <= 0 {
383+
t.Fatalf("expected migrated observation id > 0, got %d", o.ID)
384+
}
385+
if seen[o.ID] {
386+
t.Fatalf("expected unique migrated ids, duplicate %d", o.ID)
387+
}
388+
seen[o.ID] = true
389+
}
390+
391+
results, err := s.Search("legacy", SearchOptions{Project: "engram", Limit: 10})
392+
if err != nil {
393+
t.Fatalf("search after migration: %v", err)
394+
}
395+
if len(results) == 0 {
396+
t.Fatalf("expected search results after migration")
397+
}
398+
399+
newID, err := s.AddObservation(AddObservationParams{
400+
SessionID: "s1",
401+
Type: "bugfix",
402+
Title: "post migration",
403+
Content: "new row should get id",
404+
Project: "engram",
405+
Scope: "project",
406+
})
407+
if err != nil {
408+
t.Fatalf("add observation after migration: %v", err)
409+
}
410+
if newID <= 0 {
411+
t.Fatalf("expected autoincrement id after migration, got %d", newID)
412+
}
413+
}
414+
316415
func TestSuggestTopicKeyNormalizesDeterministically(t *testing.T) {
317416
got := SuggestTopicKey("Architecture", " Auth Model ", "ignored")
318417
if got != "architecture/auth-model" {

0 commit comments

Comments
 (0)