package main import ( "context" "database/sql" "embed" "encoding/json" "fmt" "strings" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var embedMigrations embed.FS type sqliteStore struct { db *sql.DB } func newSqliteStore(path string) (*sqliteStore, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } db.SetMaxOpenConns(1) goose.SetBaseFS(embedMigrations) if err := goose.SetDialect("sqlite3"); err != nil { db.Close() return nil, fmt.Errorf("set dialect: %w", err) } if err := goose.Up(db, "migrations"); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } return &sqliteStore{db: db}, nil } func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64) (int64, error) { reposJSON := marshalStringSlice(repos) tagsJSON := marshalStringSlice(tags) result, err := s.db.ExecContext(ctx, `INSERT INTO flashcard (public_id, version, note, repos, host, tags, "user", context) VALUES (?, 1, ?, ?, ?, ?, ?, ?)`, publicID, note, reposJSON, host, tagsJSON, user, context_, ) if err != nil { return 0, fmt.Errorf("insert: %w", err) } id, err := result.LastInsertId() if err != nil { return 0, fmt.Errorf("last insert id: %w", err) } if publicID == 0 { _, err = s.db.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id) if err != nil { return 0, fmt.Errorf("set public_id: %w", err) } } return id, nil } func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) { row := s.db.QueryRowContext(ctx, `SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created FROM flashcard WHERE _id = ?`, id, ) var e entry var reposJSON, tagsJSON sql.NullString var host, user, context_, deleteTimestamp sql.NullString err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("query: %w", err) } if reposJSON.Valid { e.Repos = unmarshalStringSlice(reposJSON.String) } if tagsJSON.Valid { e.Tags = unmarshalStringSlice(tagsJSON.String) } if host.Valid { e.Host = host.String } if user.Valid { e.User = user.String } if context_.Valid { e.Context = context_.String } if deleteTimestamp.Valid { e.DeleteTimestamp = deleteTimestamp.String } return &e, nil } func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (*entry, error) { row := s.db.QueryRowContext(ctx, `SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created FROM flashcard WHERE public_id = ? ORDER BY version DESC LIMIT 1`, publicID, ) var e entry var reposJSON, tagsJSON sql.NullString var host, user, context_, deleteTimestamp sql.NullString err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("query: %w", err) } if reposJSON.Valid { e.Repos = unmarshalStringSlice(reposJSON.String) } if tagsJSON.Valid { e.Tags = unmarshalStringSlice(tagsJSON.String) } if host.Valid { e.Host = host.String } if user.Valid { e.User = user.String } if context_.Valid { e.Context = context_.String } if deleteTimestamp.Valid { e.DeleteTimestamp = deleteTimestamp.String } return &e, nil } func (s *sqliteStore) tombstone(ctx context.Context, publicID int64, version int) (int64, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO flashcard (public_id, version, note, delete_timestamp) VALUES (?, ?, '', datetime('now'))`, publicID, version, ) if err != nil { return 0, fmt.Errorf("tombstone: %w", err) } id, err := result.LastInsertId() if err != nil { return 0, fmt.Errorf("last insert id: %w", err) } return id, nil } func (s *sqliteStore) close() error { return s.db.Close() } func marshalStringSlice(ss []string) string { if len(ss) == 0 { return "[]" } b, _ := json.Marshal(ss) return string(b) } func unmarshalStringSlice(raw string) []string { if raw == "" || raw == "[]" || raw == "null" { return nil } var ss []string if err := json.Unmarshal([]byte(raw), &ss); err != nil { return nil } return ss } func parseStringSlice(raw any) []string { if raw == nil { return nil } arr, ok := raw.([]any) if !ok { return nil } result := make([]string, 0, len(arr)) for _, v := range arr { if s, ok := v.(string); ok { result = append(result, s) } } return result } func parseOptionalString(raw any) string { if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" { return s } return "" } func parseInt64(raw any) int64 { if raw == nil { return 0 } switch v := raw.(type) { case float64: return int64(v) case int64: return v case int: return int64(v) } return 0 }