f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
466 lines
14 KiB
Go
466 lines
14 KiB
Go
//go:build !(windows && arm64)
|
|
|
|
package duckdb
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/export"
|
|
)
|
|
|
|
func TestOpenCreatesLocalDuckDBFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "agentsview.duckdb")
|
|
|
|
db, err := Open(path)
|
|
require.NoError(t, err, "Open")
|
|
t.Cleanup(func() {
|
|
require.NoError(t, db.Close(), "close DuckDB")
|
|
})
|
|
|
|
require.NoError(t, db.PingContext(context.Background()))
|
|
assert.FileExists(t, path)
|
|
}
|
|
|
|
func TestOpenRejectsEmptyPath(t *testing.T) {
|
|
db, err := Open("")
|
|
require.Error(t, err)
|
|
assert.Nil(t, db)
|
|
assert.Contains(t, err.Error(), "duckdb path is required")
|
|
}
|
|
|
|
func TestEnsureSchemaCreatesRequiredMirrorTables(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
for _, table := range []string{
|
|
"sync_metadata",
|
|
"sessions",
|
|
"messages",
|
|
"usage_events",
|
|
"cursor_usage_events",
|
|
"model_pricing",
|
|
"tool_calls",
|
|
"tool_result_events",
|
|
"secret_findings",
|
|
"starred_sessions",
|
|
"pinned_messages",
|
|
} {
|
|
assert.True(t, tableExists(t, db, table), "missing table %s", table)
|
|
}
|
|
|
|
var version string
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT value FROM sync_metadata WHERE key = ?`,
|
|
schemaVersionMetadataKey,
|
|
).Scan(&version))
|
|
assert.Equal(t, strconv.Itoa(SchemaVersion), version)
|
|
var repaired string
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT value FROM sync_metadata WHERE key = ?`,
|
|
defaultRepairMetadataKey,
|
|
).Scan(&repaired))
|
|
assert.Equal(t, "1", repaired)
|
|
var dedupIndex string
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT value FROM sync_metadata WHERE key = ?`,
|
|
usageDedupIndexMetadataKey,
|
|
).Scan(&dedupIndex))
|
|
assert.Equal(t, "1", dedupIndex)
|
|
var remoteScrubbed string
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT value FROM sync_metadata WHERE key = ?`,
|
|
projectIdentityRemoteScrubMetadataKey,
|
|
).Scan(&remoteScrubbed))
|
|
assert.Equal(t, "1", remoteScrubbed)
|
|
}
|
|
|
|
func TestUsageEventsDedupIndexAllowsRepeatedKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
_, err := db.ExecContext(ctx, `
|
|
INSERT INTO usage_events (id, session_id, source, model, dedup_key)
|
|
VALUES
|
|
(1, 's1', 'hermes', 'claude-test', ''),
|
|
(2, 's1', 'hermes', 'claude-test', '')`)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO usage_events (id, session_id, source, model, dedup_key)
|
|
VALUES
|
|
(3, 's1', 'hermes', 'claude-test', 'same-key'),
|
|
(4, 's1', 'hermes', 'claude-test', 'same-key')`)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestEnsureSchemaIsIdempotent(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "first EnsureSchema")
|
|
require.NoError(t, EnsureSchema(ctx, db), "second EnsureSchema")
|
|
|
|
assert.True(t, columnExists(t, db, "sessions", "secret_leak_count"))
|
|
assert.True(t, columnExists(t, db, "messages", "thinking_text"))
|
|
}
|
|
|
|
func TestEnsureSchemaAddsMissingColumnsNonDestructively(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
_, err := db.ExecContext(ctx,
|
|
`CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
machine TEXT NOT NULL,
|
|
project TEXT NOT NULL,
|
|
agent TEXT NOT NULL,
|
|
message_count INTEGER,
|
|
relationship_type TEXT,
|
|
is_automated BOOLEAN
|
|
)`,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO sessions (
|
|
id, machine, project, agent,
|
|
message_count, relationship_type, is_automated
|
|
) VALUES (?, ?, ?, ?, NULL, NULL, NULL)`,
|
|
"kept", "mac", "alpha", "claude",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
assert.True(t, columnExists(t, db, "sessions", "ended_at"))
|
|
var project string
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT project FROM sessions WHERE id = ?`, "kept",
|
|
).Scan(&project))
|
|
assert.Equal(t, "alpha", project)
|
|
var messageCount int
|
|
var relationshipType string
|
|
var isAutomated bool
|
|
require.NoError(t, db.QueryRowContext(ctx,
|
|
`SELECT message_count, relationship_type, is_automated
|
|
FROM sessions WHERE id = ?`, "kept",
|
|
).Scan(&messageCount, &relationshipType, &isAutomated))
|
|
assert.Equal(t, 0, messageCount)
|
|
assert.Equal(t, "", relationshipType)
|
|
assert.False(t, isAutomated)
|
|
}
|
|
|
|
func TestEnsureSchemaMigratesMessagesIDPrimaryKey(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
_, err := db.ExecContext(ctx, `
|
|
CREATE TABLE messages (
|
|
id BIGINT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
ordinal INTEGER NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
UNIQUE(session_id, ordinal)
|
|
)`)
|
|
require.NoError(t, err)
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO messages (id, session_id, ordinal, role, content)
|
|
VALUES (1, 'from-other-machine', 0, 'user', 'kept')`)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
hasPrimary, err := tableHasPrimaryKey(ctx, db, "messages")
|
|
require.NoError(t, err)
|
|
assert.False(t, hasPrimary)
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO messages (id, session_id, ordinal, role, content)
|
|
VALUES (1, 'from-this-machine', 0, 'user', 'same local rowid')`)
|
|
require.NoError(t, err)
|
|
assertDuckDBCountWhere(t, db, "messages", "id = ?", int64(1), 2)
|
|
}
|
|
|
|
func TestCheckSchemaCompatRejectsPendingNonIndexRepairs(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("messages id primary key", func(t *testing.T) {
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
recreateMessagesWithIDPrimaryKey(t, ctx, db)
|
|
|
|
err := CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "messages.id primary key")
|
|
})
|
|
|
|
t.Run("missing default repair metadata", func(t *testing.T) {
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM sync_metadata WHERE key = ?`,
|
|
defaultRepairMetadataKey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), defaultRepairMetadataKey)
|
|
})
|
|
|
|
t.Run("missing usage repair metadata", func(t *testing.T) {
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM sync_metadata WHERE key = ?`,
|
|
usageDedupIndexMetadataKey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), usageDedupIndexMetadataKey)
|
|
})
|
|
|
|
t.Run("missing project identity remote scrub metadata", func(t *testing.T) {
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM sync_metadata WHERE key = ?`,
|
|
projectIdentityRemoteScrubMetadataKey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), projectIdentityRemoteScrubMetadataKey)
|
|
})
|
|
|
|
t.Run("quack incompatible timestamp default", func(t *testing.T) {
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
_, err := db.ExecContext(ctx,
|
|
`ALTER TABLE starred_sessions ALTER COLUMN created_at SET DEFAULT current_timestamp`,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "starred_sessions.created_at")
|
|
})
|
|
}
|
|
|
|
func TestEnsureSchemaScrubsProjectIdentityGitRemoteCredentials(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "initial EnsureSchema")
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM sync_metadata WHERE key = ?`,
|
|
projectIdentityRemoteScrubMetadataKey,
|
|
)
|
|
require.NoError(t, err)
|
|
rawRemote := "https://" + "user:token@" + "github.com/acme/app.git"
|
|
storedRemote := "https://github.com/acme/app.git"
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO source_project_identity_observations (
|
|
project, machine, root_path, git_remote, git_remote_name,
|
|
worktree_name, worktree_root_path, observed_at,
|
|
normalized_remote, key_source, key
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"app", "laptop", "/tmp/app", rawRemote, "origin",
|
|
"", "", "2026-07-03T12:00:00Z", "", "", "",
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO source_project_identity_observations (
|
|
project, machine, root_path, git_remote, git_remote_name,
|
|
worktree_name, worktree_root_path, observed_at,
|
|
normalized_remote, key_source, key
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"app", "laptop", "/tmp/app", "", "",
|
|
"app", "/tmp/app", "2026-07-03T11:00:00Z", "", "", "",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "repair EnsureSchema")
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT git_remote, normalized_remote, key_source, key
|
|
FROM source_project_identity_observations
|
|
WHERE project = ? AND machine = ? AND root_path = ?
|
|
ORDER BY git_remote`,
|
|
"app", "laptop", "/tmp/app",
|
|
)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, rows.Close()) })
|
|
|
|
var got []export.ProjectIdentityObservation
|
|
for rows.Next() {
|
|
var obs export.ProjectIdentityObservation
|
|
require.NoError(t, rows.Scan(
|
|
&obs.GitRemote,
|
|
&obs.NormalizedRemote,
|
|
&obs.KeySource,
|
|
&obs.Key,
|
|
))
|
|
got = append(got, obs)
|
|
}
|
|
require.NoError(t, rows.Err())
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, storedRemote, got[0].GitRemote)
|
|
assert.Equal(t, "github.com/acme/app", got[0].NormalizedRemote)
|
|
assert.Equal(t, export.ProjectIdentityKeySourceGitRemote, got[0].KeySource)
|
|
assert.NotEmpty(t, got[0].Key)
|
|
}
|
|
|
|
func TestEnsureSchemaDropsQuackIncompatibleTimestampDefaults(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
_, err := db.ExecContext(ctx, `
|
|
CREATE TABLE starred_sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
|
)`)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
assert.NotContains(
|
|
t,
|
|
strings.ToLower(columnDefaultValue(t, db, "starred_sessions", "created_at")),
|
|
"current_timestamp",
|
|
)
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO starred_sessions (session_id, created_at)
|
|
VALUES (?, current_timestamp)`,
|
|
"kept",
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestCheckSchemaCompatReportsMissingTablesAndColumns(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
|
|
err := CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "missing table sessions")
|
|
|
|
db = openTestDuckDB(t)
|
|
_, err = db.ExecContext(ctx,
|
|
`CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
machine TEXT NOT NULL,
|
|
project TEXT NOT NULL,
|
|
agent TEXT NOT NULL
|
|
)`,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = CheckSchemaCompat(ctx, db)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "sessions.secret_leak_count")
|
|
}
|
|
|
|
func TestCheckSchemaCompatPassesAfterEnsureSchema(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
require.NoError(t, CheckSchemaCompat(ctx, db), "CheckSchemaCompat")
|
|
}
|
|
|
|
// TestEnsureSchemaCreatesToolCallsFilePathIndex verifies the DuckDB mirror
|
|
// builds idx_tool_calls_file_path, the parity counterpart to SQLite's
|
|
// Recent Edits index. DuckDB has no partial indexes, so it omits the
|
|
// WHERE file_path IS NOT NULL clause but indexes the same column.
|
|
func TestEnsureSchemaCreatesToolCallsFilePathIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, db), "EnsureSchema")
|
|
|
|
var count int
|
|
require.NoError(t, db.QueryRowContext(ctx, `
|
|
SELECT count(*) FROM duckdb_indexes()
|
|
WHERE table_name = 'tool_calls'
|
|
AND index_name = 'idx_tool_calls_file_path'`).Scan(&count),
|
|
"query duckdb_indexes")
|
|
assert.Equal(t, 1, count,
|
|
"idx_tool_calls_file_path must exist for Recent Edits parity")
|
|
}
|
|
|
|
func openTestDuckDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := openDuckDB("")
|
|
require.NoError(t, err, "open in-memory DuckDB")
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
require.NoError(t, configureDuckDBThreads(db))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, db.Close(), "close DuckDB")
|
|
})
|
|
return db
|
|
}
|
|
|
|
func recreateMessagesWithIDPrimaryKey(t *testing.T, ctx context.Context, db *sql.DB) {
|
|
t.Helper()
|
|
_, err := db.ExecContext(ctx, `DROP TABLE messages`)
|
|
require.NoError(t, err)
|
|
create := strings.Replace(
|
|
mirrorTableCreate("messages"),
|
|
"id BIGINT,",
|
|
"id BIGINT PRIMARY KEY,",
|
|
1,
|
|
)
|
|
require.NotEqual(t, mirrorTableCreate("messages"), create)
|
|
_, err = db.ExecContext(ctx, create)
|
|
require.NoError(t, err)
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO messages (id, session_id, ordinal, role, content)
|
|
VALUES (1, 'from-other-machine', 0, 'user', 'kept')`)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func tableExists(t *testing.T, db *sql.DB, table string) bool {
|
|
t.Helper()
|
|
var exists bool
|
|
require.NoError(t, db.QueryRow(
|
|
`SELECT count(*) > 0
|
|
FROM information_schema.tables
|
|
WHERE table_schema = current_schema()
|
|
AND table_name = ?`,
|
|
strings.ToLower(table),
|
|
).Scan(&exists))
|
|
return exists
|
|
}
|
|
|
|
func columnExists(t *testing.T, db *sql.DB, table, column string) bool {
|
|
t.Helper()
|
|
var exists bool
|
|
require.NoError(t, db.QueryRow(
|
|
`SELECT count(*) > 0
|
|
FROM information_schema.columns
|
|
WHERE table_schema = current_schema()
|
|
AND table_name = ?
|
|
AND column_name = ?`,
|
|
strings.ToLower(table),
|
|
strings.ToLower(column),
|
|
).Scan(&exists))
|
|
return exists
|
|
}
|
|
|
|
func columnDefaultValue(t *testing.T, db *sql.DB, table, column string) string {
|
|
t.Helper()
|
|
value, err := columnDefault(context.Background(), db, table, column)
|
|
require.NoError(t, err)
|
|
return value
|
|
}
|