Schema: public_id + version columns with UNIQUE constraint, delete_timestamp for soft-delete. Tools: store_flashcard accepts optional public_id, update_flashcard creates a new version preserving history, delete_flashcard inserts a tombstone row and removes the Qdrant point. Qdrant: point ID is public_id (only latest version indexed).
232 lines
5.0 KiB
Go
232 lines
5.0 KiB
Go
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
|
|
}
|