a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
441 lines
14 KiB
Go
441 lines
14 KiB
Go
package persistence
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func openTempSidecar(t *testing.T) *SidecarStore {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "sidecar.sqlite")
|
|
st, err := OpenSidecar(path)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, st)
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return st
|
|
}
|
|
|
|
func TestSidecar_OpenEmptyPathIsNoOp(t *testing.T) {
|
|
st, err := OpenSidecar("")
|
|
require.NoError(t, err)
|
|
require.Nil(t, st)
|
|
}
|
|
|
|
func TestSidecar_SameAbsPathReusesHandle(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "sidecar.sqlite")
|
|
a, err := OpenSidecar(path)
|
|
require.NoError(t, err)
|
|
b, err := OpenSidecar(path)
|
|
require.NoError(t, err)
|
|
require.Same(t, a, b, "same absolute path must return the cached handle")
|
|
t.Cleanup(func() { _ = a.Close() })
|
|
}
|
|
|
|
func TestSidecar_NotesRoundTrip(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
now := time.Now().UTC().Truncate(time.Nanosecond)
|
|
in := NoteEntry{
|
|
ID: "nt-1",
|
|
Timestamp: now,
|
|
UpdatedAt: now,
|
|
SessionID: "sess-1",
|
|
ClientName: "claude-code",
|
|
Body: "decision: switch to fastpath",
|
|
SymbolID: "pkg/foo.go::Bar",
|
|
FilePath: "pkg/foo.go",
|
|
RepoPrefix: "core",
|
|
WorkspaceID: "ws-a",
|
|
ProjectID: "proj-a",
|
|
Tags: []string{"decision", "perf"},
|
|
AutoLinks: []string{"pkg/foo.go::Bar"},
|
|
Pinned: true,
|
|
}
|
|
require.NoError(t, st.UpsertNote("rk", in))
|
|
|
|
rows, err := st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
got := rows[0]
|
|
assert.Equal(t, in.ID, got.ID)
|
|
assert.Equal(t, in.SessionID, got.SessionID)
|
|
assert.Equal(t, in.ClientName, got.ClientName)
|
|
assert.Equal(t, in.Body, got.Body)
|
|
assert.Equal(t, in.SymbolID, got.SymbolID)
|
|
assert.Equal(t, in.FilePath, got.FilePath)
|
|
assert.Equal(t, in.WorkspaceID, got.WorkspaceID)
|
|
assert.Equal(t, in.Tags, got.Tags)
|
|
assert.Equal(t, in.AutoLinks, got.AutoLinks)
|
|
assert.True(t, got.Pinned)
|
|
assert.WithinDuration(t, in.UpdatedAt, got.UpdatedAt, time.Microsecond)
|
|
|
|
// Scope isolation: another repo_key sees nothing.
|
|
other, err := st.LoadNotesRows("other")
|
|
require.NoError(t, err)
|
|
require.Empty(t, other)
|
|
|
|
// Delete.
|
|
require.NoError(t, st.DeleteNote("rk", "nt-1"))
|
|
rows, err = st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Empty(t, rows)
|
|
}
|
|
|
|
func TestSidecar_NotesTrimKeepsPinnedAndNewest(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
base := time.Now().UTC()
|
|
for i := 0; i < 10; i++ {
|
|
require.NoError(t, st.UpsertNote("rk", NoteEntry{
|
|
ID: noteID(i),
|
|
Timestamp: base.Add(time.Duration(i) * time.Second),
|
|
UpdatedAt: base.Add(time.Duration(i) * time.Second),
|
|
Pinned: i == 0 || i == 5,
|
|
}))
|
|
}
|
|
require.NoError(t, st.TrimNotes("rk", 6))
|
|
|
|
rows, err := st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 6)
|
|
ids := map[string]bool{}
|
|
for _, r := range rows {
|
|
ids[r.ID] = true
|
|
}
|
|
assert.True(t, ids[noteID(0)], "pinned[0] survives")
|
|
assert.True(t, ids[noteID(5)], "pinned[5] survives")
|
|
assert.True(t, ids[noteID(9)], "newest survives")
|
|
assert.False(t, ids[noteID(1)], "oldest non-pinned dropped")
|
|
}
|
|
|
|
func TestSidecar_MemoriesRoundTrip(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
now := time.Now().UTC()
|
|
in := MemoryEntry{
|
|
ID: "mem-1",
|
|
Timestamp: now,
|
|
UpdatedAt: now,
|
|
LastAccessed: now,
|
|
AccessCount: 7,
|
|
Body: "lock invariant for Bar",
|
|
Title: "Bar lock invariant",
|
|
Kind: "invariant",
|
|
Source: "manual",
|
|
Confidence: 0.8,
|
|
Importance: 5,
|
|
AuthorAgent: "claude-code",
|
|
SymbolIDs: []string{"pkg/foo.go::Bar"},
|
|
FilePaths: []string{"pkg/foo.go"},
|
|
AutoLinks: []string{"pkg/foo.go::Baz"},
|
|
Tags: []string{"invariant", "lock"},
|
|
WorkspaceID: "ws-a",
|
|
ProjectID: "proj-a",
|
|
RepoPrefix: "core",
|
|
Pinned: true,
|
|
SupersededBy: "mem-2",
|
|
}
|
|
require.NoError(t, st.UpsertMemory("rk", in))
|
|
|
|
rows, err := st.LoadMemoriesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
got := rows[0]
|
|
assert.Equal(t, in.ID, got.ID)
|
|
assert.Equal(t, in.Title, got.Title)
|
|
assert.Equal(t, in.Kind, got.Kind)
|
|
assert.Equal(t, in.Source, got.Source)
|
|
assert.InDelta(t, in.Confidence, got.Confidence, 1e-6)
|
|
assert.Equal(t, in.Importance, got.Importance)
|
|
assert.Equal(t, in.AuthorAgent, got.AuthorAgent)
|
|
assert.Equal(t, in.SymbolIDs, got.SymbolIDs)
|
|
assert.Equal(t, in.FilePaths, got.FilePaths)
|
|
assert.Equal(t, in.AutoLinks, got.AutoLinks)
|
|
assert.Equal(t, in.Tags, got.Tags)
|
|
assert.Equal(t, uint64(7), got.AccessCount)
|
|
assert.Equal(t, "mem-2", got.SupersededBy)
|
|
assert.True(t, got.Pinned)
|
|
|
|
require.NoError(t, st.DeleteMemory("rk", "mem-1"))
|
|
rows, err = st.LoadMemoriesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Empty(t, rows)
|
|
}
|
|
|
|
func TestSidecar_MemoriesTrimTwoPass(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
base := time.Now().UTC()
|
|
for i := 0; i < 10; i++ {
|
|
e := MemoryEntry{
|
|
ID: memID(i),
|
|
Timestamp: base.Add(time.Duration(i) * time.Second),
|
|
UpdatedAt: base.Add(time.Duration(i) * time.Second),
|
|
Importance: 4,
|
|
}
|
|
if i == 2 || i == 4 {
|
|
e.Importance = 1
|
|
}
|
|
if i == 7 {
|
|
e.Pinned = true
|
|
e.Importance = 1
|
|
}
|
|
require.NoError(t, st.UpsertMemory("rk", e))
|
|
}
|
|
require.NoError(t, st.TrimMemories("rk", 6))
|
|
|
|
rows, err := st.LoadMemoriesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 6)
|
|
ids := map[string]bool{}
|
|
for _, r := range rows {
|
|
ids[r.ID] = true
|
|
}
|
|
assert.True(t, ids[memID(7)], "pinned low-imp survives")
|
|
assert.False(t, ids[memID(2)], "low-imp dropped")
|
|
assert.False(t, ids[memID(4)], "low-imp dropped")
|
|
}
|
|
|
|
func TestSidecar_ScopesRoundTrip(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
require.NoError(t, st.UpsertScope(ScopeRow{
|
|
Name: "backend", Description: "be", Repos: []string{"api", "core"}, Paths: []string{"services/x"},
|
|
}))
|
|
require.NoError(t, st.UpsertScope(ScopeRow{Name: "frontend", Repos: []string{"web"}}))
|
|
|
|
rows, err := st.LoadScopes()
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 2)
|
|
assert.Equal(t, "backend", rows[0].Name)
|
|
assert.Equal(t, []string{"api", "core"}, rows[0].Repos)
|
|
assert.Equal(t, []string{"services/x"}, rows[0].Paths)
|
|
assert.Equal(t, 2, st.ScopeCount())
|
|
|
|
require.NoError(t, st.DeleteScope("backend"))
|
|
rows, err = st.LoadScopes()
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "frontend", rows[0].Name)
|
|
}
|
|
|
|
func TestSidecar_NotebookRoundTrip(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
now := time.Now().UTC()
|
|
in := NotebookRow{
|
|
ID: "nb-1",
|
|
Title: "design: sidecar",
|
|
Body: "use sqlite\nfor durability",
|
|
Tags: []string{"design", "storage"},
|
|
SymbolIDs: []string{"pkg/p.go::Q"},
|
|
UsedCount: 3,
|
|
LastUsed: now,
|
|
Created: now,
|
|
Updated: now,
|
|
}
|
|
require.NoError(t, st.UpsertNotebook("rk", in))
|
|
|
|
got, ok := st.GetNotebookRow("rk", "nb-1")
|
|
require.True(t, ok)
|
|
assert.Equal(t, in.Title, got.Title)
|
|
assert.Equal(t, in.Body, got.Body)
|
|
assert.Equal(t, in.Tags, got.Tags)
|
|
assert.Equal(t, in.SymbolIDs, got.SymbolIDs)
|
|
assert.Equal(t, uint64(3), got.UsedCount)
|
|
|
|
rows, err := st.LoadNotebookRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
|
|
require.NoError(t, st.DeleteNotebook("rk", "nb-1"))
|
|
_, ok = st.GetNotebookRow("rk", "nb-1")
|
|
require.False(t, ok)
|
|
}
|
|
|
|
func TestSidecar_NotebookPrune(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
old := time.Now().UTC().Add(-2 * time.Hour)
|
|
fresh := time.Now().UTC()
|
|
require.NoError(t, st.UpsertNotebook("rk", NotebookRow{ID: "stale", Updated: old}))
|
|
require.NoError(t, st.UpsertNotebook("rk", NotebookRow{ID: "fresh", Updated: fresh, LastUsed: fresh}))
|
|
|
|
require.NoError(t, st.NotebookPrune("rk", time.Now().UTC().Add(-time.Hour)))
|
|
rows, err := st.LoadNotebookRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "fresh", rows[0].ID)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration: legacy gob.gz / json → sqlite.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSidecar_MigrateLegacyNotes(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
require.NoError(t, SaveNotes(legacyDir, &NoteStore{Entries: []NoteEntry{
|
|
{ID: "nt-old", Body: "legacy note", SessionID: "s1", Pinned: true},
|
|
}}))
|
|
|
|
st := openTempSidecar(t)
|
|
require.NoError(t, st.MigrateLegacyNotes("rk", legacyDir))
|
|
|
|
rows, err := st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "nt-old", rows[0].ID)
|
|
assert.Equal(t, "legacy note", rows[0].Body)
|
|
assert.True(t, rows[0].Pinned)
|
|
|
|
// Legacy file renamed to .bak.
|
|
_, errOrig := os.Stat(filepath.Join(legacyDir, notesFile))
|
|
assert.Error(t, errOrig, "original gob.gz must be renamed away")
|
|
_, errBak := os.Stat(filepath.Join(legacyDir, notesFile+".bak"))
|
|
assert.NoError(t, errBak, ".bak must exist")
|
|
|
|
// Idempotent: a second migrate is a no-op (no duplicate rows).
|
|
require.NoError(t, st.MigrateLegacyNotes("rk", legacyDir))
|
|
rows, err = st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
}
|
|
|
|
func TestSidecar_MigrateLegacyMemories(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
require.NoError(t, SaveMemories(legacyDir, &MemoryStore{Entries: []MemoryEntry{
|
|
{ID: "mem-old", Body: "legacy memory", Kind: "invariant", Importance: 5},
|
|
}}))
|
|
|
|
st := openTempSidecar(t)
|
|
require.NoError(t, st.MigrateLegacyMemories("rk", legacyDir))
|
|
|
|
rows, err := st.LoadMemoriesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "mem-old", rows[0].ID)
|
|
assert.Equal(t, "invariant", rows[0].Kind)
|
|
|
|
_, errBak := os.Stat(filepath.Join(legacyDir, memoriesFile+".bak"))
|
|
assert.NoError(t, errBak, ".bak must exist")
|
|
}
|
|
|
|
func TestSidecar_MigrateLegacyScopes(t *testing.T) {
|
|
legacyPath := filepath.Join(t.TempDir(), "scopes.json")
|
|
require.NoError(t, os.WriteFile(legacyPath, []byte(`[{"name":"be","description":"backend","repos":["api"],"paths":["svc/x"]}]`), 0o644))
|
|
|
|
st := openTempSidecar(t)
|
|
require.NoError(t, st.MigrateLegacyScopes(legacyPath))
|
|
|
|
rows, err := st.LoadScopes()
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "be", rows[0].Name)
|
|
assert.Equal(t, []string{"api"}, rows[0].Repos)
|
|
assert.Equal(t, []string{"svc/x"}, rows[0].Paths)
|
|
|
|
_, errBak := os.Stat(legacyPath + ".bak")
|
|
assert.NoError(t, errBak)
|
|
|
|
// Idempotent.
|
|
require.NoError(t, st.MigrateLegacyScopes(legacyPath))
|
|
assert.Equal(t, 1, st.ScopeCount())
|
|
}
|
|
|
|
func TestSidecar_MigrateLegacyNotebook(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
md := "---\ntitle: old entry\ntags: [a, b]\nused_count: 4\n---\n\nbody text\n"
|
|
require.NoError(t, os.WriteFile(filepath.Join(legacyDir, "nbold.md"), []byte(md), 0o644))
|
|
|
|
st := openTempSidecar(t)
|
|
importMD := func(id, contents string) (NotebookRow, bool) {
|
|
// Minimal frontmatter parse for the test importer.
|
|
return NotebookRow{ID: id, Title: "old entry", Body: "body text\n", Tags: []string{"a", "b"}, UsedCount: 4}, true
|
|
}
|
|
require.NoError(t, st.MigrateLegacyNotebook("rk", legacyDir, importMD))
|
|
|
|
rows, err := st.LoadNotebookRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "nbold", rows[0].ID)
|
|
assert.Equal(t, "old entry", rows[0].Title)
|
|
assert.Equal(t, uint64(4), rows[0].UsedCount)
|
|
|
|
_, errBak := os.Stat(filepath.Join(legacyDir, "nbold.md.bak"))
|
|
assert.NoError(t, errBak)
|
|
}
|
|
|
|
func TestSidecar_MigrateSkippedWhenTableNonEmpty(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
require.NoError(t, SaveNotes(legacyDir, &NoteStore{Entries: []NoteEntry{{ID: "nt-old", Body: "legacy"}}}))
|
|
|
|
st := openTempSidecar(t)
|
|
// Pre-seed the table so the import is skipped (guard on existing rows).
|
|
require.NoError(t, st.UpsertNote("rk", NoteEntry{ID: "nt-existing", Body: "already here"}))
|
|
require.NoError(t, st.MigrateLegacyNotes("rk", legacyDir))
|
|
|
|
rows, err := st.LoadNotesRows("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1, "import must be skipped when the table already has rows")
|
|
assert.Equal(t, "nt-existing", rows[0].ID)
|
|
}
|
|
|
|
func TestSidecar_SuppressionsRoundTrip(t *testing.T) {
|
|
st := openTempSidecar(t)
|
|
now := time.Now().UTC().Truncate(time.Nanosecond)
|
|
in := SuppressionEntry{
|
|
IdentityKey: "ik-abc",
|
|
Rule: "nil-deref",
|
|
Category: "nil-deref",
|
|
File: "pkg/x.go",
|
|
SymbolID: "pkg/x.go::Foo",
|
|
Reason: "guarded by a prior nil check",
|
|
Author: "me@zzet.org",
|
|
HitCount: 0,
|
|
Created: now,
|
|
}
|
|
require.NoError(t, st.UpsertSuppression("rk", in))
|
|
|
|
// LoadSuppression hits.
|
|
got, ok := st.LoadSuppression("rk", "ik-abc")
|
|
require.True(t, ok)
|
|
assert.Equal(t, in.IdentityKey, got.IdentityKey)
|
|
assert.Equal(t, in.Rule, got.Rule)
|
|
assert.Equal(t, in.Category, got.Category)
|
|
assert.Equal(t, in.File, got.File)
|
|
assert.Equal(t, in.SymbolID, got.SymbolID)
|
|
assert.Equal(t, in.Reason, got.Reason)
|
|
assert.Equal(t, in.Author, got.Author)
|
|
assert.WithinDuration(t, now, got.Created, time.Microsecond)
|
|
|
|
// Missing identity → clean miss, not an error.
|
|
_, ok = st.LoadSuppression("rk", "nope")
|
|
assert.False(t, ok)
|
|
|
|
// Scope isolation: another repo_key sees nothing.
|
|
_, ok = st.LoadSuppression("other", "ik-abc")
|
|
assert.False(t, ok)
|
|
|
|
// LoadSuppressions for the repo.
|
|
all, err := st.LoadSuppressions("rk")
|
|
require.NoError(t, err)
|
|
require.Len(t, all, 1)
|
|
assert.Equal(t, "ik-abc", all[0].IdentityKey)
|
|
|
|
// Bump hit twice and confirm counters advance.
|
|
require.NoError(t, st.BumpSuppressionHit("rk", "ik-abc", now.Add(time.Second)))
|
|
require.NoError(t, st.BumpSuppressionHit("rk", "ik-abc", now.Add(2*time.Second)))
|
|
bumped, ok := st.LoadSuppression("rk", "ik-abc")
|
|
require.True(t, ok)
|
|
assert.Equal(t, int64(2), bumped.HitCount)
|
|
assert.WithinDuration(t, now.Add(2*time.Second), bumped.LastHit, time.Microsecond)
|
|
|
|
// Bumping a missing row is a no-op, not an error.
|
|
require.NoError(t, st.BumpSuppressionHit("rk", "nope", now))
|
|
|
|
// Delete.
|
|
require.NoError(t, st.DeleteSuppression("rk", "ik-abc"))
|
|
_, ok = st.LoadSuppression("rk", "ik-abc")
|
|
assert.False(t, ok)
|
|
// Deleting a missing row is not an error.
|
|
require.NoError(t, st.DeleteSuppression("rk", "ik-abc"))
|
|
}
|