feat: proposals en registry
Añade sistema de proposals al registry: modelos (ProposalKind, ProposalStatus), CRUD completo (Insert/Get/Update/Delete/List/Search con FTS), validación, migración 002_proposals.sql y subcomando CLI fn proposal (add/list/show/update). Motor de migraciones con embed.FS reemplaza schema estático. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdProposal(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
printProposalUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "add":
|
||||||
|
cmdProposalAdd(args[1:])
|
||||||
|
case "list":
|
||||||
|
cmdProposalList(args[1:])
|
||||||
|
case "show":
|
||||||
|
cmdProposalShow(args[1:])
|
||||||
|
case "update":
|
||||||
|
cmdProposalUpdate(args[1:])
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printProposalUsage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown proposal command: %s\n", args[0])
|
||||||
|
printProposalUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printProposalUsage() {
|
||||||
|
fmt.Println(`fn proposal — gestiona proposals
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fn proposal add --kind <kind> --title <title> [options]
|
||||||
|
fn proposal list [-k kind] [-s status]
|
||||||
|
fn proposal show <id>
|
||||||
|
fn proposal update <id> --status <status> [--reviewed-by <who>]
|
||||||
|
|
||||||
|
Kinds: new_function, new_type, improve_function, improve_type, new_pipeline
|
||||||
|
Status: pending, approved, rejected, implemented`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdProposalAdd(args []string) {
|
||||||
|
var id, kind, targetID, title, description, evidenceStr, createdBy string
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
switch args[i] {
|
||||||
|
case "--id":
|
||||||
|
i++
|
||||||
|
id = args[i]
|
||||||
|
case "--kind":
|
||||||
|
i++
|
||||||
|
kind = args[i]
|
||||||
|
case "--target-id":
|
||||||
|
i++
|
||||||
|
targetID = args[i]
|
||||||
|
case "--title":
|
||||||
|
i++
|
||||||
|
title = args[i]
|
||||||
|
case "--description":
|
||||||
|
i++
|
||||||
|
description = args[i]
|
||||||
|
case "--evidence":
|
||||||
|
i++
|
||||||
|
evidenceStr = args[i]
|
||||||
|
case "--created-by":
|
||||||
|
i++
|
||||||
|
createdBy = args[i]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == "" || title == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: --kind and --title are required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("proposal_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence map[string]any
|
||||||
|
if evidenceStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(evidenceStr), &evidence); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: invalid evidence JSON: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := ®istry.Proposal{
|
||||||
|
ID: id,
|
||||||
|
Kind: registry.ProposalKind(kind),
|
||||||
|
TargetID: targetID,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Evidence: evidence,
|
||||||
|
Status: registry.ProposalPending,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := registry.ValidateProposal(p); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openDB()
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.InsertProposal(p); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created proposal: %s\n", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdProposalList(args []string) {
|
||||||
|
var kind, status string
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
switch args[i] {
|
||||||
|
case "-k":
|
||||||
|
i++
|
||||||
|
kind = args[i]
|
||||||
|
case "-s":
|
||||||
|
i++
|
||||||
|
status = args[i]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openDB()
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
proposals, err := db.ListProposals(registry.ProposalKind(kind), registry.ProposalStatus(status))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(proposals) == 0 {
|
||||||
|
fmt.Println("No proposals.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tKIND\tSTATUS\tTITLE\tCREATED_BY")
|
||||||
|
for _, p := range proposals {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Kind, p.Status, truncate(p.Title, 40), p.CreatedBy)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdProposalShow(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: fn proposal show <id>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openDB()
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.GetProposal(args[0])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("ID: %s\n", p.ID)
|
||||||
|
fmt.Printf("Kind: %s\n", p.Kind)
|
||||||
|
fmt.Printf("Status: %s\n", p.Status)
|
||||||
|
fmt.Printf("Title: %s\n", p.Title)
|
||||||
|
fmt.Printf("Description: %s\n", p.Description)
|
||||||
|
if p.TargetID != "" {
|
||||||
|
fmt.Printf("Target ID: %s\n", p.TargetID)
|
||||||
|
}
|
||||||
|
if len(p.Evidence) > 0 {
|
||||||
|
ev, _ := json.MarshalIndent(p.Evidence, " ", " ")
|
||||||
|
fmt.Printf("Evidence: %s\n", string(ev))
|
||||||
|
}
|
||||||
|
fmt.Printf("Created by: %s\n", p.CreatedBy)
|
||||||
|
if p.ReviewedBy != "" {
|
||||||
|
fmt.Printf("Reviewed by: %s\n", p.ReviewedBy)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created: %s\n", p.CreatedAt.Format(time.RFC3339))
|
||||||
|
fmt.Printf("Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdProposalUpdate(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: fn proposal update <id> --status <status> [--reviewed-by <who>]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := args[0]
|
||||||
|
var status, reviewedBy string
|
||||||
|
i := 1
|
||||||
|
for i < len(args) {
|
||||||
|
switch args[i] {
|
||||||
|
case "--status":
|
||||||
|
i++
|
||||||
|
status = args[i]
|
||||||
|
case "--reviewed-by":
|
||||||
|
i++
|
||||||
|
reviewedBy = args[i]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openDB()
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.GetProposal(id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
p.Status = registry.ProposalStatus(status)
|
||||||
|
}
|
||||||
|
if reviewedBy != "" {
|
||||||
|
p.ReviewedBy = reviewedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate updated proposal
|
||||||
|
validKinds := map[string]bool{
|
||||||
|
"pending": true, "approved": true, "rejected": true, "implemented": true,
|
||||||
|
}
|
||||||
|
if status != "" && !validKinds[status] {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: invalid status %q\n", status)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.UpdateProposal(p); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated proposal: %s (status: %s)\n", p.ID, p.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatEvidence(evidence map[string]any) string {
|
||||||
|
if len(evidence) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(evidence, "", " ")
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatStrings joins a slice for display, handling nil/empty.
|
||||||
|
func formatStrings(ss []string) string {
|
||||||
|
if len(ss) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(ss, ", ")
|
||||||
|
}
|
||||||
+2
-109
@@ -9,112 +9,6 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schemaSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS functions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')),
|
|
||||||
lang TEXT NOT NULL,
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
|
||||||
purity TEXT NOT NULL CHECK(purity IN ('pure','impure')),
|
|
||||||
signature TEXT NOT NULL DEFAULT '',
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
uses_functions TEXT NOT NULL DEFAULT '[]',
|
|
||||||
uses_types TEXT NOT NULL DEFAULT '[]',
|
|
||||||
returns TEXT NOT NULL DEFAULT '[]',
|
|
||||||
returns_optional INTEGER NOT NULL DEFAULT 0,
|
|
||||||
error_type TEXT NOT NULL DEFAULT '',
|
|
||||||
imports TEXT NOT NULL DEFAULT '[]',
|
|
||||||
example TEXT NOT NULL DEFAULT '',
|
|
||||||
tested INTEGER NOT NULL DEFAULT 0,
|
|
||||||
tests TEXT NOT NULL DEFAULT '[]',
|
|
||||||
test_file_path TEXT NOT NULL DEFAULT '',
|
|
||||||
file_path TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
-- Component fields
|
|
||||||
props TEXT NOT NULL DEFAULT '[]',
|
|
||||||
emits TEXT NOT NULL DEFAULT '[]',
|
|
||||||
has_state INTEGER,
|
|
||||||
framework TEXT NOT NULL DEFAULT '',
|
|
||||||
variant TEXT NOT NULL DEFAULT '[]'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS types (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
lang TEXT NOT NULL,
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
|
||||||
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
|
|
||||||
definition TEXT NOT NULL DEFAULT '',
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
uses_types TEXT NOT NULL DEFAULT '[]',
|
|
||||||
file_path TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5(
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
signature,
|
|
||||||
domain,
|
|
||||||
content='functions',
|
|
||||||
content_rowid='rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5(
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
domain,
|
|
||||||
content='types',
|
|
||||||
content_rowid='rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS in sync
|
|
||||||
CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN
|
|
||||||
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN
|
|
||||||
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN
|
|
||||||
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
|
|
||||||
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN
|
|
||||||
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS types_ad AFTER DELETE ON types BEGIN
|
|
||||||
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS types_au AFTER UPDATE ON types BEGIN
|
|
||||||
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
|
||||||
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
|
||||||
END;
|
|
||||||
`
|
|
||||||
|
|
||||||
// DB wraps a SQLite connection for the registry.
|
// DB wraps a SQLite connection for the registry.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
@@ -134,15 +28,14 @@ func Open(path string) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAL mode: enables concurrent reads while writing.
|
// WAL mode: enables concurrent reads while writing.
|
||||||
// Persists in the file — any client opening the DB inherits it.
|
|
||||||
if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, fmt.Errorf("setting WAL mode: %w", err)
|
return nil, fmt.Errorf("setting WAL mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := conn.Exec(schemaSQL); err != nil {
|
if err := migrate(conn); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, fmt.Errorf("applying schema: %w", err)
|
return nil, fmt.Errorf("running migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DB{conn: conn, path: path}, nil
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
const migrationTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);`
|
||||||
|
|
||||||
|
// migrate applies pending migrations to the database.
|
||||||
|
func migrate(conn *sql.DB) error {
|
||||||
|
if _, err := conn.Exec(migrationTableSQL); err != nil {
|
||||||
|
return fmt.Errorf("creating schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := currentVersion(conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mf := range files {
|
||||||
|
if mf.version <= current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := migrationsFS.ReadFile(path.Join("migrations", mf.filename))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("beginning transaction for migration %d: %w", mf.version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("applying migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||||
|
mf.version, mf.filename, time.Now().UTC().Format(time.RFC3339),
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("recording migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("committing migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentVersion(conn *sql.DB) (int, error) {
|
||||||
|
var v int
|
||||||
|
err := conn.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("reading current schema version: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type migrationFile struct {
|
||||||
|
version int
|
||||||
|
filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMigrations() ([]migrationFile, error) {
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading migrations directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []migrationFile
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(e.Name(), "_", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, migrationFile{version: v, filename: e.Name()})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].version < files[j].version
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- registry schema v1.0.0
|
||||||
|
-- Functions and types tables with FTS5 search.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS functions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')),
|
||||||
|
lang TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||||
|
purity TEXT NOT NULL CHECK(purity IN ('pure','impure')),
|
||||||
|
signature TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
uses_functions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
uses_types TEXT NOT NULL DEFAULT '[]',
|
||||||
|
returns TEXT NOT NULL DEFAULT '[]',
|
||||||
|
returns_optional INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_type TEXT NOT NULL DEFAULT '',
|
||||||
|
imports TEXT NOT NULL DEFAULT '[]',
|
||||||
|
example TEXT NOT NULL DEFAULT '',
|
||||||
|
tested INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tests TEXT NOT NULL DEFAULT '[]',
|
||||||
|
test_file_path TEXT NOT NULL DEFAULT '',
|
||||||
|
file_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
-- Component fields
|
||||||
|
props TEXT NOT NULL DEFAULT '[]',
|
||||||
|
emits TEXT NOT NULL DEFAULT '[]',
|
||||||
|
has_state INTEGER,
|
||||||
|
framework TEXT NOT NULL DEFAULT '',
|
||||||
|
variant TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS types (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
lang TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||||
|
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
|
||||||
|
definition TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
uses_types TEXT NOT NULL DEFAULT '[]',
|
||||||
|
file_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
signature,
|
||||||
|
domain,
|
||||||
|
content='functions',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
domain,
|
||||||
|
content='types',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Triggers to keep FTS in sync
|
||||||
|
CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN
|
||||||
|
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN
|
||||||
|
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN
|
||||||
|
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
|
||||||
|
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN
|
||||||
|
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS types_ad AFTER DELETE ON types BEGIN
|
||||||
|
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS types_au AFTER UPDATE ON types BEGIN
|
||||||
|
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
||||||
|
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
||||||
|
END;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Proposals table: agent-generated improvement suggestions for the registry.
|
||||||
|
-- Lives in registry (not operations) because proposals benefit all projects.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proposals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('new_function','new_type','improve_function','improve_type','new_pipeline')),
|
||||||
|
target_id TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
evidence TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','implemented')),
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS proposals_fts USING fts5(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
evidence,
|
||||||
|
content='proposals',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS proposals_ai AFTER INSERT ON proposals BEGIN
|
||||||
|
INSERT INTO proposals_fts(rowid, id, title, description, evidence)
|
||||||
|
VALUES (new.rowid, new.id, new.title, new.description, new.evidence);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS proposals_ad AFTER DELETE ON proposals BEGIN
|
||||||
|
INSERT INTO proposals_fts(proposals_fts, rowid, id, title, description, evidence)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.title, old.description, old.evidence);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS proposals_au AFTER UPDATE ON proposals BEGIN
|
||||||
|
INSERT INTO proposals_fts(proposals_fts, rowid, id, title, description, evidence)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.title, old.description, old.evidence);
|
||||||
|
INSERT INTO proposals_fts(rowid, id, title, description, evidence)
|
||||||
|
VALUES (new.rowid, new.id, new.title, new.description, new.evidence);
|
||||||
|
END;
|
||||||
@@ -87,6 +87,42 @@ type Type struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProposalKind classifies a proposal.
|
||||||
|
type ProposalKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProposalNewFunction ProposalKind = "new_function"
|
||||||
|
ProposalNewType ProposalKind = "new_type"
|
||||||
|
ProposalImproveFunction ProposalKind = "improve_function"
|
||||||
|
ProposalImproveType ProposalKind = "improve_type"
|
||||||
|
ProposalNewPipeline ProposalKind = "new_pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProposalStatus represents the review state of a proposal.
|
||||||
|
type ProposalStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProposalPending ProposalStatus = "pending"
|
||||||
|
ProposalApproved ProposalStatus = "approved"
|
||||||
|
ProposalRejected ProposalStatus = "rejected"
|
||||||
|
ProposalImplemented ProposalStatus = "implemented"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proposal represents a suggested improvement to the registry.
|
||||||
|
type Proposal struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind ProposalKind `json:"kind"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Evidence map[string]any `json:"evidence"`
|
||||||
|
Status ProposalStatus `json:"status"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
ReviewedBy string `json:"reviewed_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateID builds the canonical ID: {name}_{lang}_{domain}
|
// GenerateID builds the canonical ID: {name}_{lang}_{domain}
|
||||||
func GenerateID(name, lang, domain string) string {
|
func GenerateID(name, lang, domain string) string {
|
||||||
return name + "_" + lang + "_" + domain
|
return name + "_" + lang + "_" + domain
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ func unmarshalStrings(s string) []string {
|
|||||||
return out
|
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 {
|
func marshalProps(ps []PropDef) string {
|
||||||
if ps == nil {
|
if ps == nil {
|
||||||
ps = []PropDef{}
|
ps = []PropDef{}
|
||||||
@@ -305,3 +322,153 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,138 @@ func TestPurge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProposalCRUD(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
p := &Proposal{
|
||||||
|
ID: "proposal_test_1",
|
||||||
|
Kind: ProposalNewFunction,
|
||||||
|
Title: "Add retry with backoff",
|
||||||
|
Description: "Exponential backoff for HTTP clients",
|
||||||
|
Evidence: map[string]any{"assertion_ids": []any{"a1", "a2"}},
|
||||||
|
CreatedBy: "agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.InsertProposal(p); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Status != ProposalPending {
|
||||||
|
t.Errorf("default status = %q, want pending", p.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetProposal("proposal_test_1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "Add retry with backoff" {
|
||||||
|
t.Errorf("title = %q, want %q", got.Title, "Add retry with backoff")
|
||||||
|
}
|
||||||
|
if got.Evidence["assertion_ids"] == nil {
|
||||||
|
t.Error("evidence should contain assertion_ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
got.Status = ProposalApproved
|
||||||
|
got.ReviewedBy = "lucas"
|
||||||
|
if err := db.UpdateProposal(got); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
updated, _ := db.GetProposal("proposal_test_1")
|
||||||
|
if updated.Status != ProposalApproved {
|
||||||
|
t.Errorf("status = %q, want approved", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.ReviewedBy != "lucas" {
|
||||||
|
t.Errorf("reviewed_by = %q, want lucas", updated.ReviewedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with filter
|
||||||
|
db.InsertProposal(&Proposal{
|
||||||
|
ID: "proposal_test_2", Kind: ProposalImproveType, TargetID: "ohlcv_go_finance",
|
||||||
|
Title: "Improve OHLCV", CreatedBy: "agent",
|
||||||
|
})
|
||||||
|
|
||||||
|
all, err := db.ListProposals("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list all: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Errorf("list all = %d, want 2", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
byKind, _ := db.ListProposals(ProposalNewFunction, "")
|
||||||
|
if len(byKind) != 1 {
|
||||||
|
t.Errorf("list by kind = %d, want 1", len(byKind))
|
||||||
|
}
|
||||||
|
|
||||||
|
byStatus, _ := db.ListProposals("", ProposalPending)
|
||||||
|
if len(byStatus) != 1 {
|
||||||
|
t.Errorf("list by status pending = %d, want 1", len(byStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search FTS
|
||||||
|
found, err := db.SearchProposals("backoff", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Errorf("search 'backoff' = %d, want 1", len(found))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
if err := db.DeleteProposal("proposal_test_1"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
_, err = db.GetProposal("proposal_test_1")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProposal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
p Proposal
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid new_function", Proposal{ID: "p1", Kind: ProposalNewFunction, Title: "test"}, false},
|
||||||
|
{"valid improve with target", Proposal{ID: "p2", Kind: ProposalImproveFunction, Title: "test", TargetID: "fn_go_core"}, false},
|
||||||
|
{"missing title", Proposal{ID: "p3", Kind: ProposalNewFunction}, true},
|
||||||
|
{"missing kind", Proposal{ID: "p4", Title: "test"}, true},
|
||||||
|
{"improve without target", Proposal{ID: "p5", Kind: ProposalImproveType, Title: "test"}, true},
|
||||||
|
{"invalid kind", Proposal{ID: "p6", Kind: "invalid", Title: "test"}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateProposal(&tt.p)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateProposal() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrations(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
// Verify schema_migrations table exists and has entries
|
||||||
|
var count int
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
if count < 2 {
|
||||||
|
t.Errorf("expected at least 2 migrations, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify proposals table exists
|
||||||
|
_, err = db.conn.Exec("SELECT 1 FROM proposals LIMIT 1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proposals table should exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDrop(t *testing.T) {
|
func TestDrop(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "drop.db")
|
path := filepath.Join(t.TempDir(), "drop.db")
|
||||||
db, err := Open(path)
|
db, err := Open(path)
|
||||||
|
|||||||
@@ -126,6 +126,41 @@ func ValidateFunction(f *Function, knownFunctions, knownTypes map[string]bool) *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateProposal checks integrity rules for proposals.
|
||||||
|
func ValidateProposal(p *Proposal) *ValidationError {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if p.ID == "" {
|
||||||
|
errs = append(errs, "id is required")
|
||||||
|
}
|
||||||
|
if p.Title == "" {
|
||||||
|
errs = append(errs, "title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.Kind {
|
||||||
|
case ProposalNewFunction, ProposalNewType, ProposalImproveFunction, ProposalImproveType, ProposalNewPipeline:
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "kind is required")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("invalid kind: %s", p.Kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.Status {
|
||||||
|
case ProposalPending, ProposalApproved, ProposalRejected, ProposalImplemented, "":
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("invalid status: %s", p.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.Kind == ProposalImproveFunction || p.Kind == ProposalImproveType) && p.TargetID == "" {
|
||||||
|
errs = append(errs, "target_id is required for improve_* proposals")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return &ValidationError{ID: p.ID, Errors: errs}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateType checks integrity rules for types.
|
// ValidateType checks integrity rules for types.
|
||||||
func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError {
|
func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError {
|
||||||
var errs []string
|
var errs []string
|
||||||
|
|||||||
Reference in New Issue
Block a user