Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1307 lines
45 KiB
Go

package duckdb
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"go.kenn.io/agentsview/internal/export"
)
// SchemaVersion is the version of the DuckDB mirror schema created by
// EnsureSchema. Increment it when a non-optional DuckDB column/table is added.
const SchemaVersion = 2
const schemaVersionMetadataKey = "agentsview_schema_version"
const defaultRepairMetadataKey = "agentsview_default_repair_v1"
const usageDedupIndexMetadataKey = "agentsview_usage_dedup_index_v1"
const projectIdentityRemoteScrubMetadataKey = "agentsview_project_identity_remote_scrub_v1"
// DuckDB schema notes:
//
// - DuckDB stores timestamps as TIMESTAMP for mirror tables; read queries
// should cast/format them to text when scanning into db.Session/db.Message.
// - DuckDB BOOLEAN columns scan into Go bools directly, unlike SQLite's
// integer booleans.
// - SQLite INTEGER PRIMARY KEY rowids are mirrored as BIGINT values because
// DuckDB does not provide SQLite rowid/autoincrement semantics.
// - DuckDB does not support SQLite FTS5 or PostgreSQL GIN indexes here; text
// search optimization is handled separately from this compatibility schema.
// - DuckDB Quack currently rejects catalogs with TIMESTAMP DEFAULT
// current_timestamp columns, so mirror timestamp columns avoid dynamic
// defaults and writers supply current_timestamp explicitly where needed.
type tableSpec struct {
name string
create string
columns []columnSpec
indexes []string
}
type columnSpec struct {
name string
def string
}
type timestampDefaultSpec struct {
table string
column string
}
var quackIncompatibleTimestampDefaults = []timestampDefaultSpec{
{"sessions", "created_at"},
{"secret_findings", "created_at"},
{"starred_sessions", "created_at"},
{"pinned_messages", "created_at"},
}
var mirrorTables = []tableSpec{
{
name: "sync_metadata",
create: `CREATE TABLE IF NOT EXISTS sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
columns: []columnSpec{
{"key", "key TEXT"},
{"value", "value TEXT NOT NULL DEFAULT ''"},
},
},
{
name: "source_archives",
create: `CREATE TABLE IF NOT EXISTS source_archives (
source_archive_id TEXT PRIMARY KEY,
source_archive_salt TEXT NOT NULL
)`,
columns: []columnSpec{
{"source_archive_id", "source_archive_id TEXT"},
{"source_archive_salt", "source_archive_salt TEXT NOT NULL DEFAULT ''"},
},
},
{
name: "sessions",
create: `CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
project TEXT NOT NULL,
machine TEXT NOT NULL DEFAULT 'local',
agent TEXT NOT NULL DEFAULT 'claude',
first_message TEXT,
display_name TEXT,
session_name TEXT,
started_at TIMESTAMP,
ended_at TIMESTAMP,
message_count INTEGER NOT NULL DEFAULT 0,
user_message_count INTEGER NOT NULL DEFAULT 0,
file_path TEXT,
file_size BIGINT,
file_mtime BIGINT,
file_inode BIGINT,
file_device BIGINT,
file_hash TEXT,
local_modified_at TIMESTAMP,
transcript_revision TEXT NOT NULL DEFAULT '0',
parent_session_id TEXT,
relationship_type TEXT NOT NULL DEFAULT '',
total_output_tokens INTEGER NOT NULL DEFAULT 0,
peak_context_tokens INTEGER NOT NULL DEFAULT 0,
has_total_output_tokens BOOLEAN NOT NULL DEFAULT FALSE,
has_peak_context_tokens BOOLEAN NOT NULL DEFAULT FALSE,
is_automated BOOLEAN NOT NULL DEFAULT FALSE,
tool_failure_signal_count INTEGER NOT NULL DEFAULT 0,
tool_retry_count INTEGER NOT NULL DEFAULT 0,
edit_churn_count INTEGER NOT NULL DEFAULT 0,
consecutive_failure_max INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL DEFAULT 'unknown',
outcome_confidence TEXT NOT NULL DEFAULT 'low',
ended_with_role TEXT NOT NULL DEFAULT '',
final_failure_streak INTEGER NOT NULL DEFAULT 0,
signals_pending_since TEXT,
compaction_count INTEGER NOT NULL DEFAULT 0,
mid_task_compaction_count INTEGER NOT NULL DEFAULT 0,
context_pressure_max DOUBLE,
health_score INTEGER,
health_grade TEXT,
has_tool_calls BOOLEAN NOT NULL DEFAULT FALSE,
has_context_data BOOLEAN NOT NULL DEFAULT FALSE,
quality_signal_version INTEGER NOT NULL DEFAULT 0,
short_prompt_count INTEGER NOT NULL DEFAULT 0,
unstructured_start BOOLEAN NOT NULL DEFAULT FALSE,
missing_success_criteria_count INTEGER NOT NULL DEFAULT 0,
missing_verification_count INTEGER NOT NULL DEFAULT 0,
duplicate_prompt_count INTEGER NOT NULL DEFAULT 0,
no_code_context_count INTEGER NOT NULL DEFAULT 0,
runaway_tool_loop_count INTEGER NOT NULL DEFAULT 0,
data_version INTEGER NOT NULL DEFAULT 0,
cwd TEXT NOT NULL DEFAULT '',
git_branch TEXT NOT NULL DEFAULT '',
source_session_id TEXT NOT NULL DEFAULT '',
source_version TEXT NOT NULL DEFAULT '',
transcript_fidelity TEXT NOT NULL DEFAULT '',
parser_malformed_lines INTEGER NOT NULL DEFAULT 0,
is_truncated BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP,
created_at TIMESTAMP,
termination_status TEXT,
secret_leak_count INTEGER NOT NULL DEFAULT 0,
secrets_rules_version TEXT NOT NULL DEFAULT ''
)`,
columns: []columnSpec{
{"id", "id TEXT"},
{"project", "project TEXT NOT NULL DEFAULT ''"},
{"machine", "machine TEXT NOT NULL DEFAULT 'local'"},
{"agent", "agent TEXT NOT NULL DEFAULT 'claude'"},
{"first_message", "first_message TEXT"},
{"display_name", "display_name TEXT"},
{"session_name", "session_name TEXT"},
{"started_at", "started_at TIMESTAMP"},
{"ended_at", "ended_at TIMESTAMP"},
{"message_count", "message_count INTEGER NOT NULL DEFAULT 0"},
{"user_message_count", "user_message_count INTEGER NOT NULL DEFAULT 0"},
{"file_path", "file_path TEXT"},
{"file_size", "file_size BIGINT"},
{"file_mtime", "file_mtime BIGINT"},
{"file_inode", "file_inode BIGINT"},
{"file_device", "file_device BIGINT"},
{"file_hash", "file_hash TEXT"},
{"local_modified_at", "local_modified_at TIMESTAMP"},
{"transcript_revision", "transcript_revision TEXT NOT NULL DEFAULT '0'"},
{"parent_session_id", "parent_session_id TEXT"},
{"relationship_type", "relationship_type TEXT NOT NULL DEFAULT ''"},
{"total_output_tokens", "total_output_tokens INTEGER NOT NULL DEFAULT 0"},
{"peak_context_tokens", "peak_context_tokens INTEGER NOT NULL DEFAULT 0"},
{"has_total_output_tokens", "has_total_output_tokens BOOLEAN NOT NULL DEFAULT FALSE"},
{"has_peak_context_tokens", "has_peak_context_tokens BOOLEAN NOT NULL DEFAULT FALSE"},
{"is_automated", "is_automated BOOLEAN NOT NULL DEFAULT FALSE"},
{"tool_failure_signal_count", "tool_failure_signal_count INTEGER NOT NULL DEFAULT 0"},
{"tool_retry_count", "tool_retry_count INTEGER NOT NULL DEFAULT 0"},
{"edit_churn_count", "edit_churn_count INTEGER NOT NULL DEFAULT 0"},
{"consecutive_failure_max", "consecutive_failure_max INTEGER NOT NULL DEFAULT 0"},
{"outcome", "outcome TEXT NOT NULL DEFAULT 'unknown'"},
{"outcome_confidence", "outcome_confidence TEXT NOT NULL DEFAULT 'low'"},
{"ended_with_role", "ended_with_role TEXT NOT NULL DEFAULT ''"},
{"final_failure_streak", "final_failure_streak INTEGER NOT NULL DEFAULT 0"},
{"signals_pending_since", "signals_pending_since TEXT"},
{"compaction_count", "compaction_count INTEGER NOT NULL DEFAULT 0"},
{"mid_task_compaction_count", "mid_task_compaction_count INTEGER NOT NULL DEFAULT 0"},
{"context_pressure_max", "context_pressure_max DOUBLE"},
{"health_score", "health_score INTEGER"},
{"health_grade", "health_grade TEXT"},
{"has_tool_calls", "has_tool_calls BOOLEAN NOT NULL DEFAULT FALSE"},
{"has_context_data", "has_context_data BOOLEAN NOT NULL DEFAULT FALSE"},
{"quality_signal_version", "quality_signal_version INTEGER NOT NULL DEFAULT 0"},
{"short_prompt_count", "short_prompt_count INTEGER NOT NULL DEFAULT 0"},
{"unstructured_start", "unstructured_start BOOLEAN NOT NULL DEFAULT FALSE"},
{"missing_success_criteria_count", "missing_success_criteria_count INTEGER NOT NULL DEFAULT 0"},
{"missing_verification_count", "missing_verification_count INTEGER NOT NULL DEFAULT 0"},
{"duplicate_prompt_count", "duplicate_prompt_count INTEGER NOT NULL DEFAULT 0"},
{"no_code_context_count", "no_code_context_count INTEGER NOT NULL DEFAULT 0"},
{"runaway_tool_loop_count", "runaway_tool_loop_count INTEGER NOT NULL DEFAULT 0"},
{"data_version", "data_version INTEGER NOT NULL DEFAULT 0"},
{"cwd", "cwd TEXT NOT NULL DEFAULT ''"},
{"git_branch", "git_branch TEXT NOT NULL DEFAULT ''"},
{"source_session_id", "source_session_id TEXT NOT NULL DEFAULT ''"},
{"source_version", "source_version TEXT NOT NULL DEFAULT ''"},
{"transcript_fidelity", "transcript_fidelity TEXT NOT NULL DEFAULT ''"},
{"parser_malformed_lines", "parser_malformed_lines INTEGER NOT NULL DEFAULT 0"},
{"is_truncated", "is_truncated BOOLEAN NOT NULL DEFAULT FALSE"},
{"deleted_at", "deleted_at TIMESTAMP"},
{"created_at", "created_at TIMESTAMP"},
{"termination_status", "termination_status TEXT"},
{"secret_leak_count", "secret_leak_count INTEGER NOT NULL DEFAULT 0"},
{"secrets_rules_version", "secrets_rules_version TEXT NOT NULL DEFAULT ''"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_sessions_ended ON sessions(ended_at, id)",
"CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project)",
"CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine)",
"CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)",
"CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)",
"CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)",
"CREATE INDEX IF NOT EXISTS idx_sessions_termination_status ON sessions(termination_status)",
},
},
{
name: "messages",
create: `CREATE TABLE IF NOT EXISTS messages (
id BIGINT,
session_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
thinking_text TEXT NOT NULL DEFAULT '',
timestamp TIMESTAMP,
has_thinking BOOLEAN NOT NULL DEFAULT FALSE,
has_tool_use BOOLEAN NOT NULL DEFAULT FALSE,
content_length INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
model TEXT NOT NULL DEFAULT '',
token_usage TEXT NOT NULL DEFAULT '',
context_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
has_context_tokens BOOLEAN NOT NULL DEFAULT FALSE,
has_output_tokens BOOLEAN NOT NULL DEFAULT FALSE,
claude_message_id TEXT NOT NULL DEFAULT '',
claude_request_id TEXT NOT NULL DEFAULT '',
source_type TEXT NOT NULL DEFAULT '',
source_subtype TEXT NOT NULL DEFAULT '',
source_uuid TEXT NOT NULL DEFAULT '',
source_parent_uuid TEXT NOT NULL DEFAULT '',
is_sidechain BOOLEAN NOT NULL DEFAULT FALSE,
is_compact_boundary BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE(session_id, ordinal)
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"ordinal", "ordinal INTEGER NOT NULL DEFAULT 0"},
{"role", "role TEXT NOT NULL DEFAULT ''"},
{"content", "content TEXT NOT NULL DEFAULT ''"},
{"thinking_text", "thinking_text TEXT NOT NULL DEFAULT ''"},
{"timestamp", "timestamp TIMESTAMP"},
{"has_thinking", "has_thinking BOOLEAN NOT NULL DEFAULT FALSE"},
{"has_tool_use", "has_tool_use BOOLEAN NOT NULL DEFAULT FALSE"},
{"content_length", "content_length INTEGER NOT NULL DEFAULT 0"},
{"is_system", "is_system BOOLEAN NOT NULL DEFAULT FALSE"},
{"model", "model TEXT NOT NULL DEFAULT ''"},
{"token_usage", "token_usage TEXT NOT NULL DEFAULT ''"},
{"context_tokens", "context_tokens INTEGER NOT NULL DEFAULT 0"},
{"output_tokens", "output_tokens INTEGER NOT NULL DEFAULT 0"},
{"has_context_tokens", "has_context_tokens BOOLEAN NOT NULL DEFAULT FALSE"},
{"has_output_tokens", "has_output_tokens BOOLEAN NOT NULL DEFAULT FALSE"},
{"claude_message_id", "claude_message_id TEXT NOT NULL DEFAULT ''"},
{"claude_request_id", "claude_request_id TEXT NOT NULL DEFAULT ''"},
{"source_type", "source_type TEXT NOT NULL DEFAULT ''"},
{"source_subtype", "source_subtype TEXT NOT NULL DEFAULT ''"},
{"source_uuid", "source_uuid TEXT NOT NULL DEFAULT ''"},
{"source_parent_uuid", "source_parent_uuid TEXT NOT NULL DEFAULT ''"},
{"is_sidechain", "is_sidechain BOOLEAN NOT NULL DEFAULT FALSE"},
{"is_compact_boundary", "is_compact_boundary BOOLEAN NOT NULL DEFAULT FALSE"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_messages_session_ordinal ON messages(session_id, ordinal)",
"CREATE INDEX IF NOT EXISTS idx_messages_session_role ON messages(session_id, role)",
"CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)",
},
},
{
name: "usage_events",
create: `CREATE TABLE IF NOT EXISTS usage_events (
id BIGINT,
session_id TEXT NOT NULL,
message_ordinal INTEGER,
source TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd DOUBLE,
cost_status TEXT NOT NULL DEFAULT '',
cost_source TEXT NOT NULL DEFAULT '',
occurred_at TIMESTAMP,
dedup_key TEXT NOT NULL DEFAULT ''
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"message_ordinal", "message_ordinal INTEGER"},
{"source", "source TEXT NOT NULL DEFAULT ''"},
{"model", "model TEXT NOT NULL DEFAULT ''"},
{"input_tokens", "input_tokens INTEGER NOT NULL DEFAULT 0"},
{"output_tokens", "output_tokens INTEGER NOT NULL DEFAULT 0"},
{"cache_creation_input_tokens", "cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0"},
{"cache_read_input_tokens", "cache_read_input_tokens INTEGER NOT NULL DEFAULT 0"},
{"reasoning_tokens", "reasoning_tokens INTEGER NOT NULL DEFAULT 0"},
{"cost_usd", "cost_usd DOUBLE"},
{"cost_status", "cost_status TEXT NOT NULL DEFAULT ''"},
{"cost_source", "cost_source TEXT NOT NULL DEFAULT ''"},
{"occurred_at", "occurred_at TIMESTAMP"},
{"dedup_key", "dedup_key TEXT NOT NULL DEFAULT ''"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_usage_events_session ON usage_events(session_id)",
"CREATE INDEX IF NOT EXISTS idx_usage_events_occurred ON usage_events(occurred_at)",
"CREATE INDEX IF NOT EXISTS idx_usage_events_dedup ON usage_events(session_id, source, dedup_key)",
},
},
{
name: "cursor_usage_events",
create: `CREATE TABLE IF NOT EXISTS cursor_usage_events (
id BIGINT,
occurred_at TIMESTAMP NOT NULL,
model TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT '',
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
charged_cents DOUBLE NOT NULL DEFAULT 0,
cursor_token_fee DOUBLE NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
user_email TEXT NOT NULL DEFAULT '',
is_headless BOOLEAN NOT NULL DEFAULT FALSE,
dedup_key TEXT NOT NULL DEFAULT ''
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"occurred_at", "occurred_at TIMESTAMP NOT NULL"},
{"model", "model TEXT NOT NULL DEFAULT ''"},
{"kind", "kind TEXT NOT NULL DEFAULT ''"},
{"input_tokens", "input_tokens INTEGER NOT NULL DEFAULT 0"},
{"output_tokens", "output_tokens INTEGER NOT NULL DEFAULT 0"},
{"cache_write_tokens", "cache_write_tokens INTEGER NOT NULL DEFAULT 0"},
{"cache_read_tokens", "cache_read_tokens INTEGER NOT NULL DEFAULT 0"},
{"charged_cents", "charged_cents DOUBLE NOT NULL DEFAULT 0"},
{"cursor_token_fee", "cursor_token_fee DOUBLE NOT NULL DEFAULT 0"},
{"user_id", "user_id TEXT NOT NULL DEFAULT ''"},
{"user_email", "user_email TEXT NOT NULL DEFAULT ''"},
{"is_headless", "is_headless BOOLEAN NOT NULL DEFAULT FALSE"},
{"dedup_key", "dedup_key TEXT NOT NULL DEFAULT ''"},
},
indexes: []string{
"CREATE UNIQUE INDEX IF NOT EXISTS idx_cursor_usage_events_dedup ON cursor_usage_events(dedup_key)",
"CREATE INDEX IF NOT EXISTS idx_cursor_usage_events_occurred ON cursor_usage_events(occurred_at)",
"CREATE INDEX IF NOT EXISTS idx_cursor_usage_events_model ON cursor_usage_events(model)",
},
},
{
name: "model_pricing",
create: `CREATE TABLE IF NOT EXISTS model_pricing (
model_pattern TEXT PRIMARY KEY,
input_per_mtok DOUBLE NOT NULL DEFAULT 0,
output_per_mtok DOUBLE NOT NULL DEFAULT 0,
cache_creation_per_mtok DOUBLE NOT NULL DEFAULT 0,
cache_read_per_mtok DOUBLE NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT ''
)`,
columns: []columnSpec{
{"model_pattern", "model_pattern TEXT"},
{"input_per_mtok", "input_per_mtok DOUBLE NOT NULL DEFAULT 0"},
{"output_per_mtok", "output_per_mtok DOUBLE NOT NULL DEFAULT 0"},
{"cache_creation_per_mtok", "cache_creation_per_mtok DOUBLE NOT NULL DEFAULT 0"},
{"cache_read_per_mtok", "cache_read_per_mtok DOUBLE NOT NULL DEFAULT 0"},
{"updated_at", "updated_at TEXT NOT NULL DEFAULT ''"},
},
},
{
name: "source_project_identity_observations",
create: `CREATE TABLE IF NOT EXISTS source_project_identity_observations (
source_archive_id TEXT NOT NULL DEFAULT '',
source_archive_salt TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL,
machine TEXT NOT NULL,
root_path TEXT NOT NULL DEFAULT '',
git_remote TEXT NOT NULL DEFAULT '',
git_remote_name TEXT NOT NULL DEFAULT '',
repository_path TEXT NOT NULL DEFAULT '',
worktree_name TEXT NOT NULL DEFAULT '',
worktree_root_path TEXT NOT NULL DEFAULT '',
worktree_relationship TEXT NOT NULL DEFAULT 'unknown',
checkout_state TEXT NOT NULL DEFAULT 'unknown',
git_branch TEXT NOT NULL DEFAULT '',
remote_resolution TEXT NOT NULL DEFAULT 'unknown',
remote_candidate_count INTEGER NOT NULL DEFAULT 0,
observed_at TIMESTAMP NOT NULL,
normalized_remote TEXT NOT NULL DEFAULT '',
key_source TEXT NOT NULL DEFAULT '',
key TEXT NOT NULL DEFAULT '',
PRIMARY KEY (source_archive_id, project, machine, root_path, git_remote)
)`,
columns: []columnSpec{
{"source_archive_id", "source_archive_id TEXT NOT NULL DEFAULT ''"},
{"source_archive_salt", "source_archive_salt TEXT NOT NULL DEFAULT ''"},
{"project", "project TEXT NOT NULL DEFAULT ''"},
{"machine", "machine TEXT NOT NULL DEFAULT ''"},
{"root_path", "root_path TEXT NOT NULL DEFAULT ''"},
{"git_remote", "git_remote TEXT NOT NULL DEFAULT ''"},
{"git_remote_name", "git_remote_name TEXT NOT NULL DEFAULT ''"},
{"repository_path", "repository_path TEXT NOT NULL DEFAULT ''"},
{"worktree_name", "worktree_name TEXT NOT NULL DEFAULT ''"},
{"worktree_root_path", "worktree_root_path TEXT NOT NULL DEFAULT ''"},
{"worktree_relationship", "worktree_relationship TEXT NOT NULL DEFAULT 'unknown'"},
{"checkout_state", "checkout_state TEXT NOT NULL DEFAULT 'unknown'"},
{"git_branch", "git_branch TEXT NOT NULL DEFAULT ''"},
{"remote_resolution", "remote_resolution TEXT NOT NULL DEFAULT 'unknown'"},
{"remote_candidate_count", "remote_candidate_count INTEGER NOT NULL DEFAULT 0"},
{"observed_at", "observed_at TIMESTAMP"},
{"normalized_remote", "normalized_remote TEXT NOT NULL DEFAULT ''"},
{"key_source", "key_source TEXT NOT NULL DEFAULT ''"},
{"key", "key TEXT NOT NULL DEFAULT ''"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_source_project_identity_observations_project ON source_project_identity_observations(project)",
},
},
{
name: "source_session_project_identity_snapshots",
create: `CREATE TABLE IF NOT EXISTS source_session_project_identity_snapshots (
source_archive_id TEXT NOT NULL,
source_database_generation TEXT NOT NULL,
source_session_id TEXT NOT NULL,
project TEXT NOT NULL,
machine TEXT NOT NULL,
root_path TEXT NOT NULL DEFAULT '',
git_remote TEXT NOT NULL DEFAULT '',
git_remote_name TEXT NOT NULL DEFAULT '',
repository_path TEXT NOT NULL DEFAULT '',
worktree_name TEXT NOT NULL DEFAULT '',
worktree_root_path TEXT NOT NULL DEFAULT '',
worktree_relationship TEXT NOT NULL DEFAULT 'unknown',
checkout_state TEXT NOT NULL DEFAULT 'unknown',
git_branch TEXT NOT NULL DEFAULT '',
remote_resolution TEXT NOT NULL DEFAULT 'unknown',
remote_candidate_count INTEGER NOT NULL DEFAULT 0,
observed_at TIMESTAMP NOT NULL,
normalized_remote TEXT NOT NULL DEFAULT '',
key_source TEXT NOT NULL DEFAULT '',
key TEXT NOT NULL DEFAULT '',
PRIMARY KEY (
source_archive_id, source_database_generation, source_session_id
)
)`,
columns: []columnSpec{
{"source_archive_id", "source_archive_id TEXT NOT NULL DEFAULT ''"},
{"source_database_generation", "source_database_generation TEXT NOT NULL DEFAULT ''"},
{"source_session_id", "source_session_id TEXT NOT NULL DEFAULT ''"},
{"project", "project TEXT NOT NULL DEFAULT ''"},
{"machine", "machine TEXT NOT NULL DEFAULT ''"},
{"root_path", "root_path TEXT NOT NULL DEFAULT ''"},
{"git_remote", "git_remote TEXT NOT NULL DEFAULT ''"},
{"git_remote_name", "git_remote_name TEXT NOT NULL DEFAULT ''"},
{"repository_path", "repository_path TEXT NOT NULL DEFAULT ''"},
{"worktree_name", "worktree_name TEXT NOT NULL DEFAULT ''"},
{"worktree_root_path", "worktree_root_path TEXT NOT NULL DEFAULT ''"},
{"worktree_relationship", "worktree_relationship TEXT NOT NULL DEFAULT 'unknown'"},
{"checkout_state", "checkout_state TEXT NOT NULL DEFAULT 'unknown'"},
{"git_branch", "git_branch TEXT NOT NULL DEFAULT ''"},
{"remote_resolution", "remote_resolution TEXT NOT NULL DEFAULT 'unknown'"},
{"remote_candidate_count", "remote_candidate_count INTEGER NOT NULL DEFAULT 0"},
{"observed_at", "observed_at TIMESTAMP"},
{"normalized_remote", "normalized_remote TEXT NOT NULL DEFAULT ''"},
{"key_source", "key_source TEXT NOT NULL DEFAULT ''"},
{"key", "key TEXT NOT NULL DEFAULT ''"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_source_session_project_identity_snapshots_project ON source_session_project_identity_snapshots(source_archive_id, project)",
},
},
{
name: "tool_calls",
create: `CREATE TABLE IF NOT EXISTS tool_calls (
id BIGINT,
message_id BIGINT,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
category TEXT NOT NULL,
call_index INTEGER NOT NULL DEFAULT 0,
tool_use_id TEXT NOT NULL DEFAULT '',
input_json TEXT,
skill_name TEXT,
result_content_length INTEGER,
result_content TEXT,
subagent_session_id TEXT,
file_path TEXT
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"message_id", "message_id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"tool_name", "tool_name TEXT NOT NULL DEFAULT ''"},
{"category", "category TEXT NOT NULL DEFAULT ''"},
{"call_index", "call_index INTEGER NOT NULL DEFAULT 0"},
{"tool_use_id", "tool_use_id TEXT NOT NULL DEFAULT ''"},
{"input_json", "input_json TEXT"},
{"skill_name", "skill_name TEXT"},
{"result_content_length", "result_content_length INTEGER"},
{"result_content", "result_content TEXT"},
{"subagent_session_id", "subagent_session_id TEXT"},
{"file_path", "file_path TEXT"},
},
indexes: []string{
"CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_calls_dedup ON tool_calls(session_id, message_id, call_index)",
"CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id)",
"CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_id)",
"CREATE INDEX IF NOT EXISTS idx_tool_calls_category ON tool_calls(category)",
// DuckDB has no partial indexes, so this mirrors SQLite's
// idx_tool_calls_file_path without the WHERE file_path IS NOT NULL
// clause; it backs the cross-session Recent Edits feed.
"CREATE INDEX IF NOT EXISTS idx_tool_calls_file_path ON tool_calls(file_path)",
},
},
{
name: "tool_result_events",
create: `CREATE TABLE IF NOT EXISTS tool_result_events (
id BIGINT,
session_id TEXT NOT NULL,
tool_call_message_ordinal INTEGER NOT NULL,
call_index INTEGER NOT NULL DEFAULT 0,
tool_use_id TEXT,
agent_id TEXT,
subagent_session_id TEXT,
source TEXT NOT NULL,
status TEXT NOT NULL,
content TEXT NOT NULL,
content_length INTEGER NOT NULL DEFAULT 0,
timestamp TIMESTAMP,
event_index INTEGER NOT NULL DEFAULT 0
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"tool_call_message_ordinal", "tool_call_message_ordinal INTEGER NOT NULL DEFAULT 0"},
{"call_index", "call_index INTEGER NOT NULL DEFAULT 0"},
{"tool_use_id", "tool_use_id TEXT"},
{"agent_id", "agent_id TEXT"},
{"subagent_session_id", "subagent_session_id TEXT"},
{"source", "source TEXT NOT NULL DEFAULT ''"},
{"status", "status TEXT NOT NULL DEFAULT ''"},
{"content", "content TEXT NOT NULL DEFAULT ''"},
{"content_length", "content_length INTEGER NOT NULL DEFAULT 0"},
{"timestamp", "timestamp TIMESTAMP"},
{"event_index", "event_index INTEGER NOT NULL DEFAULT 0"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_tool_result_events_session ON tool_result_events(session_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_result_events_dedup ON tool_result_events(session_id, tool_call_message_ordinal, call_index, event_index)",
},
},
{
name: "secret_findings",
create: `CREATE TABLE IF NOT EXISTS secret_findings (
id BIGINT,
session_id TEXT NOT NULL,
rule_name TEXT NOT NULL,
confidence TEXT NOT NULL,
location_kind TEXT NOT NULL,
message_ordinal INTEGER NOT NULL,
call_index INTEGER,
event_index INTEGER,
match_start INTEGER NOT NULL,
match_end INTEGER NOT NULL,
match_index INTEGER NOT NULL,
redacted_match TEXT NOT NULL,
rules_version TEXT NOT NULL,
created_at TIMESTAMP
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"rule_name", "rule_name TEXT NOT NULL DEFAULT ''"},
{"confidence", "confidence TEXT NOT NULL DEFAULT ''"},
{"location_kind", "location_kind TEXT NOT NULL DEFAULT ''"},
{"message_ordinal", "message_ordinal INTEGER NOT NULL DEFAULT 0"},
{"call_index", "call_index INTEGER"},
{"event_index", "event_index INTEGER"},
{"match_start", "match_start INTEGER NOT NULL DEFAULT 0"},
{"match_end", "match_end INTEGER NOT NULL DEFAULT 0"},
{"match_index", "match_index INTEGER NOT NULL DEFAULT 0"},
{"redacted_match", "redacted_match TEXT NOT NULL DEFAULT ''"},
{"rules_version", "rules_version TEXT NOT NULL DEFAULT ''"},
{"created_at", "created_at TIMESTAMP"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_secret_findings_session ON secret_findings(session_id)",
"CREATE INDEX IF NOT EXISTS idx_secret_findings_rule ON secret_findings(rule_name)",
},
},
{
name: "starred_sessions",
create: `CREATE TABLE IF NOT EXISTS starred_sessions (
session_id TEXT PRIMARY KEY,
created_at TIMESTAMP
)`,
columns: []columnSpec{
{"session_id", "session_id TEXT"},
{"created_at", "created_at TIMESTAMP"},
},
},
{
name: "pinned_messages",
create: `CREATE TABLE IF NOT EXISTS pinned_messages (
id BIGINT,
session_id TEXT NOT NULL,
message_id BIGINT NOT NULL,
ordinal INTEGER NOT NULL,
source_uuid TEXT NOT NULL DEFAULT '',
note TEXT,
created_at TIMESTAMP,
UNIQUE(session_id, message_id)
)`,
columns: []columnSpec{
{"id", "id BIGINT"},
{"session_id", "session_id TEXT NOT NULL DEFAULT ''"},
{"message_id", "message_id BIGINT NOT NULL DEFAULT 0"},
{"ordinal", "ordinal INTEGER NOT NULL DEFAULT 0"},
{"source_uuid", "source_uuid TEXT NOT NULL DEFAULT ''"},
{"note", "note TEXT"},
{"created_at", "created_at TIMESTAMP"},
},
indexes: []string{
"CREATE INDEX IF NOT EXISTS idx_pinned_session ON pinned_messages(session_id)",
"CREATE INDEX IF NOT EXISTS idx_pinned_message ON pinned_messages(message_id)",
"CREATE INDEX IF NOT EXISTS idx_pinned_created ON pinned_messages(created_at)",
},
},
}
type schemaOptions struct {
createIndexes bool
}
// EnsureSchema creates and additively migrates the DuckDB mirror schema.
func EnsureSchema(ctx context.Context, db *sql.DB) error {
return ensureSchema(ctx, db, schemaOptions{createIndexes: true})
}
func ensureSchema(ctx context.Context, db *sql.DB, opts schemaOptions) error {
for _, table := range mirrorTables {
if _, err := db.ExecContext(ctx, table.create); err != nil {
return fmt.Errorf("creating duckdb table %s: %w", table.name, err)
}
}
if !opts.createIndexes {
if err := checkSchemaShapeCompat(ctx, db); err != nil {
return err
}
return checkSchemaRepairsViaQuack(ctx, db)
}
existing, err := loadColumns(ctx, db)
if err != nil {
return err
}
defaultRepairDone, err := metadataKeyExists(ctx, db, defaultRepairMetadataKey)
if err != nil {
return err
}
for _, table := range mirrorTables {
have := existing[table.name]
if have == nil {
have = make(map[string]bool)
existing[table.name] = have
}
for _, column := range table.columns {
added := false
if !have[column.name] {
stmt := fmt.Sprintf(
"ALTER TABLE %s ADD COLUMN %s",
table.name, relaxedColumnDef(column.def),
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf(
"adding duckdb column %s.%s: %w",
table.name, column.name, err,
)
}
have[column.name] = true
added = true
}
if added || !defaultRepairDone {
if err := backfillAddedColumnDefault(ctx, db, table.name, column); err != nil {
return err
}
}
}
}
if err := migrateMessagesIDPrimaryKey(ctx, db); err != nil {
return err
}
if err := dropQuackIncompatibleTimestampDefaults(ctx, db); err != nil {
return err
}
projectIdentityRemoteScrubDone, err := metadataKeyExists(
ctx, db, projectIdentityRemoteScrubMetadataKey,
)
if err != nil {
return err
}
if !projectIdentityRemoteScrubDone {
if err := scrubProjectIdentityGitRemoteCredentials(ctx, db); err != nil {
return err
}
}
recordUsageDedupIndexMigration, err := migrateUsageEventsDedupIndex(ctx, db)
if err != nil {
return err
}
for _, table := range mirrorTables {
for _, stmt := range table.indexes {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("creating duckdb index for %s: %w", table.name, err)
}
}
}
if !defaultRepairDone {
if err := recordMetadataKey(ctx, db, defaultRepairMetadataKey, "1"); err != nil {
return err
}
}
if recordUsageDedupIndexMigration {
if err := recordMetadataKey(ctx, db, usageDedupIndexMetadataKey, "1"); err != nil {
return err
}
}
if !projectIdentityRemoteScrubDone {
if err := recordMetadataKey(
ctx, db, projectIdentityRemoteScrubMetadataKey, "1",
); err != nil {
return err
}
}
if err := recordMetadataKey(
ctx, db, schemaVersionMetadataKey, strconv.Itoa(SchemaVersion),
); err != nil {
return fmt.Errorf("recording duckdb schema version: %w", err)
}
return nil
}
func migrateUsageEventsDedupIndex(ctx context.Context, db *sql.DB) (bool, error) {
done, err := metadataKeyExists(ctx, db, usageDedupIndexMetadataKey)
if err != nil {
return false, err
}
if done {
return false, nil
}
if _, err := db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_usage_events_dedup`); err != nil {
return false, fmt.Errorf("dropping duckdb usage_events dedup index: %w", err)
}
return true, nil
}
func scrubProjectIdentityGitRemoteCredentials(
ctx context.Context, db *sql.DB,
) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(
"beginning duckdb project identity remote scrub: %w", err,
)
}
defer func() {
_ = tx.Rollback()
}()
rows, err := tx.QueryContext(ctx, `
SELECT source_archive_id, source_archive_salt,
project, machine, root_path, git_remote, git_remote_name,
repository_path, worktree_name, worktree_root_path,
worktree_relationship, checkout_state, git_branch,
remote_resolution, remote_candidate_count, observed_at,
normalized_remote, key_source, key
FROM source_project_identity_observations
WHERE git_remote != ''`)
if err != nil {
return fmt.Errorf(
"listing duckdb project identity remotes for scrub: %w", err,
)
}
rowsClosed := false
defer func() {
if !rowsClosed {
_ = rows.Close()
}
}()
type pendingScrub struct {
obs export.ProjectIdentityObservation
rawRemote string
}
var pending []pendingScrub
for rows.Next() {
var obs export.ProjectIdentityObservation
if err := rows.Scan(
&obs.SourceArchiveID,
&obs.SourceArchiveSalt,
&obs.Project,
&obs.Machine,
&obs.RootPath,
&obs.GitRemote,
&obs.GitRemoteName,
&obs.RepositoryPath,
&obs.WorktreeName,
&obs.WorktreeRootPath,
&obs.WorktreeRelationship,
&obs.CheckoutState,
&obs.GitBranch,
&obs.RemoteResolution,
&obs.RemoteCandidateCount,
&obs.ObservedAt,
&obs.NormalizedRemote,
&obs.KeySource,
&obs.Key,
); err != nil {
return fmt.Errorf(
"scanning duckdb project identity remote for scrub: %w", err,
)
}
sanitized := export.SanitizeGitRemoteForStorage(obs.GitRemote)
if sanitized == obs.GitRemote {
continue
}
pending = append(pending, pendingScrub{
obs: obs,
rawRemote: obs.GitRemote,
})
}
if err := rows.Err(); err != nil {
return fmt.Errorf(
"iterating duckdb project identity remotes for scrub: %w", err,
)
}
if err := rows.Close(); err != nil {
return fmt.Errorf(
"closing duckdb project identity remotes scrub rows: %w", err,
)
}
rowsClosed = true
for _, scrub := range pending {
obs := export.SanitizeStoredProjectIdentityObservation(scrub.obs)
if err := upsertProjectIdentityObservation(
func(stmt string, args ...any) error {
_, err := tx.ExecContext(ctx, stmt, args...)
return err
},
func(stmt string, args ...any) *sql.Row {
return tx.QueryRowContext(ctx, stmt, args...)
},
obs, scrub.rawRemote,
); err != nil {
return fmt.Errorf(
"upserting scrubbed duckdb project identity remote: %w", err,
)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM source_project_identity_observations
WHERE source_archive_id = ? AND project = ? AND machine = ?
AND root_path = ? AND git_remote = ?`,
scrub.obs.SourceArchiveID, scrub.obs.Project, scrub.obs.Machine,
scrub.obs.RootPath, scrub.rawRemote,
); err != nil {
return fmt.Errorf(
"removing raw duckdb project identity remote: %w", err,
)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(
"committing duckdb project identity remote scrub: %w", err,
)
}
return nil
}
func migrateMessagesIDPrimaryKey(ctx context.Context, db *sql.DB) error {
hasPrimary, err := tableHasPrimaryKey(ctx, db, "messages")
if err != nil {
return err
}
if !hasPrimary {
return nil
}
if _, err := db.ExecContext(ctx, `ALTER TABLE messages RENAME TO messages_with_id_pk`); err != nil {
return fmt.Errorf("renaming duckdb messages table for rekey: %w", err)
}
create := mirrorTableCreate("messages")
cols := mirrorTableColumns("messages")
if create == "" || len(cols) == 0 {
return fmt.Errorf("missing duckdb messages table spec")
}
if _, err := db.ExecContext(ctx, create); err != nil {
return fmt.Errorf("creating rekeyed duckdb messages table: %w", err)
}
colList := strings.Join(cols, ", ")
if _, err := db.ExecContext(ctx, fmt.Sprintf(
`INSERT INTO messages (%[1]s) SELECT %[1]s FROM messages_with_id_pk`,
colList,
)); err != nil {
return fmt.Errorf("copying rekeyed duckdb messages: %w", err)
}
if _, err := db.ExecContext(ctx, `DROP TABLE messages_with_id_pk`); err != nil {
return fmt.Errorf("dropping old duckdb messages table: %w", err)
}
return nil
}
func tableHasPrimaryKey(ctx context.Context, db *sql.DB, table string) (bool, error) {
var count int
if err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = ?
AND constraint_type = 'PRIMARY KEY'`,
strings.ToLower(table),
).Scan(&count); err != nil {
return false, fmt.Errorf("checking duckdb primary key for %s: %w", table, err)
}
return count > 0, nil
}
func mirrorTableCreate(name string) string {
for _, table := range mirrorTables {
if table.name == name {
return table.create
}
}
return ""
}
func mirrorTableColumns(name string) []string {
for _, table := range mirrorTables {
if table.name != name {
continue
}
cols := make([]string, len(table.columns))
for i, column := range table.columns {
cols[i] = column.name
}
return cols
}
return nil
}
func metadataKeyExists(ctx context.Context, db *sql.DB, key string) (bool, error) {
var exists bool
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) > 0 FROM sync_metadata WHERE key = ?`,
key,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking duckdb metadata key %s: %w", key, err)
}
return exists, nil
}
func recordMetadataKey(
ctx context.Context, db *sql.DB, key string, value string,
) error {
var existing string
err := db.QueryRowContext(ctx,
`SELECT value FROM sync_metadata WHERE key = ?`,
key,
).Scan(&existing)
if err == nil && existing == value {
return nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking duckdb metadata key %s: %w", key, err)
}
if _, err := db.ExecContext(ctx, `
INSERT INTO sync_metadata (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
); err != nil {
return fmt.Errorf("recording duckdb metadata key %s: %w", key, err)
}
return nil
}
func dropQuackIncompatibleTimestampDefaults(ctx context.Context, db *sql.DB) error {
for _, spec := range quackIncompatibleTimestampDefaults {
defaultValue, err := columnDefault(ctx, db, spec.table, spec.column)
if err != nil {
return err
}
if !strings.Contains(strings.ToLower(defaultValue), "current_timestamp") {
continue
}
stmt := fmt.Sprintf(
"ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT",
spec.table,
spec.column,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf(
"dropping quack-incompatible duckdb default %s.%s: %w",
spec.table,
spec.column,
err,
)
}
}
return nil
}
func columnDefault(
ctx context.Context, db *sql.DB, table, column string,
) (string, error) {
var defaultValue sql.NullString
err := db.QueryRowContext(ctx, `
SELECT column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND lower(table_name) = ?
AND lower(column_name) = ?`,
strings.ToLower(table),
strings.ToLower(column),
).Scan(&defaultValue)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf(
"checking duckdb column default %s.%s: %w",
table,
column,
err,
)
}
if !defaultValue.Valid {
return "", nil
}
return defaultValue.String, nil
}
func relaxedColumnDef(def string) string {
def = strings.ReplaceAll(def, " NOT NULL", "")
if i := strings.Index(def, " DEFAULT "); i >= 0 {
def = def[:i]
}
return def
}
func backfillAddedColumnDefault(
ctx context.Context, db *sql.DB, table string, column columnSpec,
) error {
defaultValue, ok := columnDefaultLiteral(column.def)
if !ok {
return nil
}
stmt := fmt.Sprintf(
"UPDATE %s SET %s = %s WHERE %s IS NULL",
table, column.name, defaultValue, column.name,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf(
"backfilling duckdb column %s.%s default: %w",
table, column.name, err,
)
}
return nil
}
func columnDefaultLiteral(def string) (string, bool) {
idx := strings.Index(strings.ToUpper(def), " DEFAULT ")
if idx < 0 {
return "", false
}
value := strings.TrimSpace(def[idx+len(" DEFAULT "):])
if value == "" || strings.Contains(strings.ToLower(value), "current_timestamp") {
return "", false
}
return value, true
}
// CheckSchemaCompat verifies that the DuckDB mirror has the required
// read/push tables and columns. It does not mutate the database.
func CheckSchemaCompat(ctx context.Context, db *sql.DB) error {
if err := checkSchemaShapeCompat(ctx, db); err != nil {
return err
}
pendingRepairs, err := pendingSchemaRepairs(ctx, db)
if err != nil {
return err
}
return schemaRepairError(pendingRepairs)
}
func CheckSchemaCompatViaQuack(ctx context.Context, db *sql.DB) error {
if err := checkSchemaShapeCompat(ctx, db); err != nil {
return err
}
return checkSchemaRepairsViaQuack(ctx, db)
}
func checkSchemaShapeCompat(ctx context.Context, db *sql.DB) error {
existing, err := loadColumns(ctx, db)
if err != nil {
return err
}
var missing []string
for _, table := range mirrorTables {
have, ok := existing[table.name]
if !ok || len(have) == 0 {
missing = append(missing, "missing table "+table.name)
continue
}
for _, column := range table.columns {
if !have[column.name] {
missing = append(missing, table.name+"."+column.name)
}
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf(
"duckdb schema incompatible; run agentsview duckdb push to migrate; missing: %s",
strings.Join(missing, ", "),
)
}
var version string
err = db.QueryRowContext(ctx,
`SELECT value FROM sync_metadata WHERE key = ?`,
schemaVersionMetadataKey,
).Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf(
"duckdb schema incompatible; missing %s in sync_metadata",
schemaVersionMetadataKey,
)
}
if err != nil {
return fmt.Errorf("checking duckdb schema version: %w", err)
}
got, err := strconv.Atoi(version)
if err != nil {
return fmt.Errorf(
"duckdb schema incompatible; invalid schema version %q",
version,
)
}
if got < SchemaVersion {
return fmt.Errorf(
"duckdb schema incompatible; version %d is older than required %d",
got, SchemaVersion,
)
}
return nil
}
func checkSchemaRepairsViaQuack(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx,
"SELECT issue FROM "+quackAttachmentName+".query(?)",
pendingSchemaRepairsQuery(),
)
if err != nil {
return fmt.Errorf("checking duckdb schema repairs through quack: %w", err)
}
pendingRepairs, err := collectSchemaRepairIssues(rows)
if err != nil {
return err
}
return schemaRepairError(pendingRepairs)
}
func pendingSchemaRepairs(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx, pendingSchemaRepairsQuery())
if err != nil {
return nil, fmt.Errorf("checking duckdb schema repairs: %w", err)
}
return collectSchemaRepairIssues(rows)
}
func collectSchemaRepairIssues(rows *sql.Rows) ([]string, error) {
defer func() {
_ = rows.Close()
}()
var pendingRepairs []string
for rows.Next() {
var issue string
if err := rows.Scan(&issue); err != nil {
return nil, fmt.Errorf("scanning duckdb schema repair check: %w", err)
}
pendingRepairs = append(pendingRepairs, issue)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating duckdb schema repair check: %w", err)
}
return pendingRepairs, nil
}
func schemaRepairError(pendingRepairs []string) error {
if len(pendingRepairs) == 0 {
return nil
}
sort.Strings(pendingRepairs)
return fmt.Errorf(
"duckdb schema incompatible; run full DuckDB schema migration on the base database; pending repairs: %s",
strings.Join(pendingRepairs, ", "),
)
}
func pendingSchemaRepairsQuery() string {
metadataValues := []string{
"(" + duckLiteral(defaultRepairMetadataKey) + ")",
"(" + duckLiteral(usageDedupIndexMetadataKey) + ")",
"(" + duckLiteral(projectIdentityRemoteScrubMetadataKey) + ")",
}
defaultValues := make([]string, len(quackIncompatibleTimestampDefaults))
for i, spec := range quackIncompatibleTimestampDefaults {
defaultValues[i] = "(" + duckLiteral(spec.table) + ", " +
duckLiteral(spec.column) + ")"
}
return `
WITH required_metadata(key) AS (
VALUES ` + strings.Join(metadataValues, ", ") + `
),
checked_defaults(table_name, column_name) AS (
VALUES ` + strings.Join(defaultValues, ", ") + `
)
SELECT 'missing ' || key AS issue
FROM required_metadata m
WHERE NOT EXISTS (
SELECT 1 FROM sync_metadata sm WHERE sm.key = m.key
)
UNION ALL
SELECT 'messages.id primary key' AS issue
WHERE EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND lower(table_name) = 'messages'
AND constraint_type = 'PRIMARY KEY'
)
UNION ALL
SELECT lower(c.table_name) || '.' || lower(c.column_name) ||
' current_timestamp default' AS issue
FROM information_schema.columns c
JOIN checked_defaults d
ON lower(c.table_name) = d.table_name
AND lower(c.column_name) = d.column_name
WHERE c.table_schema = current_schema()
AND lower(coalesce(c.column_default, '')) LIKE '%current_timestamp%'`
}
func loadColumns(ctx context.Context, db *sql.DB) (map[string]map[string]bool, error) {
rows, err := db.QueryContext(ctx, `
SELECT lower(table_name), lower(column_name)
FROM information_schema.columns
WHERE table_schema = current_schema()`)
if err != nil {
return nil, fmt.Errorf("loading duckdb schema columns: %w", err)
}
defer rows.Close()
columns := make(map[string]map[string]bool)
for rows.Next() {
var table, column string
if err := rows.Scan(&table, &column); err != nil {
return nil, fmt.Errorf("scanning duckdb schema columns: %w", err)
}
if columns[table] == nil {
columns[table] = make(map[string]bool)
}
columns[table][column] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating duckdb schema columns: %w", err)
}
return columns, nil
}