Files
kandev/apps/backend/internal/editors/store/sqlite.go
T
2026-02-20 18:30:33 +00:00

345 lines
8.8 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"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 *sqlx.DB // writer
ro *sqlx.DB // reader
ownsDB bool
}
var _ Repository = (*sqliteRepository)(nil)
func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) {
return newSQLiteRepository(writer, reader, false)
}
func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB}
if err := repo.initSchema(); err != nil {
if ownsDB {
if closeErr := writer.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
}
}
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
if err := repo.ensureDefaults(context.Background()); err != nil {
if ownsDB {
if closeErr := writer.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close database after defaults error: %w", closeErr)
}
}
return nil, fmt.Errorf("failed to seed editor defaults: %w", err)
}
return repo, nil
}
func (r *sqliteRepository) Close() error {
if !r.ownsDB {
return nil
}
return r.db.Close()
}
func (r *sqliteRepository) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS editors (
id TEXT PRIMARY KEY,
type TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
kind TEXT NOT NULL,
command TEXT NOT NULL,
scheme TEXT NOT NULL,
config TEXT,
installed INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
`
_, err := r.db.Exec(schema)
return err
}
func (r *sqliteRepository) ensureDefaults(ctx context.Context) error {
definitions, err := discovery.LoadDefaults()
if err != nil {
return err
}
typeSet := make(map[string]struct{}, len(definitions))
now := time.Now().UTC()
defaults := make([]*models.Editor, 0, len(definitions))
for _, def := range definitions {
if def.Type == "" || def.Name == "" {
continue
}
typeSet[def.Type] = struct{}{}
// Internal editors (no command) are always installed.
// External editors check PATH via exec.LookPath.
kind := "built_in"
installed := false
if def.Command == "" {
kind = "internal_vscode"
installed = true
} else {
_, lookupErr := exec.LookPath(def.Command)
installed = lookupErr == nil
}
defaults = append(defaults, &models.Editor{
ID: uuid.New().String(),
Type: def.Type,
Name: def.Name,
Kind: kind,
Command: def.Command,
Scheme: def.Scheme,
Installed: installed,
Enabled: def.Enabled,
CreatedAt: now,
UpdatedAt: now,
})
}
if len(defaults) == 0 {
return fmt.Errorf("no editor defaults loaded")
}
if err := r.UpsertEditors(ctx, defaults); err != nil {
return err
}
if err := r.deleteBuiltinsNotIn(ctx, typeSet); err != nil {
return err
}
return nil
}
func (r *sqliteRepository) ListEditors(ctx context.Context) ([]*models.Editor, error) {
rows, err := r.ro.QueryContext(ctx, `
SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at
FROM editors
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var editors []*models.Editor
for rows.Next() {
editor, err := scanEditor(rows)
if err != nil {
return nil, err
}
editors = append(editors, editor)
}
if err := rows.Err(); err != nil {
return nil, err
}
return editors, nil
}
func (r *sqliteRepository) GetEditorByType(ctx context.Context, editorType string) (*models.Editor, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at
FROM editors
WHERE type = ?
`), editorType)
return scanEditor(row)
}
func (r *sqliteRepository) GetEditorByID(ctx context.Context, editorID string) (*models.Editor, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at
FROM editors
WHERE id = ?
`), editorID)
return scanEditor(row)
}
func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models.Editor) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
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
name = excluded.name,
kind = excluded.kind,
command = excluded.command,
scheme = excluded.scheme,
config = excluded.config,
installed = excluded.installed,
enabled = excluded.enabled,
updated_at = excluded.updated_at
`))
if err != nil {
_ = tx.Rollback()
return err
}
defer func() {
if err := stmt.Close(); err != nil {
_ = tx.Rollback()
}
}()
for _, editor := range editors {
if editor.ID == "" {
editor.ID = uuid.New().String()
}
if editor.CreatedAt.IsZero() {
editor.CreatedAt = time.Now().UTC()
}
editor.UpdatedAt = time.Now().UTC()
configValue, err := marshalConfig(editor.Config)
if err != nil {
_ = tx.Rollback()
return err
}
_, err = stmt.ExecContext(
ctx,
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,
)
if err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *sqliteRepository) CreateEditor(ctx context.Context, editor *models.Editor) error {
if editor == nil {
return fmt.Errorf("editor is nil")
}
if editor.ID == "" {
editor.ID = uuid.New().String()
}
if editor.CreatedAt.IsZero() {
editor.CreatedAt = time.Now().UTC()
}
editor.UpdatedAt = time.Now().UTC()
configValue, err := marshalConfig(editor.Config)
if err != nil {
return err
}
_, 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, dialect.BoolToInt(editor.Installed), dialect.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt)
return err
}
func (r *sqliteRepository) UpdateEditor(ctx context.Context, editor *models.Editor) error {
if editor == nil {
return fmt.Errorf("editor is nil")
}
editor.UpdatedAt = time.Now().UTC()
configValue, err := marshalConfig(editor.Config)
if err != nil {
return err
}
_, 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, 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, r.db.Rebind(`DELETE FROM editors WHERE id = ?`), editorID)
return err
}
func scanEditor(scanner interface{ Scan(dest ...any) error }) (*models.Editor, error) {
editor := &models.Editor{}
var config sql.NullString
var installed int
var enabled int
if err := scanner.Scan(
&editor.ID,
&editor.Type,
&editor.Name,
&editor.Kind,
&editor.Command,
&editor.Scheme,
&config,
&installed,
&enabled,
&editor.CreatedAt,
&editor.UpdatedAt,
); err != nil {
return nil, err
}
if config.Valid && config.String != "" {
editor.Config = json.RawMessage(config.String)
}
editor.Installed = installed == 1
editor.Enabled = enabled == 1
return editor, nil
}
func marshalConfig(config json.RawMessage) (string, error) {
if len(config) == 0 {
return "", nil
}
if !json.Valid(config) {
return "", fmt.Errorf("invalid editor config json")
}
return string(config), nil
}
func (r *sqliteRepository) deleteBuiltinsNotIn(ctx context.Context, types map[string]struct{}) error {
if len(types) == 0 {
return nil
}
placeholders := make([]string, 0, len(types))
args := make([]any, 0, len(types)+1)
for editorType := range types {
placeholders = append(placeholders, "?")
args = append(args, editorType)
}
query := fmt.Sprintf(
`DELETE FROM editors WHERE kind = 'built_in' AND type NOT IN (%s)`,
strings.Join(placeholders, ","),
)
_, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...)
return err
}