// Package graphsync glues the disk-resident brain markdown documents to // the relational graph in [graphstore]. It is a tiny seam so that the // MCP handlers can call one function after every successful write or // ingest without having to know either the parser or the postgres // schema. // // Every operation is best-effort from the caller's perspective: if the // graph store is unconfigured or the doc parses to nothing usable, the // helpers return nil. Real database errors are surfaced so the caller // can log them. package graphsync import ( "context" "fmt" "os" "path/filepath" "github.com/mathiasbq/hyperguild/ingestion/internal/graph" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" ) // Store is the subset of graphstore.PGStore that graphsync requires. // Tests can substitute a fake by satisfying this interface. type Store interface { UpsertEntity(ctx context.Context, e graph.Entity) error ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error DeleteByDoc(ctx context.Context, docPath string) error } // Compile-time assertion that *graphstore.PGStore satisfies Store. var _ Store = (*graphstore.PGStore)(nil) // IndexDoc reads docPath under brainDir and pushes one Entity + its // outgoing wikilink Edges into store. relPath must be the // forward-slash path relative to brainDir (the same shape returned by // api.WriteNote). // // nil store is a valid no-op so callers can wire the helper // unconditionally and let configuration decide whether the graph is // populated. func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error { if store == nil { return nil } if relPath == "" { return nil } abs := filepath.Join(brainDir, filepath.FromSlash(relPath)) content, err := os.ReadFile(abs) if err != nil { return fmt.Errorf("read %q: %w", relPath, err) } ent, edges, ok := graph.Extract(relPath, content) if !ok { return nil } if err := store.UpsertEntity(ctx, ent); err != nil { return fmt.Errorf("upsert entity: %w", err) } if err := store.ReplaceEdgesForDoc(ctx, relPath, edges); err != nil { return fmt.Errorf("replace edges: %w", err) } return nil } // BackfillFromBrainDir walks every markdown file under brainDir/wiki/ // and brainDir/knowledge/, parses each, and upserts the resulting // Entity + Edges. Existing rows are overwritten; orphan rows for // already-deleted files are NOT cleaned up — call this only on a // fresh store, or follow with a separate prune pass. // // Intended for one-shot startup runs against a populated brain dir. // Cost scales linearly with corpus size; ~30 wiki pages plus the // knowledge corpus is a few hundred ms. func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (indexed int, _ error) { if store == nil { return 0, nil } roots := []string{"wiki", "knowledge"} for _, root := range roots { base := filepath.Join(brainDir, root) if _, err := os.Stat(base); os.IsNotExist(err) { continue } err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } if filepath.Ext(path) != ".md" { return nil } rel, relErr := filepath.Rel(brainDir, path) if relErr != nil { return fmt.Errorf("rel %q: %w", path, relErr) } rel = filepath.ToSlash(rel) if err := IndexDoc(ctx, store, brainDir, rel); err != nil { return fmt.Errorf("index %q: %w", rel, err) } indexed++ return nil }) if err != nil { return indexed, fmt.Errorf("walk %s: %w", root, err) } } return indexed, nil }