From da6cbf65ba400a802c5acf2a0235a009c62772b9 Mon Sep 17 00:00:00 2001 From: duhd Date: Sun, 29 Mar 2026 22:57:31 +0700 Subject: [PATCH] feat(store): add Upsert() to BuiltinToolStore for additive tool seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed() performs a reconcile DELETE that removes all tools not in the provided list. This breaks fork workflows where Seed() is called twice: once for upstream tools, once for fork-local additions — the second call wipes all upstream tools. Add Upsert() that performs the same INSERT ON CONFLICT DO UPDATE but skips the reconcile DELETE. Fork-specific seed functions can use Upsert() to additively register tools without affecting the upstream set. Implemented for both PostgreSQL and SQLite backends. Fixes #336 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/store/builtin_tool_store.go | 3 ++ internal/store/pg/builtin_tools.go | 51 +++++++++++++++++++++ internal/store/sqlitestore/builtin-tools.go | 51 +++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/internal/store/builtin_tool_store.go b/internal/store/builtin_tool_store.go index fa30ef8a4..e62f8c467 100644 --- a/internal/store/builtin_tool_store.go +++ b/internal/store/builtin_tool_store.go @@ -29,6 +29,9 @@ type BuiltinToolStore interface { Get(ctx context.Context, name string) (*BuiltinToolDef, error) Update(ctx context.Context, name string, updates map[string]any) error Seed(ctx context.Context, tools []BuiltinToolDef) error + // Upsert inserts/updates tools without reconcile — safe for additive fork-specific tools. + // Does NOT delete rows not in the provided list. + Upsert(ctx context.Context, tools []BuiltinToolDef) error ListEnabled(ctx context.Context) ([]BuiltinToolDef, error) GetSettings(ctx context.Context, name string) (json.RawMessage, error) } diff --git a/internal/store/pg/builtin_tools.go b/internal/store/pg/builtin_tools.go index 8e6a7aef3..553beb185 100644 --- a/internal/store/pg/builtin_tools.go +++ b/internal/store/pg/builtin_tools.go @@ -159,6 +159,57 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool return tx.Commit() } +// Upsert inserts or updates builtin tool definitions without reconcile DELETE. +// Safe for additive fork-specific tools -- does NOT delete rows not in the list. +func (s *PGBuiltinToolStore) Upsert(ctx context.Context, tools []store.BuiltinToolDef) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + ON CONFLICT (name) DO UPDATE SET + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + category = EXCLUDED.category, + requires = EXCLUDED.requires, + metadata = EXCLUDED.metadata, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings::text IN ('{}', 'null') + THEN EXCLUDED.settings + ELSE builtin_tools.settings + END, + updated_at = EXCLUDED.updated_at`) + if err != nil { + return fmt.Errorf("prepare upsert stmt: %w", err) + } + defer stmt.Close() + + now := time.Now() + for _, t := range tools { + settings := t.Settings + if settings == nil { + settings = json.RawMessage("{}") + } + metadata := t.Metadata + if metadata == nil { + metadata = json.RawMessage("{}") + } + _, err := stmt.ExecContext(ctx, + t.Name, t.DisplayName, t.Description, t.Category, + t.Enabled, []byte(settings), pqStringArray(t.Requires), []byte(metadata), now, + ) + if err != nil { + return fmt.Errorf("upsert tool %s: %w", t.Name, err) + } + } + + return tx.Commit() +} + func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) { var def store.BuiltinToolDef var settings []byte diff --git a/internal/store/sqlitestore/builtin-tools.go b/internal/store/sqlitestore/builtin-tools.go index 33e603e07..3cd541dfe 100644 --- a/internal/store/sqlitestore/builtin-tools.go +++ b/internal/store/sqlitestore/builtin-tools.go @@ -168,6 +168,57 @@ func (s *SQLiteBuiltinToolStore) Seed(ctx context.Context, tools []store.Builtin return tx.Commit() } +// Upsert inserts or updates builtin tool definitions without reconcile DELETE. +// Safe for additive fork-specific tools -- does NOT delete rows not in the list. +func (s *SQLiteBuiltinToolStore) Upsert(ctx context.Context, tools []store.BuiltinToolDef) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + category = excluded.category, + requires = excluded.requires, + metadata = excluded.metadata, + settings = CASE + WHEN builtin_tools.settings IS NULL OR builtin_tools.settings IN ('{}', 'null') + THEN excluded.settings + ELSE builtin_tools.settings + END, + updated_at = excluded.updated_at`) + if err != nil { + return fmt.Errorf("prepare upsert stmt: %w", err) + } + defer stmt.Close() + + now := time.Now() + for _, t := range tools { + settings := t.Settings + if settings == nil { + settings = json.RawMessage("{}") + } + metadata := t.Metadata + if metadata == nil { + metadata = json.RawMessage("{}") + } + _, err := stmt.ExecContext(ctx, + t.Name, t.DisplayName, t.Description, t.Category, + t.Enabled, []byte(settings), jsonStringArray(t.Requires), []byte(metadata), now, now, + ) + if err != nil { + return fmt.Errorf("upsert tool %s: %w", t.Name, err) + } + } + + return tx.Commit() +} + func (s *SQLiteBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) { var def store.BuiltinToolDef var settings []byte