Merge pull request 'feat(api): item dependency extraction, indexing, and resolve endpoints' (#151) from feat/kc-dependencies into main

Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
2026-02-19 00:55:55 +00:00
8 changed files with 357 additions and 7 deletions

View File

@@ -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)
}

View File

@@ -56,6 +56,7 @@ type Server struct {
cfg *config.Config cfg *config.Config
settings *db.SettingsRepository settings *db.SettingsRepository
metadata *db.ItemMetadataRepository metadata *db.ItemMetadataRepository
deps *db.ItemDependencyRepository
} }
// NewServer creates a new API server. // NewServer creates a new API server.
@@ -85,6 +86,7 @@ func NewServer(
settings := db.NewSettingsRepository(database) settings := db.NewSettingsRepository(database)
locations := db.NewLocationRepository(database) locations := db.NewLocationRepository(database)
metadata := db.NewItemMetadataRepository(database) metadata := db.NewItemMetadataRepository(database)
itemDeps := db.NewItemDependencyRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas} seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore) partgen := partnum.NewGenerator(schemas, seqStore)
@@ -114,6 +116,7 @@ func NewServer(
cfg: cfg, cfg: cfg,
settings: settings, settings: settings,
metadata: metadata, metadata: metadata,
deps: itemDeps,
} }
} }

View File

@@ -398,6 +398,38 @@ func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey s
"updated_by": username, "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") s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
} }

View File

@@ -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{ input := &kc.PackInput{
Manifest: manifest, Manifest: manifest,
Metadata: metadata, Metadata: metadata,
History: history, History: history,
Dependencies: []any{}, // empty for Phase 2 Dependencies: deps,
} }
return kc.Pack(data, input) return kc.Pack(data, input)
@@ -95,3 +117,19 @@ func derefStr(p *string, fallback string) string {
} }
return fallback 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
}

View File

@@ -173,6 +173,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.csv", server.HandleExportBOMCSV)
r.Get("/bom/export.ods", server.HandleExportBOMODS) r.Get("/bom/export.ods", server.HandleExportBOMODS)
r.Get("/metadata", server.HandleGetMetadata) r.Get("/metadata", server.HandleGetMetadata)
r.Get("/dependencies", server.HandleGetDependencies)
r.Get("/dependencies/resolve", server.HandleResolveDependencies)
// DAG (gated by dag module) // DAG (gated by dag module)
r.Route("/dag", func(r chi.Router) { r.Route("/dag", func(r chi.Router) {

View File

@@ -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
}

View File

@@ -30,10 +30,21 @@ type Metadata struct {
Fields map[string]any `json:"fields"` 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. // ExtractResult holds the parsed silo/ directory contents from a .kc file.
type ExtractResult struct { type ExtractResult struct {
Manifest *Manifest Manifest *Manifest
Metadata *Metadata Metadata *Metadata
Dependencies []Dependency
} }
// HistoryEntry represents one entry in silo/history.json. // HistoryEntry represents one entry in silo/history.json.
@@ -52,7 +63,7 @@ type PackInput struct {
Manifest *Manifest Manifest *Manifest
Metadata *Metadata Metadata *Metadata
History []HistoryEntry 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. // 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) return nil, fmt.Errorf("kc: open zip: %w", err)
} }
var manifestFile, metadataFile *zip.File var manifestFile, metadataFile, dependenciesFile *zip.File
hasSiloDir := false hasSiloDir := false
for _, f := range r.File { for _, f := range r.File {
@@ -76,6 +87,8 @@ func Extract(data []byte) (*ExtractResult, error) {
manifestFile = f manifestFile = f
case "silo/metadata.json": case "silo/metadata.json":
metadataFile = f metadataFile = f
case "silo/dependencies.json":
dependenciesFile = f
} }
} }
@@ -101,6 +114,16 @@ func Extract(data []byte) (*ExtractResult, error) {
result.Metadata = m 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 return result, nil
} }

View File

@@ -82,7 +82,7 @@ func TestPack_RoundTrip(t *testing.T) {
Manifest: newManifest, Manifest: newManifest,
Metadata: newMetadata, Metadata: newMetadata,
History: history, History: history,
Dependencies: []any{}, Dependencies: []Dependency{},
}) })
if err != nil { if err != nil {
t.Fatalf("Pack error: %v", err) t.Fatalf("Pack error: %v", err)
@@ -193,7 +193,7 @@ func TestPack_EmptyDependencies(t *testing.T) {
packed, err := Pack(original, &PackInput{ packed, err := Pack(original, &PackInput{
Manifest: &Manifest{UUID: "x"}, Manifest: &Manifest{UUID: "x"},
Dependencies: []any{}, Dependencies: []Dependency{},
}) })
if err != nil { if err != nil {
t.Fatalf("Pack error: %v", err) t.Fatalf("Pack error: %v", err)