From 9baa985bbffb6b43faf17783e6e68298f9746a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 17 Feb 2026 17:37:55 +0000 Subject: [PATCH] feat: improve db abstraction (#238) --- apps/backend/cmd/kandev/storage.go | 4 +- apps/backend/cmd/kandev/worktree.go | 5 +- apps/backend/go.mod | 6 + apps/backend/go.sum | 19 + .../internal/agent/settings/store/provider.go | 4 +- .../internal/agent/settings/store/sqlite.go | 77 ++-- .../internal/analytics/repository/provider.go | 4 +- .../analytics/repository/sqlite/repository.go | 8 +- .../repository/sqlite/repository_test.go | 61 +-- .../analytics/repository/sqlite/stats.go | 296 ++++++-------- apps/backend/internal/common/config/config.go | 12 +- apps/backend/internal/common/sqlite/utils.go | 56 --- apps/backend/internal/db/dialect/dialect.go | 20 + .../internal/db/dialect/dialect_test.go | 174 ++++++++ apps/backend/internal/db/dialect/insert.go | 29 ++ apps/backend/internal/db/dialect/json.go | 33 ++ apps/backend/internal/db/dialect/search.go | 12 + apps/backend/internal/db/dialect/time.go | 82 ++++ apps/backend/internal/db/postgres.go | 34 ++ .../internal/editors/store/provider.go | 4 +- apps/backend/internal/editors/store/sqlite.go | 41 +- .../internal/integration/integration_test.go | 10 +- .../internal/integration/orchestrator_test.go | 10 +- .../internal/notifications/store/provider.go | 4 +- .../internal/notifications/store/sqlite.go | 59 +-- .../orchestrator/event_handlers_test.go | 6 +- .../orchestrator/scheduler/scheduler_test.go | 6 +- apps/backend/internal/persistence/provider.go | 46 ++- .../internal/prompts/service/service_test.go | 6 +- .../internal/prompts/store/provider.go | 4 +- apps/backend/internal/prompts/store/sqlite.go | 35 +- .../internal/prompts/store/sqlite_test.go | 6 +- .../internal/task/repository/provider.go | 4 +- .../internal/task/repository/sqlite/base.go | 149 +++---- .../task/repository/sqlite/defaults.go | 40 +- .../task/repository/sqlite/environment.go | 18 +- .../task/repository/sqlite/executor.go | 34 +- .../task/repository/sqlite/git_snapshots.go | 28 +- .../task/repository/sqlite/message.go | 59 +-- .../internal/task/repository/sqlite/plan.go | 14 +- .../repository/sqlite/repository_entity.go | 44 +- .../internal/task/repository/sqlite/review.go | 10 +- .../task/repository/sqlite/session.go | 124 +++--- .../internal/task/repository/sqlite/task.go | 76 ++-- .../task/repository/sqlite/task_repository.go | 20 +- .../task/repository/sqlite/workflow.go | 24 +- .../task/repository/sqlite/workspace.go | 14 +- .../internal/task/repository/sqlite_test.go | 22 +- .../task/service/repository_discovery_test.go | 6 +- .../internal/task/service/service_test.go | 8 +- apps/backend/internal/user/store/provider.go | 4 +- apps/backend/internal/user/store/sqlite.go | 31 +- apps/backend/internal/workflow/provider.go | 5 +- .../internal/workflow/repository/sqlite.go | 378 +++--------------- .../internal/workflow/service/service_test.go | 22 +- apps/backend/internal/worktree/provider.go | 4 +- apps/backend/internal/worktree/store.go | 47 +-- docs/db.md | 74 ++++ 58 files changed, 1270 insertions(+), 1162 deletions(-) delete mode 100644 apps/backend/internal/common/sqlite/utils.go create mode 100644 apps/backend/internal/db/dialect/dialect.go create mode 100644 apps/backend/internal/db/dialect/dialect_test.go create mode 100644 apps/backend/internal/db/dialect/insert.go create mode 100644 apps/backend/internal/db/dialect/json.go create mode 100644 apps/backend/internal/db/dialect/search.go create mode 100644 apps/backend/internal/db/dialect/time.go create mode 100644 apps/backend/internal/db/postgres.go create mode 100644 docs/db.md diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 72b445a5fc..fa0d4cdee0 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -1,7 +1,7 @@ package main import ( - "database/sql" + "github.com/jmoiron/sqlx" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/config" @@ -17,7 +17,7 @@ import ( userstore "github.com/kandev/kandev/internal/user/store" ) -func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repositories, []func() error, error) { +func provideRepositories(cfg *config.Config, log *logger.Logger) (*sqlx.DB, *Repositories, []func() error, error) { cleanups := make([]func() error, 0, 6) dbConn, cleanup, err := persistence.Provide(cfg, log) if err != nil { diff --git a/apps/backend/cmd/kandev/worktree.go b/apps/backend/cmd/kandev/worktree.go index 84ccdefb79..774c8570c5 100644 --- a/apps/backend/cmd/kandev/worktree.go +++ b/apps/backend/cmd/kandev/worktree.go @@ -2,7 +2,8 @@ package main import ( "context" - "database/sql" + + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/config" @@ -57,7 +58,7 @@ func (a *bootMsgAdapter) UpdateMessage(ctx context.Context, message *models.Mess return a.svc.UpdateMessage(ctx, message) } -func provideWorktreeManager(dbConn *sql.DB, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, *worktree.Recreator, func() error, error) { +func provideWorktreeManager(dbConn *sqlx.DB, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, *worktree.Recreator, func() error, error) { manager, cleanup, err := worktree.Provide(dbConn, cfg, log) if err != nil { return nil, nil, nil, err diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 355c0d27c7..67238a0312 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -12,6 +12,8 @@ require ( github.com/github/copilot-sdk/go v0.1.18 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.8.0 + github.com/jmoiron/sqlx v1.4.0 github.com/mark3labs/mcp-go v0.43.2 github.com/mattn/go-sqlite3 v1.14.33 github.com/nats-io/nats.go v1.31.0 @@ -47,6 +49,9 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.0 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -88,6 +93,7 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index e523842671..b1fc27aa8b 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -121,6 +123,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -196,6 +200,16 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -219,6 +233,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -227,6 +243,7 @@ github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -441,6 +458,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/apps/backend/internal/agent/settings/store/provider.go b/apps/backend/internal/agent/settings/store/provider.go index 4ce1eecc91..16c86e4d0d 100644 --- a/apps/backend/internal/agent/settings/store/provider.go +++ b/apps/backend/internal/agent/settings/store/provider.go @@ -1,9 +1,9 @@ package store -import "database/sql" +import "github.com/jmoiron/sqlx" // Provide creates the SQLite agent settings store using the shared database connection. -func Provide(db *sql.DB) (*sqliteRepository, func() error, error) { +func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) { repo, err := newSQLiteRepositoryWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index fa1ef41952..9706a1153e 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -8,23 +8,24 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/agent/settings/models" - "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" ) type sqliteRepository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } var _ Repository = (*sqliteRepository)(nil) -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { +func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(dbConn, false) } -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -45,8 +46,8 @@ func (r *sqliteRepository) initSchema() error { workspace_id TEXT DEFAULT NULL, supports_mcp INTEGER NOT NULL DEFAULT 0, mcp_config_path TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS agent_profiles ( @@ -61,9 +62,9 @@ func (r *sqliteRepository) initSchema() error { cli_passthrough INTEGER NOT NULL DEFAULT 0, user_modified INTEGER NOT NULL DEFAULT 0, plan TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE ); @@ -72,8 +73,8 @@ func (r *sqliteRepository) initSchema() error { enabled INTEGER NOT NULL DEFAULT 0, servers_json TEXT NOT NULL DEFAULT '{}', meta_json TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE ); @@ -86,10 +87,6 @@ func (r *sqliteRepository) initSchema() error { return err } - // Migration: add user_modified column to existing databases. - // Error is intentionally ignored: ALTER TABLE fails with "duplicate column" if it already exists. - _, _ = r.db.Exec(`ALTER TABLE agent_profiles ADD COLUMN user_modified INTEGER NOT NULL DEFAULT 0`) - return nil } @@ -107,35 +104,35 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) now := time.Now().UTC() agent.CreatedAt = now agent.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, agent.ID, agent.Name, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt) + `), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt) return err } func (r *sqliteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at FROM agents WHERE id = ? - `, id) + `), id) return scanAgent(row) } func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at FROM agents WHERE name = ? - `, name) + `), name) return scanAgent(row) } func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error { agent.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ? WHERE id = ? - `, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID) + `), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID) if err != nil { return err } @@ -147,7 +144,7 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) } func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM agents WHERE id = ?`), id) if err != nil { return err } @@ -182,11 +179,11 @@ func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.Agent, err } func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at FROM agent_profile_mcp_configs WHERE profile_id = ? - `, profileID) + `), profileID) var config models.AgentProfileMcpConfig var enabled int @@ -230,7 +227,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf return fmt.Errorf("failed to serialize MCP meta: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO agent_profile_mcp_configs (profile_id, enabled, servers_json, meta_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(profile_id) DO UPDATE SET @@ -238,7 +235,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf servers_json = excluded.servers_json, meta_json = excluded.meta_json, updated_at = excluded.updated_at - `, config.ProfileID, sqlite.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt) + `), config.ProfileID, dialect.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt) return err } @@ -249,22 +246,22 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode now := time.Now().UTC() profile.CreatedAt = now profile.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?) - `, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove), - sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) + `), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove), + dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) return err } func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { profile.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE agent_profiles SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove), - sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID) + `), profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove), + dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID) if err != nil { return err } @@ -277,9 +274,9 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE agent_profiles SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) + `), now, now, id) if err != nil { return err } @@ -291,18 +288,18 @@ func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) er } func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE id = ? AND deleted_at IS NULL - `, id) + `), id) return scanAgentProfile(row) } func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC - `, agentID) + `), agentID) if err != nil { return nil, err } diff --git a/apps/backend/internal/analytics/repository/provider.go b/apps/backend/internal/analytics/repository/provider.go index 7ba7b2f87b..ac131f6275 100644 --- a/apps/backend/internal/analytics/repository/provider.go +++ b/apps/backend/internal/analytics/repository/provider.go @@ -1,13 +1,13 @@ package repository import ( - "database/sql" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/analytics/repository/sqlite" ) // Provide creates the SQLite analytics repository using the shared database connection. -func Provide(db *sql.DB) (Repository, func() error, error) { +func Provide(db *sqlx.DB) (Repository, func() error, error) { repo, err := sqlite.NewWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/analytics/repository/sqlite/repository.go b/apps/backend/internal/analytics/repository/sqlite/repository.go index f6b50f253a..614cda26f8 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository.go @@ -1,16 +1,16 @@ // Package sqlite provides SQLite-based analytics repository implementations. package sqlite -import "database/sql" +import "github.com/jmoiron/sqlx" // Repository provides SQLite-based analytics operations. type Repository struct { - db *sql.DB + db *sqlx.DB } // NewWithDB creates a new analytics repository with an existing database connection. // It automatically creates performance indexes for stats queries. -func NewWithDB(dbConn *sql.DB) (*Repository, error) { +func NewWithDB(dbConn *sqlx.DB) (*Repository, error) { repo := &Repository{db: dbConn} if err := repo.ensureStatsIndexes(); err != nil { return nil, err @@ -79,7 +79,6 @@ func (r *Repository) ensureStatsIndexes() error { `CREATE INDEX IF NOT EXISTS idx_repos_workspace ON repositories(workspace_id) WHERE deleted_at IS NULL`, - } for _, idx := range indexes { @@ -90,4 +89,3 @@ func (r *Repository) ensureStatsIndexes() error { return nil } - diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index a80d119d72..0bbd1a8003 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -2,38 +2,39 @@ package sqlite import ( "context" - "database/sql" "path/filepath" "testing" "time" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" ) // createTestDB sets up a SQLite database with all required schemas for analytics queries. -func createTestDB(t *testing.T) *sql.DB { +func createTestDB(t *testing.T) *sqlx.DB { t.Helper() tmpDir := t.TempDir() dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) if err != nil { t.Fatalf("failed to open sqlite db: %v", err) } - t.Cleanup(func() { _ = dbConn.Close() }) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) // Create tables that analytics queries depend on schema := ` CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS boards ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS workflow_steps ( id TEXT PRIMARY KEY, @@ -45,8 +46,8 @@ func createTestDB(t *testing.T) *sql.DB { events TEXT, allow_manual_move INTEGER DEFAULT 1, auto_archive_after_hours INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, @@ -55,25 +56,25 @@ func createTestDB(t *testing.T) *sql.DB { workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, state TEXT DEFAULT 'TODO', - archived_at DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS repositories ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'local', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS task_repositories ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, repository_id TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, UNIQUE(task_id, repository_id) ); CREATE TABLE IF NOT EXISTS task_sessions ( @@ -83,18 +84,18 @@ func createTestDB(t *testing.T) *sql.DB { agent_profile_snapshot TEXT DEFAULT '{}', repository_id TEXT DEFAULT '', state TEXT NOT NULL DEFAULT 'CREATED', - started_at DATETIME NOT NULL, - completed_at DATETIME, - updated_at DATETIME NOT NULL + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS task_session_turns ( id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, task_id TEXT NOT NULL, - started_at DATETIME NOT NULL, - completed_at DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS task_session_messages ( id TEXT PRIMARY KEY, @@ -103,26 +104,26 @@ func createTestDB(t *testing.T) *sql.DB { author_type TEXT NOT NULL DEFAULT 'user', type TEXT NOT NULL DEFAULT 'message', content TEXT NOT NULL, - created_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS task_session_commits ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, commit_sha TEXT NOT NULL, - committed_at DATETIME NOT NULL, + committed_at TIMESTAMP NOT NULL, files_changed INTEGER DEFAULT 0, insertions INTEGER DEFAULT 0, deletions INTEGER DEFAULT 0, - created_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL ); ` - if _, err := dbConn.Exec(schema); err != nil { + if _, err := sqlxDB.Exec(schema); err != nil { t.Fatalf("failed to create schema: %v", err) } - return dbConn + return sqlxDB } -func execOrFatal(t *testing.T, dbConn *sql.DB, query string, args ...any) { +func execOrFatal(t *testing.T, dbConn *sqlx.DB, query string, args ...any) { t.Helper() if _, err := dbConn.Exec(query, args...); err != nil { t.Fatalf("exec failed: %v", err) diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 896818ba3d..5ffb8a8a78 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -3,9 +3,11 @@ package sqlite import ( "context" "database/sql" + "fmt" "time" "github.com/kandev/kandev/internal/analytics/models" + "github.com/kandev/kandev/internal/db/dialect" ) // parseTimeString parses time strings in various SQLite formats @@ -33,36 +35,32 @@ func parseTimeString(s string) time.Time { // GetTaskStats retrieves aggregated statistics for all tasks in a workspace func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.TaskStats, error) { - var startArg interface{} + var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } - // Use separate subqueries for counts and duration to avoid row multiplication - query := ` + drv := r.db.DriverName() + dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + + query := fmt.Sprintf(` SELECT - t.id, - t.title, - t.workspace_id, - t.workflow_id, - t.state, + t.id, t.title, t.workspace_id, t.workflow_id, t.state, COALESCE(session_stats.session_count, 0) as session_count, COALESCE(session_stats.turn_count, 0) as turn_count, COALESCE(session_stats.message_count, 0) as message_count, COALESCE(session_stats.user_message_count, 0) as user_message_count, COALESCE(session_stats.tool_call_count, 0) as tool_call_count, COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms, - t.created_at, - session_stats.last_completed_at + t.created_at, session_stats.last_completed_at FROM tasks t LEFT JOIN ( - SELECT - s.task_id, + SELECT s.task_id, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, - COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count, + COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count, MAX(s.completed_at) as last_completed_at FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id @@ -71,13 +69,8 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start GROUP BY s.task_id ) session_stats ON session_stats.task_id = t.id LEFT JOIN ( - SELECT - s.task_id, - SUM(CASE - WHEN turn.completed_at IS NOT NULL - THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 - ELSE 0 - END) as total_duration_ms + SELECT s.task_id, + SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as total_duration_ms FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id WHERE (? IS NULL OR s.started_at >= ?) @@ -85,13 +78,10 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start ) turn_stats ON turn_stats.task_id = t.id WHERE t.workspace_id = ? AND (? IS NULL OR t.created_at >= ?) ORDER BY t.updated_at DESC - ` + `, dur) - rows, err := r.db.QueryContext( - ctx, - query, - startArg, startArg, - startArg, startArg, + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), + startArg, startArg, startArg, startArg, workspaceID, startArg, startArg, ) if err != nil { @@ -99,34 +89,27 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start } defer func() { _ = rows.Close() }() + return r.scanTaskStats(rows) +} + +func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) { var results []*models.TaskStats for rows.Next() { var stat models.TaskStats - var completedAtStr sql.NullString // Use NullString to handle legacy string dates - var createdAtStr string // Handle legacy string dates - var totalDurationMs float64 // SQLite returns float from julianday math + var completedAtStr sql.NullString + var createdAtStr string + var totalDurationMs float64 err := rows.Scan( - &stat.TaskID, - &stat.TaskTitle, - &stat.WorkspaceID, - &stat.WorkflowID, - &stat.State, - &stat.SessionCount, - &stat.TurnCount, - &stat.MessageCount, - &stat.UserMessageCount, - &stat.ToolCallCount, - &totalDurationMs, - &createdAtStr, - &completedAtStr, + &stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, &stat.WorkflowID, &stat.State, + &stat.SessionCount, &stat.TurnCount, &stat.MessageCount, + &stat.UserMessageCount, &stat.ToolCallCount, &totalDurationMs, + &createdAtStr, &completedAtStr, ) if err != nil { return nil, err } stat.TotalDurationMs = int64(totalDurationMs) - // Parse created_at from string stat.CreatedAt = parseTimeString(createdAtStr) - // Parse completed_at from string if valid if completedAtStr.Valid && completedAtStr.String != "" { parsedTime := parseTimeString(completedAtStr.String) if !parsedTime.IsZero() { @@ -135,20 +118,20 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start } results = append(results, &stat) } - return results, rows.Err() } // GetGlobalStats retrieves workspace-wide aggregated statistics func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) { - var startArg interface{} + var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } - // Use separate subqueries to avoid row multiplication from JOINs - // Count tasks in the last workflow step (highest position) as completed - query := ` + drv := r.db.DriverName() + dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + + query := fmt.Sprintf(` SELECT (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND (? IS NULL OR created_at >= ?)) as total_tasks, (SELECT COUNT(*) FROM tasks t @@ -174,20 +157,18 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta (SELECT COUNT(*) FROM task_session_messages msg JOIN task_sessions s ON s.id = msg.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND msg.type LIKE 'tool_%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls, + WHERE t.workspace_id = ? AND msg.type LIKE 'tool_%%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls, (SELECT COALESCE(SUM( - CASE WHEN turn.completed_at IS NOT NULL - THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 - ELSE 0 END + CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END ), 0) FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_duration_ms - ` + `, dur) var stats models.GlobalStats - var totalDurationMs float64 // SQLite returns float from julianday math - err := r.db.QueryRowContext(ctx, query, + var totalDurationMs float64 + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), workspaceID, startArg, startArg, workspaceID, startArg, startArg, workspaceID, startArg, startArg, @@ -198,22 +179,15 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta workspaceID, startArg, startArg, workspaceID, startArg, startArg, ).Scan( - &stats.TotalTasks, - &stats.CompletedTasks, - &stats.InProgressTasks, - &stats.TotalSessions, - &stats.TotalTurns, - &stats.TotalMessages, - &stats.TotalUserMessages, - &stats.TotalToolCalls, - &totalDurationMs, + &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, + &stats.TotalSessions, &stats.TotalTurns, &stats.TotalMessages, + &stats.TotalUserMessages, &stats.TotalToolCalls, &totalDurationMs, ) if err != nil { return nil, err } stats.TotalDurationMs = int64(totalDurationMs) - // Calculate averages if stats.TotalTasks > 0 { stats.AvgTurnsPerTask = float64(stats.TotalTurns) / float64(stats.TotalTasks) stats.AvgMessagesPerTask = float64(stats.TotalMessages) / float64(stats.TotalTasks) @@ -225,13 +199,18 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta // GetDailyActivity retrieves daily activity statistics for the last N days func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, days int) ([]*models.DailyActivity, error) { - query := ` + drv := r.db.DriverName() + dateStart := dialect.DateNowMinusDays(drv, "?") + datePlus := dialect.DatePlusOneDay(drv, "date") + curDate := dialect.CurrentDate(drv) + dateOfTurn := dialect.DateOf(drv, "turn.started_at") + dateOfMsg := dialect.DateOf(drv, "msg.created_at") + + query := fmt.Sprintf(` WITH RECURSIVE dates(date) AS ( - SELECT date('now', '-' || ? || ' days') + SELECT %s UNION ALL - SELECT date(date, '+1 day') - FROM dates - WHERE date < date('now') + SELECT %s FROM dates WHERE date < %s ) SELECT d.date, @@ -241,7 +220,7 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d FROM dates d LEFT JOIN ( SELECT - date(turn.started_at) as activity_date, + %s as activity_date, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT t.id) as task_count @@ -249,14 +228,14 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id - AND date(msg.created_at) = date(turn.started_at) + AND %s = %s WHERE t.workspace_id = ? - GROUP BY date(turn.started_at) + GROUP BY %s ) activity ON activity.activity_date = d.date ORDER BY d.date ASC - ` + `, dateStart, datePlus, curDate, dateOfTurn, dateOfMsg, dateOfTurn, dateOfTurn) - rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), days-1, workspaceID) if err != nil { return nil, err } @@ -265,13 +244,7 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d var results []*models.DailyActivity for rows.Next() { var activity models.DailyActivity - err := rows.Scan( - &activity.Date, - &activity.TurnCount, - &activity.MessageCount, - &activity.TaskCount, - ) - if err != nil { + if err := rows.Scan(&activity.Date, &activity.TurnCount, &activity.MessageCount, &activity.TaskCount); err != nil { return nil, err } results = append(results, &activity) @@ -282,39 +255,37 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d // GetCompletedTaskActivity retrieves completed task counts for the last N days func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID string, days int) ([]*models.CompletedTaskActivity, error) { - query := ` + drv := r.db.DriverName() + dateStart := dialect.DateNowMinusDays(drv, "?") + datePlus := dialect.DatePlusOneDay(drv, "date") + curDate := dialect.CurrentDate(drv) + dateOfCompleted := dialect.DateOf(drv, "ts.completed_at") + + query := fmt.Sprintf(` WITH RECURSIVE dates(date) AS ( - SELECT date('now', '-' || ? || ' days') + SELECT %s UNION ALL - SELECT date(date, '+1 day') - FROM dates - WHERE date < date('now') + SELECT %s FROM dates WHERE date < %s ) - SELECT - d.date, - COALESCE(activity.completed_tasks, 0) as completed_tasks + SELECT d.date, COALESCE(activity.completed_tasks, 0) as completed_tasks FROM dates d LEFT JOIN ( - SELECT - date(ts.completed_at) as activity_date, - COUNT(DISTINCT t.id) as completed_tasks + SELECT %s as activity_date, COUNT(DISTINCT t.id) as completed_tasks FROM tasks t LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id JOIN ( SELECT task_id, MAX(completed_at) as completed_at - FROM task_sessions - WHERE completed_at IS NOT NULL - GROUP BY task_id + FROM task_sessions WHERE completed_at IS NOT NULL GROUP BY task_id ) ts ON ts.task_id = t.id WHERE t.workspace_id = ? AND (t.archived_at IS NOT NULL OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) - GROUP BY date(ts.completed_at) + GROUP BY %s ) activity ON activity.activity_date = d.date ORDER BY d.date ASC - ` + `, dateStart, datePlus, curDate, dateOfCompleted, dateOfCompleted) - rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), days-1, workspaceID) if err != nil { return nil, err } @@ -323,11 +294,7 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s var results []*models.CompletedTaskActivity for rows.Next() { var activity models.CompletedTaskActivity - err := rows.Scan( - &activity.Date, - &activity.CompletedTasks, - ) - if err != nil { + if err := rows.Scan(&activity.Date, &activity.CompletedTasks); err != nil { return nil, err } results = append(results, &activity) @@ -338,15 +305,17 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s // GetRepositoryStats retrieves aggregated statistics for repositories in a workspace func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) { - var startArg interface{} + var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } - query := ` + drv := r.db.DriverName() + dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + + query := fmt.Sprintf(` SELECT - r.id, - r.name, + r.id, r.name, COALESCE(task_stats.total_tasks, 0) as total_tasks, COALESCE(task_stats.completed_tasks, 0) as completed_tasks, COALESCE(task_stats.in_progress_tasks, 0) as in_progress_tasks, @@ -362,8 +331,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, COALESCE(git_stats.total_deletions, 0) as total_deletions FROM repositories r LEFT JOIN ( - SELECT - tr.repository_id, + SELECT tr.repository_id, COUNT(DISTINCT t.id) as total_tasks, COUNT(DISTINCT CASE WHEN ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) THEN t.id END) as completed_tasks, COUNT(DISTINCT CASE WHEN t.state = 'IN_PROGRESS' THEN t.id END) as in_progress_tasks @@ -374,13 +342,12 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, GROUP BY tr.repository_id ) task_stats ON task_stats.repository_id = r.id LEFT JOIN ( - SELECT - tr.repository_id, + SELECT tr.repository_id, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, - COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count + COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count FROM task_repositories tr JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id @@ -389,13 +356,8 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, GROUP BY tr.repository_id ) session_stats ON session_stats.repository_id = r.id LEFT JOIN ( - SELECT - tr.repository_id, - COALESCE(SUM(CASE - WHEN turn.completed_at IS NOT NULL - THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 - ELSE 0 - END), 0) as total_duration_ms + SELECT tr.repository_id, + COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms FROM task_repositories tr JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id @@ -403,8 +365,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, GROUP BY tr.repository_id ) duration_stats ON duration_stats.repository_id = r.id LEFT JOIN ( - SELECT - s.repository_id, + SELECT s.repository_id, COUNT(DISTINCT c.id) as total_commits, COALESCE(SUM(c.files_changed), 0) as total_files_changed, COALESCE(SUM(c.insertions), 0) as total_insertions, @@ -414,18 +375,13 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, WHERE s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) GROUP BY s.repository_id ) git_stats ON git_stats.repository_id = r.id - WHERE r.workspace_id = ? - AND r.deleted_at IS NULL + WHERE r.workspace_id = ? AND r.deleted_at IS NULL ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC - ` + `, dur) - rows, err := r.db.QueryContext( - ctx, - query, - startArg, startArg, - startArg, startArg, - startArg, startArg, - startArg, startArg, + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), + startArg, startArg, startArg, startArg, + startArg, startArg, startArg, startArg, workspaceID, ) if err != nil { @@ -438,21 +394,12 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, var stats models.RepositoryStats var totalDurationMs float64 err := rows.Scan( - &stats.RepositoryID, - &stats.RepositoryName, - &stats.TotalTasks, - &stats.CompletedTasks, - &stats.InProgressTasks, - &stats.SessionCount, - &stats.TurnCount, - &stats.MessageCount, - &stats.UserMessageCount, - &stats.ToolCallCount, - &totalDurationMs, - &stats.TotalCommits, - &stats.TotalFilesChanged, - &stats.TotalInsertions, - &stats.TotalDeletions, + &stats.RepositoryID, &stats.RepositoryName, + &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, + &stats.SessionCount, &stats.TurnCount, &stats.MessageCount, + &stats.UserMessageCount, &stats.ToolCallCount, &totalDurationMs, + &stats.TotalCommits, &stats.TotalFilesChanged, + &stats.TotalInsertions, &stats.TotalDeletions, ) if err != nil { return nil, err @@ -466,32 +413,27 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, // GetAgentUsage retrieves usage statistics per agent profile func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) { - var startArg interface{} + var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } - query := ` + drv := r.db.DriverName() + dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") + jeName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "name") + jeDisplay := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "agent_display_name") + jeModel := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model") + jeModelName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model_name") + jeLLM := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "llm") + + query := fmt.Sprintf(` SELECT s.agent_profile_id, - COALESCE( - json_extract(s.agent_profile_snapshot, '$.name'), - json_extract(s.agent_profile_snapshot, '$.agent_display_name'), - s.agent_profile_id - ) as agent_profile_name, - COALESCE( - json_extract(s.agent_profile_snapshot, '$.model'), - json_extract(s.agent_profile_snapshot, '$.model_name'), - json_extract(s.agent_profile_snapshot, '$.llm'), - '' - ) as agent_model, + COALESCE(%s, %s, s.agent_profile_id) as agent_profile_name, + COALESCE(%s, %s, %s, '') as agent_model, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, - COALESCE(SUM(CASE - WHEN turn.completed_at IS NOT NULL - THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 - ELSE 0 - END), 0) as total_duration_ms + COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms FROM task_sessions s JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id @@ -499,9 +441,9 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi GROUP BY s.agent_profile_id ORDER BY session_count DESC LIMIT ? - ` + `, jeName, jeDisplay, jeModel, jeModelName, jeLLM, dur) - rows, err := r.db.QueryContext(ctx, query, workspaceID, startArg, startArg, limit) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), workspaceID, startArg, startArg, limit) if err != nil { return nil, err } @@ -512,12 +454,8 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi var usage models.AgentUsage var totalDurationMs float64 err := rows.Scan( - &usage.AgentProfileID, - &usage.AgentProfileName, - &usage.AgentModel, - &usage.SessionCount, - &usage.TurnCount, - &totalDurationMs, + &usage.AgentProfileID, &usage.AgentProfileName, &usage.AgentModel, + &usage.SessionCount, &usage.TurnCount, &totalDurationMs, ) if err != nil { return nil, err @@ -531,7 +469,7 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi // GetGitStats retrieves aggregated git statistics for a workspace func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) { - var startArg interface{} + var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } @@ -549,11 +487,9 @@ func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start ` var stats models.GitStats - err := r.db.QueryRowContext(ctx, query, workspaceID, startArg, startArg).Scan( - &stats.TotalCommits, - &stats.TotalFilesChanged, - &stats.TotalInsertions, - &stats.TotalDeletions, + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), workspaceID, startArg, startArg).Scan( + &stats.TotalCommits, &stats.TotalFilesChanged, + &stats.TotalInsertions, &stats.TotalDeletions, ) if err != nil { return nil, err diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 2ccc32591d..ca08656443 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -36,6 +36,8 @@ type ServerConfig struct { // DatabaseConfig holds database connection configuration. type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + Path string `mapstructure:"path"` Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` @@ -161,6 +163,8 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.writeTimeout", 30) // Database defaults + v.SetDefault("database.driver", "sqlite") + v.SetDefault("database.path", "./kandev.db") v.SetDefault("database.host", "localhost") v.SetDefault("database.port", 5432) v.SetDefault("database.user", "kandev") @@ -302,16 +306,16 @@ func validate(cfg *Config) error { errs = append(errs, "server.port must be between 1 and 65535") } - // Database validation - only if host is set (optional for in-memory mode) - if cfg.Database.Host != "" { + // Database validation + if cfg.Database.Driver == "postgres" { if cfg.Database.Port <= 0 || cfg.Database.Port > 65535 { errs = append(errs, "database.port must be between 1 and 65535") } if cfg.Database.User == "" { - errs = append(errs, "database.user is required when database.host is set") + errs = append(errs, "database.user is required for postgres driver") } if cfg.Database.DBName == "" { - errs = append(errs, "database.dbName is required when database.host is set") + errs = append(errs, "database.dbName is required for postgres driver") } } diff --git a/apps/backend/internal/common/sqlite/utils.go b/apps/backend/internal/common/sqlite/utils.go deleted file mode 100644 index 7e60328b6e..0000000000 --- a/apps/backend/internal/common/sqlite/utils.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package sqlite provides common SQLite utility functions. -package sqlite - -import ( - "database/sql" - "fmt" -) - -// BoolToInt converts a boolean to an integer (for SQLite). -func BoolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -// EnsureColumn adds a column to a table if it doesn't exist. -func EnsureColumn(db *sql.DB, table, column, definition string) error { - exists, err := ColumnExists(db, table, column) - if err != nil { - return err - } - if exists { - return nil - } - query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition) - _, err = db.Exec(query) - return err -} - -// ColumnExists checks if a column exists in a table. -func ColumnExists(db *sql.DB, table, column string) (bool, error) { - rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) - if err != nil { - return false, err - } - defer func() { - _ = rows.Close() - }() - - for rows.Next() { - var cid int - var name, colType string - var notNull int - var defaultValue sql.NullString - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { - return false, err - } - if name == column { - return true, nil - } - } - return false, rows.Err() -} - diff --git a/apps/backend/internal/db/dialect/dialect.go b/apps/backend/internal/db/dialect/dialect.go new file mode 100644 index 0000000000..a6a867a1d0 --- /dev/null +++ b/apps/backend/internal/db/dialect/dialect.go @@ -0,0 +1,20 @@ +// Package dialect provides SQL fragment helpers for SQLite/PostgreSQL portability. +package dialect + +const ( + SQLite3 = "sqlite3" + PGX = "pgx" +) + +// IsPostgres returns true if the driver is PostgreSQL (pgx). +func IsPostgres(driver string) bool { + return driver == PGX +} + +// BoolToInt converts a boolean to an integer for SQL storage. +func BoolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/apps/backend/internal/db/dialect/dialect_test.go b/apps/backend/internal/db/dialect/dialect_test.go new file mode 100644 index 0000000000..081bd4a483 --- /dev/null +++ b/apps/backend/internal/db/dialect/dialect_test.go @@ -0,0 +1,174 @@ +package dialect + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" +) + +func TestIsPostgres(t *testing.T) { + if !IsPostgres(PGX) { + t.Error("expected pgx to be postgres") + } + if IsPostgres(SQLite3) { + t.Error("expected sqlite3 to not be postgres") + } +} + +func TestBoolToInt(t *testing.T) { + if BoolToInt(true) != 1 { + t.Error("expected 1 for true") + } + if BoolToInt(false) != 0 { + t.Error("expected 0 for false") + } +} + +func TestJSONExtract(t *testing.T) { + got := JSONExtract(SQLite3, "metadata", "status") + if got != "json_extract(metadata, '$.status')" { + t.Errorf("sqlite: got %q", got) + } + got = JSONExtract(PGX, "metadata", "status") + if got != "metadata::jsonb->>'status'" { + t.Errorf("pgx: got %q", got) + } +} + +func TestJSONExtractIsNotNull(t *testing.T) { + got := JSONExtractIsNotNull(SQLite3, "m", "id") + if got != "json_extract(m, '$.id') IS NOT NULL" { + t.Errorf("sqlite: got %q", got) + } + got = JSONExtractIsNotNull(PGX, "m", "id") + if got != "m::jsonb->>'id' IS NOT NULL" { + t.Errorf("pgx: got %q", got) + } +} + +func TestJSONSet(t *testing.T) { + got := JSONSet(SQLite3, "metadata", "status", "complete") + if got != "json_set(metadata, '$.status', 'complete')" { + t.Errorf("sqlite: got %q", got) + } + got = JSONSet(PGX, "metadata", "status", "complete") + if got != `jsonb_set(metadata::jsonb, '{status}', '"complete"')::text` { + t.Errorf("pgx: got %q", got) + } +} + +func TestDurationMs(t *testing.T) { + got := DurationMs(SQLite3, "completed_at", "started_at") + if got != "(julianday(completed_at) - julianday(started_at)) * 86400000" { + t.Errorf("sqlite: got %q", got) + } + got = DurationMs(PGX, "completed_at", "started_at") + if got != "EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000" { + t.Errorf("pgx: got %q", got) + } +} + +func TestDateOf(t *testing.T) { + got := DateOf(SQLite3, "created_at") + if got != "date(created_at)" { + t.Errorf("sqlite: got %q", got) + } + got = DateOf(PGX, "created_at") + if got != "(created_at)::date" { + t.Errorf("pgx: got %q", got) + } +} + +func TestNow(t *testing.T) { + if Now(SQLite3) != "datetime('now')" { + t.Errorf("sqlite: got %q", Now(SQLite3)) + } + if Now(PGX) != "NOW()" { + t.Errorf("pgx: got %q", Now(PGX)) + } +} + +func TestNowMinusHours(t *testing.T) { + got := NowMinusHours(SQLite3, "ws.hours") + if got != "datetime('now', '-' || ws.hours || ' hours')" { + t.Errorf("sqlite: got %q", got) + } + got = NowMinusHours(PGX, "ws.hours") + if got != "NOW() - (ws.hours || ' hours')::interval" { + t.Errorf("pgx: got %q", got) + } +} + +func TestCurrentDate(t *testing.T) { + if CurrentDate(SQLite3) != "date('now')" { + t.Errorf("sqlite: got %q", CurrentDate(SQLite3)) + } + if CurrentDate(PGX) != "CURRENT_DATE" { + t.Errorf("pgx: got %q", CurrentDate(PGX)) + } +} + +func TestDateNowMinusDays(t *testing.T) { + got := DateNowMinusDays(SQLite3, "?") + if got != "date('now', '-' || ? || ' days')" { + t.Errorf("sqlite: got %q", got) + } + got = DateNowMinusDays(PGX, "?") + if got != "CURRENT_DATE - (? || ' days')::interval" { + t.Errorf("pgx: got %q", got) + } +} + +func TestDatePlusOneDay(t *testing.T) { + got := DatePlusOneDay(SQLite3, "date") + if got != "date(date, '+1 day')" { + t.Errorf("sqlite: got %q", got) + } + got = DatePlusOneDay(PGX, "date") + if got != "(date)::date + INTERVAL '1 day'" { + t.Errorf("pgx: got %q", got) + } +} + +func TestLike(t *testing.T) { + if Like(SQLite3) != "LIKE" { + t.Errorf("sqlite: got %q", Like(SQLite3)) + } + if Like(PGX) != "ILIKE" { + t.Errorf("pgx: got %q", Like(PGX)) + } +} + +func TestInsertReturningID_SQLite(t *testing.T) { + tmpDir := t.TempDir() + rawDB, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + sqlxDB := sqlx.NewDb(rawDB, SQLite3) + t.Cleanup(func() { _ = sqlxDB.Close() }) + + _, err = sqlxDB.Exec(`CREATE TABLE test_insert (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`) + if err != nil { + t.Fatalf("create table: %v", err) + } + + id, err := InsertReturningID(context.Background(), sqlxDB, `INSERT INTO test_insert (name) VALUES (?)`, "hello") + if err != nil { + t.Fatalf("insert: %v", err) + } + if id != 1 { + t.Errorf("expected id 1, got %d", id) + } + + id, err = InsertReturningID(context.Background(), sqlxDB, `INSERT INTO test_insert (name) VALUES (?)`, "world") + if err != nil { + t.Fatalf("insert: %v", err) + } + if id != 2 { + t.Errorf("expected id 2, got %d", id) + } +} diff --git a/apps/backend/internal/db/dialect/insert.go b/apps/backend/internal/db/dialect/insert.go new file mode 100644 index 0000000000..b630e2d2f5 --- /dev/null +++ b/apps/backend/internal/db/dialect/insert.go @@ -0,0 +1,29 @@ +package dialect + +import ( + "context" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// InsertReturningID executes an INSERT and returns the auto-generated ID. +// +// Postgres: appends RETURNING id and scans the result. +// SQLite: uses LastInsertId() from the exec result. +func InsertReturningID(ctx context.Context, db *sqlx.DB, query string, args ...any) (int64, error) { + if IsPostgres(db.DriverName()) { + var id int64 + err := db.QueryRowContext(ctx, db.Rebind(query+" RETURNING id"), args...).Scan(&id) + if err != nil { + return 0, fmt.Errorf("insert returning id: %w", err) + } + return id, nil + } + + result, err := db.ExecContext(ctx, db.Rebind(query), args...) + if err != nil { + return 0, err + } + return result.LastInsertId() +} diff --git a/apps/backend/internal/db/dialect/json.go b/apps/backend/internal/db/dialect/json.go new file mode 100644 index 0000000000..f91f09a2fe --- /dev/null +++ b/apps/backend/internal/db/dialect/json.go @@ -0,0 +1,33 @@ +package dialect + +import "fmt" + +// JSONExtract returns the SQL fragment to extract a JSON value. +// +// SQLite: json_extract(col, '$.path') +// Postgres: col::jsonb->>'path' +func JSONExtract(driver, col, path string) string { + if IsPostgres(driver) { + return fmt.Sprintf("%s::jsonb->>'%s'", col, path) + } + return fmt.Sprintf("json_extract(%s, '$.%s')", col, path) +} + +// JSONExtractIsNotNull returns the SQL fragment to check that a JSON path is not null. +// +// SQLite: json_extract(col, '$.path') IS NOT NULL +// Postgres: col::jsonb->>'path' IS NOT NULL +func JSONExtractIsNotNull(driver, col, path string) string { + return JSONExtract(driver, col, path) + " IS NOT NULL" +} + +// JSONSet returns the SQL fragment to set a JSON value. +// +// SQLite: json_set(col, '$.path', 'value') +// Postgres: jsonb_set(col::jsonb, '{path}', '"value"')::text +func JSONSet(driver, col, path, value string) string { + if IsPostgres(driver) { + return fmt.Sprintf("jsonb_set(%s::jsonb, '{%s}', '\"%s\"')::text", col, path, value) + } + return fmt.Sprintf("json_set(%s, '$.%s', '%s')", col, path, value) +} diff --git a/apps/backend/internal/db/dialect/search.go b/apps/backend/internal/db/dialect/search.go new file mode 100644 index 0000000000..9c3b18d16d --- /dev/null +++ b/apps/backend/internal/db/dialect/search.go @@ -0,0 +1,12 @@ +package dialect + +// Like returns the SQL LIKE operator appropriate for the driver. +// +// SQLite: LIKE (case-insensitive for ASCII by default) +// Postgres: ILIKE (case-insensitive) +func Like(driver string) string { + if IsPostgres(driver) { + return "ILIKE" + } + return "LIKE" +} diff --git a/apps/backend/internal/db/dialect/time.go b/apps/backend/internal/db/dialect/time.go new file mode 100644 index 0000000000..46c8db8651 --- /dev/null +++ b/apps/backend/internal/db/dialect/time.go @@ -0,0 +1,82 @@ +package dialect + +import "fmt" + +// DurationMs returns the SQL expression for the difference between two timestamps in milliseconds. +// +// SQLite: (julianday(end) - julianday(start)) * 86400000 +// Postgres: EXTRACT(EPOCH FROM (end - start)) * 1000 +func DurationMs(driver, end, start string) string { + if IsPostgres(driver) { + return fmt.Sprintf("EXTRACT(EPOCH FROM (%s - %s)) * 1000", end, start) + } + return fmt.Sprintf("(julianday(%s) - julianday(%s)) * 86400000", end, start) +} + +// DateOf returns the SQL expression to extract the date portion from a timestamp. +// +// SQLite: date(expr) +// Postgres: (expr)::date +func DateOf(driver, expr string) string { + if IsPostgres(driver) { + return fmt.Sprintf("(%s)::date", expr) + } + return fmt.Sprintf("date(%s)", expr) +} + +// Now returns the SQL expression for the current timestamp. +// +// SQLite: datetime('now') +// Postgres: NOW() +func Now(driver string) string { + if IsPostgres(driver) { + return "NOW()" + } + return "datetime('now')" +} + +// NowMinusHours returns the SQL expression for "current time minus N hours", +// where hoursExpr is a column or expression producing the number of hours. +// +// SQLite: datetime('now', '-' || hoursExpr || ' hours') +// Postgres: NOW() - (hoursExpr || ' hours')::interval +func NowMinusHours(driver, hoursExpr string) string { + if IsPostgres(driver) { + return fmt.Sprintf("NOW() - (%s || ' hours')::interval", hoursExpr) + } + return fmt.Sprintf("datetime('now', '-' || %s || ' hours')", hoursExpr) +} + +// CurrentDate returns the SQL expression for the current date (no time component). +// +// SQLite: date('now') +// Postgres: CURRENT_DATE +func CurrentDate(driver string) string { + if IsPostgres(driver) { + return "CURRENT_DATE" + } + return "date('now')" +} + +// DateNowMinusDays returns the SQL expression for "current date minus N days", +// where daysExpr is a parameter placeholder (e.g., "?") for the number of days. +// +// SQLite: date('now', '-' || ? || ' days') +// Postgres: CURRENT_DATE - (? || ' days')::interval +func DateNowMinusDays(driver, daysExpr string) string { + if IsPostgres(driver) { + return fmt.Sprintf("CURRENT_DATE - (%s || ' days')::interval", daysExpr) + } + return fmt.Sprintf("date('now', '-' || %s || ' days')", daysExpr) +} + +// DatePlusOneDay returns the SQL expression to add one day to a date expression. +// +// SQLite: date(expr, '+1 day') +// Postgres: (expr)::date + INTERVAL '1 day' +func DatePlusOneDay(driver, expr string) string { + if IsPostgres(driver) { + return fmt.Sprintf("(%s)::date + INTERVAL '1 day'", expr) + } + return fmt.Sprintf("date(%s, '+1 day')", expr) +} diff --git a/apps/backend/internal/db/postgres.go b/apps/backend/internal/db/postgres.go new file mode 100644 index 0000000000..dcde3ef9fc --- /dev/null +++ b/apps/backend/internal/db/postgres.go @@ -0,0 +1,34 @@ +package db + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// OpenPostgres opens a PostgreSQL database connection using pgx. +// If maxConns or minConns are 0, they default to 25 and 5 respectively. +func OpenPostgres(dsn string, maxConns, minConns int) (*sql.DB, error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open postgres database: %w", err) + } + + if maxConns <= 0 { + maxConns = 25 + } + if minConns <= 0 { + minConns = 5 + } + + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(minConns) + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to ping postgres database: %w", err) + } + + return db, nil +} diff --git a/apps/backend/internal/editors/store/provider.go b/apps/backend/internal/editors/store/provider.go index 9a383215a6..a61483886a 100644 --- a/apps/backend/internal/editors/store/provider.go +++ b/apps/backend/internal/editors/store/provider.go @@ -1,9 +1,9 @@ package store -import "database/sql" +import "github.com/jmoiron/sqlx" // Provide creates the SQLite editors store using the shared database connection. -func Provide(db *sql.DB) (*sqliteRepository, func() error, error) { +func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) { repo, err := newSQLiteRepositoryWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/editors/store/sqlite.go b/apps/backend/internal/editors/store/sqlite.go index d8415b4431..28d716d83c 100644 --- a/apps/backend/internal/editors/store/sqlite.go +++ b/apps/backend/internal/editors/store/sqlite.go @@ -10,24 +10,25 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/editors/discovery" "github.com/kandev/kandev/internal/editors/models" ) type sqliteRepository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } var _ Repository = (*sqliteRepository)(nil) -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { +func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(dbConn, false) } -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -67,8 +68,8 @@ func (r *sqliteRepository) initSchema() error { config TEXT, installed INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); ` _, err := r.db.Exec(schema) @@ -146,20 +147,20 @@ func (r *sqliteRepository) ListEditors(ctx context.Context) ([]*models.Editor, e } func (r *sqliteRepository) GetEditorByType(ctx context.Context, editorType string) (*models.Editor, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at FROM editors WHERE type = ? - `, editorType) + `), editorType) return scanEditor(row) } func (r *sqliteRepository) GetEditorByID(ctx context.Context, editorID string) (*models.Editor, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at FROM editors WHERE id = ? - `, editorID) + `), editorID) return scanEditor(row) } @@ -174,7 +175,7 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models. } }() - stmt, err := tx.PrepareContext(ctx, ` + stmt, err := tx.PrepareContext(ctx, r.db.Rebind(` INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(type) DO UPDATE SET @@ -186,7 +187,7 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models. installed = excluded.installed, enabled = excluded.enabled, updated_at = excluded.updated_at - `) + `)) if err != nil { _ = tx.Rollback() return err @@ -219,8 +220,8 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models. editor.Command, editor.Scheme, configValue, - sqlite.BoolToInt(editor.Installed), - sqlite.BoolToInt(editor.Enabled), + dialect.BoolToInt(editor.Installed), + dialect.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt, ) @@ -248,10 +249,10 @@ func (r *sqliteRepository) CreateEditor(ctx context.Context, editor *models.Edit if err != nil { return err } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt) + `), editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, dialect.BoolToInt(editor.Installed), dialect.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt) return err } @@ -264,16 +265,16 @@ func (r *sqliteRepository) UpdateEditor(ctx context.Context, editor *models.Edit if err != nil { return err } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` UPDATE editors SET name = ?, kind = ?, command = ?, scheme = ?, config = ?, installed = ?, enabled = ?, updated_at = ? WHERE id = ? - `, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.UpdatedAt, editor.ID) + `), editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, dialect.BoolToInt(editor.Installed), dialect.BoolToInt(editor.Enabled), editor.UpdatedAt, editor.ID) return err } func (r *sqliteRepository) DeleteEditor(ctx context.Context, editorID string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM editors WHERE id = ?`, editorID) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM editors WHERE id = ?`), editorID) return err } @@ -329,6 +330,6 @@ func (r *sqliteRepository) deleteBuiltinsNotIn(ctx context.Context, types map[st `DELETE FROM editors WHERE kind = 'built_in' AND type NOT IN (%s)`, strings.Join(placeholders, ","), ) - _, err := r.db.ExecContext(ctx, query, args...) + _, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) return err } diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 6e61539e68..d0dd0506d0 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -65,11 +66,12 @@ func NewTestServer(t *testing.T) *TestServer { tmpDir := t.TempDir() dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) require.NoError(t, err) - taskRepoImpl, cleanup, err := repository.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + taskRepoImpl, cleanup, err := repository.Provide(sqlxDB) require.NoError(t, err) taskRepo := repository.Repository(taskRepoImpl) t.Cleanup(func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if cleanup != nil { @@ -78,12 +80,12 @@ func NewTestServer(t *testing.T) *TestServer { } } }) - if _, err := worktree.NewSQLiteStore(dbConn); err != nil { + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { t.Fatalf("failed to init worktree store: %v", err) } // Initialize workflow service - _, workflowSvc, _, err := workflow.Provide(dbConn, log) + _, workflowSvc, _, err := workflow.Provide(sqlxDB, log) require.NoError(t, err) // Initialize task service and wire workflow step creator diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index ac4524fabf..db0460706f 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -637,11 +638,12 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { tmpDir := t.TempDir() dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) require.NoError(t, err) - taskRepoImpl, cleanup, err := repository.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + taskRepoImpl, cleanup, err := repository.Provide(sqlxDB) require.NoError(t, err) taskRepo := repository.Repository(taskRepoImpl) t.Cleanup(func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if cleanup != nil { @@ -650,12 +652,12 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { } } }) - if _, err := worktree.NewSQLiteStore(dbConn); err != nil { + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { t.Fatalf("failed to init worktree store: %v", err) } // Initialize workflow service - _, workflowSvc, _, err := workflow.Provide(dbConn, log) + _, workflowSvc, _, err := workflow.Provide(sqlxDB, log) require.NoError(t, err) // Initialize task service and wire workflow step creator diff --git a/apps/backend/internal/notifications/store/provider.go b/apps/backend/internal/notifications/store/provider.go index 1046192ec3..57674a7089 100644 --- a/apps/backend/internal/notifications/store/provider.go +++ b/apps/backend/internal/notifications/store/provider.go @@ -1,9 +1,9 @@ package store -import "database/sql" +import "github.com/jmoiron/sqlx" // Provide creates the SQLite notifications store using the shared database connection. -func Provide(db *sql.DB) (*sqliteRepository, func() error, error) { +func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) { repo, err := newSQLiteRepositoryWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/notifications/store/sqlite.go b/apps/backend/internal/notifications/store/sqlite.go index db564e7bc9..08f55beb7b 100644 --- a/apps/backend/internal/notifications/store/sqlite.go +++ b/apps/backend/internal/notifications/store/sqlite.go @@ -2,29 +2,29 @@ package store import ( "context" - "database/sql" "encoding/json" "fmt" "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/notifications/models" ) type sqliteRepository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } var _ Repository = (*sqliteRepository)(nil) -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { +func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(dbConn, false) } -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -53,8 +53,8 @@ func (r *sqliteRepository) initSchema() error { type TEXT NOT NULL, config TEXT DEFAULT '{}', enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS notification_subscriptions ( @@ -63,8 +63,8 @@ func (r *sqliteRepository) initSchema() error { provider_id TEXT NOT NULL, event_type TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, UNIQUE(provider_id, event_type), FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE ); @@ -75,7 +75,7 @@ func (r *sqliteRepository) initSchema() error { provider_id TEXT NOT NULL, event_type TEXT NOT NULL, task_session_id TEXT NOT NULL, - created_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, UNIQUE(provider_id, event_type, task_session_id) ); @@ -101,10 +101,10 @@ func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models. if err != nil { return fmt.Errorf("failed to serialize provider config: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt) + `), provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt) return err } @@ -117,30 +117,30 @@ func (r *sqliteRepository) UpdateProvider(ctx context.Context, provider *models. if err != nil { return fmt.Errorf("failed to serialize provider config: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` UPDATE notification_providers SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ? WHERE id = ? - `, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID) + `), provider.Name, provider.Type, string(configJSON), dialect.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID) return err } func (r *sqliteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, user_id, name, type, config, enabled, created_at, updated_at FROM notification_providers WHERE id = ? - `, id) + `), id) return scanProvider(row) } func (r *sqliteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, user_id, name, type, config, enabled, created_at, updated_at FROM notification_providers WHERE user_id = ? ORDER BY created_at ASC - `, userID) + `), userID) if err != nil { return nil, err } @@ -163,17 +163,17 @@ func (r *sqliteRepository) ListProvidersByUser(ctx context.Context, userID strin } func (r *sqliteRepository) DeleteProvider(ctx context.Context, id string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM notification_providers WHERE id = ?`, id) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_providers WHERE id = ?`), id) return err } func (r *sqliteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at FROM notification_subscriptions WHERE provider_id = ? ORDER BY created_at ASC - `, providerID) + `), providerID) if err != nil { return nil, err } @@ -201,17 +201,17 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID, return err } - if _, err := tx.ExecContext(ctx, `DELETE FROM notification_subscriptions WHERE provider_id = ?`, providerID); err != nil { + if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM notification_subscriptions WHERE provider_id = ?`), providerID); err != nil { _ = tx.Rollback() return err } now := time.Now().UTC() for _, eventType := range events { subID := uuid.New().String() - if _, err := tx.ExecContext(ctx, ` + if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, subID, userID, providerID, eventType, sqlite.BoolToInt(true), now, now); err != nil { + `), subID, userID, providerID, eventType, dialect.BoolToInt(true), now, now); err != nil { _ = tx.Rollback() return err } @@ -225,10 +225,11 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID, func (r *sqliteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) { delivery.ID = uuid.New().String() delivery.CreatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` - INSERT OR IGNORE INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at) + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at) VALUES (?, ?, ?, ?, ?, ?) - `, delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt) + ON CONFLICT DO NOTHING + `), delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt) if err != nil { return false, err } @@ -240,10 +241,10 @@ func (r *sqliteRepository) InsertDelivery(ctx context.Context, delivery *models. } func (r *sqliteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` DELETE FROM notification_deliveries WHERE provider_id = ? AND event_type = ? AND task_session_id = ? - `, providerID, eventType, taskSessionID) + `), providerID, eventType, taskSessionID) return err } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 7dd63faa68..d52fbe0ecf 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/kandev/kandev/internal/db" @@ -132,9 +133,10 @@ func setupTestRepo(t *testing.T) repository.Repository { if err != nil { t.Fatalf("failed to open test database: %v", err) } - t.Cleanup(func() { _ = dbConn.Close() }) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) - repo, cleanup, err := repository.Provide(dbConn) + repo, cleanup, err := repository.Provide(sqlxDB) if err != nil { t.Fatalf("failed to create test repository: %v", err) } diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 031ca9ffcb..3732443491 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/orchestrator/executor" @@ -139,13 +140,14 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo if err != nil { t.Fatalf("failed to open test database: %v", err) } - repoImpl, cleanup, err := repository.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repoImpl, cleanup, err := repository.Provide(sqlxDB) if err != nil { t.Fatalf("failed to create test repository: %v", err) } repo := repository.Repository(repoImpl) t.Cleanup(func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := cleanup(); err != nil { diff --git a/apps/backend/internal/persistence/provider.go b/apps/backend/internal/persistence/provider.go index c6d3eab5d0..33634f08d7 100644 --- a/apps/backend/internal/persistence/provider.go +++ b/apps/backend/internal/persistence/provider.go @@ -1,10 +1,9 @@ package persistence import ( - "database/sql" "fmt" - "os" + "github.com/jmoiron/sqlx" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/config" @@ -13,25 +12,26 @@ import ( ) // Provide creates the database connection used by repositories. -func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, error) { - _ = cfg - - driver := os.Getenv("KANDEV_DB_DRIVER") +func Provide(cfg *config.Config, log *logger.Logger) (*sqlx.DB, func() error, error) { + driver := cfg.Database.Driver if driver == "" { driver = "sqlite" } - dbPath := os.Getenv("KANDEV_DB_PATH") - if dbPath == "" { - dbPath = "./kandev.db" - } - switch driver { case "sqlite": + dbPath := cfg.Database.Path + if dbPath == "" { + dbPath = "./kandev.db" + } + dbConn, err := db.OpenSQLite(dbPath) if err != nil { return nil, nil, fmt.Errorf("failed to open sqlite database: %w", err) } + + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + if log != nil { log.Info("Database initialized", zap.String("db_path", dbPath), zap.String("db_driver", driver)) } @@ -39,10 +39,28 @@ func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, err // Run PRAGMA optimize before closing to update query planner // statistics for tables that need it. This is the SQLite-recommended // way to maintain stats — lightweight and safe to call on every close. - _, _ = dbConn.Exec("PRAGMA optimize") - return dbConn.Close() + _, _ = sqlxDB.Exec("PRAGMA optimize") + return sqlxDB.Close() } - return dbConn, cleanup, nil + return sqlxDB, cleanup, nil + + case "postgres": + dsn := cfg.Database.DSN() + dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns) + if err != nil { + return nil, nil, fmt.Errorf("failed to open postgres database: %w", err) + } + + sqlxDB := sqlx.NewDb(dbConn, "pgx") + + if log != nil { + log.Info("Database initialized", zap.String("db_driver", driver)) + } + cleanup := func() error { + return sqlxDB.Close() + } + return sqlxDB, cleanup, nil + default: return nil, nil, fmt.Errorf("unsupported database driver: %s", driver) } diff --git a/apps/backend/internal/prompts/service/service_test.go b/apps/backend/internal/prompts/service/service_test.go index e396ec9f49..684bbff78d 100644 --- a/apps/backend/internal/prompts/service/service_test.go +++ b/apps/backend/internal/prompts/service/service_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" promptstore "github.com/kandev/kandev/internal/prompts/store" ) @@ -16,12 +17,13 @@ func createService(t *testing.T) (*Service, func()) { if err != nil { t.Fatalf("open sqlite: %v", err) } - repoImpl, repoCleanup, err := promptstore.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repoImpl, repoCleanup, err := promptstore.Provide(sqlxDB) if err != nil { t.Fatalf("create repo: %v", err) } cleanup := func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("close sqlite: %v", err) } if err := repoCleanup(); err != nil { diff --git a/apps/backend/internal/prompts/store/provider.go b/apps/backend/internal/prompts/store/provider.go index cbfa5cda30..65c9f810d8 100644 --- a/apps/backend/internal/prompts/store/provider.go +++ b/apps/backend/internal/prompts/store/provider.go @@ -1,9 +1,9 @@ package store -import "database/sql" +import "github.com/jmoiron/sqlx" // Provide creates the SQLite prompts store using the shared database connection. -func Provide(db *sql.DB) (*sqliteRepository, func() error, error) { +func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) { repo, err := newSQLiteRepositoryWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/prompts/store/sqlite.go b/apps/backend/internal/prompts/store/sqlite.go index 8c6d1e0533..623424a227 100644 --- a/apps/backend/internal/prompts/store/sqlite.go +++ b/apps/backend/internal/prompts/store/sqlite.go @@ -9,20 +9,21 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/prompts/models" ) type sqliteRepository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { +func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(dbConn, false) } -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -42,8 +43,8 @@ func (r *sqliteRepository) initSchema() error { name TEXT NOT NULL UNIQUE, content TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); ` if _, err := r.db.Exec(schema); err != nil { @@ -95,11 +96,11 @@ func (r *sqliteRepository) ListPrompts(ctx context.Context) ([]*models.Prompt, e } func (r *sqliteRepository) GetPromptByID(ctx context.Context, id string) (*models.Prompt, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, content, builtin, created_at, updated_at FROM custom_prompts WHERE id = ? - `, id) + `), id) prompt := &models.Prompt{} var builtinInt int if err := row.Scan(&prompt.ID, &prompt.Name, &prompt.Content, &builtinInt, &prompt.CreatedAt, &prompt.UpdatedAt); err != nil { @@ -110,11 +111,11 @@ func (r *sqliteRepository) GetPromptByID(ctx context.Context, id string) (*model } func (r *sqliteRepository) GetPromptByName(ctx context.Context, name string) (*models.Prompt, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, content, builtin, created_at, updated_at FROM custom_prompts WHERE name = ? - `, name) + `), name) prompt := &models.Prompt{} var builtinInt int if err := row.Scan(&prompt.ID, &prompt.Name, &prompt.Content, &builtinInt, &prompt.CreatedAt, &prompt.UpdatedAt); err != nil { @@ -140,10 +141,10 @@ func (r *sqliteRepository) CreatePrompt(ctx context.Context, prompt *models.Prom builtinInt = 1 } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `, prompt.ID, prompt.Name, prompt.Content, builtinInt, prompt.CreatedAt, prompt.UpdatedAt) + `), prompt.ID, prompt.Name, prompt.Content, builtinInt, prompt.CreatedAt, prompt.UpdatedAt) return err } @@ -154,16 +155,16 @@ func (r *sqliteRepository) UpdatePrompt(ctx context.Context, prompt *models.Prom prompt.Name = strings.TrimSpace(prompt.Name) prompt.Content = strings.TrimSpace(prompt.Content) prompt.UpdatedAt = time.Now().UTC() - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE custom_prompts SET name = ?, content = ?, updated_at = ? WHERE id = ? - `, prompt.Name, prompt.Content, prompt.UpdatedAt, prompt.ID) + `), prompt.Name, prompt.Content, prompt.UpdatedAt, prompt.ID) return err } func (r *sqliteRepository) DeletePrompt(ctx context.Context, id string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM custom_prompts WHERE id = ?`, id) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM custom_prompts WHERE id = ?`), id) return err } @@ -174,7 +175,7 @@ func (r *sqliteRepository) seedBuiltinPrompts() error { for _, prompt := range builtinPrompts { // Check if prompt already exists var exists bool - err := r.db.QueryRow("SELECT 1 FROM custom_prompts WHERE id = ?", prompt.ID).Scan(&exists) + err := r.db.QueryRow(r.db.Rebind("SELECT 1 FROM custom_prompts WHERE id = ?"), prompt.ID).Scan(&exists) if err != nil && err != sql.ErrNoRows { return err } @@ -183,10 +184,10 @@ func (r *sqliteRepository) seedBuiltinPrompts() error { } // Insert built-in prompt - _, err = r.db.Exec(` + _, err = r.db.Exec(r.db.Rebind(` INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `, prompt.ID, prompt.Name, prompt.Content, 1, prompt.CreatedAt, prompt.UpdatedAt) + `), prompt.ID, prompt.Name, prompt.Content, 1, prompt.CreatedAt, prompt.UpdatedAt) if err != nil { return fmt.Errorf("failed to insert built-in prompt %s: %w", prompt.ID, err) } diff --git a/apps/backend/internal/prompts/store/sqlite_test.go b/apps/backend/internal/prompts/store/sqlite_test.go index eb9cb29858..94556d9238 100644 --- a/apps/backend/internal/prompts/store/sqlite_test.go +++ b/apps/backend/internal/prompts/store/sqlite_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/prompts/models" ) @@ -16,12 +17,13 @@ func createTestRepo(t *testing.T) (*sqliteRepository, func()) { if err != nil { t.Fatalf("failed to open sqlite db: %v", err) } - repo, err := newSQLiteRepositoryWithDB(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := newSQLiteRepositoryWithDB(sqlxDB) if err != nil { t.Fatalf("failed to create repo: %v", err) } cleanup := func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := repo.Close(); err != nil { diff --git a/apps/backend/internal/task/repository/provider.go b/apps/backend/internal/task/repository/provider.go index 019c114db3..eccb8a5308 100644 --- a/apps/backend/internal/task/repository/provider.go +++ b/apps/backend/internal/task/repository/provider.go @@ -1,13 +1,13 @@ package repository import ( - "database/sql" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/task/repository/sqlite" ) // Provide creates the SQLite repository using the shared database connection. -func Provide(db *sql.DB) (Repository, func() error, error) { +func Provide(db *sqlx.DB) (Repository, func() error, error) { repo, err := sqlite.NewWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index aa148f4888..759c5d11a2 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -4,20 +4,22 @@ package sqlite import ( "database/sql" "fmt" + + "github.com/jmoiron/sqlx" ) // Repository provides SQLite-based task storage operations. type Repository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } // NewWithDB creates a new SQLite repository with an existing database connection (shared ownership). -func NewWithDB(dbConn *sql.DB) (*Repository, error) { +func NewWithDB(dbConn *sqlx.DB) (*Repository, error) { return newRepository(dbConn, false) } -func newRepository(dbConn *sql.DB, ownsDB bool) (*Repository, error) { +func newRepository(dbConn *sqlx.DB, ownsDB bool) (*Repository, error) { repo := &Repository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -40,7 +42,7 @@ func (r *Repository) Close() error { // DB returns the underlying sql.DB instance for shared access func (r *Repository) DB() *sql.DB { - return r.db + return r.db.DB } // ensureWorkspaceIndexes creates workspace-related indexes @@ -65,8 +67,8 @@ func (r *Repository) initSchema() error { default_executor_id TEXT DEFAULT '', default_environment_id TEXT DEFAULT '', default_agent_profile_id TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS executors ( @@ -77,9 +79,9 @@ func (r *Repository) initSchema() error { is_system INTEGER NOT NULL DEFAULT 0, resumable INTEGER NOT NULL DEFAULT 1, config TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS executors_running ( @@ -99,10 +101,10 @@ func (r *Repository) initSchema() error { worktree_id TEXT DEFAULT '', worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', - last_seen_at DATETIME, + last_seen_at TIMESTAMP, error_message TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS environments ( @@ -114,9 +116,9 @@ func (r *Repository) initSchema() error { image_tag TEXT DEFAULT '', dockerfile TEXT DEFAULT '', build_config TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS workflows ( @@ -125,8 +127,8 @@ func (r *Repository) initSchema() error { workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, description TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS tasks ( @@ -140,8 +142,9 @@ func (r *Repository) initSchema() error { priority INTEGER DEFAULT 0, position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE ); @@ -152,8 +155,8 @@ func (r *Repository) initSchema() error { base_branch TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, UNIQUE(task_id, repository_id) @@ -175,9 +178,9 @@ func (r *Repository) initSchema() error { setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', dev_script TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); @@ -187,13 +190,14 @@ func (r *Repository) initSchema() error { name TEXT NOT NULL, command TEXT NOT NULL, position INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id); CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id); + CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at); CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id); CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); @@ -212,8 +216,8 @@ func (r *Repository) initSchema() error { title TEXT NOT NULL DEFAULT 'Plan', content TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT 'agent', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id); @@ -235,7 +239,7 @@ func (r *Repository) initSchema() error { requests_input INTEGER DEFAULT 0, type TEXT NOT NULL DEFAULT 'message', metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE ); @@ -249,11 +253,11 @@ func (r *Repository) initSchema() error { id TEXT PRIMARY KEY, task_session_id TEXT NOT NULL, task_id TEXT NOT NULL, - started_at DATETIME NOT NULL, - completed_at DATETIME, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); @@ -278,11 +282,12 @@ func (r *Repository) initSchema() error { state TEXT NOT NULL DEFAULT 'CREATED', error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', - started_at DATETIME NOT NULL, - completed_at DATETIME, - updated_at DATETIME NOT NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, is_primary INTEGER DEFAULT 0, workflow_step_id TEXT DEFAULT '', + is_passthrough INTEGER DEFAULT 0, review_status TEXT DEFAULT '', FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); @@ -300,10 +305,10 @@ func (r *Repository) initSchema() error { worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - merged_at DATETIME, - deleted_at DATETIME, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, UNIQUE(session_id, worktree_id) ); @@ -333,7 +338,7 @@ func (r *Repository) initSchema() error { files TEXT DEFAULT '{}', triggered_by TEXT DEFAULT '', metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); @@ -348,13 +353,13 @@ func (r *Repository) initSchema() error { author_name TEXT DEFAULT '', author_email TEXT DEFAULT '', commit_message TEXT DEFAULT '', - committed_at DATETIME NOT NULL, + committed_at TIMESTAMP NOT NULL, pre_commit_snapshot_id TEXT DEFAULT '', post_commit_snapshot_id TEXT DEFAULT '', files_changed INTEGER DEFAULT 0, insertions INTEGER DEFAULT 0, deletions INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); @@ -373,9 +378,9 @@ func (r *Repository) initSchema() error { file_path TEXT NOT NULL, reviewed INTEGER NOT NULL DEFAULT 0, diff_hash TEXT NOT NULL DEFAULT '', - reviewed_at DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + reviewed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, UNIQUE(session_id, file_path) ); @@ -398,58 +403,6 @@ func (r *Repository) initSchema() error { return err } - if err := r.ensureArchivedAtColumn(); err != nil { - return err - } - - if err := r.ensureIsPassthroughColumn(); err != nil { - return err - } - return nil } -// ensureColumnExists checks if a column exists on a table and runs the provided -// SQL statements to add it if missing. -func (r *Repository) ensureColumnExists(table, column string, migrationSQL ...string) error { - rows, err := r.db.Query("PRAGMA table_info(" + table + ")") - if err != nil { - return err - } - defer func() { _ = rows.Close() }() - - for rows.Next() { - var cid int - var name, colType string - var notNull int - var dfltValue sql.NullString - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return err - } - if name == column { - return nil - } - } - if err := rows.Err(); err != nil { - return err - } - - for _, stmt := range migrationSQL { - if _, err := r.db.Exec(stmt); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureIsPassthroughColumn() error { - return r.ensureColumnExists("task_sessions", "is_passthrough", - "ALTER TABLE task_sessions ADD COLUMN is_passthrough INTEGER DEFAULT 0") -} - -func (r *Repository) ensureArchivedAtColumn() error { - return r.ensureColumnExists("tasks", "archived_at", - "ALTER TABLE tasks ADD COLUMN archived_at DATETIME", - "CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)") -} diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index 6529c01a2a..c7a6fa50c3 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" "github.com/kandev/kandev/internal/common/config" - commonsqlite "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -39,7 +39,7 @@ func (r *Repository) ensureDefaultWorkspace() error { workspaceName = "Migrated Workspace" workspaceDescription = "" } - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workspaces ( id, name, @@ -52,16 +52,16 @@ func (r *Repository) ensureDefaultWorkspace() error { updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil { + `), defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil { return err } if workflowCount == 0 && taskCount == 0 { workflowID := uuid.New().String() - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil { + `), workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil { return err } // Note: Workflow steps will be created by the workflow repository after it initializes @@ -73,9 +73,9 @@ func (r *Repository) ensureDefaultWorkspace() error { return err } - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workflows SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL - `, defaultWorkspaceID); err != nil { + `), defaultWorkspaceID); err != nil { return err } @@ -148,19 +148,19 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { if err != nil { return fmt.Errorf("failed to serialize executor config: %w", err) } - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, executor.id, executor.name, executor.execType, executor.status, commonsqlite.BoolToInt(executor.isSystem), commonsqlite.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil { + `), executor.id, executor.name, executor.execType, executor.status, dialect.BoolToInt(executor.isSystem), dialect.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil { return err } } } else { // Ensure system executors have is_system flag set for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} { - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE executors SET is_system = 1 WHERE id = ? - `, systemID); err != nil { + `), systemID); err != nil { return err } } @@ -172,41 +172,41 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { } if envCount == 0 { now := time.Now().UTC() - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { + `), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { return err } } else { var localCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments WHERE id = ?", models.EnvironmentIDLocal).Scan(&localCount); err != nil { + if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM environments WHERE id = ?"), models.EnvironmentIDLocal).Scan(&localCount); err != nil { return err } if localCount == 0 { now := time.Now().UTC() - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { + `), models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, dialect.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { return err } } - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE environments SET is_system = 1, image_tag = '', dockerfile = '', build_config = '{}' WHERE id = ? - `, models.EnvironmentIDLocal); err != nil { + `), models.EnvironmentIDLocal); err != nil { return err } - if _, err := r.db.ExecContext(ctx, ` + if _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE environments SET worktree_root = ? WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '') - `, "~/kandev", models.EnvironmentIDLocal); err != nil { + `), "~/kandev", models.EnvironmentIDLocal); err != nil { return err } } diff --git a/apps/backend/internal/task/repository/sqlite/environment.go b/apps/backend/internal/task/repository/sqlite/environment.go index d51f637dfa..06f09883d5 100644 --- a/apps/backend/internal/task/repository/sqlite/environment.go +++ b/apps/backend/internal/task/repository/sqlite/environment.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" - commonsqlite "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -27,10 +27,10 @@ func (r *Repository) CreateEnvironment(ctx context.Context, environment *models. return fmt.Errorf("failed to serialize environment build config: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, environment.ID, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt) + `), environment.ID, environment.Name, environment.Kind, dialect.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt) return err } @@ -40,10 +40,10 @@ func (r *Repository) GetEnvironment(ctx context.Context, id string) (*models.Env var buildConfigJSON string var isSystem int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at FROM environments WHERE id = ? AND deleted_at IS NULL - `, id).Scan( + `), id).Scan( &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, &environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt, @@ -72,10 +72,10 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models. return fmt.Errorf("failed to serialize environment build config: %w", err) } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) + `), environment.Name, environment.Kind, dialect.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) if err != nil { return err } @@ -89,9 +89,9 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models. // DeleteEnvironment soft-deletes an environment by ID func (r *Repository) DeleteEnvironment(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE environments SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) + `), now, now, id) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 9076c40874..dd4446a168 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" - commonsqlite "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -28,10 +28,10 @@ func (r *Repository) CreateExecutor(ctx context.Context, executor *models.Execut return fmt.Errorf("failed to serialize executor config: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, executor.ID, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt) + `), executor.ID, executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt) return err } @@ -41,10 +41,10 @@ func (r *Repository) GetExecutor(ctx context.Context, id string) (*models.Execut var isSystem int var resumable int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at FROM executors WHERE id = ? AND deleted_at IS NULL - `, id).Scan( + `), id).Scan( &executor.ID, &executor.Name, &executor.Type, &executor.Status, &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ) @@ -73,10 +73,10 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut return fmt.Errorf("failed to serialize executor config: %w", err) } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID) + `), executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID) if err != nil { return err } @@ -89,9 +89,9 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut func (r *Repository) DeleteExecutor(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE executors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) + `), now, now, id) if err != nil { return err } @@ -152,7 +152,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. } running.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO executors_running ( id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, @@ -178,14 +178,14 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. last_seen_at = excluded.last_seen_at, error_message = excluded.error_message, updated_at = excluded.updated_at - `, + `), running.ID, running.SessionID, running.TaskID, running.ExecutorID, running.Runtime, running.Status, - commonsqlite.BoolToInt(running.Resumable), + dialect.BoolToInt(running.Resumable), running.ResumeToken, running.AgentExecutionID, running.ContainerID, @@ -256,14 +256,14 @@ func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionI var resumable int var lastSeen sql.NullTime - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, created_at, updated_at FROM executors_running WHERE session_id = ? - `, sessionID).Scan( + `), sessionID).Scan( &running.ID, &running.SessionID, &running.TaskID, @@ -302,7 +302,7 @@ func (r *Repository) DeleteExecutorRunningBySessionID(ctx context.Context, sessi if sessionID == "" { return fmt.Errorf("session_id is required") } - result, err := r.db.ExecContext(ctx, `DELETE FROM executors_running WHERE session_id = ?`, sessionID) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM executors_running WHERE session_id = ?`), sessionID) if err != nil { return err } @@ -315,11 +315,11 @@ func (r *Repository) DeleteExecutorRunningBySessionID(ctx context.Context, sessi func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { var exists int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT 1 FROM task_sessions WHERE executor_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') LIMIT 1 - `, executorID).Scan(&exists) + `), executorID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } diff --git a/apps/backend/internal/task/repository/sqlite/git_snapshots.go b/apps/backend/internal/task/repository/sqlite/git_snapshots.go index 5363d1b974..db3522c4d1 100644 --- a/apps/backend/internal/task/repository/sqlite/git_snapshots.go +++ b/apps/backend/internal/task/repository/sqlite/git_snapshots.go @@ -39,12 +39,12 @@ func (r *Repository) CreateGitSnapshot(ctx context.Context, snapshot *models.Git metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_git_snapshots ( id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, ahead, behind, files, triggered_by, metadata, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch, + `), snapshot.ID, snapshot.SessionID, string(snapshot.SnapshotType), snapshot.Branch, snapshot.RemoteBranch, snapshot.HeadCommit, snapshot.BaseCommit, snapshot.Ahead, snapshot.Behind, filesJSON, snapshot.TriggeredBy, metadataJSON, snapshot.CreatedAt) @@ -59,13 +59,13 @@ func (r *Repository) GetLatestGitSnapshot(ctx context.Context, sessionID string) var filesJSON string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, ahead, behind, files, triggered_by, metadata, created_at FROM task_session_git_snapshots WHERE session_id = ? ORDER BY created_at DESC LIMIT 1 - `, sessionID).Scan( + `), sessionID).Scan( &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, @@ -98,13 +98,13 @@ func (r *Repository) GetFirstGitSnapshot(ctx context.Context, sessionID string) var filesJSON string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, session_id, snapshot_type, branch, remote_branch, head_commit, base_commit, ahead, behind, files, triggered_by, metadata, created_at FROM task_session_git_snapshots WHERE session_id = ? ORDER BY created_at ASC LIMIT 1 - `, sessionID).Scan( + `), sessionID).Scan( &snapshot.ID, &snapshot.SessionID, &snapshotType, &snapshot.Branch, &snapshot.RemoteBranch, &snapshot.HeadCommit, &snapshot.BaseCommit, &snapshot.Ahead, &snapshot.Behind, &filesJSON, &snapshot.TriggeredBy, @@ -144,7 +144,7 @@ func (r *Repository) GetGitSnapshotsBySession(ctx context.Context, sessionID str query += fmt.Sprintf(" LIMIT %d", limit) } - rows, err := r.db.QueryContext(ctx, query, sessionID) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), sessionID) if err != nil { return nil, err } @@ -197,13 +197,13 @@ func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.Ses commit.CreatedAt = time.Now().UTC() } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_commits ( id, session_id, commit_sha, parent_sha, author_name, author_email, commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, files_changed, insertions, deletions, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA, + `), commit.ID, commit.SessionID, commit.CommitSHA, commit.ParentSHA, commit.AuthorName, commit.AuthorEmail, commit.CommitMessage, commit.CommittedAt, commit.PreCommitSnapshotID, commit.PostCommitSnapshotID, commit.FilesChanged, commit.Insertions, commit.Deletions, commit.CreatedAt) @@ -214,14 +214,14 @@ func (r *Repository) CreateSessionCommit(ctx context.Context, commit *models.Ses // GetSessionCommits retrieves all commits for a session, ordered by committed_at descending. // Returns an empty slice if no commits are found. func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, session_id, commit_sha, parent_sha, author_name, author_email, commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, files_changed, insertions, deletions, created_at FROM task_session_commits WHERE session_id = ? ORDER BY committed_at DESC - `, sessionID) + `), sessionID) if err != nil { return nil, err } @@ -253,14 +253,14 @@ func (r *Repository) GetSessionCommits(ctx context.Context, sessionID string) ([ func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { commit := &models.SessionCommit{} - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, session_id, commit_sha, parent_sha, author_name, author_email, commit_message, committed_at, pre_commit_snapshot_id, post_commit_snapshot_id, files_changed, insertions, deletions, created_at FROM task_session_commits WHERE session_id = ? ORDER BY committed_at DESC LIMIT 1 - `, sessionID).Scan( + `), sessionID).Scan( &commit.ID, &commit.SessionID, &commit.CommitSHA, &commit.ParentSHA, &commit.AuthorName, &commit.AuthorEmail, &commit.CommitMessage, &commit.CommittedAt, &commit.PreCommitSnapshotID, &commit.PostCommitSnapshotID, @@ -275,6 +275,6 @@ func (r *Repository) GetLatestSessionCommit(ctx context.Context, sessionID strin // DeleteSessionCommit removes a commit record from the database. func (r *Repository) DeleteSessionCommit(ctx context.Context, id string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM task_session_commits WHERE id = ?`, id) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_commits WHERE id = ?`), id) return err } diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index e3b1ed1a48..4a38dff3a4 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -45,10 +46,10 @@ func (r *Repository) CreateMessage(ctx context.Context, message *models.Message) metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt) + `), message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt) return err } @@ -59,10 +60,10 @@ func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message var requestsInput int var messageType string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at FROM task_session_messages WHERE id = ? - `, id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) + `), id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err } @@ -80,10 +81,10 @@ func (r *Repository) GetMessage(ctx context.Context, id string) (*models.Message // ListMessages returns all messages for a session ordered by creation time. func (r *Repository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at FROM task_session_messages WHERE task_session_id = ? ORDER BY created_at ASC - `, sessionID) + `), sessionID) if err != nil { return nil, err } @@ -168,7 +169,7 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string args = append(args, limit+1) } - rows, err := r.db.QueryContext(ctx, query, args...) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) if err != nil { return nil, false, err } @@ -208,7 +209,7 @@ func (r *Repository) ListMessagesPaginated(ctx context.Context, sessionID string // DeleteMessage deletes a message by ID func (r *Repository) DeleteMessage(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_messages WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_messages WHERE id = ?`), id) if err != nil { return err } @@ -228,11 +229,13 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool var requestsInput int var messageType string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + drv := r.db.DriverName() + query := fmt.Sprintf(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ? + FROM task_session_messages WHERE task_session_id = ? AND %s = ? ORDER BY created_at ASC LIMIT 1 - `, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + `, dialect.JSONExtract(drv, "metadata", "tool_call_id")) + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err @@ -251,10 +254,12 @@ func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendi var requestsInput int var messageType string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + drv := r.db.DriverName() + query := fmt.Sprintf(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.pending_id') = ? - `, sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + FROM task_session_messages WHERE task_session_id = ? AND %s = ? + `, dialect.JSONExtract(drv, "metadata", "pending_id")) + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err @@ -274,11 +279,13 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin var requestsInput int var messageType string var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + drv := r.db.DriverName() + query := fmt.Sprintf(` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE json_extract(metadata, '$.pending_id') = ? + FROM task_session_messages WHERE %s = ? ORDER BY created_at DESC LIMIT 1 - `, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + `, dialect.JSONExtract(drv, "metadata", "pending_id")) + err := r.db.QueryRowContext(ctx, r.db.Rebind(query), pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err @@ -294,13 +301,17 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin // CompleteRunningToolCallsForTurn marks all "running" tool call messages for a turn as "complete". // This is a safety net to ensure no tool calls remain stuck in "running" state after a turn completes. func (r *Repository) CompleteRunningToolCallsForTurn(ctx context.Context, turnID string) (int64, error) { - result, err := r.db.ExecContext(ctx, ` + drv := r.db.DriverName() + query := fmt.Sprintf(` UPDATE task_session_messages - SET metadata = json_set(metadata, '$.status', 'complete') + SET metadata = %s WHERE turn_id = ? - AND json_extract(metadata, '$.status') = 'running' - AND json_extract(metadata, '$.tool_call_id') IS NOT NULL - `, turnID) + AND %s = 'running' + AND %s + `, dialect.JSONSet(drv, "metadata", "status", "complete"), + dialect.JSONExtract(drv, "metadata", "status"), + dialect.JSONExtractIsNotNull(drv, "metadata", "tool_call_id")) + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), turnID) if err != nil { return 0, fmt.Errorf("failed to complete running tool calls for turn %s: %w", turnID, err) } @@ -321,10 +332,10 @@ func (r *Repository) UpdateMessage(ctx context.Context, message *models.Message) requestsInput = 1 } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ? WHERE id = ? - `, message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID) + `), message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/plan.go b/apps/backend/internal/task/repository/sqlite/plan.go index 2e9d62e89a..cbb7446411 100644 --- a/apps/backend/internal/task/repository/sqlite/plan.go +++ b/apps/backend/internal/task/repository/sqlite/plan.go @@ -26,20 +26,20 @@ func (r *Repository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) plan.CreatedBy = "agent" } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt) + `), plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt) return err } // GetTaskPlan retrieves a task plan by task ID. func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { plan := &models.TaskPlan{} - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, title, content, created_by, created_at, updated_at FROM task_plans WHERE task_id = ? - `, taskID).Scan(&plan.ID, &plan.TaskID, &plan.Title, &plan.Content, &plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt) + `), taskID).Scan(&plan.ID, &plan.TaskID, &plan.Title, &plan.Content, &plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt) if err == sql.ErrNoRows { return nil, nil // Return nil, nil when no plan exists @@ -54,10 +54,10 @@ func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.Ta func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { plan.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_plans SET title = ?, content = ?, created_by = ?, updated_at = ? WHERE task_id = ? - `, plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID) + `), plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID) if err != nil { return fmt.Errorf("failed to update task plan: %w", err) } @@ -71,7 +71,7 @@ func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) // DeleteTaskPlan deletes a task plan by task ID. func (r *Repository) DeleteTaskPlan(ctx context.Context, taskID string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_plans WHERE task_id = ?`, taskID) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_plans WHERE task_id = ?`), taskID) if err != nil { return fmt.Errorf("failed to delete task plan: %w", err) } diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 420f6345f7..9cbcd0840c 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" - commonsqlite "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -21,14 +21,14 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re repository.CreatedAt = now repository.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, + `), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, - commonsqlite.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) + dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -37,11 +37,11 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { repository := &models.Repository{} - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at FROM repositories WHERE id = ? AND deleted_at IS NULL - `, id).Scan( + `), id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, @@ -57,13 +57,13 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Repository) error { repository.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, - repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, commonsqlite.BoolToInt(repository.PullBeforeWorktree), + `), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, + repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) if err != nil { return err @@ -79,9 +79,9 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re // DeleteRepository soft-deletes a repository by ID func (r *Repository) DeleteRepository(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repositories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) + `), now, now, id) if err != nil { return err } @@ -94,11 +94,11 @@ func (r *Repository) DeleteRepository(ctx context.Context, id string) error { // ListRepositories returns all repositories for a workspace func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC - `, workspaceID) + `), workspaceID) if err != nil { return nil, err } @@ -129,10 +129,10 @@ func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models. script.CreatedAt = now script.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO repository_scripts (id, repository_id, name, command, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt) + `), script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt) return err } @@ -140,10 +140,10 @@ func (r *Repository) CreateRepositoryScript(ctx context.Context, script *models. // GetRepositoryScript retrieves a repository script by ID func (r *Repository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { script := &models.RepositoryScript{} - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, repository_id, name, command, position, created_at, updated_at FROM repository_scripts WHERE id = ? - `, id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) + `), id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("repository script not found: %s", id) } @@ -153,9 +153,9 @@ func (r *Repository) GetRepositoryScript(ctx context.Context, id string) (*model // UpdateRepositoryScript updates an existing repository script func (r *Repository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { script.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repository_scripts SET name = ?, command = ?, position = ?, updated_at = ? WHERE id = ? - `, script.Name, script.Command, script.Position, script.UpdatedAt, script.ID) + `), script.Name, script.Command, script.Position, script.UpdatedAt, script.ID) if err != nil { return err } @@ -168,7 +168,7 @@ func (r *Repository) UpdateRepositoryScript(ctx context.Context, script *models. // DeleteRepositoryScript deletes a repository script by ID func (r *Repository) DeleteRepositoryScript(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM repository_scripts WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM repository_scripts WHERE id = ?`), id) if err != nil { return err } @@ -181,10 +181,10 @@ func (r *Repository) DeleteRepositoryScript(ctx context.Context, id string) erro // ListRepositoryScripts returns all scripts for a repository func (r *Repository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, repository_id, name, command, position, created_at, updated_at FROM repository_scripts WHERE repository_id = ? ORDER BY position - `, repositoryID) + `), repositoryID) if err != nil { return nil, err } diff --git a/apps/backend/internal/task/repository/sqlite/review.go b/apps/backend/internal/task/repository/sqlite/review.go index ce7c315553..1f24272d83 100644 --- a/apps/backend/internal/task/repository/sqlite/review.go +++ b/apps/backend/internal/task/repository/sqlite/review.go @@ -20,7 +20,7 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models } review.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO session_file_reviews ( id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) @@ -29,7 +29,7 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models diff_hash = excluded.diff_hash, reviewed_at = excluded.reviewed_at, updated_at = excluded.updated_at - `, review.ID, review.SessionID, review.FilePath, review.Reviewed, + `), review.ID, review.SessionID, review.FilePath, review.Reviewed, review.DiffHash, review.ReviewedAt, review.CreatedAt, review.UpdatedAt) return err @@ -37,12 +37,12 @@ func (r *Repository) UpsertSessionFileReview(ctx context.Context, review *models // GetSessionFileReviews retrieves all file reviews for a session. func (r *Repository) GetSessionFileReviews(ctx context.Context, sessionID string) ([]*models.SessionFileReview, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, session_id, file_path, reviewed, diff_hash, reviewed_at, created_at, updated_at FROM session_file_reviews WHERE session_id = ? ORDER BY file_path - `, sessionID) + `), sessionID) if err != nil { return nil, err } @@ -69,6 +69,6 @@ func (r *Repository) GetSessionFileReviews(ctx context.Context, sessionID string // DeleteSessionFileReviews deletes all file reviews for a session. func (r *Repository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM session_file_reviews WHERE session_id = ?`, sessionID) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM session_file_reviews WHERE session_id = ?`), sessionID) return err } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 357091fe52..cbe6ad2df4 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" - commonsqlite "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) @@ -40,10 +40,10 @@ func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) + `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) return err } @@ -53,10 +53,10 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE id = ? - `, id).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + `), id).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } @@ -76,12 +76,12 @@ func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID str turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE task_session_id = ? AND completed_at IS NULL ORDER BY started_at DESC LIMIT 1 - `, sessionID).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + `), sessionID).Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } @@ -109,11 +109,11 @@ func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error { metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns SET completed_at = ?, metadata = ?, updated_at = ? WHERE id = ? - `, turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID) + `), turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID) return err } @@ -121,20 +121,20 @@ func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error { // CompleteTurn marks a turn as completed with the current time func (r *Repository) CompleteTurn(ctx context.Context, id string) error { now := time.Now().UTC() - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns SET completed_at = ?, updated_at = ? WHERE id = ? - `, now, now, id) + `), now, now, id) return err } // ListTurnsBySession returns all turns for a session ordered by start time func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE task_session_id = ? ORDER BY started_at ASC - `, sessionID) + `), sessionID) if err != nil { return nil, err } @@ -199,7 +199,7 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task if err != nil { return fmt.Errorf("failed to serialize repository snapshot: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, @@ -207,13 +207,13 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, + `), session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, - commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, - commonsqlite.BoolToInt(session.IsPassthrough)) + dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, + dialect.BoolToInt(session.IsPassthrough)) return err } @@ -233,14 +233,14 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas var workflowStepID sql.NullString var reviewStatus sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE id = ? - `, id).Scan( + `), id).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, @@ -318,14 +318,14 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) var workflowStepID sql.NullString var reviewStatus sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 - `, taskID).Scan( + `), taskID).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, @@ -403,7 +403,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st var workflowStepID sql.NullString var reviewStatus sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, @@ -412,7 +412,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 - `, taskID).Scan( + `), taskID).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, @@ -500,7 +500,7 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task return fmt.Errorf("failed to serialize repository snapshot: %w", err) } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, @@ -508,12 +508,12 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?, is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ? WHERE id = ? - `, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, + `), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, - commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, - commonsqlite.BoolToInt(session.IsPassthrough), + dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, + dialect.BoolToInt(session.IsPassthrough), session.ID) if err != nil { return err @@ -535,9 +535,9 @@ func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, stat completedAt = &now } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET state = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ? - `, string(status), errorMessage, completedAt, now, id) + `), string(status), errorMessage, completedAt, now, id) if err != nil { return err } @@ -553,9 +553,9 @@ func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, stat // This is used when a stale execution ID needs to be removed (e.g., after a failed resume on startup). func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET agent_execution_id = '', container_id = '', updated_at = ? WHERE id = ? - `, now, id) + `), now, id) if err != nil { return err } @@ -568,14 +568,14 @@ func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) err // ListTaskSessions returns all agent sessions for a task func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC - `, taskID) + `), taskID) if err != nil { return nil, err } @@ -626,14 +626,14 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task // ListActiveTaskSessionsByTaskID returns all active agent sessions for a specific task func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC - `, taskID) + `), taskID) if err != nil { return nil, err } @@ -655,11 +655,11 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { var exists int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT 1 FROM task_sessions WHERE agent_profile_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') LIMIT 1 - `, agentProfileID).Scan(&exists) + `), agentProfileID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } @@ -668,11 +668,11 @@ func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, ag func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { var exists int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT 1 FROM task_sessions WHERE environment_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') LIMIT 1 - `, environmentID).Scan(&exists) + `), environmentID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } @@ -681,14 +681,14 @@ func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, env func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { var exists int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT 1 FROM task_sessions s INNER JOIN task_repositories tr ON tr.task_id = s.task_id WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') AND tr.repository_id = ? LIMIT 1 - `, repositoryID).Scan(&exists) + `), repositoryID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } @@ -769,7 +769,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m // DeleteTaskSession deletes an agent session by ID func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_sessions WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), id) if err != nil { return err } @@ -791,7 +791,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt sessionWorktree.CreatedAt = now updatedAt := now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_worktrees ( id, session_id, worktree_id, repository_id, position, worktree_path, worktree_branch, created_at, updated_at @@ -802,7 +802,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt worktree_path = excluded.worktree_path, worktree_branch = excluded.worktree_branch, updated_at = excluded.updated_at - `, + `), sessionWorktree.ID, sessionWorktree.SessionID, sessionWorktree.WorktreeID, @@ -817,14 +817,14 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt } func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position, tsw.worktree_path, tsw.worktree_branch, tsw.created_at FROM task_session_worktrees tsw WHERE tsw.session_id = ? ORDER BY tsw.position ASC, tsw.created_at ASC - `, sessionID) + `), sessionID) if err != nil { return nil, err } @@ -852,7 +852,7 @@ func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID str } func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE id = ?`), id) if err != nil { return err } @@ -865,7 +865,7 @@ func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) e } func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE session_id = ?`, sessionID) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE session_id = ?`), sessionID) return err } @@ -884,14 +884,14 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin var workflowStepID sql.NullString var reviewStatus sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1 - `, taskID).Scan( + `), taskID).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, @@ -974,7 +974,7 @@ func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs WHERE task_id IN (%s) AND is_primary = 1 `, strings.Join(placeholders, ",")) - rows, err := r.db.QueryContext(ctx, query, args...) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) if err != nil { return nil, err } @@ -1011,7 +1011,7 @@ func (r *Repository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []st GROUP BY task_id `, strings.Join(placeholders, ",")) - rows, err := r.db.QueryContext(ctx, query, args...) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) if err != nil { return nil, err } @@ -1049,7 +1049,7 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs WHERE task_id IN (%s) AND is_primary = 1 `, strings.Join(placeholders, ",")) - rows, err := r.db.QueryContext(ctx, query, args...) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) if err != nil { return nil, err } @@ -1079,7 +1079,7 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er // First, get the task_id for this session var taskID string - err := r.db.QueryRowContext(ctx, `SELECT task_id FROM task_sessions WHERE id = ?`, sessionID).Scan(&taskID) + err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID) if err == sql.ErrNoRows { return fmt.Errorf("session not found: %s", sessionID) } @@ -1088,17 +1088,17 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er } // Clear primary flag on all sessions for this task - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ? - `, now, taskID) + `), now, taskID) if err != nil { return err } // Set primary flag on the specified session - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ? - `, now, sessionID) + `), now, sessionID) if err != nil { return err } @@ -1114,9 +1114,9 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ? - `, stepID, now, sessionID) + `), stepID, now, sessionID) if err != nil { return err } @@ -1132,9 +1132,9 @@ func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID st func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ? - `, status, now, sessionID) + `), status, now, sessionID) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index e32c8e3d6d..7fa5c81e2b 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -32,10 +33,10 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error { return err } - _, err = tx.ExecContext(ctx, ` + _, err = tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) + `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) @@ -51,10 +52,10 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro task := &models.Task{} var metadata string var archivedAt sql.NullTime - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks WHERE id = ? - `, id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &archivedAt, &task.CreatedAt, &task.UpdatedAt) + `), id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &archivedAt, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -79,10 +80,10 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { metadata = []byte("{}") } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ? WHERE id = ? - `, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) + `), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) if err != nil { return err } @@ -96,7 +97,7 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { // DeleteTask deletes a task by ID func (r *Repository) DeleteTask(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM tasks WHERE id = ?`), id) if err != nil { return err } @@ -110,12 +111,12 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error { // ListTasks returns all non-archived tasks for a workflow func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks WHERE workflow_id = ? AND archived_at IS NULL ORDER BY position - `, workflowID) + `), workflowID) if err != nil { return nil, err } @@ -127,7 +128,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model // CountTasksByWorkflow returns the number of non-archived tasks in a workflow func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { var count int - err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL`, workflowID).Scan(&count) + err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL`), workflowID).Scan(&count) if err != nil { return 0, err } @@ -137,7 +138,7 @@ func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string // CountTasksByWorkflowStep returns the number of non-archived tasks in a workflow step func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { var count int - err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL`, stepID).Scan(&count) + err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL`), stepID).Scan(&count) if err != nil { return 0, err } @@ -146,11 +147,11 @@ func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string // ListTasksByWorkflowStep returns all non-archived tasks in a workflow step func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL ORDER BY position - `, workflowStepID) + `), workflowStepID) if err != nil { return nil, err } @@ -180,58 +181,61 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin if query == "" { // No search query - use simple query - err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter, workspaceID).Scan(&total) + err = r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter), workspaceID).Scan(&total) if err != nil { return nil, 0, err } - rows, err = r.db.QueryContext(ctx, ` + rows, err = r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks WHERE workspace_id = ?`+archiveFilter+` ORDER BY updated_at DESC LIMIT ? OFFSET ? - `, workspaceID, pageSize, offset) + `), workspaceID, pageSize, offset) } else { // Search query - use JOIN with repositories searchPattern := "%" + query + "%" + like := dialect.Like(r.db.DriverName()) tFilter := "" if !includeArchived { tFilter = " AND t.archived_at IS NULL" } - err = r.db.QueryRowContext(ctx, ` + countQuery := fmt.Sprintf(` SELECT COUNT(DISTINCT t.id) FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ?`+tFilter+` + WHERE t.workspace_id = ?%s AND ( - t.title LIKE ? OR - t.description LIKE ? OR - r.name LIKE ? OR - r.local_path LIKE ? + t.title %s ? OR + t.description %s ? OR + r.name %s ? OR + r.local_path %s ? ) - `, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total) + `, tFilter, like, like, like, like) + err = r.db.QueryRowContext(ctx, r.db.Rebind(countQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total) if err != nil { return nil, 0, err } - rows, err = r.db.QueryContext(ctx, ` + selectQuery := fmt.Sprintf(` SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ?`+tFilter+` + WHERE t.workspace_id = ?%s AND ( - t.title LIKE ? OR - t.description LIKE ? OR - r.name LIKE ? OR - r.local_path LIKE ? + t.title %s ? OR + t.description %s ? OR + r.name %s ? OR + r.local_path %s ? ) ORDER BY t.updated_at DESC LIMIT ? OFFSET ? - `, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset) + `, tFilter, like, like, like, like) + rows, err = r.db.QueryContext(ctx, r.db.Rebind(selectQuery), workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset) } if err != nil { @@ -284,7 +288,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { // ArchiveTask sets the archived_at timestamp on a task func (r *Repository) ArchiveTask(ctx context.Context, id string) error { now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, `UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`, now, now, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`), now, now, id) if err != nil { return err } @@ -297,14 +301,16 @@ func (r *Repository) ArchiveTask(ctx context.Context, id string) error { // ListTasksForAutoArchive returns tasks eligible for auto-archiving based on workflow step settings func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { - rows, err := r.db.QueryContext(ctx, ` + drv := r.db.DriverName() + query := fmt.Sprintf(` SELECT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at FROM tasks t JOIN workflow_steps ws ON ws.id = t.workflow_step_id WHERE ws.auto_archive_after_hours > 0 AND t.archived_at IS NULL - AND t.updated_at <= datetime('now', '-' || ws.auto_archive_after_hours || ' hours') - `) + AND t.updated_at <= %s + `, dialect.NowMinusHours(drv, "ws.auto_archive_after_hours")) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query)) if err != nil { return nil, err } @@ -314,7 +320,7 @@ func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Tas // UpdateTaskState updates the state of a task func (r *Repository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { - result, err := r.db.ExecContext(ctx, `UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, state, time.Now().UTC(), id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`), state, time.Now().UTC(), id) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/task_repository.go b/apps/backend/internal/task/repository/sqlite/task_repository.go index cb8dd3911c..7d09dd007d 100644 --- a/apps/backend/internal/task/repository/sqlite/task_repository.go +++ b/apps/backend/internal/task/repository/sqlite/task_repository.go @@ -26,11 +26,11 @@ func (r *Repository) CreateTaskRepository(ctx context.Context, taskRepo *models. metadataJSON = []byte("{}") } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_repositories ( id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) + `), taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) return err } @@ -39,10 +39,10 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models. taskRepo := &models.TaskRepository{} var metadataJSON string - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at FROM task_repositories WHERE id = ? - `, id).Scan( + `), id).Scan( &taskRepo.ID, &taskRepo.TaskID, &taskRepo.RepositoryID, @@ -68,12 +68,12 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models. // ListTaskRepositories returns all repository links for a task func (r *Repository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at FROM task_repositories WHERE task_id = ? ORDER BY position ASC, created_at ASC - `, taskID) + `), taskID) if err != nil { return nil, err } @@ -114,11 +114,11 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models. metadataJSON = []byte("{}") } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_repositories SET task_id = ?, repository_id = ?, base_branch = ?, position = ?, metadata = ?, updated_at = ? WHERE id = ? - `, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) + `), taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) if err != nil { return err } @@ -131,7 +131,7 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models. // DeleteTaskRepository deletes a task-repository link by ID func (r *Repository) DeleteTaskRepository(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_repositories WHERE id = ?`), id) if err != nil { return err } @@ -144,7 +144,7 @@ func (r *Repository) DeleteTaskRepository(ctx context.Context, id string) error // DeleteTaskRepositoriesByTask deletes all repository links for a task func (r *Repository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE task_id = ?`, taskID) + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_repositories WHERE task_id = ?`), taskID) return err } diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index cbec86a05c..cf0ff4ffde 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -13,17 +13,17 @@ import ( // AddTaskToWorkflow adds a task to a workflow with placement func (r *Repository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workflow_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ? - `, workflowID, workflowStepID, position, time.Now().UTC(), taskID) + `), workflowID, workflowStepID, position, time.Now().UTC(), taskID) return err } // RemoveTaskFromWorkflow removes a task from a workflow func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workflow_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND workflow_id = ? - `, time.Now().UTC(), taskID, workflowID) + `), time.Now().UTC(), taskID, workflowID) return err } @@ -38,10 +38,10 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl workflow.CreatedAt = now workflow.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt) + `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt) return err } @@ -51,10 +51,10 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl workflow := &models.Workflow{} var workflowTemplateID sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM workflows WHERE id = ? - `, id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) + `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow not found: %s", id) @@ -73,9 +73,9 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error { workflow.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workflows SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? - `, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) + `), workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) if err != nil { return err } @@ -89,7 +89,7 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl // DeleteWorkflow deletes a workflow by ID func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE id = ?`), id) if err != nil { return err } @@ -113,7 +113,7 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]* } query += " ORDER BY created_at DESC" - rows, err := r.db.QueryContext(ctx, query, args...) + rows, err := r.db.QueryContext(ctx, r.db.Rebind(query), args...) if err != nil { return nil, err } diff --git a/apps/backend/internal/task/repository/sqlite/workspace.go b/apps/backend/internal/task/repository/sqlite/workspace.go index 260ec43af8..4796a6f9dc 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace.go +++ b/apps/backend/internal/task/repository/sqlite/workspace.go @@ -20,7 +20,7 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work workspace.CreatedAt = now workspace.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workspaces ( id, name, @@ -33,7 +33,7 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.CreatedAt, workspace.UpdatedAt) + `), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.CreatedAt, workspace.UpdatedAt) return err } @@ -45,10 +45,10 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works var defaultEnvironmentID sql.NullString var defaultAgentProfileID sql.NullString - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, created_at, updated_at FROM workspaces WHERE id = ? - `, id).Scan( + `), id).Scan( &workspace.ID, &workspace.Name, &workspace.Description, @@ -79,7 +79,7 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { workspace.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workspaces SET name = ?, description = ?, @@ -88,7 +88,7 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work default_agent_profile_id = ?, updated_at = ? WHERE id = ? - `, workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.UpdatedAt, workspace.ID) + `), workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.UpdatedAt, workspace.ID) if err != nil { return err } @@ -102,7 +102,7 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work // DeleteWorkspace deletes a workspace by ID func (r *Repository) DeleteWorkspace(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workspaces WHERE id = ?`), id) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 1c07ee41dd..eb72e4b590 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/worktree" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" @@ -22,16 +23,17 @@ func createTestSQLiteRepo(t *testing.T) (*sqlite.Repository, func()) { if err != nil { t.Fatalf("failed to open SQLite database: %v", err) } - repo, err := sqlite.NewWithDB(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqlite.NewWithDB(sqlxDB) if err != nil { t.Fatalf("failed to create SQLite repository: %v", err) } - if _, err := worktree.NewSQLiteStore(repo.DB()); err != nil { + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { t.Fatalf("failed to init worktree store: %v", err) } cleanup := func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := repo.Close(); err != nil { @@ -488,7 +490,8 @@ func TestSQLiteRepository_Persistence(t *testing.T) { if err != nil { t.Fatalf("failed to open SQLite database: %v", err) } - repo1, err := sqlite.NewWithDB(dbConn1) + sqlxDB1 := sqlx.NewDb(dbConn1, "sqlite3") + repo1, err := sqlite.NewWithDB(sqlxDB1) if err != nil { t.Fatalf("failed to create first repository: %v", err) } @@ -499,7 +502,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) { if err := repo1.Close(); err != nil { t.Fatalf("failed to close repo: %v", err) } - if err := dbConn1.Close(); err != nil { + if err := sqlxDB1.Close(); err != nil { t.Fatalf("failed to close sqlite db: %v", err) } @@ -508,12 +511,13 @@ func TestSQLiteRepository_Persistence(t *testing.T) { if err != nil { t.Fatalf("failed to open SQLite database: %v", err) } - repo2, err := sqlite.NewWithDB(dbConn2) + sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") + repo2, err := sqlite.NewWithDB(sqlxDB2) if err != nil { t.Fatalf("failed to create second repository: %v", err) } defer func() { - if err := dbConn2.Close(); err != nil { + if err := sqlxDB2.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := repo2.Close(); err != nil { @@ -1317,8 +1321,8 @@ func TestSQLiteRepository_ListTasksForAutoArchive(t *testing.T) { allow_manual_move INTEGER DEFAULT 1, is_start_step INTEGER DEFAULT 0, auto_archive_after_hours INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ) `) if err != nil { diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index ba7126b4bb..549025f6a8 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" @@ -278,13 +279,14 @@ func newDiscoveryService(t *testing.T, root string) *Service { if err != nil { t.Fatalf("failed to open test database: %v", err) } - repoImpl, cleanup, err := repository.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repoImpl, cleanup, err := repository.Provide(sqlxDB) if err != nil { t.Fatalf("failed to create test repository: %v", err) } repo := repository.Repository(repoImpl) t.Cleanup(func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := cleanup(); err != nil { diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 2a6495ab39..e1a66be874 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" @@ -77,16 +78,17 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, repository.Reposi if err != nil { t.Fatalf("failed to open test database: %v", err) } - repoImpl, cleanup, err := repository.Provide(dbConn) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repoImpl, cleanup, err := repository.Provide(sqlxDB) if err != nil { t.Fatalf("failed to create test repository: %v", err) } repo := repository.Repository(repoImpl) - if _, err := worktree.NewSQLiteStore(dbConn); err != nil { + if _, err := worktree.NewSQLiteStore(sqlxDB); err != nil { t.Fatalf("failed to init worktree store: %v", err) } t.Cleanup(func() { - if err := dbConn.Close(); err != nil { + if err := sqlxDB.Close(); err != nil { t.Errorf("failed to close sqlite db: %v", err) } if err := cleanup(); err != nil { diff --git a/apps/backend/internal/user/store/provider.go b/apps/backend/internal/user/store/provider.go index 3f586df10c..b1c085a351 100644 --- a/apps/backend/internal/user/store/provider.go +++ b/apps/backend/internal/user/store/provider.go @@ -1,9 +1,9 @@ package store -import "database/sql" +import "github.com/jmoiron/sqlx" // Provide creates the SQLite user store using the shared database connection. -func Provide(db *sql.DB) (*sqliteRepository, func() error, error) { +func Provide(db *sqlx.DB) (*sqliteRepository, func() error, error) { repo, err := newSQLiteRepositoryWithDB(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index caa4919cc9..b4de78d4e0 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -2,11 +2,12 @@ package store import ( "context" - "database/sql" "encoding/json" "fmt" "time" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/user/models" ) @@ -16,17 +17,17 @@ const ( ) type sqliteRepository struct { - db *sql.DB + db *sqlx.DB ownsDB bool } var _ Repository = (*sqliteRepository)(nil) -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { +func newSQLiteRepositoryWithDB(dbConn *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(dbConn, false) } -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(dbConn *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { @@ -45,8 +46,8 @@ func (r *sqliteRepository) initSchema() error { id TEXT PRIMARY KEY, email TEXT NOT NULL, settings TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); ` if _, err := r.db.Exec(schema); err != nil { @@ -59,15 +60,15 @@ func (r *sqliteRepository) initSchema() error { func (r *sqliteRepository) ensureDefaultUser() error { ctx := context.Background() var count int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users WHERE id = ?", DefaultUserID).Scan(&count); err != nil { + if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM users WHERE id = ?"), DefaultUserID).Scan(&count); err != nil { return err } if count == 0 { now := time.Now().UTC() - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO users (id, email, settings, created_at, updated_at) VALUES (?, ?, '{}', ?, ?) - `, DefaultUserID, DefaultUserEmail, now, now) + `), DefaultUserID, DefaultUserEmail, now, now) if err != nil { return err } @@ -83,10 +84,10 @@ func (r *sqliteRepository) Close() error { } func (r *sqliteRepository) GetUser(ctx context.Context, id string) (*models.User, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, email, created_at, updated_at FROM users WHERE id = ? - `, id) + `), id) return scanUser(row) } @@ -95,10 +96,10 @@ func (r *sqliteRepository) GetDefaultUser(ctx context.Context) (*models.User, er } func (r *sqliteRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT settings, updated_at FROM users WHERE id = ? - `, userID) + `), userID) settings, err := scanUserSettings(row, userID) if err != nil { return nil, err @@ -141,11 +142,11 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod if err != nil { return err } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE users SET settings = ?, updated_at = ? WHERE id = ? - `, string(settingsPayload), settings.UpdatedAt, settings.UserID) + `), string(settingsPayload), settings.UpdatedAt, settings.UserID) if err != nil { return err } diff --git a/apps/backend/internal/workflow/provider.go b/apps/backend/internal/workflow/provider.go index 5efb694921..01c4662728 100644 --- a/apps/backend/internal/workflow/provider.go +++ b/apps/backend/internal/workflow/provider.go @@ -2,7 +2,7 @@ package workflow import ( - "database/sql" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/workflow/repository" @@ -10,7 +10,7 @@ import ( ) // Provide creates the workflow repository and service using the shared database connection. -func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) { +func Provide(db *sqlx.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) { repo, err := repository.NewWithDB(db) if err != nil { return nil, nil, nil, err @@ -18,4 +18,3 @@ func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.S svc := service.NewService(repo, log) return repo, svc, func() error { return nil }, nil } - diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index 269a6912e6..522ad27806 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -9,18 +9,19 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/common/sqlite" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/workflow/models" ) // Repository provides SQLite-based workflow storage operations. type Repository struct { - db *sql.DB + db *sqlx.DB } // NewWithDB creates a new SQLite repository with an existing database connection. -func NewWithDB(db *sql.DB) (*Repository, error) { +func NewWithDB(db *sqlx.DB) (*Repository, error) { repo := &Repository{db: db} if err := repo.initSchema(); err != nil { return nil, fmt.Errorf("failed to initialize workflow schema: %w", err) @@ -28,11 +29,6 @@ func NewWithDB(db *sql.DB) (*Repository, error) { return repo, nil } -// DB returns the underlying sql.DB instance for shared access. -func (r *Repository) DB() *sql.DB { - return r.db -} - // initSchema creates the database tables if they don't exist. func (r *Repository) initSchema() error { // Create workflow_templates table @@ -43,8 +39,8 @@ func (r *Repository) initSchema() error { description TEXT, is_system INTEGER DEFAULT 0, steps TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); ` if _, err := r.db.Exec(templatesSchema); err != nil { @@ -62,8 +58,10 @@ func (r *Repository) initSchema() error { prompt TEXT, events TEXT, allow_manual_move INTEGER DEFAULT 1, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, + is_start_step INTEGER DEFAULT 0, + auto_archive_after_hours INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id); @@ -72,32 +70,23 @@ func (r *Repository) initSchema() error { return fmt.Errorf("failed to create workflow_steps table: %w", err) } - // Migrate old schema to new schema if needed - if err := r.migrateWorkflowSteps(); err != nil { - return fmt.Errorf("failed to migrate workflow_steps: %w", err) + // Create session_step_history table (id column differs between SQLite and PostgreSQL) + var idCol string + if r.db.DriverName() == "pgx" { + idCol = "id BIGSERIAL PRIMARY KEY" + } else { + idCol = "id INTEGER PRIMARY KEY AUTOINCREMENT" } - - // Add is_start_step column if not present - if err := r.addIsStartStepColumn(); err != nil { - return fmt.Errorf("failed to add is_start_step column: %w", err) - } - - // Add auto_archive_after_hours column if not present - if err := r.addAutoArchiveColumn(); err != nil { - return fmt.Errorf("failed to add auto_archive_after_hours column: %w", err) - } - - // Create session_step_history table historySchema := ` CREATE TABLE IF NOT EXISTS session_step_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + ` + idCol + `, session_id TEXT NOT NULL, from_step_id TEXT, to_step_id TEXT NOT NULL, trigger TEXT NOT NULL, actor_id TEXT, metadata TEXT, - created_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_session_step_history_session ON session_step_history(session_id); @@ -119,261 +108,6 @@ func (r *Repository) initSchema() error { return nil } -// migrateWorkflowSteps detects old schema (has step_type column) and migrates data to new schema. -func (r *Repository) migrateWorkflowSteps() error { - // Check if old column exists - var hasStepType bool - rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") - if err != nil { - return err - } - defer func() { _ = rows.Close() }() - - for rows.Next() { - var cid int - var name, colType string - var notNull int - var dfltValue sql.NullString - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return err - } - if name == "step_type" { - hasStepType = true - break - } - } - if err := rows.Err(); err != nil { - return err - } - - if !hasStepType { - return nil // Already on new schema - } - - // Migrate: read old data, recreate table with new schema, insert converted data - type oldStep struct { - ID string - WorkflowID string - Name string - Position int - Color sql.NullString - AutoStartAgent int - PlanMode int - RequireApproval int - PromptPrefix sql.NullString - PromptSuffix sql.NullString - OnCompleteStepID sql.NullString - OnApprovalStepID sql.NullString - AllowManualMove int - CreatedAt time.Time - UpdatedAt time.Time - } - - oldRows, err := r.db.Query(` - SELECT id, workflow_id, name, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, - allow_manual_move, created_at, updated_at - FROM workflow_steps - `) - if err != nil { - return fmt.Errorf("failed to read old workflow_steps: %w", err) - } - defer func() { _ = oldRows.Close() }() - - var oldSteps []oldStep - for oldRows.Next() { - var s oldStep - if err := oldRows.Scan(&s.ID, &s.WorkflowID, &s.Name, &s.Position, &s.Color, - &s.AutoStartAgent, &s.PlanMode, &s.RequireApproval, - &s.PromptPrefix, &s.PromptSuffix, &s.OnCompleteStepID, &s.OnApprovalStepID, - &s.AllowManualMove, &s.CreatedAt, &s.UpdatedAt); err != nil { - return fmt.Errorf("failed to scan old workflow step: %w", err) - } - oldSteps = append(oldSteps, s) - } - if err := oldRows.Err(); err != nil { - return err - } - - // Drop old table and recreate within a transaction for atomicity - tx, err := r.db.Begin() - if err != nil { - return fmt.Errorf("failed to begin migration transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - if _, err := tx.Exec("DROP TABLE workflow_steps"); err != nil { - return fmt.Errorf("failed to drop old workflow_steps table: %w", err) - } - - newSchema := ` - CREATE TABLE workflow_steps ( - id TEXT PRIMARY KEY, - workflow_id TEXT NOT NULL, - name TEXT NOT NULL, - position INTEGER NOT NULL, - color TEXT, - prompt TEXT, - events TEXT, - allow_manual_move INTEGER DEFAULT 1, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id); - ` - if _, err := tx.Exec(newSchema); err != nil { - return fmt.Errorf("failed to recreate workflow_steps table: %w", err) - } - - // Convert and insert old data - for _, s := range oldSteps { - events := convertOldStepToEvents(s.AutoStartAgent == 1, s.PlanMode == 1, s.RequireApproval == 1, - s.OnCompleteStepID, s.OnApprovalStepID) - eventsJSON, err := json.Marshal(events) - if err != nil { - return fmt.Errorf("failed to marshal events for step %s: %w", s.ID, err) - } - - // Combine old prompt prefix/suffix into single prompt - prompt := "" - if s.PromptPrefix.Valid && s.PromptPrefix.String != "" { - prompt = s.PromptPrefix.String + "\n\n{{task_prompt}}" - if s.PromptSuffix.Valid && s.PromptSuffix.String != "" { - prompt += "\n\n" + s.PromptSuffix.String - } - } else if s.PromptSuffix.Valid && s.PromptSuffix.String != "" { - prompt = "{{task_prompt}}\n\n" + s.PromptSuffix.String - } - - color := "" - if s.Color.Valid { - color = s.Color.String - } - - if _, err := tx.Exec(` - INSERT INTO workflow_steps (id, workflow_id, name, position, color, prompt, events, allow_manual_move, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, s.ID, s.WorkflowID, s.Name, s.Position, color, prompt, string(eventsJSON), s.AllowManualMove, s.CreatedAt, s.UpdatedAt); err != nil { - return fmt.Errorf("failed to insert migrated step %s: %w", s.ID, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit migration transaction: %w", err) - } - - return nil -} - -// addIsStartStepColumn adds the is_start_step column to workflow_steps if it doesn't exist. -func (r *Repository) addIsStartStepColumn() error { - rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") - if err != nil { - return err - } - defer func() { _ = rows.Close() }() - - var hasColumn bool - for rows.Next() { - var cid int - var name, colType string - var notNull int - var dfltValue sql.NullString - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return err - } - if name == "is_start_step" { - hasColumn = true - break - } - } - if err := rows.Err(); err != nil { - return err - } - if hasColumn { - return nil - } - - _, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN is_start_step INTEGER DEFAULT 0") - return err -} - -// addAutoArchiveColumn adds the auto_archive_after_hours column to workflow_steps if it doesn't exist. -func (r *Repository) addAutoArchiveColumn() error { - rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") - if err != nil { - return err - } - defer func() { _ = rows.Close() }() - - var hasColumn bool - for rows.Next() { - var cid int - var name, colType string - var notNull int - var dfltValue sql.NullString - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return err - } - if name == "auto_archive_after_hours" { - hasColumn = true - break - } - } - if err := rows.Err(); err != nil { - return err - } - if hasColumn { - return nil - } - - _, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN auto_archive_after_hours INTEGER DEFAULT 0") - return err -} - -// convertOldStepToEvents converts old boolean/pointer fields to new event-driven model. -func convertOldStepToEvents(autoStart, planMode, requireApproval bool, onCompleteStepID, onApprovalStepID sql.NullString) models.StepEvents { - events := models.StepEvents{} - - // On Enter actions - if planMode { - events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterEnablePlanMode}) - } - if autoStart { - events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterAutoStartAgent}) - } - - // On Turn Complete actions - if planMode { - events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{Type: models.OnTurnCompleteDisablePlanMode}) - } - - if !requireApproval { - // If not requiring approval and has on_complete, add move transition - if onCompleteStepID.Valid && onCompleteStepID.String != "" { - events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{ - Type: models.OnTurnCompleteMoveToStep, - Config: map[string]interface{}{"step_id": onCompleteStepID.String}, - }) - } - } else if onApprovalStepID.Valid && onApprovalStepID.String != "" { - // Require approval before transitioning to the approval step - events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{ - Type: models.OnTurnCompleteMoveToStep, - Config: map[string]interface{}{ - "step_id": onApprovalStepID.String, - "requires_approval": true, - }, - }) - } - - return events -} // seedDefaultWorkflowSteps creates default workflow steps for workflows that don't have any. // Uses the simple template as the default workflow. @@ -419,15 +153,15 @@ func (r *Repository) seedDefaultWorkflowSteps() error { return fmt.Errorf("failed to marshal events: %w", err) } - if _, err := r.db.Exec(` + if _, err := r.db.Exec(r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, + `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, - stepDef.Prompt, string(eventsJSON), sqlite.BoolToInt(stepDef.AllowManualMove), - sqlite.BoolToInt(stepDef.IsStartStep), now, now, + stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove), + dialect.BoolToInt(stepDef.IsStartStep), now, now, ); err != nil { return err } @@ -448,7 +182,7 @@ func (r *Repository) seedSystemTemplates() error { return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err) } - _, err = r.db.Exec(` + _, err = r.db.Exec(r.db.Rebind(` INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -456,7 +190,7 @@ func (r *Repository) seedSystemTemplates() error { description = excluded.description, steps = excluded.steps, updated_at = excluded.updated_at - `, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt) + `), tmpl.ID, tmpl.Name, tmpl.Description, dialect.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt) if err != nil { return fmt.Errorf("failed to upsert template %s: %w", tmpl.ID, err) } @@ -745,10 +479,10 @@ func (r *Repository) CreateTemplate(ctx context.Context, template *models.Workfl return fmt.Errorf("failed to marshal steps: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, template.ID, template.Name, template.Description, sqlite.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt) + `), template.ID, template.Name, template.Description, dialect.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt) return err } @@ -759,10 +493,10 @@ func (r *Repository) GetTemplate(ctx context.Context, id string) (*models.Workfl var stepsJSON string var isSystem int - err := r.db.QueryRowContext(ctx, ` + err := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT id, name, description, is_system, steps, created_at, updated_at FROM workflow_templates WHERE id = ? - `, id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt) + `), id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow template not found: %s", id) @@ -788,10 +522,10 @@ func (r *Repository) UpdateTemplate(ctx context.Context, template *models.Workfl return fmt.Errorf("failed to marshal steps: %w", err) } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workflow_templates SET name = ?, description = ?, steps = ?, updated_at = ? WHERE id = ? AND is_system = 0 - `, template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID) + `), template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID) if err != nil { return err } @@ -805,7 +539,7 @@ func (r *Repository) UpdateTemplate(ctx context.Context, template *models.Workfl // DeleteTemplate deletes a workflow template by ID. func (r *Repository) DeleteTemplate(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`), id) if err != nil { return err } @@ -907,14 +641,14 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) return fmt.Errorf("failed to marshal events: %w", err) } - _, err = r.db.ExecContext(ctx, ` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, step.ID, step.WorkflowID, step.Name, step.Position, step.Color, - step.Prompt, string(eventsJSON), sqlite.BoolToInt(step.AllowManualMove), - sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) + `), step.ID, step.WorkflowID, step.Name, step.Position, step.Color, + step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove), + dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) return err } @@ -959,10 +693,10 @@ const stepSelectColumns = `id, workflow_id, name, position, color, prompt, event // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT `+stepSelectColumns+` FROM workflow_steps WHERE id = ? - `, id) + `), id) step, err := r.scanStep(row) if err == sql.ErrNoRows { @@ -983,15 +717,15 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) return fmt.Errorf("failed to marshal events: %w", err) } - result, err := r.db.ExecContext(ctx, ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, allow_manual_move = ?, is_start_step = ?, auto_archive_after_hours = ?, updated_at = ? WHERE id = ? - `, step.Name, step.Position, step.Color, + `), step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) + dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) if err != nil { return err } @@ -1005,21 +739,21 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) // ClearStartStepFlag clears the is_start_step flag for all steps in a workflow except the given step. func (r *Repository) ClearStartStepFlag(ctx context.Context, workflowID, exceptStepID string) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE workflow_steps SET is_start_step = 0 WHERE workflow_id = ? AND id != ? - `, workflowID, exceptStepID) + `), workflowID, exceptStepID) return err } // GetStartStep returns the step marked as is_start_step for a workflow. // Returns nil if no step is marked. func (r *Repository) GetStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) { - row := r.db.QueryRowContext(ctx, ` + row := r.db.QueryRowContext(ctx, r.db.Rebind(` SELECT `+stepSelectColumns+` FROM workflow_steps WHERE workflow_id = ? AND is_start_step = 1 LIMIT 1 - `, workflowID) + `), workflowID) step, err := r.scanStep(row) if err == sql.ErrNoRows { @@ -1064,7 +798,7 @@ func (r *Repository) ClearStepReferences(ctx context.Context, workflowID, stepID // DeleteStep deletes a workflow step by ID. func (r *Repository) DeleteStep(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_steps WHERE id = ?`, id) + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflow_steps WHERE id = ?`), id) if err != nil { return err } @@ -1078,10 +812,10 @@ func (r *Repository) DeleteStep(ctx context.Context, id string) error { // ListStepsByWorkflow returns all workflow steps for a workflow. func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*models.WorkflowStep, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT `+stepSelectColumns+` FROM workflow_steps WHERE workflow_id = ? ORDER BY position - `, workflowID) + `), workflowID) if err != nil { return nil, err } @@ -1122,16 +856,10 @@ func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionS metadataJSON = &s } - result, err := r.db.ExecContext(ctx, ` - INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt) - if err != nil { - return err - } - - // Get the auto-incremented ID - id, err := result.LastInsertId() + id, err := dialect.InsertReturningID(ctx, r.db, + `INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt) if err != nil { return err } @@ -1142,10 +870,10 @@ func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionS // ListHistoryBySession returns all step history entries for a session. func (r *Repository) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) { - rows, err := r.db.QueryContext(ctx, ` + rows, err := r.db.QueryContext(ctx, r.db.Rebind(` SELECT id, session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at FROM session_step_history WHERE session_id = ? ORDER BY created_at - `, sessionID) + `), sessionID) if err != nil { return nil, err } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index bd14971867..e6c3d2888a 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "testing" + "time" + "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,28 +16,30 @@ import ( "github.com/kandev/kandev/internal/workflow/repository" ) -func setupTestService(t *testing.T) (*Service, *sql.DB) { - db, err := sql.Open("sqlite3", ":memory:") +func setupTestService(t *testing.T) (*Service, *sqlx.DB) { + rawDB, err := sql.Open("sqlite3", ":memory:") require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) + sqlxDB := sqlx.NewDb(rawDB, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) // Create workflows table (normally created by task repo) - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS workflows ( + _, err = sqlxDB.Exec(`CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, - description TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL + description TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL )`) require.NoError(t, err) - repo, err := repository.NewWithDB(db) + repo, err := repository.NewWithDB(sqlxDB) require.NoError(t, err) log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) - return NewService(repo, log), db + return NewService(repo, log), sqlxDB } -func insertWorkflow(t *testing.T, db *sql.DB, id, name string) { - _, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, datetime('now'), datetime('now'))", id, name) +func insertWorkflow(t *testing.T, db *sqlx.DB, id, name string) { + now := time.Now().UTC() + _, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, ?, ?)", id, name, now, now) require.NoError(t, err) } diff --git a/apps/backend/internal/worktree/provider.go b/apps/backend/internal/worktree/provider.go index e817788dca..43de232973 100644 --- a/apps/backend/internal/worktree/provider.go +++ b/apps/backend/internal/worktree/provider.go @@ -1,14 +1,14 @@ package worktree import ( - "database/sql" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" ) // Provide creates the worktree manager using the shared database connection. -func Provide(db *sql.DB, cfg *config.Config, log *logger.Logger) (*Manager, func() error, error) { +func Provide(db *sqlx.DB, cfg *config.Config, log *logger.Logger) (*Manager, func() error, error) { store, err := NewSQLiteStore(db) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/worktree/store.go b/apps/backend/internal/worktree/store.go index 5178bd5975..6ae6d1381b 100644 --- a/apps/backend/internal/worktree/store.go +++ b/apps/backend/internal/worktree/store.go @@ -7,16 +7,17 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" ) // SQLiteStore implements Store interface using SQLite. type SQLiteStore struct { - db *sql.DB + db *sqlx.DB } // NewSQLiteStore creates a new SQLite-backed worktree store. -// It uses the provided sql.DB connection and ensures the task_session_worktrees table exists. -func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { +// It uses the provided sqlx.DB connection and ensures the task_session_worktrees table exists. +func NewSQLiteStore(db *sqlx.DB) (*SQLiteStore, error) { store := &SQLiteStore{db: db} if err := store.initSchema(); err != nil { return nil, fmt.Errorf("failed to initialize worktree schema: %w", err) @@ -36,10 +37,10 @@ func (s *SQLiteStore) initSchema() error { worktree_path TEXT DEFAULT '', worktree_branch TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - merged_at DATETIME, - deleted_at DATETIME, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP, FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, UNIQUE(session_id, worktree_id) ); @@ -73,7 +74,7 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { wt.UpdatedAt = now } - _, err := s.db.ExecContext(ctx, ` + _, err := s.db.ExecContext(ctx, s.db.Rebind(` INSERT INTO task_session_worktrees ( id, session_id, worktree_id, repository_id, position, worktree_path, worktree_branch, status, @@ -87,7 +88,7 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { updated_at = excluded.updated_at, merged_at = excluded.merged_at, deleted_at = excluded.deleted_at - `, uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0, + `), uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0, wt.Path, wt.Branch, wt.Status, wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt) @@ -100,7 +101,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree var mergedAt, deletedAt sql.NullTime var repositoryPath, baseBranch sql.NullString - err := s.db.QueryRowContext(ctx, ` + err := s.db.QueryRowContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -119,7 +120,7 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree LEFT JOIN task_sessions s ON tsw.session_id = s.id LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE tsw.worktree_id = ? - `, id).Scan( + `), id).Scan( &wt.ID, &wt.SessionID, &wt.TaskID, @@ -164,7 +165,7 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri var mergedAt, deletedAt sql.NullTime var repositoryPath, baseBranch sql.NullString - err := s.db.QueryRowContext(ctx, ` + err := s.db.QueryRowContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -183,7 +184,7 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri INNER JOIN task_sessions s ON tsw.session_id = s.id LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE tsw.session_id = ? AND tsw.status = ? - `, sessionID, StatusActive).Scan( + `), sessionID, StatusActive).Scan( &wt.ID, &wt.SessionID, &wt.TaskID, @@ -229,7 +230,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* var mergedAt, deletedAt sql.NullTime var repositoryPath, baseBranch sql.NullString - err := s.db.QueryRowContext(ctx, ` + err := s.db.QueryRowContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -249,7 +250,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE s.task_id = ? AND tsw.status = ? ORDER BY tsw.created_at DESC LIMIT 1 - `, taskID, StatusActive).Scan( + `), taskID, StatusActive).Scan( &wt.ID, &wt.SessionID, &wt.TaskID, @@ -290,7 +291,7 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* // GetWorktreesByTaskID retrieves all worktrees for a task. func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) { - rows, err := s.db.QueryContext(ctx, ` + rows, err := s.db.QueryContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -309,7 +310,7 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ( INNER JOIN task_sessions s ON tsw.session_id = s.id LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE s.task_id = ? ORDER BY tsw.created_at DESC - `, taskID) + `), taskID) if err != nil { return nil, err } @@ -320,7 +321,7 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ( // GetWorktreesByRepositoryID retrieves all worktrees for a repository. func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID string) ([]*Worktree, error) { - rows, err := s.db.QueryContext(ctx, ` + rows, err := s.db.QueryContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -339,7 +340,7 @@ func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID str LEFT JOIN task_sessions s ON tsw.session_id = s.id LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE tsw.repository_id = ? - `, repoID) + `), repoID) if err != nil { return nil, err } @@ -373,7 +374,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { args = append(args, wt.SessionID) } - result, err := s.db.ExecContext(ctx, query, args...) + result, err := s.db.ExecContext(ctx, s.db.Rebind(query), args...) if err != nil { return err } @@ -387,7 +388,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { // DeleteWorktree removes a worktree record. func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { - result, err := s.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE worktree_id = ?`, id) + result, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM task_session_worktrees WHERE worktree_id = ?`), id) if err != nil { return err } @@ -401,7 +402,7 @@ func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { // ListActiveWorktrees returns all worktrees with status 'active'. func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) { - rows, err := s.db.QueryContext(ctx, ` + rows, err := s.db.QueryContext(ctx, s.db.Rebind(` SELECT tsw.worktree_id, tsw.session_id, @@ -420,7 +421,7 @@ func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, err LEFT JOIN task_sessions s ON tsw.session_id = s.id LEFT JOIN repositories r ON tsw.repository_id = r.id WHERE tsw.status = ? - `, StatusActive) + `), StatusActive) if err != nil { return nil, err } diff --git a/docs/db.md b/docs/db.md new file mode 100644 index 0000000000..e6ca816794 --- /dev/null +++ b/docs/db.md @@ -0,0 +1,74 @@ +# Database Configuration + +Kandev supports two database backends: **SQLite** (default) and **PostgreSQL**. + +## SQLite (default) + +SQLite requires no external services and works out of the box. + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver | +| `KANDEV_DATABASE_PATH` | `./kandev.db` | Path to the SQLite database file | + +### Config file (`config.yaml`) + +```yaml +database: + driver: sqlite + path: ./kandev.db +``` + +No additional setup is needed — the database file is created automatically on first run. + +## PostgreSQL + +### 1. Create the database + +```sql +CREATE USER kandev WITH PASSWORD 'your-password'; +CREATE DATABASE kandev OWNER kandev; +``` + +### 2. Configure Kandev + +#### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KANDEV_DATABASE_DRIVER` | `sqlite` | Set to `postgres` | +| `KANDEV_DATABASE_HOST` | `localhost` | PostgreSQL host | +| `KANDEV_DATABASE_PORT` | `5432` | PostgreSQL port | +| `KANDEV_DATABASE_USER` | `kandev` | Database user | +| `KANDEV_DATABASE_PASSWORD` | (empty) | Database password | +| `KANDEV_DATABASE_DBNAME` | `kandev` | Database name | +| `KANDEV_DATABASE_SSLMODE` | `disable` | SSL mode (`disable`, `require`, `verify-ca`, `verify-full`) | +| `KANDEV_DATABASE_MAXCONNS` | `25` | Maximum open connections | +| `KANDEV_DATABASE_MINCONNS` | `5` | Minimum idle connections | + +#### Config file (`config.yaml`) + +```yaml +database: + driver: postgres + host: localhost + port: 5432 + user: kandev + password: your-password + dbName: kandev + sslMode: disable + maxConns: 25 + minConns: 5 +``` + +### 3. Run + +```bash +KANDEV_DATABASE_DRIVER=postgres \ +KANDEV_DATABASE_PASSWORD=your-password \ +kandev +``` + +Tables are created automatically on first run — no manual migrations needed.