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
This commit is contained in:
125
internal/api/dependency_handlers.go
Normal file
125
internal/api/dependency_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
127
internal/db/item_dependencies.go
Normal file
127
internal/db/item_dependencies.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user