34ecadf5a4
Nueva columna params_schema en functions con migración 009. Almacena JSON con descripción semántica de inputs/outputs para que agentes razonen sobre composabilidad de funciones. Incluye: campo en modelo Go, parsing de params/output del frontmatter YAML, serialización a JSON, FTS5 rebuild con nueva columna, hash de contenido actualizado, y warning en indexer cuando faltan params.
855 lines
23 KiB
Go
855 lines
23 KiB
Go
package registry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func marshalStrings(ss []string) string {
|
|
if ss == nil {
|
|
ss = []string{}
|
|
}
|
|
b, _ := json.Marshal(ss)
|
|
return string(b)
|
|
}
|
|
|
|
func unmarshalStrings(s string) []string {
|
|
var out []string
|
|
json.Unmarshal([]byte(s), &out)
|
|
if out == nil {
|
|
out = []string{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func marshalJSON(v map[string]any) string {
|
|
if v == nil {
|
|
v = map[string]any{}
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
func unmarshalJSON(s string) map[string]any {
|
|
var out map[string]any
|
|
json.Unmarshal([]byte(s), &out)
|
|
if out == nil {
|
|
out = map[string]any{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func marshalProps(ps []PropDef) string {
|
|
if ps == nil {
|
|
ps = []PropDef{}
|
|
}
|
|
b, _ := json.Marshal(ps)
|
|
return string(b)
|
|
}
|
|
|
|
func unmarshalProps(s string) []PropDef {
|
|
var out []PropDef
|
|
json.Unmarshal([]byte(s), &out)
|
|
return out
|
|
}
|
|
|
|
// InsertFunction inserts or replaces a function entry.
|
|
func (db *DB) InsertFunction(f *Function) error {
|
|
now := time.Now().UTC()
|
|
if f.CreatedAt.IsZero() {
|
|
f.CreatedAt = now
|
|
}
|
|
if f.UpdatedAt.IsZero() {
|
|
f.UpdatedAt = now
|
|
}
|
|
|
|
if f.ID == "" {
|
|
f.ID = GenerateID(f.Name, f.Lang, f.Domain)
|
|
}
|
|
|
|
var hasState *int
|
|
if f.HasState != nil {
|
|
v := 0
|
|
if *f.HasState {
|
|
v = 1
|
|
}
|
|
hasState = &v
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO functions (
|
|
id, name, kind, lang, domain, version, purity, signature,
|
|
description, tags, uses_functions, uses_types, returns,
|
|
returns_optional, error_type, imports, example, tested,
|
|
tests, test_file_path, file_path, content_hash, created_at, updated_at,
|
|
props, emits, has_state, framework, variant,
|
|
notes, documentation, code,
|
|
source_repo, source_license, source_file,
|
|
params_schema
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?,
|
|
?, ?, ?,
|
|
?
|
|
)`,
|
|
f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature,
|
|
f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns),
|
|
f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested,
|
|
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.ContentHash, f.CreatedAt.Format(time.RFC3339), f.UpdatedAt.Format(time.RFC3339),
|
|
marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant),
|
|
f.Notes, f.Documentation, f.Code,
|
|
f.SourceRepo, f.SourceLicense, f.SourceFile,
|
|
f.ParamsSchema,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// InsertType inserts or replaces a type entry.
|
|
func (db *DB) InsertType(t *Type) error {
|
|
now := time.Now().UTC()
|
|
if t.CreatedAt.IsZero() {
|
|
t.CreatedAt = now
|
|
}
|
|
if t.UpdatedAt.IsZero() {
|
|
t.UpdatedAt = now
|
|
}
|
|
|
|
if t.ID == "" {
|
|
t.ID = GenerateID(t.Name, t.Lang, t.Domain)
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO types (
|
|
id, name, lang, domain, version, algebraic,
|
|
definition, description, tags, uses_types,
|
|
file_path, content_hash, created_at, updated_at,
|
|
examples, notes, documentation, code,
|
|
source_repo, source_license, source_file
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic),
|
|
t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes),
|
|
t.FilePath, t.ContentHash, t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
|
|
t.Examples, t.Notes, t.Documentation, t.Code,
|
|
t.SourceRepo, t.SourceLicense, t.SourceFile,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// SearchFunctions performs FTS search on functions with optional filters.
|
|
func (db *DB) SearchFunctions(query string, kind Kind, purity Purity, lang, domain string) ([]Function, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "f.id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if kind != "" {
|
|
where = append(where, "f.kind = ?")
|
|
args = append(args, string(kind))
|
|
}
|
|
if purity != "" {
|
|
where = append(where, "f.purity = ?")
|
|
args = append(args, string(purity))
|
|
}
|
|
if lang != "" {
|
|
where = append(where, "f.lang = ?")
|
|
args = append(args, lang)
|
|
}
|
|
if domain != "" {
|
|
where = append(where, "f.domain = ?")
|
|
args = append(args, domain)
|
|
}
|
|
|
|
sql := "SELECT * FROM functions f"
|
|
if len(where) > 0 {
|
|
sql += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sql += " ORDER BY f.name"
|
|
|
|
rows, err := db.conn.Query(sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search functions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanFunctions(rows)
|
|
}
|
|
|
|
// SearchTypes performs FTS search on types with optional filters.
|
|
func (db *DB) SearchTypes(query string, lang, domain string) ([]Type, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "t.id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if lang != "" {
|
|
where = append(where, "t.lang = ?")
|
|
args = append(args, lang)
|
|
}
|
|
if domain != "" {
|
|
where = append(where, "t.domain = ?")
|
|
args = append(args, domain)
|
|
}
|
|
|
|
sql := "SELECT * FROM types t"
|
|
if len(where) > 0 {
|
|
sql += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sql += " ORDER BY t.name"
|
|
|
|
rows, err := db.conn.Query(sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search types: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTypes(rows)
|
|
}
|
|
|
|
// GetFunction returns a single function by ID.
|
|
func (db *DB) GetFunction(id string) (*Function, error) {
|
|
rows, err := db.conn.Query("SELECT * FROM functions WHERE id = ?", id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
fns, err := scanFunctions(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(fns) == 0 {
|
|
return nil, fmt.Errorf("function %q not found", id)
|
|
}
|
|
return &fns[0], nil
|
|
}
|
|
|
|
// GetType returns a single type by ID.
|
|
func (db *DB) GetType(id string) (*Type, error) {
|
|
rows, err := db.conn.Query("SELECT * FROM types WHERE id = ?", id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
ts, err := scanTypes(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ts) == 0 {
|
|
return nil, fmt.Errorf("type %q not found", id)
|
|
}
|
|
return &ts[0], nil
|
|
}
|
|
|
|
// GetFunctionsByName returns all functions matching a given name (across langs/domains).
|
|
func (db *DB) GetFunctionsByName(name string) ([]Function, error) {
|
|
rows, err := db.conn.Query("SELECT * FROM functions WHERE name = ? ORDER BY lang, domain", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanFunctions(rows)
|
|
}
|
|
|
|
// DeleteFunction removes a function by ID.
|
|
func (db *DB) DeleteFunction(id string) error {
|
|
_, err := db.conn.Exec("DELETE FROM functions WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// DeleteType removes a type by ID.
|
|
func (db *DB) DeleteType(id string) error {
|
|
_, err := db.conn.Exec("DELETE FROM types WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// InsertApp inserts or replaces an app entry.
|
|
func (db *DB) InsertApp(a *App) error {
|
|
now := time.Now().UTC()
|
|
if a.CreatedAt.IsZero() {
|
|
a.CreatedAt = now
|
|
}
|
|
if a.UpdatedAt.IsZero() {
|
|
a.UpdatedAt = now
|
|
}
|
|
|
|
if a.ID == "" {
|
|
a.ID = GenerateID(a.Name, a.Lang, a.Domain)
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO apps (
|
|
id, name, lang, domain, description, tags,
|
|
uses_functions, uses_types, framework, entry_point,
|
|
documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
|
|
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
|
|
a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
|
|
a.RepoURL,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetApp returns a single app by ID.
|
|
func (db *DB) GetApp(id string) (*App, error) {
|
|
rows, err := db.conn.Query("SELECT * FROM apps WHERE id = ?", id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
apps, err := scanApps(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(apps) == 0 {
|
|
return nil, fmt.Errorf("app %q not found", id)
|
|
}
|
|
return &apps[0], nil
|
|
}
|
|
|
|
// SearchApps performs FTS search on apps with optional filters.
|
|
func (db *DB) SearchApps(query string, lang, domain string) ([]App, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "a.id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if lang != "" {
|
|
where = append(where, "a.lang = ?")
|
|
args = append(args, lang)
|
|
}
|
|
if domain != "" {
|
|
where = append(where, "a.domain = ?")
|
|
args = append(args, domain)
|
|
}
|
|
|
|
sql := "SELECT * FROM apps a"
|
|
if len(where) > 0 {
|
|
sql += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sql += " ORDER BY a.name"
|
|
|
|
rows, err := db.conn.Query(sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search apps: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanApps(rows)
|
|
}
|
|
|
|
func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) {
|
|
var result []App
|
|
for rows.Next() {
|
|
var a App
|
|
var tagsJSON, usesFnJSON, usesTypJSON string
|
|
var createdAt, updatedAt string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
|
|
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
|
|
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash,
|
|
&a.RepoURL,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning app: %w", err)
|
|
}
|
|
|
|
a.Tags = unmarshalStrings(tagsJSON)
|
|
a.UsesFunctions = unmarshalStrings(usesFnJSON)
|
|
a.UsesTypes = unmarshalStrings(usesTypJSON)
|
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
|
|
result = append(result, a)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Purge deletes all data from functions, types, apps and analysis. Used before re-indexing.
|
|
func (db *DB) Purge() error {
|
|
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := db.conn.Exec("DELETE FROM types"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := db.conn.Exec("DELETE FROM apps"); err != nil {
|
|
return err
|
|
}
|
|
_, err := db.conn.Exec("DELETE FROM analysis")
|
|
return err
|
|
}
|
|
|
|
// PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis.
|
|
// Remote-only records (repo_url set, not in localAppIDs/localAnalysisIDs) are preserved.
|
|
func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs map[string]bool) error {
|
|
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := db.conn.Exec("DELETE FROM types"); err != nil {
|
|
return err
|
|
}
|
|
// Delete local apps (those scanned from disk)
|
|
for id := range localAppIDs {
|
|
if _, err := db.conn.Exec("DELETE FROM apps WHERE id = ?", id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Delete apps without repo_url (legacy local-only apps not yet pushed)
|
|
if _, err := db.conn.Exec("DELETE FROM apps WHERE repo_url = '' OR repo_url IS NULL"); err != nil {
|
|
return err
|
|
}
|
|
// Same for analysis
|
|
for id := range localAnalysisIDs {
|
|
if _, err := db.conn.Exec("DELETE FROM analysis WHERE id = ?", id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := db.conn.Exec("DELETE FROM analysis WHERE repo_url = '' OR repo_url IS NULL"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Analysis CRUD ---
|
|
|
|
// InsertAnalysis inserts or replaces an analysis entry.
|
|
func (db *DB) InsertAnalysis(a *Analysis) error {
|
|
now := time.Now().UTC()
|
|
if a.CreatedAt.IsZero() {
|
|
a.CreatedAt = now
|
|
}
|
|
if a.UpdatedAt.IsZero() {
|
|
a.UpdatedAt = now
|
|
}
|
|
|
|
if a.ID == "" {
|
|
a.ID = GenerateID(a.Name, a.Lang, a.Domain)
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO analysis (
|
|
id, name, lang, domain, description, tags,
|
|
uses_functions, uses_types, framework, entry_point,
|
|
documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
|
|
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
|
|
a.Documentation, a.Notes, a.RepoURL, a.DirPath, a.ContentHash,
|
|
a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetAnalysis returns a single analysis by ID.
|
|
func (db *DB) GetAnalysis(id string) (*Analysis, error) {
|
|
rows, err := db.conn.Query("SELECT * FROM analysis WHERE id = ?", id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items, err := scanAnalysis(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, fmt.Errorf("analysis %q not found", id)
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
// SearchAnalysis performs FTS search on analysis with optional filters.
|
|
func (db *DB) SearchAnalysis(query string, lang, domain string) ([]Analysis, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "a.id IN (SELECT id FROM analysis_fts WHERE analysis_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if lang != "" {
|
|
where = append(where, "a.lang = ?")
|
|
args = append(args, lang)
|
|
}
|
|
if domain != "" {
|
|
where = append(where, "a.domain = ?")
|
|
args = append(args, domain)
|
|
}
|
|
|
|
sql := "SELECT * FROM analysis a"
|
|
if len(where) > 0 {
|
|
sql += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sql += " ORDER BY a.name"
|
|
|
|
rows, err := db.conn.Query(sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search analysis: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanAnalysis(rows)
|
|
}
|
|
|
|
// ListAllAnalysis returns all analysis entries.
|
|
func (db *DB) ListAllAnalysis() ([]Analysis, error) {
|
|
return db.SearchAnalysis("", "", "")
|
|
}
|
|
|
|
// ListAllApps returns all app entries.
|
|
func (db *DB) ListAllApps() ([]App, error) {
|
|
return db.SearchApps("", "", "")
|
|
}
|
|
|
|
func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis, error) {
|
|
var result []Analysis
|
|
for rows.Next() {
|
|
var a Analysis
|
|
var tagsJSON, usesFnJSON, usesTypJSON string
|
|
var createdAt, updatedAt string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
|
|
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
|
|
&a.Documentation, &a.Notes, &a.RepoURL, &a.DirPath, &a.ContentHash,
|
|
&createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning analysis: %w", err)
|
|
}
|
|
|
|
a.Tags = unmarshalStrings(tagsJSON)
|
|
a.UsesFunctions = unmarshalStrings(usesFnJSON)
|
|
a.UsesTypes = unmarshalStrings(usesTypJSON)
|
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
|
|
result = append(result, a)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) {
|
|
var result []Function
|
|
for rows.Next() {
|
|
var f Function
|
|
var tagsJSON, usesFnJSON, usesTypJSON, returnsJSON, importsJSON, testsJSON string
|
|
var propsJSON, emitsJSON, variantJSON string
|
|
var hasState *int
|
|
var createdAt, updatedAt string
|
|
|
|
err := rows.Scan(
|
|
&f.ID, &f.Name, &f.Kind, &f.Lang, &f.Domain, &f.Version, &f.Purity, &f.Signature,
|
|
&f.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &returnsJSON,
|
|
&f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested,
|
|
&testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt,
|
|
&propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON,
|
|
&f.Notes, &f.Documentation, &f.Code, &f.ContentHash,
|
|
&f.SourceRepo, &f.SourceLicense, &f.SourceFile,
|
|
&f.ParamsSchema,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning function: %w", err)
|
|
}
|
|
|
|
f.Tags = unmarshalStrings(tagsJSON)
|
|
f.UsesFunctions = unmarshalStrings(usesFnJSON)
|
|
f.UsesTypes = unmarshalStrings(usesTypJSON)
|
|
f.Returns = unmarshalStrings(returnsJSON)
|
|
f.Imports = unmarshalStrings(importsJSON)
|
|
f.Tests = unmarshalStrings(testsJSON)
|
|
f.Props = unmarshalProps(propsJSON)
|
|
f.Emits = unmarshalStrings(emitsJSON)
|
|
f.Variant = unmarshalStrings(variantJSON)
|
|
f.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
f.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
|
|
if hasState != nil {
|
|
v := *hasState == 1
|
|
f.HasState = &v
|
|
}
|
|
|
|
result = append(result, f)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error) {
|
|
var result []Type
|
|
for rows.Next() {
|
|
var t Type
|
|
var tagsJSON, usesTypJSON string
|
|
var createdAt, updatedAt string
|
|
|
|
err := rows.Scan(
|
|
&t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic,
|
|
&t.Definition, &t.Description, &tagsJSON, &usesTypJSON,
|
|
&t.FilePath, &createdAt, &updatedAt,
|
|
&t.Examples, &t.Notes, &t.Documentation, &t.Code, &t.ContentHash,
|
|
&t.SourceRepo, &t.SourceLicense, &t.SourceFile,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning type: %w", err)
|
|
}
|
|
|
|
t.Tags = unmarshalStrings(tagsJSON)
|
|
t.UsesTypes = unmarshalStrings(usesTypJSON)
|
|
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
|
|
result = append(result, t)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- Unit Tests CRUD ---
|
|
|
|
// InsertUnitTest inserts or replaces a unit test entry.
|
|
func (db *DB) InsertUnitTest(ut *UnitTest) error {
|
|
now := time.Now().UTC()
|
|
if ut.CreatedAt.IsZero() {
|
|
ut.CreatedAt = now
|
|
}
|
|
if ut.UpdatedAt.IsZero() {
|
|
ut.UpdatedAt = now
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO unit_tests (
|
|
id, function_id, name, code, file_path, lang, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
ut.ID, ut.FunctionID, ut.Name, ut.Code, ut.FilePath, ut.Lang,
|
|
ut.CreatedAt.Format(time.RFC3339), ut.UpdatedAt.Format(time.RFC3339),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetUnitTestsByFunction returns all unit tests for a given function ID.
|
|
func (db *DB) GetUnitTestsByFunction(functionID string) ([]UnitTest, error) {
|
|
rows, err := db.conn.Query(
|
|
"SELECT id, function_id, name, code, file_path, lang, created_at, updated_at FROM unit_tests WHERE function_id = ? ORDER BY name",
|
|
functionID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanUnitTests(rows)
|
|
}
|
|
|
|
// SearchUnitTests performs FTS search on unit tests.
|
|
func (db *DB) SearchUnitTests(query string, lang string) ([]UnitTest, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "ut.id IN (SELECT id FROM unit_tests_fts WHERE unit_tests_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if lang != "" {
|
|
where = append(where, "ut.lang = ?")
|
|
args = append(args, lang)
|
|
}
|
|
|
|
sql := "SELECT id, function_id, name, code, file_path, lang, created_at, updated_at FROM unit_tests ut"
|
|
if len(where) > 0 {
|
|
sql += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sql += " ORDER BY ut.function_id, ut.name"
|
|
|
|
rows, err := db.conn.Query(sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search unit tests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanUnitTests(rows)
|
|
}
|
|
|
|
func scanUnitTests(rows interface{ Next() bool; Scan(...any) error }) ([]UnitTest, error) {
|
|
var result []UnitTest
|
|
for rows.Next() {
|
|
var ut UnitTest
|
|
var createdAt, updatedAt string
|
|
err := rows.Scan(&ut.ID, &ut.FunctionID, &ut.Name, &ut.Code, &ut.FilePath, &ut.Lang, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning unit test: %w", err)
|
|
}
|
|
ut.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
ut.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
result = append(result, ut)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// PurgeUnitTests deletes all unit test entries. Used before re-indexing.
|
|
func (db *DB) PurgeUnitTests() error {
|
|
_, err := db.conn.Exec("DELETE FROM unit_tests")
|
|
return err
|
|
}
|
|
|
|
// --- Proposal CRUD ---
|
|
|
|
// InsertProposal inserts or replaces a proposal.
|
|
func (db *DB) InsertProposal(p *Proposal) error {
|
|
now := time.Now().UTC()
|
|
if p.CreatedAt.IsZero() {
|
|
p.CreatedAt = now
|
|
}
|
|
p.UpdatedAt = now
|
|
|
|
if p.Status == "" {
|
|
p.Status = ProposalPending
|
|
}
|
|
|
|
_, err := db.conn.Exec(`
|
|
INSERT OR REPLACE INTO proposals (
|
|
id, kind, target_id, title, description, evidence,
|
|
status, created_by, reviewed_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
p.ID, string(p.Kind), p.TargetID, p.Title, p.Description,
|
|
marshalJSON(p.Evidence), string(p.Status), p.CreatedBy, p.ReviewedBy,
|
|
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetProposal returns a proposal by ID.
|
|
func (db *DB) GetProposal(id string) (*Proposal, error) {
|
|
rows, err := db.conn.Query(`
|
|
SELECT id, kind, target_id, title, description, evidence,
|
|
status, created_by, reviewed_by, created_at, updated_at
|
|
FROM proposals WHERE id = ?`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
ps, err := scanProposals(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ps) == 0 {
|
|
return nil, fmt.Errorf("proposal %q not found", id)
|
|
}
|
|
return &ps[0], nil
|
|
}
|
|
|
|
// UpdateProposal updates an existing proposal.
|
|
func (db *DB) UpdateProposal(p *Proposal) error {
|
|
p.UpdatedAt = time.Now().UTC()
|
|
_, err := db.conn.Exec(`
|
|
UPDATE proposals SET kind=?, target_id=?, title=?, description=?, evidence=?,
|
|
status=?, created_by=?, reviewed_by=?, updated_at=?
|
|
WHERE id=?`,
|
|
string(p.Kind), p.TargetID, p.Title, p.Description,
|
|
marshalJSON(p.Evidence), string(p.Status), p.CreatedBy, p.ReviewedBy,
|
|
p.UpdatedAt.Format(time.RFC3339), p.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// DeleteProposal removes a proposal by ID.
|
|
func (db *DB) DeleteProposal(id string) error {
|
|
_, err := db.conn.Exec("DELETE FROM proposals WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// ListProposals returns proposals filtered by kind and/or status.
|
|
func (db *DB) ListProposals(kind ProposalKind, status ProposalStatus) ([]Proposal, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
if kind != "" {
|
|
where = append(where, "kind = ?")
|
|
args = append(args, string(kind))
|
|
}
|
|
if status != "" {
|
|
where = append(where, "status = ?")
|
|
args = append(args, string(status))
|
|
}
|
|
|
|
q := `SELECT id, kind, target_id, title, description, evidence,
|
|
status, created_by, reviewed_by, created_at, updated_at
|
|
FROM proposals`
|
|
if len(where) > 0 {
|
|
q += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
q += " ORDER BY created_at DESC"
|
|
|
|
rows, err := db.conn.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanProposals(rows)
|
|
}
|
|
|
|
// SearchProposals performs FTS search on proposals with optional filters.
|
|
func (db *DB) SearchProposals(query string, kind ProposalKind, status ProposalStatus) ([]Proposal, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if query != "" {
|
|
where = append(where, "p.id IN (SELECT id FROM proposals_fts WHERE proposals_fts MATCH ?)")
|
|
args = append(args, query)
|
|
}
|
|
if kind != "" {
|
|
where = append(where, "p.kind = ?")
|
|
args = append(args, string(kind))
|
|
}
|
|
if status != "" {
|
|
where = append(where, "p.status = ?")
|
|
args = append(args, string(status))
|
|
}
|
|
|
|
q := `SELECT p.id, p.kind, p.target_id, p.title, p.description, p.evidence,
|
|
p.status, p.created_by, p.reviewed_by, p.created_at, p.updated_at
|
|
FROM proposals p`
|
|
if len(where) > 0 {
|
|
q += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
q += " ORDER BY p.created_at DESC"
|
|
|
|
rows, err := db.conn.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanProposals(rows)
|
|
}
|
|
|
|
func scanProposals(rows interface{ Next() bool; Scan(...any) error }) ([]Proposal, error) {
|
|
var result []Proposal
|
|
for rows.Next() {
|
|
var p Proposal
|
|
var evidenceJSON, createdAt, updatedAt string
|
|
err := rows.Scan(
|
|
&p.ID, &p.Kind, &p.TargetID, &p.Title, &p.Description, &evidenceJSON,
|
|
&p.Status, &p.CreatedBy, &p.ReviewedBy, &createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning proposal: %w", err)
|
|
}
|
|
p.Evidence = unmarshalJSON(evidenceJSON)
|
|
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
result = append(result, p)
|
|
}
|
|
return result, nil
|
|
}
|