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).
This commit is contained in:
didier committed 2026-06-03 20:15:18 -04:00
1 parent 726ab62c62
commit e700a81c25
10 files changed
+344 -64

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ OPENAI_API_KEY=sk-...
EMBEDDING_MODEL=text-embedding-3-small EMBEDDING_MODEL=text-embedding-3-small
MCP_TOKEN=change-me MCP_TOKEN=change-me
PORT=8080 PORT=8080
SQLITE_PATH=knowledge.db SQLITE_PATH=flashcard.db
+7 -7
View File
@@ -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. database and OpenAI embeddings.
## Tools ## Tools
### `store_knowledge` ### `store_flashcard`
Store a piece of knowledge for later retrieval. Automatically generates Store a flashcard for later retrieval. Automatically generates
embeddings via OpenAI and persists them in Qdrant. embeddings via OpenAI and persists them in Qdrant.
Parameters: Parameters:
@@ -17,8 +17,8 @@ Parameters:
- `user` (string, optional): User identifier - `user` (string, optional): User identifier
- `context` (string, optional): What triggered this - `context` (string, optional): What triggered this
### `search_knowledge` ### `search_flashcard`
Semantic search across stored knowledge. Semantic search across stored flashcards.
Parameters: Parameters:
- `query` (string, required): Search query - `query` (string, required): Search query
@@ -106,7 +106,7 @@ Add to your MCP client config:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"knowledge": { "flashcard": {
"url": "http://your-server:8080/mcp", "url": "http://your-server:8080/mcp",
"headers": { "headers": {
"Authorization": "Bearer my-secret-token" "Authorization": "Bearer my-secret-token"
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
description = "St-Pierre - MCP Knowledge Server"; description = "St-Pierre - MCP Flashcard Server";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
@@ -33,7 +33,7 @@
in in
{ {
options.services.st-pierre = { options.services.st-pierre = {
enable = lib.mkEnableOption "St-Pierre knowledge MCP server"; enable = lib.mkEnableOption "St-Pierre flashcard MCP server";
port = lib.mkOption { port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
+184 -26
View File
@@ -55,7 +55,7 @@ func main() {
emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL) emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL)
qdrantStore := newQdrantStore(qdrantClient, emb) qdrantStore := newQdrantStore(qdrantClient, emb)
sqlitePath := envOrDefault("SQLITE_PATH", "knowledge.db") sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db")
sqliteStore, err := newSqliteStore(sqlitePath) sqliteStore, err := newSqliteStore(sqlitePath)
if err != nil { if err != nil {
logger.Fatal("failed to init sqlite", zap.Error(err)) logger.Fatal("failed to init sqlite", zap.Error(err))
@@ -67,14 +67,16 @@ func main() {
} }
mcpServer := server.NewMCPServer( mcpServer := server.NewMCPServer(
"knowledge-mcp", "flashcard-mcp",
version, version,
server.WithToolCapabilities(true), server.WithToolCapabilities(true),
server.WithLogging(), server.WithLogging(),
) )
registerStoreKnowledge(mcpServer, sqliteStore, qdrantStore, logger) registerStoreFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerSearchKnowledge(mcpServer, sqliteStore, qdrantStore, logger) registerSearchFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerUpdateFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerDeleteFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
streamableServer := server.NewStreamableHTTPServer(mcpServer) 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( mcpServer.AddTool(
mcp.NewTool("store_knowledge", mcp.NewTool("store_flashcard",
mcp.WithDescription("Store a piece of knowledge for later retrieval. Use this to persist useful information discovered during a session."), mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session."),
mcp.WithString("note", mcp.WithString("note",
mcp.Required(), mcp.Required(),
mcp.Description("The markdown body — keep it concise, one paragraph usually"), 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.WithArray("repos",
mcp.Description("Optional list of repository names"), 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"), 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( mcpServer.AddTool(
mcp.NewTool("search_knowledge", mcp.NewTool("search_flashcard",
mcp.WithDescription("Search previously stored knowledge using semantic search. Returns the most relevant knowledge entries for the given query."), mcp.WithDescription("Search previously stored flashcards using semantic search. Returns the most relevant entries for the given query."),
mcp.WithString("query", mcp.WithString("query",
mcp.Required(), mcp.Required(),
mcp.Description("The search query"), 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)"), 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) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any) args, ok := request.Params.Arguments.(map[string]any)
if !ok { if !ok {
return mcp.NewToolResultError("invalid arguments"), nil return mcp.NewToolResultError("invalid arguments"), nil
} }
note, ok := args["note"].(string) publicID := parseInt64(args["public_id"])
if !ok || note == "" { if publicID == 0 {
return mcp.NewToolResultError("note is required and must be a non-empty string"), nil return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil
} }
repos := parseStringSlice(args["repos"]) latest, err := sqlite.getLatestByPublicID(ctx, publicID)
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)
if err != nil { 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)) 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) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any) args, ok := request.Params.Arguments.(map[string]any)
if !ok { if !ok {
+18
View File
@@ -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;
-14
View File
@@ -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;
+10
View File
@@ -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;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE flashcard ADD COLUMN delete_timestamp TEXT;
-- +goose Down
ALTER TABLE flashcard DROP COLUMN delete_timestamp;
+26 -6
View File
@@ -8,16 +8,19 @@ import (
"github.com/qdrant/go-client/qdrant" "github.com/qdrant/go-client/qdrant"
) )
const collectionName = "knowledge" const collectionName = "flashcard"
type entry struct { type entry struct {
ID int64 `json:"id"` ID int64 `json:"_id"`
PublicID int64 `json:"public_id"`
Version int `json:"version"`
Note string `json:"note"` Note string `json:"note"`
Repos []string `json:"repos,omitempty"` Repos []string `json:"repos,omitempty"`
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
Context string `json:"context,omitempty"` Context string `json:"context,omitempty"`
DeleteTimestamp string `json:"delete_timestamp,omitempty"`
Created string `json:"created"` Created string `json:"created"`
} }
@@ -58,14 +61,16 @@ func (s *qdrantStore) ensureCollection(ctx context.Context) error {
return nil 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) vec, err := s.embedder.embedText(ctx, note)
if err != nil { if err != nil {
return fmt.Errorf("embed text: %w", err) return fmt.Errorf("embed text: %w", err)
} }
payload := map[string]*qdrant.Value{ payload := map[string]*qdrant.Value{
"id": qdrant.NewValueString(strconv.FormatInt(id, 10)), "_id": qdrant.NewValueString(strconv.FormatInt(id, 10)),
"public_id": qdrant.NewValueString(strconv.FormatInt(publicID, 10)),
"version": qdrant.NewValueInt(int64(version)),
"content": qdrant.NewValueString(note), "content": qdrant.NewValueString(note),
} }
@@ -73,7 +78,7 @@ func (s *qdrantStore) index(ctx context.Context, id int64, note string) error {
CollectionName: collectionName, CollectionName: collectionName,
Points: []*qdrant.PointStruct{ Points: []*qdrant.PointStruct{
{ {
Id: qdrant.NewIDNum(uint64(id)), Id: qdrant.NewIDNum(uint64(publicID)),
Vectors: qdrant.NewVectors(vec...), Vectors: qdrant.NewVectors(vec...),
Payload: payload, Payload: payload,
}, },
@@ -86,6 +91,21 @@ func (s *qdrantStore) index(ctx context.Context, id int64, note string) error {
return nil 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) { func (s *qdrantStore) search(ctx context.Context, query string, limit uint64) ([]searchResult, error) {
vec, err := s.embedder.embedText(ctx, query) vec, err := s.embedder.embedText(ctx, query)
if err != nil { 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 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 { if n, err := strconv.ParseInt(v.GetStringValue(), 10, 64); err == nil {
r.Entry.ID = n r.Entry.ID = n
} }
+91 -8
View File
@@ -42,14 +42,14 @@ func newSqliteStore(path string) (*sqliteStore, error) {
return &sqliteStore{db: db}, nil 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) reposJSON := marshalStringSlice(repos)
tagsJSON := marshalStringSlice(tags) tagsJSON := marshalStringSlice(tags)
result, err := s.db.ExecContext(ctx, result, err := s.db.ExecContext(ctx,
`INSERT INTO knowledge (note, repos, host, tags, "user", context) `INSERT INTO flashcard (public_id, version, note, repos, host, tags, "user", context)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, 1, ?, ?, ?, ?, ?, ?)`,
note, reposJSON, host, tagsJSON, user, context_, publicID, note, reposJSON, host, tagsJSON, user, context_,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("insert: %w", err) 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) 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 return id, nil
} }
func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) { func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) {
row := s.db.QueryRowContext(ctx, row := s.db.QueryRowContext(ctx,
`SELECT id, note, repos, host, tags, "user", context, created `SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM knowledge WHERE id = ?`, id, FROM flashcard WHERE _id = ?`, id,
) )
var e entry var e entry
var reposJSON, tagsJSON sql.NullString 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -96,10 +103,71 @@ func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) {
if context_.Valid { if context_.Valid {
e.Context = context_.String e.Context = context_.String
} }
if deleteTimestamp.Valid {
e.DeleteTimestamp = deleteTimestamp.String
}
return &e, nil 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 { func (s *sqliteStore) close() error {
return s.db.Close() return s.db.Close()
} }
@@ -146,3 +214,18 @@ func parseOptionalString(raw any) string {
} }
return "" 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
}