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:
1 parent
726ab62c62
commit
e700a81c25
10 files changed
+344
-64
No files matched your search
+1
-1
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -8,16 +8,19 @@ import (
|
||||
"github.com/qdrant/go-client/qdrant"
|
||||
)
|
||||
|
||||
const collectionName = "knowledge"
|
||||
const collectionName = "flashcard"
|
||||
|
||||
type entry struct {
|
||||
ID int64 `json:"id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -58,14 +61,16 @@ 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)),
|
||||
"_id": qdrant.NewValueString(strconv.FormatInt(id, 10)),
|
||||
"public_id": qdrant.NewValueString(strconv.FormatInt(publicID, 10)),
|
||||
"version": qdrant.NewValueInt(int64(version)),
|
||||
"content": qdrant.NewValueString(note),
|
||||
}
|
||||
|
||||
@@ -73,7 +78,7 @@ func (s *qdrantStore) index(ctx context.Context, id int64, note string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user