feat: tabla apps en registry — modelo, parser, indexer y CLI

Agrega soporte completo para indexar aplicaciones del directorio apps/.
Cada app tiene un descriptor app.md con frontmatter YAML que el indexer
recoge automaticamente. Incluye migracion 004, modelo App, ParseAppMD,
ValidateApp, store CRUD con FTS5, y soporte en fn list/search/show.
Crea descriptores app.md para docker_tui, pipeline_launcher y metabase_registry.
This commit is contained in:
2026-03-29 00:13:57 +01:00
parent 95959f713c
commit f570e783fe
11 changed files with 465 additions and 6 deletions
+35
View File
@@ -11,6 +11,7 @@ import (
type IndexResult struct {
Functions int
Types int
Apps int
ValidationErrors []string
Errors []string
}
@@ -76,6 +77,28 @@ func Index(db *DB, root string) (*IndexResult, error) {
})
}
// Parse apps from apps/*/app.md
var apps []*App
appsDir := filepath.Join(root, "apps")
if fi, err := os.Stat(appsDir); err == nil && fi.IsDir() {
entries, _ := os.ReadDir(appsDir)
for _, e := range entries {
if !e.IsDir() {
continue
}
appMD := filepath.Join(appsDir, e.Name(), "app.md")
if _, err := os.Stat(appMD); err != nil {
continue
}
a, err := ParseAppMD(appMD, root)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", appMD, err))
continue
}
apps = append(apps, a)
}
}
// Build known ID sets
knownFunctions := make(map[string]bool, len(functions))
for _, f := range functions {
@@ -111,6 +134,18 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.Functions++
}
for _, a := range apps {
if verr := ValidateApp(a, knownFunctions, knownTypes); verr != nil {
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
continue
}
if err := db.InsertApp(a); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", a.ID, err))
continue
}
result.Apps++
}
return result, nil
}
+48
View File
@@ -0,0 +1,48 @@
-- Apps table: applications that consume functions/types from the registry.
CREATE TABLE IF NOT EXISTS apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lang TEXT NOT NULL,
domain TEXT NOT NULL,
description TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
uses_functions TEXT NOT NULL DEFAULT '[]',
uses_types TEXT NOT NULL DEFAULT '[]',
framework TEXT NOT NULL DEFAULT '',
entry_point TEXT NOT NULL DEFAULT '',
documentation TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
dir_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
id,
name,
description,
tags,
domain,
documentation,
notes,
content='apps',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS apps_ai AFTER INSERT ON apps BEGIN
INSERT INTO apps_fts(rowid, id, name, description, tags, domain, documentation, notes)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS apps_ad AFTER DELETE ON apps BEGIN
INSERT INTO apps_fts(apps_fts, rowid, id, name, description, tags, domain, documentation, notes)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes);
END;
CREATE TRIGGER IF NOT EXISTS apps_au AFTER UPDATE ON apps BEGIN
INSERT INTO apps_fts(apps_fts, rowid, id, name, description, tags, domain, documentation, notes)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes);
INSERT INTO apps_fts(rowid, id, name, description, tags, domain, documentation, notes)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes);
END;
+19
View File
@@ -94,6 +94,25 @@ type Type struct {
UpdatedAt time.Time `json:"updated_at"`
}
// App represents an entry in the apps table.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Lang string `json:"lang"`
Domain string `json:"domain"`
Description string `json:"description"`
Tags []string `json:"tags"`
UsesFunctions []string `json:"uses_functions"`
UsesTypes []string `json:"uses_types"`
Framework string `json:"framework"`
EntryPoint string `json:"entry_point"`
Documentation string `json:"documentation"`
Notes string `json:"notes"`
DirPath string `json:"dir_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProposalKind classifies a proposal.
type ProposalKind string
+59
View File
@@ -54,6 +54,20 @@ type rawType struct {
FilePath string `yaml:"file_path"`
}
// rawApp mirrors the YAML frontmatter of an app .md file.
type rawApp struct {
Name string `yaml:"name"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
Framework string `yaml:"framework"`
EntryPoint string `yaml:"entry_point"`
DirPath string `yaml:"dir_path"`
}
// extractFrontmatter splits a .md file into YAML frontmatter and body.
func extractFrontmatter(data []byte) ([]byte, []byte, error) {
content := data
@@ -198,6 +212,51 @@ func ParseTypeMD(path string, root string) (*Type, error) {
return t, nil
}
// ParseAppMD parses an app .md file into an App.
func ParseAppMD(path string, root string) (*App, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawApp
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
a := &App{
ID: GenerateID(raw.Name, raw.Lang, raw.Domain),
Name: raw.Name,
Lang: raw.Lang,
Domain: raw.Domain,
Description: raw.Description,
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
Framework: raw.Framework,
EntryPoint: raw.EntryPoint,
Documentation: sections.documentation,
Notes: sections.notes,
DirPath: raw.DirPath,
}
return a, nil
}
// bodySections holds the extracted sections from a .md body.
type bodySections struct {
example string // content under ## Ejemplo
+108 -2
View File
@@ -261,12 +261,118 @@ func (db *DB) DeleteType(id string) error {
return err
}
// Purge deletes all data from both tables. Used before re-indexing.
// InsertApp inserts or replaces an app entry.
func (db *DB) InsertApp(a *App) error {
now := time.Now().UTC().Format(time.RFC3339)
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
a.UpdatedAt = time.Now().UTC()
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, 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.DirPath, a.CreatedAt.Format(time.RFC3339), now,
)
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,
)
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 and apps. Used before re-indexing.
func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM types")
if _, err := db.conn.Exec("DELETE FROM types"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM apps")
return err
}
+38
View File
@@ -161,6 +161,44 @@ func ValidateProposal(p *Proposal) *ValidationError {
return nil
}
// ValidateApp checks integrity rules for apps.
func ValidateApp(a *App, knownFunctions, knownTypes map[string]bool) *ValidationError {
var errs []string
if a.Name == "" {
errs = append(errs, "name is required")
}
if a.Lang == "" {
errs = append(errs, "lang is required")
}
if a.Domain == "" {
errs = append(errs, "domain is required")
}
if a.Description == "" {
errs = append(errs, "description is required")
}
if a.DirPath != "" && strings.HasPrefix(a.DirPath, "/") {
errs = append(errs, "dir_path must be relative to registry root")
}
for _, ref := range a.UsesFunctions {
if !knownFunctions[ref] {
errs = append(errs, fmt.Sprintf("uses_functions references unknown function: %s", ref))
}
}
for _, ref := range a.UsesTypes {
if !knownTypes[ref] {
errs = append(errs, fmt.Sprintf("uses_types references unknown type: %s", ref))
}
}
if len(errs) > 0 {
return &ValidationError{ID: a.ID, Errors: errs}
}
return nil
}
// ValidateType checks integrity rules for types.
func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError {
var errs []string