docs(flows): DoD obligatorio con user-facing surface + abrir issues 0100-0103 (taxonomia, frontmatter migration, dev_console, work dashboard)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:07:03 +02:00
parent 212875ed0d
commit 5d2a14e50a
77 changed files with 4062 additions and 311 deletions
+18 -1
View File
@@ -61,6 +61,7 @@ func ComputeAppHash(a *App) string {
fmt.Fprintf(h, "|%s", marshalStrings(a.Tags))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesModules))
fmt.Fprintf(h, "|%s|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.RepoURL)
return fmt.Sprintf("%x", h.Sum(nil))
}
@@ -73,10 +74,22 @@ func ComputeAnalysisHash(a *Analysis) string {
fmt.Fprintf(h, "|%s", marshalStrings(a.Tags))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesModules))
fmt.Fprintf(h, "|%s|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.RepoURL)
return fmt.Sprintf("%x", h.Sum(nil))
}
// ComputeModuleHash computes a deterministic hash of all content fields of a Module.
func ComputeModuleHash(m *Module) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s",
m.ID, m.Name, m.Version, m.Lang, m.Description)
fmt.Fprintf(h, "|%s", marshalStrings(m.Members))
fmt.Fprintf(h, "|%s", marshalStrings(m.Tags))
fmt.Fprintf(h, "|%s|%s|%s|%s", m.DirPath, m.RepoURL, m.Documentation, m.Notes)
return fmt.Sprintf("%x", h.Sum(nil))
}
// ComputeProjectHash computes a deterministic hash of all content fields of a Project.
func ComputeProjectHash(p *Project) string {
h := sha256.New()
@@ -98,7 +111,7 @@ func ComputeVaultHash(v *Vault) string {
// LoadTimestamps reads existing id → {created_at, updated_at, content_hash} from all tables.
// Called before Purge so we can preserve dates across reindexing.
func (db *DB) LoadTimestamps() (funcs, types, apps, analysis, projects, vaults map[string]timestampRecord, err error) {
func (db *DB) LoadTimestamps() (funcs, types, apps, analysis, projects, vaults, modules map[string]timestampRecord, err error) {
funcs, err = loadTable(db, "functions")
if err != nil {
return
@@ -120,6 +133,10 @@ func (db *DB) LoadTimestamps() (funcs, types, apps, analysis, projects, vaults m
return
}
vaults, err = loadTable(db, "vaults")
if err != nil {
return
}
modules, err = loadTable(db, "modules")
return
}
+59 -2
View File
@@ -16,6 +16,7 @@ type IndexResult struct {
Analysis int
Projects int
Vaults int
Modules int
UnitTests int
ValidationErrors []string
Warnings []string
@@ -31,7 +32,7 @@ type IndexResult struct {
// directories (e.g. python/functions/, python/types/).
func Index(db *DB, root string) (*IndexResult, error) {
// Load existing timestamps before purging so we can preserve created_at
oldFuncs, oldTypes, oldApps, oldAnalysis, oldProjects, oldVaults, err := db.LoadTimestamps()
oldFuncs, oldTypes, oldApps, oldAnalysis, oldProjects, oldVaults, oldModules, err := db.LoadTimestamps()
if err != nil {
return nil, fmt.Errorf("loading timestamps: %w", err)
}
@@ -62,6 +63,20 @@ func Index(db *DB, root string) (*IndexResult, error) {
}
}
// Discover module directories (modules/<name>/) — each may contain function .md
// files alongside the module.md. Module entrypoint .md files (e.g. data_table.md)
// live in their module dir; types still live in types/ to keep cross-module reuse.
modRoot := filepath.Join(root, "modules")
if fi, err := os.Stat(modRoot); err == nil && fi.IsDir() {
modEntries, _ := os.ReadDir(modRoot)
for _, me := range modEntries {
if !me.IsDir() {
continue
}
funcDirs = append(funcDirs, filepath.Join(modRoot, me.Name()))
}
}
for _, dir := range funcDirs {
walkMD(dir, func(path string) {
f, err := ParseFunctionMD(path, root)
@@ -146,6 +161,31 @@ func Index(db *DB, root string) (*IndexResult, error) {
}
}
// Parse modules from modules/*/module.md
var modules []*Module
modulesDir := filepath.Join(root, "modules")
if fi, err := os.Stat(modulesDir); err == nil && fi.IsDir() {
modEntries, _ := os.ReadDir(modulesDir)
for _, me := range modEntries {
if !me.IsDir() {
continue
}
modMD := filepath.Join(modulesDir, me.Name(), "module.md")
if _, err := os.Stat(modMD); err != nil {
continue
}
m, err := ParseModuleMD(modMD, root)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", modMD, err))
continue
}
if m.DirPath == "" {
m.DirPath = filepath.Join("modules", me.Name())
}
modules = append(modules, m)
}
}
// Parse projects from projects/*/project.md
var projects []*Project
var vaults []*Vault
@@ -347,6 +387,19 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.Vaults++
}
for _, m := range modules {
m.ContentHash = ComputeModuleHash(m)
applyTimestamps(&m.CreatedAt, &m.UpdatedAt, m.ContentHash, oldModules[m.ID], now)
if err := db.InsertModule(m); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert module %s: %v", m.ID, err))
continue
}
if err := emitModuleVersionHeader(m, root); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("module %s: codegen version header: %v", m.ID, err))
}
result.Modules++
}
// Extract unit tests from test files of tested functions
if err := db.PurgeUnitTests(); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("purging unit_tests: %v", err))
@@ -437,7 +490,8 @@ func applyTimestamps(createdAt, updatedAt *time.Time, newHash string, old timest
}
}
// walkMD walks a directory recursively and calls fn for each .md file found.
// walkMD walks a directory recursively and calls fn for each .md file found,
// skipping module.md (which is parsed separately as a Module entry).
func walkMD(dir string, fn func(path string)) {
if _, err := os.Stat(dir); err != nil {
return
@@ -446,6 +500,9 @@ func walkMD(dir string, fn func(path string)) {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
if filepath.Base(path) == "module.md" {
return nil
}
fn(path)
return nil
})
+57
View File
@@ -0,0 +1,57 @@
-- Modules: reusable cohesive units (e.g. data_table) versioned with semver.
-- A module groups a set of related registry functions/types under a single
-- versioned artefact that apps opt into via uses_modules in app.md.
--
-- Modules son datos vivos: fn sync los replica entre PCs igual que apps/proposals.
-- Aunque la fuente es modules/*/module.md (parseable), conservamos created_at /
-- updated_at de forma persistente para mantener historico cross-PC.
CREATE TABLE IF NOT EXISTS modules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '0.0.0',
lang TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
members TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]',
dir_path TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT '',
documentation TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS modules_fts USING fts5(
id,
name,
description,
tags,
members,
documentation,
notes,
content='modules',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS modules_ai AFTER INSERT ON modules BEGIN
INSERT INTO modules_fts(rowid, id, name, description, tags, members, documentation, notes)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.members, new.documentation, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS modules_ad AFTER DELETE ON modules BEGIN
INSERT INTO modules_fts(modules_fts, rowid, id, name, description, tags, members, documentation, notes)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.members, old.documentation, old.notes);
END;
CREATE TRIGGER IF NOT EXISTS modules_au AFTER UPDATE ON modules BEGIN
INSERT INTO modules_fts(modules_fts, rowid, id, name, description, tags, members, documentation, notes)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.members, old.documentation, old.notes);
INSERT INTO modules_fts(rowid, id, name, description, tags, members, documentation, notes)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.members, new.documentation, new.notes);
END;
-- uses_modules en apps/analysis: lista declarativa de modulos consumidos.
ALTER TABLE apps ADD COLUMN uses_modules TEXT NOT NULL DEFAULT '[]';
ALTER TABLE analysis ADD COLUMN uses_modules TEXT NOT NULL DEFAULT '[]';
+29
View File
@@ -113,6 +113,7 @@ type App struct {
Tags []string `json:"tags"`
UsesFunctions []string `json:"uses_functions"`
UsesTypes []string `json:"uses_types"`
UsesModules []string `json:"uses_modules"`
Framework string `json:"framework"`
EntryPoint string `json:"entry_point"`
Documentation string `json:"documentation"`
@@ -135,6 +136,7 @@ type Analysis struct {
Tags []string `json:"tags"`
UsesFunctions []string `json:"uses_functions"`
UsesTypes []string `json:"uses_types"`
UsesModules []string `json:"uses_modules"`
Framework string `json:"framework"`
EntryPoint string `json:"entry_point"`
Documentation string `json:"documentation"`
@@ -147,6 +149,27 @@ type Analysis struct {
UpdatedAt time.Time `json:"updated_at"`
}
// Module represents an entry in the modules table.
// A module groups related registry functions/types under a single versioned
// artefact that apps opt into via uses_modules in app.md. Living data: kept
// in sync across PCs via fn sync.
type Module struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Lang string `json:"lang"`
Description string `json:"description"`
Members []string `json:"members"`
Tags []string `json:"tags"`
DirPath string `json:"dir_path"`
RepoURL string `json:"repo_url"`
Documentation string `json:"documentation"`
Notes string `json:"notes"`
ContentHash string `json:"content_hash"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProposalKind classifies a proposal.
type ProposalKind string
@@ -241,3 +264,9 @@ type PcLocation struct {
func GenerateID(name, lang, domain string) string {
return name + "_" + lang + "_" + domain
}
// GenerateModuleID builds the module canonical ID: {name}_{lang}.
// Modules are language-scoped but domain-agnostic; they live at modules/<name>/.
func GenerateModuleID(name, lang string) string {
return name + "_" + lang
}
+60
View File
@@ -0,0 +1,60 @@
package registry
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// emitModuleVersionHeader writes <dir_path>/version_generated.h for a module.
// The header exposes constants the C++ side can read:
//
// FN_MODULE_<NAME>_VERSION (const char*)
// FN_MODULE_<NAME>_NAME (const char*)
// FN_MODULE_<NAME>_DESCRIPTION (const char*)
//
// For C++ modules only (lang == "cpp"). For other langs, returns nil silently.
// Idempotent: only rewrites when content differs.
func emitModuleVersionHeader(m *Module, root string) error {
if m.Lang != "cpp" {
return nil
}
if m.DirPath == "" {
return nil
}
upper := strings.ToUpper(m.Name)
macroPrefix := "FN_MODULE_" + upper
guard := macroPrefix + "_VERSION_GENERATED_H"
body := fmt.Sprintf(`// Auto-generated by `+"`fn index`"+` — do not edit.
// Module: %s
// Source of truth: modules/%s/module.md
#ifndef %s
#define %s
#define %s_NAME %q
#define %s_VERSION %q
#define %s_DESCRIPTION %q
#endif // %s
`, m.Name, m.Name,
guard, guard,
macroPrefix, m.Name,
macroPrefix, m.Version,
macroPrefix, m.Description,
guard)
headerPath := filepath.Join(root, m.DirPath, "version_generated.h")
// Idempotent: skip write when content already matches.
if existing, err := os.ReadFile(headerPath); err == nil && string(existing) == body {
return nil
}
if err := os.MkdirAll(filepath.Dir(headerPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(headerPath), err)
}
return os.WriteFile(headerPath, []byte(body), 0o644)
}
+65
View File
@@ -82,6 +82,7 @@ type rawApp struct {
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
UsesModules []string `yaml:"uses_modules"`
Framework string `yaml:"framework"`
EntryPoint string `yaml:"entry_point"`
DirPath string `yaml:"dir_path"`
@@ -97,12 +98,25 @@ type rawAnalysis struct {
Tags []string `yaml:"tags"`
UsesFunctions []string `yaml:"uses_functions"`
UsesTypes []string `yaml:"uses_types"`
UsesModules []string `yaml:"uses_modules"`
Framework string `yaml:"framework"`
EntryPoint string `yaml:"entry_point"`
DirPath string `yaml:"dir_path"`
RepoURL string `yaml:"repo_url"`
}
// rawModule mirrors the YAML frontmatter of a module.md file.
type rawModule struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Lang string `yaml:"lang"`
Description string `yaml:"description"`
Members []string `yaml:"members"`
Tags []string `yaml:"tags"`
DirPath string `yaml:"dir_path"`
RepoURL string `yaml:"repo_url"`
}
// rawProject mirrors the YAML frontmatter of a project .md file.
type rawProject struct {
Name string `yaml:"name"`
@@ -320,6 +334,7 @@ func ParseAppMD(path string, root string) (*App, error) {
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
UsesModules: raw.UsesModules,
Framework: raw.Framework,
EntryPoint: raw.EntryPoint,
Documentation: sections.documentation,
@@ -366,6 +381,7 @@ func ParseAnalysisMD(path string, root string) (*Analysis, error) {
Tags: raw.Tags,
UsesFunctions: raw.UsesFunctions,
UsesTypes: raw.UsesTypes,
UsesModules: raw.UsesModules,
Framework: raw.Framework,
EntryPoint: raw.EntryPoint,
Documentation: sections.documentation,
@@ -377,6 +393,55 @@ func ParseAnalysisMD(path string, root string) (*Analysis, error) {
return an, nil
}
// ParseModuleMD parses a module .md file into a Module.
func ParseModuleMD(path string, root string) (*Module, 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 rawModule
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.Lang == "" {
return nil, fmt.Errorf("%s: lang is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
if raw.Version == "" {
raw.Version = "0.0.0"
}
sections := extractSections(body)
m := &Module{
ID: GenerateModuleID(raw.Name, raw.Lang),
Name: raw.Name,
Version: raw.Version,
Lang: raw.Lang,
Description: raw.Description,
Members: raw.Members,
Tags: raw.Tags,
DirPath: raw.DirPath,
RepoURL: raw.RepoURL,
Documentation: sections.documentation,
Notes: sections.notes,
}
return m, nil
}
// ParseProjectMD parses a project .md file into a Project.
func ParseProjectMD(path string, root string) (*Project, error) {
data, err := os.ReadFile(path)
+133 -12
View File
@@ -307,12 +307,12 @@ func (db *DB) InsertApp(a *App) error {
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, project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url, project_id, uses_modules
) 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, a.ProjectID,
a.RepoURL, a.ProjectID, marshalStrings(a.UsesModules),
)
return err
}
@@ -372,14 +372,14 @@ 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 tagsJSON, usesFnJSON, usesTypJSON, usesModJSON 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, &a.ProjectID,
&a.RepoURL, &a.ProjectID, &usesModJSON,
)
if err != nil {
return nil, fmt.Errorf("scanning app: %w", err)
@@ -388,6 +388,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
a.Tags = unmarshalStrings(tagsJSON)
a.UsesFunctions = unmarshalStrings(usesFnJSON)
a.UsesTypes = unmarshalStrings(usesTypJSON)
a.UsesModules = unmarshalStrings(usesModJSON)
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
@@ -396,7 +397,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
return result, nil
}
// Purge deletes all data from functions, types, apps, analysis, projects and vaults. Used before re-indexing.
// Purge deletes all data from functions, types, apps, analysis, projects, vaults and modules. Used before re-indexing.
func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
return err
@@ -413,7 +414,10 @@ func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM projects"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM vaults")
if _, err := db.conn.Exec("DELETE FROM vaults"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM modules")
return err
}
@@ -458,6 +462,10 @@ func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs, localProjectIDs map[
if _, err := db.conn.Exec("DELETE FROM vaults"); err != nil {
return err
}
// Modules: always purge and re-insert from modules/*/module.md
if _, err := db.conn.Exec("DELETE FROM modules"); err != nil {
return err
}
return nil
}
@@ -481,12 +489,12 @@ func (db *DB) InsertAnalysis(a *Analysis) error {
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, project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at, project_id, uses_modules
) 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), a.ProjectID,
a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), a.ProjectID, marshalStrings(a.UsesModules),
)
return err
}
@@ -556,14 +564,14 @@ func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis
var result []Analysis
for rows.Next() {
var a Analysis
var tagsJSON, usesFnJSON, usesTypJSON string
var tagsJSON, usesFnJSON, usesTypJSON, usesModJSON 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, &a.ProjectID,
&createdAt, &updatedAt, &a.ProjectID, &usesModJSON,
)
if err != nil {
return nil, fmt.Errorf("scanning analysis: %w", err)
@@ -572,6 +580,7 @@ func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis
a.Tags = unmarshalStrings(tagsJSON)
a.UsesFunctions = unmarshalStrings(usesFnJSON)
a.UsesTypes = unmarshalStrings(usesTypJSON)
a.UsesModules = unmarshalStrings(usesModJSON)
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
@@ -1223,3 +1232,115 @@ func (db *DB) AllProposals() ([]Proposal, error) {
func (db *DB) AllVaults() ([]Vault, error) {
return db.SearchVaults("", "")
}
// --- Module CRUD ---
// InsertModule inserts or replaces a module entry.
func (db *DB) InsertModule(m *Module) error {
now := time.Now().UTC()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
if m.UpdatedAt.IsZero() {
m.UpdatedAt = now
}
if m.ID == "" {
m.ID = GenerateModuleID(m.Name, m.Lang)
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO modules (
id, name, version, lang, description, members, tags,
dir_path, repo_url, documentation, notes, content_hash, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.ID, m.Name, m.Version, m.Lang, m.Description,
marshalStrings(m.Members), marshalStrings(m.Tags),
m.DirPath, m.RepoURL, m.Documentation, m.Notes, m.ContentHash,
m.CreatedAt.Format(time.RFC3339), m.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetModule returns a single module by ID.
func (db *DB) GetModule(id string) (*Module, error) {
rows, err := db.conn.Query("SELECT * FROM modules WHERE id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanModules(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("module %q not found", id)
}
return &items[0], nil
}
// SearchModules performs FTS search on modules with optional filters.
func (db *DB) SearchModules(query, lang string) ([]Module, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "m.id IN (SELECT id FROM modules_fts WHERE modules_fts MATCH ?)")
args = append(args, query)
}
if lang != "" {
where = append(where, "m.lang = ?")
args = append(args, lang)
}
sql := "SELECT * FROM modules m"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY m.name"
rows, err := db.conn.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("search modules: %w", err)
}
defer rows.Close()
return scanModules(rows)
}
// ListAllModules returns all module entries.
func (db *DB) ListAllModules() ([]Module, error) {
return db.SearchModules("", "")
}
// AllModules returns all modules (for sync export).
func (db *DB) AllModules() ([]Module, error) {
return db.SearchModules("", "")
}
func scanModules(rows interface{ Next() bool; Scan(...any) error }) ([]Module, error) {
var result []Module
for rows.Next() {
var m Module
var membersJSON, tagsJSON string
var createdAt, updatedAt string
err := rows.Scan(
&m.ID, &m.Name, &m.Version, &m.Lang, &m.Description,
&membersJSON, &tagsJSON,
&m.DirPath, &m.RepoURL, &m.Documentation, &m.Notes, &m.ContentHash,
&createdAt, &updatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning module: %w", err)
}
m.Members = unmarshalStrings(membersJSON)
m.Tags = unmarshalStrings(tagsJSON)
m.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
m.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, m)
}
return result, nil
}