From e700a81c2514e981045166127ed1ebaf484351d2 Mon Sep 17 00:00:00 2001 From: didier amyot Date: Wed, 3 Jun 2026 20:15:18 -0400 Subject: [PATCH] Add versioning, update tool, and soft-delete 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). --- .env.example | 2 +- README.md | 14 +- flake.nix | 4 +- main.go | 210 ++++++++++++++++++--- migrations/000001_create_flashcard.sql | 18 ++ migrations/000001_create_knowledge.sql | 14 -- migrations/000002_add_versioning.sql | 10 + migrations/000003_add_delete_timestamp.sql | 5 + qdrant.go | 48 +++-- sqlite.go | 99 +++++++++- 10 files changed, 352 insertions(+), 72 deletions(-) create mode 100644 migrations/000001_create_flashcard.sql delete mode 100644 migrations/000001_create_knowledge.sql create mode 100644 migrations/000002_add_versioning.sql create mode 100644 migrations/000003_add_delete_timestamp.sql diff --git a/.env.example b/.env.example index dd08071..649d929 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,4 @@ OPENAI_API_KEY=sk-... EMBEDDING_MODEL=text-embedding-3-small MCP_TOKEN=change-me PORT=8080 -SQLITE_PATH=knowledge.db +SQLITE_PATH=flashcard.db diff --git a/README.md b/README.md index e4903b0..2c2cc3b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# St-Pierre - Knowledge MCP Server +# St-Pierre - Flashcard MCP Server -Remote MCP server that stores and retrieves knowledge using Qdrant vector +Remote MCP server that stores and retrieves flashcards using Qdrant vector database and OpenAI embeddings. ## Tools -### `store_knowledge` -Store a piece of knowledge for later retrieval. Automatically generates +### `store_flashcard` +Store a flashcard for later retrieval. Automatically generates embeddings via OpenAI and persists them in Qdrant. Parameters: @@ -17,8 +17,8 @@ Parameters: - `user` (string, optional): User identifier - `context` (string, optional): What triggered this -### `search_knowledge` -Semantic search across stored knowledge. +### `search_flashcard` +Semantic search across stored flashcards. Parameters: - `query` (string, required): Search query @@ -106,7 +106,7 @@ Add to your MCP client config: ```json { "mcpServers": { - "knowledge": { + "flashcard": { "url": "http://your-server:8080/mcp", "headers": { "Authorization": "Bearer my-secret-token" diff --git a/flake.nix b/flake.nix index 99d37cf..2be5c01 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "St-Pierre - MCP Knowledge Server"; + description = "St-Pierre - MCP Flashcard Server"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -33,7 +33,7 @@ in { options.services.st-pierre = { - enable = lib.mkEnableOption "St-Pierre knowledge MCP server"; + enable = lib.mkEnableOption "St-Pierre flashcard MCP server"; port = lib.mkOption { type = lib.types.port; diff --git a/main.go b/main.go index b5a626b..0d8bbfd 100644 --- a/main.go +++ b/main.go @@ -55,7 +55,7 @@ func main() { emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL) qdrantStore := newQdrantStore(qdrantClient, emb) - sqlitePath := envOrDefault("SQLITE_PATH", "knowledge.db") + sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db") sqliteStore, err := newSqliteStore(sqlitePath) if err != nil { logger.Fatal("failed to init sqlite", zap.Error(err)) @@ -67,14 +67,16 @@ func main() { } mcpServer := server.NewMCPServer( - "knowledge-mcp", + "flashcard-mcp", version, server.WithToolCapabilities(true), server.WithLogging(), ) - registerStoreKnowledge(mcpServer, sqliteStore, qdrantStore, logger) - registerSearchKnowledge(mcpServer, sqliteStore, qdrantStore, logger) + registerStoreFlashcard(mcpServer, sqliteStore, qdrantStore, logger) + registerSearchFlashcard(mcpServer, sqliteStore, qdrantStore, logger) + registerUpdateFlashcard(mcpServer, sqliteStore, qdrantStore, logger) + registerDeleteFlashcard(mcpServer, sqliteStore, qdrantStore, logger) streamableServer := server.NewStreamableHTTPServer(mcpServer) @@ -119,14 +121,17 @@ func main() { } } -func registerStoreKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { +func registerStoreFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { mcpServer.AddTool( - mcp.NewTool("store_knowledge", - mcp.WithDescription("Store a piece of knowledge for later retrieval. Use this to persist useful information discovered during a session."), + mcp.NewTool("store_flashcard", + mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session."), mcp.WithString("note", mcp.Required(), mcp.Description("The markdown body — keep it concise, one paragraph usually"), ), + mcp.WithNumber("public_id", + mcp.Description("Optional public identifier. Auto-generated if omitted."), + ), mcp.WithArray("repos", mcp.Description("Optional list of repository names"), ), @@ -143,14 +148,14 @@ func registerStoreKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qd mcp.Description("Optional context — what triggered this"), ), ), - handleStoreKnowledge(sqlite, qdrant, logger), + handleStoreFlashcard(sqlite, qdrant, logger), ) } -func registerSearchKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { +func registerSearchFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { mcpServer.AddTool( - mcp.NewTool("search_knowledge", - mcp.WithDescription("Search previously stored knowledge using semantic search. Returns the most relevant knowledge entries for the given query."), + mcp.NewTool("search_flashcard", + mcp.WithDescription("Search previously stored flashcards using semantic search. Returns the most relevant entries for the given query."), mcp.WithString("query", mcp.Required(), mcp.Description("The search query"), @@ -159,34 +164,94 @@ func registerSearchKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, q mcp.Description("Maximum number of results to return (default: 5)"), ), ), - handleSearchKnowledge(sqlite, qdrant), + handleSearchFlashcard(sqlite, qdrant), ) } -func handleStoreKnowledge(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) server.ToolHandlerFunc { +func registerUpdateFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { + mcpServer.AddTool( + mcp.NewTool("update_flashcard", + mcp.WithDescription("Update an existing flashcard. Creates a new version while preserving history."), + mcp.WithNumber("public_id", + mcp.Required(), + mcp.Description("Public identifier of the flashcard to update"), + ), + mcp.WithString("note", + mcp.Description("The markdown body"), + ), + mcp.WithArray("repos", + mcp.Description("Optional list of repository names"), + ), + mcp.WithString("host", + mcp.Description("Optional hostname or null if universal"), + ), + mcp.WithArray("tags", + mcp.Description("Optional list of tags"), + ), + mcp.WithString("user", + mcp.Description("Optional user identifier"), + ), + mcp.WithString("context", + mcp.Description("Optional context — what triggered this"), + ), + ), + handleUpdateFlashcard(sqlite, qdrant, logger), + ) +} + +func handleUpdateFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args, ok := request.Params.Arguments.(map[string]any) if !ok { return mcp.NewToolResultError("invalid arguments"), nil } - note, ok := args["note"].(string) - if !ok || note == "" { - return mcp.NewToolResultError("note is required and must be a non-empty string"), nil + publicID := parseInt64(args["public_id"]) + if publicID == 0 { + return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil } - repos := parseStringSlice(args["repos"]) - host := parseOptionalString(args["host"]) - tags := parseStringSlice(args["tags"]) - user := parseOptionalString(args["user"]) - context_ := parseOptionalString(args["context"]) - - id, err := sqlite.save(ctx, note, host, user, context_, repos, tags) + latest, err := sqlite.getLatestByPublicID(ctx, publicID) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to query: %v", err)), nil + } + if latest == nil { + return mcp.NewToolResultError(fmt.Sprintf("no flashcard found with public_id %d", publicID)), nil } - if err := qdrant.index(ctx, id, note); err != nil { + note := latest.Note + repos := latest.Repos + host := latest.Host + tags := latest.Tags + user := latest.User + context_ := latest.Context + + if v := parseOptionalString(args["note"]); v != "" { + note = v + } + if v := parseStringSlice(args["repos"]); v != nil { + repos = v + } + if v := parseOptionalString(args["host"]); v != "" { + host = v + } + if v := parseStringSlice(args["tags"]); v != nil { + tags = v + } + if v := parseOptionalString(args["user"]); v != "" { + user = v + } + if v := parseOptionalString(args["context"]); v != "" { + context_ = v + } + + version := latest.Version + 1 + id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to persist update: %v", err)), nil + } + + if err := qdrant.index(ctx, id, publicID, version, note); err != nil { logger.Warn("qdrant index failed (entry in sqlite)", zap.Int64("id", id), zap.Error(err)) } @@ -204,7 +269,100 @@ func handleStoreKnowledge(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap. } } -func handleSearchKnowledge(sqlite *sqliteStore, qdrant *qdrantStore) server.ToolHandlerFunc { +func registerDeleteFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) { + mcpServer.AddTool( + mcp.NewTool("delete_flashcard", + mcp.WithDescription("Delete a flashcard. Creates a tombstone version and removes it from the vector index."), + mcp.WithNumber("public_id", + mcp.Required(), + mcp.Description("Public identifier of the flashcard to delete"), + ), + ), + handleDeleteFlashcard(sqlite, qdrant, logger), + ) +} + +func handleDeleteFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, ok := request.Params.Arguments.(map[string]any) + if !ok { + return mcp.NewToolResultError("invalid arguments"), nil + } + + publicID := parseInt64(args["public_id"]) + if publicID == 0 { + return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil + } + + latest, err := sqlite.getLatestByPublicID(ctx, publicID) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to query: %v", err)), nil + } + if latest == nil { + return mcp.NewToolResultError(fmt.Sprintf("no flashcard found with public_id %d", publicID)), nil + } + if latest.DeleteTimestamp != "" { + return mcp.NewToolResultError(fmt.Sprintf("flashcard %d is already deleted", publicID)), nil + } + + if _, err := sqlite.tombstone(ctx, publicID, latest.Version+1); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to tombstone: %v", err)), nil + } + + if err := qdrant.delete(ctx, publicID); err != nil { + logger.Warn("qdrant delete failed (tombstone in sqlite)", zap.Int64("public_id", publicID), zap.Error(err)) + } + + return mcp.NewToolResultText(fmt.Sprintf("flashcard %d deleted", publicID)), nil + } +} + +func handleStoreFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, ok := request.Params.Arguments.(map[string]any) + if !ok { + return mcp.NewToolResultError("invalid arguments"), nil + } + + note, ok := args["note"].(string) + if !ok || note == "" { + return mcp.NewToolResultError("note is required and must be a non-empty string"), nil + } + + repos := parseStringSlice(args["repos"]) + host := parseOptionalString(args["host"]) + tags := parseStringSlice(args["tags"]) + user := parseOptionalString(args["user"]) + context_ := parseOptionalString(args["context"]) + publicID := parseInt64(args["public_id"]) + + id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil + } + + if publicID == 0 { + publicID = id + } + if err := qdrant.index(ctx, id, publicID, 1, note); err != nil { + logger.Warn("qdrant index failed (entry in sqlite)", zap.Int64("id", id), zap.Error(err)) + } + + entry, err := sqlite.getByID(ctx, id) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to retrieve: %v", err)), nil + } + + output, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(output)), nil + } +} + +func handleSearchFlashcard(sqlite *sqliteStore, qdrant *qdrantStore) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args, ok := request.Params.Arguments.(map[string]any) if !ok { diff --git a/migrations/000001_create_flashcard.sql b/migrations/000001_create_flashcard.sql new file mode 100644 index 0000000..7826e47 --- /dev/null +++ b/migrations/000001_create_flashcard.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS flashcard ( + _id INTEGER PRIMARY KEY, + public_id INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + note TEXT NOT NULL, + repos TEXT, + host TEXT, + tags TEXT, + "user" TEXT, + context TEXT, + delete_timestamp TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(public_id, version) +); + +-- +goose Down +DROP TABLE IF EXISTS flashcard; diff --git a/migrations/000001_create_knowledge.sql b/migrations/000001_create_knowledge.sql deleted file mode 100644 index ea1c2c3..0000000 --- a/migrations/000001_create_knowledge.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS knowledge ( - id INTEGER PRIMARY KEY, - note TEXT NOT NULL, - repos TEXT, - host TEXT, - tags TEXT, - "user" TEXT, - context TEXT, - created TEXT NOT NULL DEFAULT (datetime('now')) -); - --- +goose Down -DROP TABLE IF EXISTS knowledge; diff --git a/migrations/000002_add_versioning.sql b/migrations/000002_add_versioning.sql new file mode 100644 index 0000000..b0d1641 --- /dev/null +++ b/migrations/000002_add_versioning.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE flashcard ADD COLUMN public_id INTEGER NOT NULL DEFAULT 0; +ALTER TABLE flashcard ADD COLUMN version INTEGER NOT NULL DEFAULT 1; +UPDATE flashcard SET public_id = _id WHERE public_id = 0; +CREATE UNIQUE INDEX IF NOT EXISTS idx_flashcard_public_id_version ON flashcard(public_id, version); + +-- +goose Down +DROP INDEX IF EXISTS idx_flashcard_public_id_version; +ALTER TABLE flashcard DROP COLUMN version; +ALTER TABLE flashcard DROP COLUMN public_id; diff --git a/migrations/000003_add_delete_timestamp.sql b/migrations/000003_add_delete_timestamp.sql new file mode 100644 index 0000000..767158c --- /dev/null +++ b/migrations/000003_add_delete_timestamp.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE flashcard ADD COLUMN delete_timestamp TEXT; + +-- +goose Down +ALTER TABLE flashcard DROP COLUMN delete_timestamp; diff --git a/qdrant.go b/qdrant.go index 8e91aa9..efa7403 100644 --- a/qdrant.go +++ b/qdrant.go @@ -8,17 +8,20 @@ import ( "github.com/qdrant/go-client/qdrant" ) -const collectionName = "knowledge" +const collectionName = "flashcard" type entry struct { - ID int64 `json:"id"` - Note string `json:"note"` - Repos []string `json:"repos,omitempty"` - Host string `json:"host,omitempty"` - Tags []string `json:"tags,omitempty"` - User string `json:"user,omitempty"` - Context string `json:"context,omitempty"` - Created string `json:"created"` + ID int64 `json:"_id"` + PublicID int64 `json:"public_id"` + Version int `json:"version"` + Note string `json:"note"` + Repos []string `json:"repos,omitempty"` + Host string `json:"host,omitempty"` + Tags []string `json:"tags,omitempty"` + User string `json:"user,omitempty"` + Context string `json:"context,omitempty"` + DeleteTimestamp string `json:"delete_timestamp,omitempty"` + Created string `json:"created"` } type searchResult struct { @@ -58,22 +61,24 @@ func (s *qdrantStore) ensureCollection(ctx context.Context) error { return nil } -func (s *qdrantStore) index(ctx context.Context, id int64, note string) error { +func (s *qdrantStore) index(ctx context.Context, id, publicID int64, version int, note string) error { vec, err := s.embedder.embedText(ctx, note) if err != nil { return fmt.Errorf("embed text: %w", err) } payload := map[string]*qdrant.Value{ - "id": qdrant.NewValueString(strconv.FormatInt(id, 10)), - "content": qdrant.NewValueString(note), + "_id": qdrant.NewValueString(strconv.FormatInt(id, 10)), + "public_id": qdrant.NewValueString(strconv.FormatInt(publicID, 10)), + "version": qdrant.NewValueInt(int64(version)), + "content": qdrant.NewValueString(note), } _, err = s.client.Upsert(ctx, &qdrant.UpsertPoints{ CollectionName: collectionName, Points: []*qdrant.PointStruct{ { - Id: qdrant.NewIDNum(uint64(id)), + Id: qdrant.NewIDNum(uint64(publicID)), Vectors: qdrant.NewVectors(vec...), Payload: payload, }, @@ -86,6 +91,21 @@ func (s *qdrantStore) index(ctx context.Context, id int64, note string) error { return nil } +func (s *qdrantStore) delete(ctx context.Context, publicID int64) error { + wait := true + _, err := s.client.Delete(ctx, &qdrant.DeletePoints{ + CollectionName: collectionName, + Wait: &wait, + Points: qdrant.NewPointsSelector( + qdrant.NewIDNum(uint64(publicID)), + ), + }) + if err != nil { + return fmt.Errorf("delete point: %w", err) + } + return nil +} + func (s *qdrantStore) search(ctx context.Context, query string, limit uint64) ([]searchResult, error) { vec, err := s.embedder.embedText(ctx, query) if err != nil { @@ -113,7 +133,7 @@ func (s *qdrantStore) search(ctx context.Context, query string, limit uint64) ([ } if payload := pt.GetPayload(); payload != nil { - if v, ok := payload["id"]; ok { + if v, ok := payload["_id"]; ok { if n, err := strconv.ParseInt(v.GetStringValue(), 10, 64); err == nil { r.Entry.ID = n } diff --git a/sqlite.go b/sqlite.go index 94deddf..18c160f 100644 --- a/sqlite.go +++ b/sqlite.go @@ -42,14 +42,14 @@ func newSqliteStore(path string) (*sqliteStore, error) { return &sqliteStore{db: db}, nil } -func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string) (int64, error) { +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 knowledge (note, repos, host, tags, "user", context) - VALUES (?, ?, ?, ?, ?, ?)`, - note, reposJSON, host, tagsJSON, user, context_, + `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) @@ -60,20 +60,27 @@ func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ strin 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, note, repos, host, tags, "user", context, created - FROM knowledge WHERE id = ?`, id, + `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_ sql.NullString + var host, user, context_, deleteTimestamp sql.NullString - err := row.Scan(&e.ID, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &e.Created) + 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 } @@ -96,10 +103,71 @@ func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) { 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() } @@ -146,3 +214,18 @@ func parseOptionalString(raw any) string { } 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 +}