mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-24 20:06:21 +02:00
254 lines
7.2 KiB
Go
254 lines
7.2 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/syntrex-lab/gomcp/internal/domain/crystal"
|
|
)
|
|
|
|
// CrystalRepo implements crystal.CrystalStore using SQLite.
|
|
// Compatible with crystals.db schema.
|
|
type CrystalRepo struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewCrystalRepo creates a CrystalRepo and ensures the schema exists.
|
|
func NewCrystalRepo(db *DB) (*CrystalRepo, error) {
|
|
repo := &CrystalRepo{db: db}
|
|
if err := repo.migrate(); err != nil {
|
|
return nil, fmt.Errorf("crystal repo migrate: %w", err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func (r *CrystalRepo) migrate() error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS crystals (
|
|
path TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
content BLOB,
|
|
primitives_count INTEGER,
|
|
token_count INTEGER,
|
|
indexed_at REAL,
|
|
source_mtime REAL,
|
|
source_hash TEXT,
|
|
last_validated REAL,
|
|
human_confirmed INTEGER DEFAULT 0
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS metadata (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_mtime ON crystals(source_mtime)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_indexed ON crystals(indexed_at)`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := r.db.Exec(s); err != nil {
|
|
return fmt.Errorf("exec migration: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// serializedCrystal is the JSON structure stored in the content BLOB.
|
|
type serializedCrystal struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
TokenCount int `json:"token_count"`
|
|
ContentHash string `json:"content_hash"`
|
|
Primitives []crystal.Primitive `json:"primitives"`
|
|
}
|
|
|
|
// Upsert inserts or replaces a crystal.
|
|
func (r *CrystalRepo) Upsert(ctx context.Context, c *crystal.Crystal) error {
|
|
sc := serializedCrystal{
|
|
Path: c.Path,
|
|
Name: c.Name,
|
|
TokenCount: c.TokenCount,
|
|
ContentHash: c.ContentHash,
|
|
Primitives: c.Primitives,
|
|
}
|
|
blob, err := json.Marshal(sc)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal crystal: %w", err)
|
|
}
|
|
|
|
_, err = r.db.Exec(`INSERT OR REPLACE INTO crystals
|
|
(path, name, content, primitives_count, token_count,
|
|
indexed_at, source_mtime, source_hash, last_validated, human_confirmed)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
c.Path, c.Name, blob, c.PrimitivesCount, c.TokenCount,
|
|
c.IndexedAt, c.SourceMtime, c.SourceHash,
|
|
c.LastValidated, boolToInt(c.HumanConfirmed),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert crystal: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a crystal by path.
|
|
func (r *CrystalRepo) Get(ctx context.Context, path string) (*crystal.Crystal, error) {
|
|
row := r.db.QueryRow(`SELECT path, name, content, primitives_count, token_count,
|
|
indexed_at, source_mtime, source_hash, last_validated, human_confirmed
|
|
FROM crystals WHERE path = ?`, path)
|
|
|
|
var c crystal.Crystal
|
|
var blob []byte
|
|
var lastValidated sql.NullFloat64
|
|
var humanConfirmed int
|
|
|
|
err := row.Scan(&c.Path, &c.Name, &blob, &c.PrimitivesCount, &c.TokenCount,
|
|
&c.IndexedAt, &c.SourceMtime, &c.SourceHash, &lastValidated, &humanConfirmed)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("crystal %s not found", path)
|
|
}
|
|
return nil, fmt.Errorf("scan crystal: %w", err)
|
|
}
|
|
|
|
if lastValidated.Valid {
|
|
c.LastValidated = lastValidated.Float64
|
|
}
|
|
c.HumanConfirmed = humanConfirmed != 0
|
|
|
|
if len(blob) > 0 {
|
|
var sc serializedCrystal
|
|
if err := json.Unmarshal(blob, &sc); err != nil {
|
|
return nil, fmt.Errorf("unmarshal crystal content: %w", err)
|
|
}
|
|
c.Primitives = sc.Primitives
|
|
c.ContentHash = sc.ContentHash
|
|
}
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
// Delete removes a crystal by path.
|
|
func (r *CrystalRepo) Delete(ctx context.Context, path string) error {
|
|
result, err := r.db.Exec(`DELETE FROM crystals WHERE path = ?`, path)
|
|
if err != nil {
|
|
return fmt.Errorf("delete crystal: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("crystal %s not found", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns crystals matching a path pattern. Empty pattern returns all.
|
|
func (r *CrystalRepo) List(ctx context.Context, pattern string, limit int) ([]*crystal.Crystal, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
|
|
var rows *sql.Rows
|
|
var err error
|
|
if pattern == "" {
|
|
rows, err = r.db.Query(`SELECT path, name, content, primitives_count, token_count,
|
|
indexed_at, source_mtime, source_hash, last_validated, human_confirmed
|
|
FROM crystals ORDER BY indexed_at DESC LIMIT ?`, limit)
|
|
} else {
|
|
rows, err = r.db.Query(`SELECT path, name, content, primitives_count, token_count,
|
|
indexed_at, source_mtime, source_hash, last_validated, human_confirmed
|
|
FROM crystals WHERE path LIKE ? ORDER BY indexed_at DESC LIMIT ?`, pattern, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list crystals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanCrystals(rows)
|
|
}
|
|
|
|
// Search searches crystal primitives by name/value containing query.
|
|
func (r *CrystalRepo) Search(ctx context.Context, query string, limit int) ([]*crystal.Crystal, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
|
|
// Search in content BLOB (JSON text stored as BLOB) for primitive names/values.
|
|
// CAST to TEXT required because SQLite LIKE doesn't match on raw BLOB.
|
|
rows, err := r.db.Query(`SELECT path, name, content, primitives_count, token_count,
|
|
indexed_at, source_mtime, source_hash, last_validated, human_confirmed
|
|
FROM crystals WHERE CAST(content AS TEXT) LIKE ? OR name LIKE ? LIMIT ?`,
|
|
"%"+query+"%", "%"+query+"%", limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search crystals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanCrystals(rows)
|
|
}
|
|
|
|
// Stats returns aggregate statistics.
|
|
func (r *CrystalRepo) Stats(ctx context.Context) (*crystal.CrystalStats, error) {
|
|
stats := &crystal.CrystalStats{
|
|
ByExtension: make(map[string]int),
|
|
}
|
|
|
|
row := r.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(primitives_count),0), COALESCE(SUM(token_count),0) FROM crystals`)
|
|
if err := row.Scan(&stats.TotalCrystals, &stats.TotalPrimitives, &stats.TotalTokens); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Count by file extension.
|
|
rows, err := r.db.Query(`SELECT path FROM crystals`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var p string
|
|
if err := rows.Scan(&p); err != nil {
|
|
return nil, err
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(p))
|
|
if ext == "" {
|
|
ext = "(no ext)"
|
|
}
|
|
stats.ByExtension[ext]++
|
|
}
|
|
return stats, rows.Err()
|
|
}
|
|
|
|
func scanCrystals(rows *sql.Rows) ([]*crystal.Crystal, error) {
|
|
var result []*crystal.Crystal
|
|
for rows.Next() {
|
|
var c crystal.Crystal
|
|
var blob []byte
|
|
var lastValidated sql.NullFloat64
|
|
var humanConfirmed int
|
|
|
|
err := rows.Scan(&c.Path, &c.Name, &blob, &c.PrimitivesCount, &c.TokenCount,
|
|
&c.IndexedAt, &c.SourceMtime, &c.SourceHash, &lastValidated, &humanConfirmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan crystal row: %w", err)
|
|
}
|
|
|
|
if lastValidated.Valid {
|
|
c.LastValidated = lastValidated.Float64
|
|
}
|
|
c.HumanConfirmed = humanConfirmed != 0
|
|
|
|
if len(blob) > 0 {
|
|
var sc serializedCrystal
|
|
if err := json.Unmarshal(blob, &sc); err != nil {
|
|
return nil, fmt.Errorf("unmarshal crystal content: %w", err)
|
|
}
|
|
c.Primitives = sc.Primitives
|
|
c.ContentHash = sc.ContentHash
|
|
}
|
|
|
|
result = append(result, &c)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Ensure CrystalRepo implements crystal.CrystalStore.
|
|
var _ crystal.CrystalStore = (*CrystalRepo)(nil)
|