package main import ( "context" "database/sql" "embed" "encoding/json" "fmt" "os" "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) { dir := path if idx := strings.LastIndexByte(path, '/'); idx > 0 { dir = path[:idx] if fi, err := os.Stat(dir); err != nil { return nil, fmt.Errorf("sqlite dir %s: %w", dir, err) } else if fi.Mode().Perm()&0200 == 0 { return nil, fmt.Errorf("sqlite dir %s is not writable (mode %o)", dir, fi.Mode().Perm()) } } db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } db.SetMaxOpenConns(1) if _, err := db.Exec("SELECT 1"); err != nil { db.Close() return nil, fmt.Errorf("sqlite ping: %w", err) } if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { db.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) } 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) listAllPublicIDs(ctx context.Context) ([]int64, error) { rows, err := s.db.QueryContext(ctx, `SELECT f.public_id FROM flashcard f WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id) AND f.delete_timestamp IS NULL AND f.public_id != 0 ORDER BY f.public_id`, ) if err != nil { return nil, fmt.Errorf("list ids: %w", err) } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan id: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows: %w", err) } return ids, nil } func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*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 = ? AND version = ?`, publicID, version, ) 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) saveVote(ctx context.Context, flashcardID int64, value int, user, repo, message string) (*vote, error) { _, err := s.db.ExecContext(ctx, `INSERT INTO vote (flashcard_id, value, "user", repo, message) VALUES (?, ?, ?, ?, ?) ON CONFLICT(flashcard_id, "user", repo) DO UPDATE SET value = excluded.value, message = excluded.message`, flashcardID, value, user, repo, message, ) if err != nil { return nil, fmt.Errorf("upsert vote: %w", err) } row := s.db.QueryRowContext(ctx, `SELECT _id, flashcard_id, value, "user", repo, message, created FROM vote WHERE flashcard_id = ? AND "user" = ? AND repo = ?`, flashcardID, user, repo, ) var v vote err = row.Scan(&v.ID, &v.FlashcardID, &v.Value, &v.User, &v.Repo, &v.Message, &v.Created) if err != nil { return nil, fmt.Errorf("read vote: %w", err) } return &v, 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) indexFTS(ctx context.Context, id int64, note string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO flashcard_fts(rowid, note) VALUES (?, ?)`, id, note, ) if err != nil { return fmt.Errorf("index fts: %w", err) } return nil } func (s *sqliteStore) deleteFTS(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM flashcard_fts WHERE rowid = ?`, id, ) if err != nil { return fmt.Errorf("delete fts: %w", err) } return nil } func (s *sqliteStore) searchFTS(ctx context.Context, query string, limit uint64) ([]searchResult, error) { if limit == 0 { limit = 5 } rows, err := s.db.QueryContext(ctx, `SELECT rowid, rank FROM flashcard_fts WHERE note MATCH ? ORDER BY rank LIMIT ?`, query, limit, ) if err != nil { return nil, fmt.Errorf("fts query: %w", err) } type hit struct { id int64 rank float64 } var hits []hit for rows.Next() { var h hit if err := rows.Scan(&h.id, &h.rank); err != nil { rows.Close() return nil, fmt.Errorf("scan: %w", err) } hits = append(hits, h) } rows.Close() if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows: %w", err) } results := make([]searchResult, 0, len(hits)) for _, h := range hits { entry, err := s.getByID(ctx, h.id) if err != nil { return nil, fmt.Errorf("get entry: %w", err) } if entry == nil { continue } results = append(results, searchResult{ Entry: *entry, Score: float32(-h.rank), }) } return results, 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 }