From cffcf560851d5083736f0a3a8fa0269e0efe3036 Mon Sep 17 00:00:00 2001 From: Forbes Date: Wed, 18 Feb 2026 18:53:40 -0600 Subject: [PATCH] feat(api): item dependency extraction, indexing, and resolve endpoints - Add Dependency type to internal/kc and extract silo/dependencies.json from .kc files on commit - Create ItemDependencyRepository with ReplaceForRevision, ListByItem, and Resolve (LEFT JOIN against items table) - Add GET /{partNumber}/dependencies and GET /{partNumber}/dependencies/resolve endpoints - Index dependencies in extractKCMetadata with SSE broadcast - Pack real dependency data into .kc files on checkout - Update PackInput.Dependencies from []any to []Dependency Closes #143 --- internal/api/dependency_handlers.go | 125 +++++++++++++++++++++++++++ internal/api/handlers.go | 3 + internal/api/metadata_handlers.go | 32 +++++++ internal/api/pack_handlers.go | 40 ++++++++- internal/api/routes.go | 2 + internal/db/item_dependencies.go | 127 ++++++++++++++++++++++++++++ internal/kc/kc.go | 31 ++++++- internal/kc/pack_test.go | 4 +- 8 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 internal/api/dependency_handlers.go create mode 100644 internal/db/item_dependencies.go diff --git a/internal/api/dependency_handlers.go b/internal/api/dependency_handlers.go new file mode 100644 index 0000000..f501775 --- /dev/null +++ b/internal/api/dependency_handlers.go @@ -0,0 +1,125 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/storage" +) + +// DependencyResponse is the JSON representation for GET /dependencies. +type DependencyResponse struct { + UUID string `json:"uuid"` + PartNumber *string `json:"part_number"` + Revision *int `json:"revision"` + Quantity *float64 `json:"quantity"` + Label *string `json:"label"` + Relationship string `json:"relationship"` +} + +// ResolvedDependencyResponse is the JSON representation for GET /dependencies/resolve. +type ResolvedDependencyResponse struct { + UUID string `json:"uuid"` + PartNumber *string `json:"part_number"` + Label *string `json:"label"` + Revision *int `json:"revision"` + Quantity *float64 `json:"quantity"` + Resolved bool `json:"resolved"` + FileAvailable bool `json:"file_available"` +} + +// HandleGetDependencies returns the raw dependency list for an item. +// GET /api/items/{partNumber}/dependencies +func (s *Server) HandleGetDependencies(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + deps, err := s.deps.ListByItem(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list dependencies") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list dependencies") + return + } + + resp := make([]DependencyResponse, len(deps)) + for i, d := range deps { + resp[i] = DependencyResponse{ + UUID: d.ChildUUID, + PartNumber: d.ChildPartNumber, + Revision: d.ChildRevision, + Quantity: d.Quantity, + Label: d.Label, + Relationship: d.Relationship, + } + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleResolveDependencies returns dependencies with UUIDs resolved to part numbers +// and file availability status. +// GET /api/items/{partNumber}/dependencies/resolve +func (s *Server) HandleResolveDependencies(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + deps, err := s.deps.Resolve(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to resolve dependencies") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve dependencies") + return + } + + resp := make([]ResolvedDependencyResponse, len(deps)) + for i, d := range deps { + // Use resolved part number if available, fall back to .kc-provided value. + pn := d.ChildPartNumber + rev := d.ChildRevision + if d.Resolved { + pn = d.ResolvedPartNumber + rev = d.ResolvedRevision + } + + fileAvailable := false + if d.Resolved && pn != nil && rev != nil && s.storage != nil { + key := storage.FileKey(*pn, *rev) + if exists, err := s.storage.Exists(ctx, key); err == nil { + fileAvailable = exists + } + } + + resp[i] = ResolvedDependencyResponse{ + UUID: d.ChildUUID, + PartNumber: pn, + Label: d.Label, + Revision: rev, + Quantity: d.Quantity, + Resolved: d.Resolved, + FileAvailable: fileAvailable, + } + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 4f8dfa4..98bb9fd 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -56,6 +56,7 @@ type Server struct { cfg *config.Config settings *db.SettingsRepository metadata *db.ItemMetadataRepository + deps *db.ItemDependencyRepository } // NewServer creates a new API server. @@ -85,6 +86,7 @@ func NewServer( settings := db.NewSettingsRepository(database) locations := db.NewLocationRepository(database) metadata := db.NewItemMetadataRepository(database) + itemDeps := db.NewItemDependencyRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -114,6 +116,7 @@ func NewServer( cfg: cfg, settings: settings, metadata: metadata, + deps: itemDeps, } } diff --git a/internal/api/metadata_handlers.go b/internal/api/metadata_handlers.go index 82850e4..8b134fb 100644 --- a/internal/api/metadata_handlers.go +++ b/internal/api/metadata_handlers.go @@ -398,6 +398,38 @@ func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey s "updated_by": username, })) + // Index dependencies from silo/dependencies.json. + if result.Dependencies != nil { + dbDeps := make([]*db.ItemDependency, len(result.Dependencies)) + for i, d := range result.Dependencies { + pn := d.PartNumber + rev := d.Revision + qty := d.Quantity + label := d.Label + rel := d.Relationship + if rel == "" { + rel = "component" + } + dbDeps[i] = &db.ItemDependency{ + ParentItemID: item.ID, + ChildUUID: d.UUID, + ChildPartNumber: &pn, + ChildRevision: &rev, + Quantity: &qty, + Label: &label, + Relationship: rel, + } + } + if err := s.deps.ReplaceForRevision(ctx, item.ID, rev.RevisionNumber, dbDeps); err != nil { + s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index dependencies") + } else { + s.broker.Publish("dependencies.changed", mustMarshal(map[string]any{ + "part_number": item.PartNumber, + "count": len(dbDeps), + })) + } + } + s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully") } diff --git a/internal/api/pack_handlers.go b/internal/api/pack_handlers.go index b5c694c..0220f11 100644 --- a/internal/api/pack_handlers.go +++ b/internal/api/pack_handlers.go @@ -52,11 +52,33 @@ func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev } } + // Build dependencies from item_dependencies table. + var deps []kc.Dependency + dbDeps, err := s.deps.ListByItem(ctx, item.ID) + if err != nil { + s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query dependencies for packing") + } else { + deps = make([]kc.Dependency, len(dbDeps)) + for i, d := range dbDeps { + deps[i] = kc.Dependency{ + UUID: d.ChildUUID, + PartNumber: derefStr(d.ChildPartNumber, ""), + Revision: derefInt(d.ChildRevision, 0), + Quantity: derefFloat(d.Quantity, 0), + Label: derefStr(d.Label, ""), + Relationship: d.Relationship, + } + } + } + if deps == nil { + deps = []kc.Dependency{} + } + input := &kc.PackInput{ Manifest: manifest, Metadata: metadata, History: history, - Dependencies: []any{}, // empty for Phase 2 + Dependencies: deps, } return kc.Pack(data, input) @@ -95,3 +117,19 @@ func derefStr(p *string, fallback string) string { } return fallback } + +// derefInt returns the value of a *int pointer, or fallback if nil. +func derefInt(p *int, fallback int) int { + if p != nil { + return *p + } + return fallback +} + +// derefFloat returns the value of a *float64 pointer, or fallback if nil. +func derefFloat(p *float64, fallback float64) float64 { + if p != nil { + return *p + } + return fallback +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 14f3b3d..f4b6999 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -173,6 +173,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) r.Get("/metadata", server.HandleGetMetadata) + r.Get("/dependencies", server.HandleGetDependencies) + r.Get("/dependencies/resolve", server.HandleResolveDependencies) // DAG (gated by dag module) r.Route("/dag", func(r chi.Router) { diff --git a/internal/db/item_dependencies.go b/internal/db/item_dependencies.go new file mode 100644 index 0000000..6d9c0eb --- /dev/null +++ b/internal/db/item_dependencies.go @@ -0,0 +1,127 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ItemDependency represents a row in the item_dependencies table. +type ItemDependency struct { + ID string + ParentItemID string + ChildUUID string + ChildPartNumber *string + ChildRevision *int + Quantity *float64 + Label *string + Relationship string + RevisionNumber int + CreatedAt time.Time +} + +// ResolvedDependency extends ItemDependency with resolution info from a LEFT JOIN. +type ResolvedDependency struct { + ItemDependency + ResolvedPartNumber *string + ResolvedRevision *int + Resolved bool +} + +// ItemDependencyRepository provides item_dependencies database operations. +type ItemDependencyRepository struct { + db *DB +} + +// NewItemDependencyRepository creates a new item dependency repository. +func NewItemDependencyRepository(db *DB) *ItemDependencyRepository { + return &ItemDependencyRepository{db: db} +} + +// ReplaceForRevision atomically replaces all dependencies for an item's revision. +// Deletes existing rows for the parent item and inserts the new set. +func (r *ItemDependencyRepository) ReplaceForRevision(ctx context.Context, parentItemID string, revisionNumber int, deps []*ItemDependency) error { + return r.db.Tx(ctx, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM item_dependencies WHERE parent_item_id = $1`, parentItemID) + if err != nil { + return fmt.Errorf("deleting old dependencies: %w", err) + } + + for _, d := range deps { + _, err := tx.Exec(ctx, ` + INSERT INTO item_dependencies + (parent_item_id, child_uuid, child_part_number, child_revision, + quantity, label, relationship, revision_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, parentItemID, d.ChildUUID, d.ChildPartNumber, d.ChildRevision, + d.Quantity, d.Label, d.Relationship, revisionNumber) + if err != nil { + return fmt.Errorf("inserting dependency: %w", err) + } + } + return nil + }) +} + +// ListByItem returns all dependencies for an item. +func (r *ItemDependencyRepository) ListByItem(ctx context.Context, parentItemID string) ([]*ItemDependency, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT id, parent_item_id, child_uuid, child_part_number, child_revision, + quantity, label, relationship, revision_number, created_at + FROM item_dependencies + WHERE parent_item_id = $1 + ORDER BY label NULLS LAST + `, parentItemID) + if err != nil { + return nil, fmt.Errorf("listing dependencies: %w", err) + } + defer rows.Close() + + var deps []*ItemDependency + for rows.Next() { + d := &ItemDependency{} + if err := rows.Scan( + &d.ID, &d.ParentItemID, &d.ChildUUID, &d.ChildPartNumber, &d.ChildRevision, + &d.Quantity, &d.Label, &d.Relationship, &d.RevisionNumber, &d.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scanning dependency: %w", err) + } + deps = append(deps, d) + } + return deps, nil +} + +// Resolve returns dependencies with child UUIDs resolved against the items table. +// Unresolvable UUIDs (external or deleted items) have Resolved=false. +func (r *ItemDependencyRepository) Resolve(ctx context.Context, parentItemID string) ([]*ResolvedDependency, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT d.id, d.parent_item_id, d.child_uuid, d.child_part_number, d.child_revision, + d.quantity, d.label, d.relationship, d.revision_number, d.created_at, + i.part_number, i.current_revision + FROM item_dependencies d + LEFT JOIN items i ON i.id = d.child_uuid AND i.archived_at IS NULL + WHERE d.parent_item_id = $1 + ORDER BY d.label NULLS LAST + `, parentItemID) + if err != nil { + return nil, fmt.Errorf("resolving dependencies: %w", err) + } + defer rows.Close() + + var deps []*ResolvedDependency + for rows.Next() { + d := &ResolvedDependency{} + if err := rows.Scan( + &d.ID, &d.ParentItemID, &d.ChildUUID, &d.ChildPartNumber, &d.ChildRevision, + &d.Quantity, &d.Label, &d.Relationship, &d.RevisionNumber, &d.CreatedAt, + &d.ResolvedPartNumber, &d.ResolvedRevision, + ); err != nil { + return nil, fmt.Errorf("scanning resolved dependency: %w", err) + } + d.Resolved = d.ResolvedPartNumber != nil + deps = append(deps, d) + } + return deps, nil +} diff --git a/internal/kc/kc.go b/internal/kc/kc.go index feef87f..4865a93 100644 --- a/internal/kc/kc.go +++ b/internal/kc/kc.go @@ -30,10 +30,21 @@ type Metadata struct { Fields map[string]any `json:"fields"` } +// Dependency represents one entry in silo/dependencies.json. +type Dependency struct { + UUID string `json:"uuid"` + PartNumber string `json:"part_number"` + Revision int `json:"revision"` + Quantity float64 `json:"quantity"` + Label string `json:"label"` + Relationship string `json:"relationship"` +} + // ExtractResult holds the parsed silo/ directory contents from a .kc file. type ExtractResult struct { - Manifest *Manifest - Metadata *Metadata + Manifest *Manifest + Metadata *Metadata + Dependencies []Dependency } // HistoryEntry represents one entry in silo/history.json. @@ -52,7 +63,7 @@ type PackInput struct { Manifest *Manifest Metadata *Metadata History []HistoryEntry - Dependencies []any // empty [] for Phase 2; structured types in Phase 3+ + Dependencies []Dependency } // Extract opens a ZIP archive from data and parses the silo/ directory. @@ -64,7 +75,7 @@ func Extract(data []byte) (*ExtractResult, error) { return nil, fmt.Errorf("kc: open zip: %w", err) } - var manifestFile, metadataFile *zip.File + var manifestFile, metadataFile, dependenciesFile *zip.File hasSiloDir := false for _, f := range r.File { @@ -76,6 +87,8 @@ func Extract(data []byte) (*ExtractResult, error) { manifestFile = f case "silo/metadata.json": metadataFile = f + case "silo/dependencies.json": + dependenciesFile = f } } @@ -101,6 +114,16 @@ func Extract(data []byte) (*ExtractResult, error) { result.Metadata = m } + if dependenciesFile != nil { + deps, err := readJSON[[]Dependency](dependenciesFile) + if err != nil { + return nil, fmt.Errorf("kc: parse dependencies.json: %w", err) + } + if deps != nil { + result.Dependencies = *deps + } + } + return result, nil } diff --git a/internal/kc/pack_test.go b/internal/kc/pack_test.go index eb1f523..a4013fc 100644 --- a/internal/kc/pack_test.go +++ b/internal/kc/pack_test.go @@ -82,7 +82,7 @@ func TestPack_RoundTrip(t *testing.T) { Manifest: newManifest, Metadata: newMetadata, History: history, - Dependencies: []any{}, + Dependencies: []Dependency{}, }) if err != nil { t.Fatalf("Pack error: %v", err) @@ -193,7 +193,7 @@ func TestPack_EmptyDependencies(t *testing.T) { packed, err := Pack(original, &PackInput{ Manifest: &Manifest{UUID: "x"}, - Dependencies: []any{}, + Dependencies: []Dependency{}, }) if err != nil { t.Fatalf("Pack error: %v", err)