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, project_id ) 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, ) 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, &a.ProjectID, ) 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, analysis, projects and vaults. 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 } if _, err := db.conn.Exec("DELETE FROM analysis"); err != nil { return err } if _, err := db.conn.Exec("DELETE FROM projects"); err != nil { return err } _, err := db.conn.Exec("DELETE FROM vaults") return err } // PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis/projects/vaults. // Remote-only records (repo_url set, not in local ID maps) are preserved. func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs, localProjectIDs 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 } // Projects: delete locally-scanned, preserve remote-only for id := range localProjectIDs { if _, err := db.conn.Exec("DELETE FROM projects WHERE id = ?", id); err != nil { return err } } if _, err := db.conn.Exec("DELETE FROM projects WHERE repo_url = '' OR repo_url IS NULL"); err != nil { return err } // Vaults: always purge and re-insert from vault.yaml if _, err := db.conn.Exec("DELETE FROM vaults"); 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, project_id ) 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, ) 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, &a.ProjectID, ) 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 } // --- Project CRUD --- // InsertProject inserts or replaces a project entry. func (db *DB) InsertProject(p *Project) error { now := time.Now().UTC() if p.CreatedAt.IsZero() { p.CreatedAt = now } if p.UpdatedAt.IsZero() { p.UpdatedAt = now } _, err := db.conn.Exec(` INSERT OR REPLACE INTO projects ( id, name, description, tags, repo_url, dir_path, documentation, notes, content_hash, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Name, p.Description, marshalStrings(p.Tags), p.RepoURL, p.DirPath, p.Documentation, p.Notes, p.ContentHash, p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), ) return err } // GetProject returns a single project by ID. func (db *DB) GetProject(id string) (*Project, error) { rows, err := db.conn.Query("SELECT * FROM projects WHERE id = ?", id) if err != nil { return nil, err } defer rows.Close() ps, err := scanProjects(rows) if err != nil { return nil, err } if len(ps) == 0 { return nil, fmt.Errorf("project %q not found", id) } return &ps[0], nil } // SearchProjects performs FTS search on projects. func (db *DB) SearchProjects(query string) ([]Project, error) { where := []string{} args := []any{} if query != "" { where = append(where, "p.id IN (SELECT id FROM projects_fts WHERE projects_fts MATCH ?)") args = append(args, query) } sql := "SELECT * FROM projects p" if len(where) > 0 { sql += " WHERE " + strings.Join(where, " AND ") } sql += " ORDER BY p.name" rows, err := db.conn.Query(sql, args...) if err != nil { return nil, fmt.Errorf("search projects: %w", err) } defer rows.Close() return scanProjects(rows) } // ListAllProjects returns all project entries. func (db *DB) ListAllProjects() ([]Project, error) { return db.SearchProjects("") } // GetProjectApps returns all apps belonging to a project. func (db *DB) GetProjectApps(projectID string) ([]App, error) { rows, err := db.conn.Query("SELECT * FROM apps WHERE project_id = ? ORDER BY name", projectID) if err != nil { return nil, err } defer rows.Close() return scanApps(rows) } // GetProjectAnalysis returns all analysis entries belonging to a project. func (db *DB) GetProjectAnalysis(projectID string) ([]Analysis, error) { rows, err := db.conn.Query("SELECT * FROM analysis WHERE project_id = ? ORDER BY name", projectID) if err != nil { return nil, err } defer rows.Close() return scanAnalysis(rows) } func scanProjects(rows interface{ Next() bool; Scan(...any) error }) ([]Project, error) { var result []Project for rows.Next() { var p Project var tagsJSON string var createdAt, updatedAt string err := rows.Scan( &p.ID, &p.Name, &p.Description, &tagsJSON, &p.RepoURL, &p.DirPath, &p.Documentation, &p.Notes, &p.ContentHash, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("scanning project: %w", err) } p.Tags = unmarshalStrings(tagsJSON) p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) result = append(result, p) } return result, nil } // --- Vault CRUD --- // InsertVault inserts or replaces a vault entry. func (db *DB) InsertVault(v *Vault) error { now := time.Now().UTC() if v.CreatedAt.IsZero() { v.CreatedAt = now } if v.UpdatedAt.IsZero() { v.UpdatedAt = now } sym := 0 if v.Symlink { sym = 1 } _, err := db.conn.Exec(` INSERT OR REPLACE INTO vaults ( id, name, project_id, description, path, symlink, tags, content_hash, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, v.ID, v.Name, v.ProjectID, v.Description, v.Path, sym, marshalStrings(v.Tags), v.ContentHash, v.CreatedAt.Format(time.RFC3339), v.UpdatedAt.Format(time.RFC3339), ) return err } // GetVault returns a single vault by ID. func (db *DB) GetVault(id string) (*Vault, error) { rows, err := db.conn.Query("SELECT * FROM vaults WHERE id = ?", id) if err != nil { return nil, err } defer rows.Close() vs, err := scanVaults(rows) if err != nil { return nil, err } if len(vs) == 0 { return nil, fmt.Errorf("vault %q not found", id) } return &vs[0], nil } // SearchVaults performs search on vaults with optional project filter. func (db *DB) SearchVaults(query, projectID string) ([]Vault, error) { where := []string{} args := []any{} if query != "" { where = append(where, "name LIKE ? OR description LIKE ?") q := "%" + query + "%" args = append(args, q, q) } if projectID != "" { where = append(where, "project_id = ?") args = append(args, projectID) } sql := "SELECT * FROM vaults" if len(where) > 0 { sql += " WHERE " + strings.Join(where, " AND ") } sql += " ORDER BY name" rows, err := db.conn.Query(sql, args...) if err != nil { return nil, fmt.Errorf("search vaults: %w", err) } defer rows.Close() return scanVaults(rows) } // GetProjectVaults returns all vaults belonging to a project. func (db *DB) GetProjectVaults(projectID string) ([]Vault, error) { return db.SearchVaults("", projectID) } func scanVaults(rows interface{ Next() bool; Scan(...any) error }) ([]Vault, error) { var result []Vault for rows.Next() { var v Vault var tagsJSON string var createdAt, updatedAt string var sym int err := rows.Scan( &v.ID, &v.Name, &v.ProjectID, &v.Description, &v.Path, &sym, &tagsJSON, &v.ContentHash, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("scanning vault: %w", err) } v.Symlink = sym == 1 v.Tags = unmarshalStrings(tagsJSON) v.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) v.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) result = append(result, v) } 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 } // --- PcLocation CRUD --- // InsertPcLocation inserts or replaces a pc_location entry. func (db *DB) InsertPcLocation(loc *PcLocation) error { now := time.Now().UTC() if loc.CreatedAt.IsZero() { loc.CreatedAt = now } if loc.UpdatedAt.IsZero() { loc.UpdatedAt = now } if loc.ID == "" { loc.ID = loc.EntityType + "_" + loc.EntityID + "_" + loc.PcID } if loc.Status == "" { loc.Status = "active" } _, err := db.conn.Exec(` INSERT OR REPLACE INTO pc_locations ( id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, loc.ID, loc.EntityType, loc.EntityID, loc.PcID, loc.DirPath, loc.Status, loc.Notes, loc.CreatedAt.Format(time.RFC3339), loc.UpdatedAt.Format(time.RFC3339), ) return err } // GetPcLocationsByPC returns all locations for a given PC. func (db *DB) GetPcLocationsByPC(pcID string) ([]PcLocation, error) { rows, err := db.conn.Query( "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE pc_id = ? ORDER BY entity_type, entity_id", pcID, ) if err != nil { return nil, err } defer rows.Close() return scanPcLocations(rows) } // GetPcLocationsByEntity returns all PC locations for a given entity. func (db *DB) GetPcLocationsByEntity(entityType, entityID string) ([]PcLocation, error) { rows, err := db.conn.Query( "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE entity_type = ? AND entity_id = ? ORDER BY pc_id", entityType, entityID, ) if err != nil { return nil, err } defer rows.Close() return scanPcLocations(rows) } // ListAllPcLocations returns all pc_location entries. func (db *DB) ListAllPcLocations() ([]PcLocation, error) { rows, err := db.conn.Query( "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations ORDER BY pc_id, entity_type, entity_id", ) if err != nil { return nil, err } defer rows.Close() return scanPcLocations(rows) } // DeletePcLocationsByPC removes all locations for a given PC. func (db *DB) DeletePcLocationsByPC(pcID string) error { _, err := db.conn.Exec("DELETE FROM pc_locations WHERE pc_id = ?", pcID) return err } func scanPcLocations(rows interface{ Next() bool; Scan(...any) error }) ([]PcLocation, error) { var result []PcLocation for rows.Next() { var loc PcLocation var createdAt, updatedAt string err := rows.Scan( &loc.ID, &loc.EntityType, &loc.EntityID, &loc.PcID, &loc.DirPath, &loc.Status, &loc.Notes, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("scanning pc_location: %w", err) } loc.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) loc.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) result = append(result, loc) } return result, nil } // --- Sync helpers --- // AllApps returns all apps (for sync export). func (db *DB) AllApps() ([]App, error) { return db.SearchApps("", "", "") } // AllAnalysis returns all analysis entries (for sync export). func (db *DB) AllAnalysis() ([]Analysis, error) { return db.SearchAnalysis("", "", "") } // AllProposals returns all proposals (for sync export). func (db *DB) AllProposals() ([]Proposal, error) { return db.ListProposals("", "") } // AllVaults returns all vaults (for sync export). func (db *DB) AllVaults() ([]Vault, error) { return db.SearchVaults("", "") }