summary - use the indexed primary key (`id`) for `getbyid`, `updateserver`, and list ordering/cursor, instead of extracting `_meta...id` from jsonb - in `updateserver`, error if the body tries to change the id; set `meta.official.id` when missing changes - database: `getbyid` → `where id = $1` - database: `updateserver` → `where id = $2`; fail on id mismatch; set id if missing - database: `list` orders by `id` and paginates via `id > $cursor` - no schema or api changes <details> <summary>performance</summary> - env: repo docker compose postgres, 5k–10k rows, warm cache - getbyid: `id` ≈ 0.02 ms vs jsonb path ≈ 0.80 ms - update: `id` ≈ 0.15 ms vs jsonb path ≈ 0.78 ms - list (limit 100): order by `id` ≈ 0.13 ms vs jsonb path id ≈ 2.24 ms (~17x slower) </details> <details> <summary>explain analyze snippets</summary> getbyid by `id` ``` Index Scan using idx_benchmark_servers_id on benchmark_servers (actual time≈0.01–0.02 ms) Buffers: shared hit≈3 ``` getbyid by jsonb path ``` Seq Scan on benchmark_servers (actual time≈0.43–0.83 ms) Filter: ((value->'_meta'->'io.modelcontextprotocol.registry/official'->>'id') = $1) Rows Removed by Filter: ≈4999 Buffers: shared hit≈455 ``` update by `id` ``` Update on benchmark_servers … -> Index Scan using idx_benchmark_servers_id … (actual time≈0.007 ms) Execution Time: ≈0.15 ms ``` update by jsonb path ``` Update on benchmark_servers … -> Seq Scan on benchmark_servers … Rows Removed by Filter: ≈4999 Execution Time: ≈0.78 ms ``` </details> <details> <summary>reproduce quickly</summary> - start postgres: `docker compose up -d postgres` - create a simple table (id varchar pk, value jsonb) with several thousand rows - compare plans: `explain (analyze, buffers) select value from … where id = $1` vs the jsonb path predicate </details> --------- Co-authored-by: Adam Jones <adamj@anthropic.com>
266 lines
7 KiB
Go
266 lines
7 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
)
|
|
|
|
// PostgreSQL is an implementation of the Database interface using PostgreSQL
|
|
type PostgreSQL struct {
|
|
conn *pgx.Conn
|
|
}
|
|
|
|
// NewPostgreSQL creates a new instance of the PostgreSQL database
|
|
func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, error) {
|
|
conn, err := pgx.Connect(ctx, connectionURI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
|
|
}
|
|
|
|
// Test the connection
|
|
if err = conn.Ping(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to ping PostgreSQL: %w", err)
|
|
}
|
|
|
|
// Run migrations
|
|
migrator := NewMigrator(conn)
|
|
if err := migrator.Migrate(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to run database migrations: %w", err)
|
|
}
|
|
|
|
return &PostgreSQL{
|
|
conn: conn,
|
|
}, nil
|
|
}
|
|
|
|
//nolint:cyclop // Database filtering logic is inherently complex but clear
|
|
func (db *PostgreSQL) List(
|
|
ctx context.Context,
|
|
filter *ServerFilter,
|
|
cursor string,
|
|
limit int,
|
|
) ([]*apiv0.ServerJSON, string, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return nil, "", ctx.Err()
|
|
}
|
|
|
|
// Build WHERE clause for filtering
|
|
var whereConditions []string
|
|
args := []any{}
|
|
argIndex := 1
|
|
|
|
// Add filters using JSON operators
|
|
if filter != nil {
|
|
if filter.Name != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' = $%d", argIndex))
|
|
args = append(args, *filter.Name)
|
|
argIndex++
|
|
}
|
|
if filter.RemoteURL != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex))
|
|
args = append(args, *filter.RemoteURL)
|
|
argIndex++
|
|
}
|
|
if filter.UpdatedSince != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("(value->'_meta'->'io.modelcontextprotocol.registry/official'->>'updated_at')::timestamp > $%d", argIndex))
|
|
args = append(args, *filter.UpdatedSince)
|
|
argIndex++
|
|
}
|
|
if filter.SubstringName != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' ILIKE $%d", argIndex))
|
|
args = append(args, "%"+*filter.SubstringName+"%")
|
|
argIndex++
|
|
}
|
|
if filter.Version != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("(value->'version_detail'->>'version') = $%d", argIndex))
|
|
args = append(args, *filter.Version)
|
|
argIndex++
|
|
}
|
|
if filter.IsLatest != nil {
|
|
whereConditions = append(whereConditions, fmt.Sprintf("(value->'_meta'->'io.modelcontextprotocol.registry/official'->>'is_latest')::boolean = $%d", argIndex))
|
|
args = append(args, *filter.IsLatest)
|
|
argIndex++
|
|
}
|
|
}
|
|
|
|
// Add cursor pagination using primary key ID
|
|
if cursor != "" {
|
|
if _, err := uuid.Parse(cursor); err != nil {
|
|
return nil, "", fmt.Errorf("invalid cursor format: %w", err)
|
|
}
|
|
whereConditions = append(whereConditions, fmt.Sprintf("id > $%d", argIndex))
|
|
args = append(args, cursor)
|
|
argIndex++
|
|
}
|
|
|
|
// Build the WHERE clause
|
|
whereClause := ""
|
|
if len(whereConditions) > 0 {
|
|
whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
|
|
}
|
|
|
|
// Simple query on servers table
|
|
query := fmt.Sprintf(`
|
|
SELECT value
|
|
FROM servers
|
|
%s
|
|
ORDER BY id
|
|
LIMIT $%d
|
|
`, whereClause, argIndex)
|
|
args = append(args, limit)
|
|
|
|
rows, err := db.conn.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to query servers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []*apiv0.ServerJSON
|
|
for rows.Next() {
|
|
var valueJSON []byte
|
|
|
|
err := rows.Scan(&valueJSON)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to scan server row: %w", err)
|
|
}
|
|
|
|
// Parse the complete ServerJSON from JSONB
|
|
var serverJSON apiv0.ServerJSON
|
|
if err := json.Unmarshal(valueJSON, &serverJSON); err != nil {
|
|
return nil, "", fmt.Errorf("failed to unmarshal server JSON: %w", err)
|
|
}
|
|
|
|
results = append(results, &serverJSON)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, "", fmt.Errorf("error iterating rows: %w", err)
|
|
}
|
|
|
|
// Determine next cursor using registry metadata ID
|
|
nextCursor := ""
|
|
if len(results) > 0 && len(results) >= limit {
|
|
lastResult := results[len(results)-1]
|
|
if lastResult.Meta != nil && lastResult.Meta.Official != nil {
|
|
nextCursor = lastResult.Meta.Official.ID
|
|
}
|
|
}
|
|
|
|
return results, nextCursor, nil
|
|
}
|
|
|
|
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
query := `
|
|
SELECT value
|
|
FROM servers
|
|
WHERE id = $1
|
|
`
|
|
|
|
var valueJSON []byte
|
|
|
|
err := db.conn.QueryRow(ctx, query, id).Scan(&valueJSON)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to get server by ID: %w", err)
|
|
}
|
|
|
|
// Parse the complete ServerJSON from JSONB
|
|
var serverJSON apiv0.ServerJSON
|
|
if err := json.Unmarshal(valueJSON, &serverJSON); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal server JSON: %w", err)
|
|
}
|
|
|
|
return &serverJSON, nil
|
|
}
|
|
|
|
// CreateServer adds a new server to the database
|
|
func (db *PostgreSQL) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Get the ID from the registry metadata
|
|
if server.Meta == nil || server.Meta.Official == nil {
|
|
return nil, fmt.Errorf("server must have registry metadata with ID")
|
|
}
|
|
|
|
id := server.Meta.Official.ID
|
|
|
|
// Marshal the complete server to JSONB
|
|
valueJSON, err := json.Marshal(server)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal server JSON: %w", err)
|
|
}
|
|
|
|
// Insert into simple servers table
|
|
query := `
|
|
INSERT INTO servers (id, value)
|
|
VALUES ($1, $2)
|
|
`
|
|
|
|
_, err = db.conn.Exec(ctx, query, id, valueJSON)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to insert server: %w", err)
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
// UpdateServer updates an existing server record with new server details
|
|
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Validate that meta structure exists and ID matches path
|
|
if server.Meta == nil || server.Meta.Official == nil || server.Meta.Official.ID != id {
|
|
return nil, fmt.Errorf("%w: io.modelcontextprotocol.registry/official.id must match path id (%s)", ErrInvalidInput, id)
|
|
}
|
|
|
|
// Marshal updated server
|
|
valueJSON, err := json.Marshal(server)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal updated server: %w", err)
|
|
}
|
|
|
|
// Update the complete server record in simple table
|
|
query := `
|
|
UPDATE servers
|
|
SET value = $1
|
|
WHERE id = $2
|
|
`
|
|
|
|
result, err := db.conn.Exec(ctx, query, valueJSON, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update server: %w", err)
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func (db *PostgreSQL) Close() error {
|
|
return db.conn.Close(context.Background())
|
|
}
|