feat(kc): commit extraction pipeline + metadata API (Phase 1)
Implements issue #141 — .kc server-side metadata integration Phase 1. When a .kc file is uploaded, the server extracts silo/manifest.json and silo/metadata.json from the ZIP archive and indexes them into the item_metadata table. Plain .fcstd files continue to work unchanged. Extraction is best-effort: failures are logged but do not block the upload. New packages: - internal/kc: ZIP extraction library (Extract, Manifest, Metadata types) - internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields, UpdateLifecycle, SetTags) New API endpoints under /api/items/{partNumber}: - GET /metadata — read indexed metadata (viewer) - PUT /metadata — merge fields into JSONB (editor) - PATCH /metadata/lifecycle — transition lifecycle state (editor) - PATCH /metadata/tags — add/remove tags (editor) SSE events: metadata.updated, metadata.lifecycle, metadata.tags Lifecycle transitions (Phase 1): draft→review→released→obsolete, review→draft (reject). Closes #141
This commit is contained in:
@@ -53,6 +53,7 @@ type Server struct {
|
||||
modules *modules.Registry
|
||||
cfg *config.Config
|
||||
settings *db.SettingsRepository
|
||||
metadata *db.ItemMetadataRepository
|
||||
}
|
||||
|
||||
// NewServer creates a new API server.
|
||||
@@ -81,6 +82,7 @@ func NewServer(
|
||||
jobs := db.NewJobRepository(database)
|
||||
settings := db.NewSettingsRepository(database)
|
||||
locations := db.NewLocationRepository(database)
|
||||
metadata := db.NewItemMetadataRepository(database)
|
||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||
|
||||
@@ -109,6 +111,7 @@ func NewServer(
|
||||
modules: registry,
|
||||
cfg: cfg,
|
||||
settings: settings,
|
||||
metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1652,6 +1655,9 @@ func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
Int64("size", result.Size).
|
||||
Msg("file uploaded")
|
||||
|
||||
// .kc metadata extraction (best-effort)
|
||||
s.extractKCMetadata(ctx, item, fileKey, rev)
|
||||
|
||||
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
|
||||
}
|
||||
|
||||
|
||||
419
internal/api/metadata_handlers.go
Normal file
419
internal/api/metadata_handlers.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/kc"
|
||||
)
|
||||
|
||||
// validTransitions defines allowed lifecycle state transitions for Phase 1.
|
||||
var validTransitions = map[string][]string{
|
||||
"draft": {"review"},
|
||||
"review": {"draft", "released"},
|
||||
"released": {"obsolete"},
|
||||
"obsolete": {},
|
||||
}
|
||||
|
||||
// MetadataResponse is the JSON representation returned by GET /metadata.
|
||||
type MetadataResponse struct {
|
||||
SchemaName *string `json:"schema_name"`
|
||||
LifecycleState string `json:"lifecycle_state"`
|
||||
Tags []string `json:"tags"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
Manifest *ManifestInfo `json:"manifest,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
UpdatedBy *string `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestInfo is the manifest subset included in MetadataResponse.
|
||||
type ManifestInfo struct {
|
||||
UUID *string `json:"uuid,omitempty"`
|
||||
SiloInstance *string `json:"silo_instance,omitempty"`
|
||||
RevisionHash *string `json:"revision_hash,omitempty"`
|
||||
KCVersion *string `json:"kc_version,omitempty"`
|
||||
}
|
||||
|
||||
func metadataToResponse(m *db.ItemMetadata) MetadataResponse {
|
||||
resp := MetadataResponse{
|
||||
SchemaName: m.SchemaName,
|
||||
LifecycleState: m.LifecycleState,
|
||||
Tags: m.Tags,
|
||||
Fields: m.Fields,
|
||||
UpdatedAt: m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
}
|
||||
if m.ManifestUUID != nil || m.SiloInstance != nil || m.RevisionHash != nil || m.KCVersion != nil {
|
||||
resp.Manifest = &ManifestInfo{
|
||||
UUID: m.ManifestUUID,
|
||||
SiloInstance: m.SiloInstance,
|
||||
RevisionHash: m.RevisionHash,
|
||||
KCVersion: m.KCVersion,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleGetMetadata returns indexed metadata for an item.
|
||||
// GET /api/items/{partNumber}/metadata
|
||||
func (s *Server) HandleGetMetadata(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
|
||||
}
|
||||
|
||||
meta, err := s.metadata.Get(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get metadata")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
||||
}
|
||||
|
||||
// HandleUpdateMetadata merges fields into the metadata JSONB.
|
||||
// PUT /api/items/{partNumber}/metadata
|
||||
func (s *Server) HandleUpdateMetadata(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if len(req.Fields) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Fields must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
if err := s.metadata.UpdateFields(ctx, item.ID, req.Fields, username); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update metadata fields")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.metadata.Get(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to read back metadata")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read metadata")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"changed_fields": fieldKeys(req.Fields),
|
||||
"lifecycle_state": meta.LifecycleState,
|
||||
"updated_by": username,
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
||||
}
|
||||
|
||||
// HandleUpdateLifecycle transitions the lifecycle state.
|
||||
// PATCH /api/items/{partNumber}/metadata/lifecycle
|
||||
func (s *Server) HandleUpdateLifecycle(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.State == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "State is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.metadata.Get(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get metadata")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate transition
|
||||
allowed := validTransitions[meta.LifecycleState]
|
||||
valid := false
|
||||
for _, s := range allowed {
|
||||
if s == req.State {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_transition",
|
||||
"Cannot transition from '"+meta.LifecycleState+"' to '"+req.State+"'")
|
||||
return
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
fromState := meta.LifecycleState
|
||||
if err := s.metadata.UpdateLifecycle(ctx, item.ID, req.State, username); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update lifecycle")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update lifecycle")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("metadata.lifecycle", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"from_state": fromState,
|
||||
"to_state": req.State,
|
||||
"updated_by": username,
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"lifecycle_state": req.State})
|
||||
}
|
||||
|
||||
// HandleUpdateTags adds/removes tags.
|
||||
// PATCH /api/items/{partNumber}/metadata/tags
|
||||
func (s *Server) HandleUpdateTags(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Add []string `json:"add"`
|
||||
Remove []string `json:"remove"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if len(req.Add) == 0 && len(req.Remove) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Must provide 'add' or 'remove'")
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.metadata.Get(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get metadata")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
||||
return
|
||||
}
|
||||
|
||||
// Compute new tag set: (existing + add) - remove
|
||||
tagSet := make(map[string]struct{})
|
||||
for _, t := range meta.Tags {
|
||||
tagSet[t] = struct{}{}
|
||||
}
|
||||
for _, t := range req.Add {
|
||||
tagSet[t] = struct{}{}
|
||||
}
|
||||
removeSet := make(map[string]struct{})
|
||||
for _, t := range req.Remove {
|
||||
removeSet[t] = struct{}{}
|
||||
}
|
||||
var newTags []string
|
||||
for t := range tagSet {
|
||||
if _, removed := removeSet[t]; !removed {
|
||||
newTags = append(newTags, t)
|
||||
}
|
||||
}
|
||||
if newTags == nil {
|
||||
newTags = []string{}
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
if err := s.metadata.SetTags(ctx, item.ID, newTags, username); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update tags")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tags")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("metadata.tags", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"added": req.Add,
|
||||
"removed": req.Remove,
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tags": newTags})
|
||||
}
|
||||
|
||||
// extractKCMetadata attempts to extract and index silo/ metadata from an
|
||||
// uploaded .kc file. Failures are logged but non-fatal for Phase 1.
|
||||
func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey string, rev *db.Revision) {
|
||||
if s.storage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := s.storage.Get(ctx, fileKey)
|
||||
if err != nil {
|
||||
s.logger.Warn().Err(err).Str("file_key", fileKey).Msg("kc: failed to read back file for extraction")
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
s.logger.Warn().Err(err).Msg("kc: failed to read file bytes")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := kc.Extract(data)
|
||||
if err != nil {
|
||||
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: extraction failed")
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
return // plain .fcstd, no silo/ directory
|
||||
}
|
||||
|
||||
// Validate manifest UUID matches item
|
||||
if result.Manifest != nil && result.Manifest.UUID != "" && result.Manifest.UUID != item.ID {
|
||||
s.logger.Warn().
|
||||
Str("manifest_uuid", result.Manifest.UUID).
|
||||
Str("item_id", item.ID).
|
||||
Msg("kc: manifest UUID does not match item, skipping indexing")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for no-op (revision_hash unchanged)
|
||||
if result.Manifest != nil && result.Manifest.RevisionHash != "" {
|
||||
existing, _ := s.metadata.Get(ctx, item.ID)
|
||||
if existing != nil && existing.RevisionHash != nil && *existing.RevisionHash == result.Manifest.RevisionHash {
|
||||
s.logger.Debug().Str("part_number", item.PartNumber).Msg("kc: revision_hash unchanged, skipping")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
username := ""
|
||||
if rev.CreatedBy != nil {
|
||||
username = *rev.CreatedBy
|
||||
}
|
||||
|
||||
meta := &db.ItemMetadata{
|
||||
ItemID: item.ID,
|
||||
LifecycleState: "draft",
|
||||
Fields: make(map[string]any),
|
||||
Tags: []string{},
|
||||
UpdatedBy: strPtr(username),
|
||||
}
|
||||
|
||||
if result.Manifest != nil {
|
||||
meta.KCVersion = strPtr(result.Manifest.KCVersion)
|
||||
meta.ManifestUUID = strPtr(result.Manifest.UUID)
|
||||
meta.SiloInstance = strPtr(result.Manifest.SiloInstance)
|
||||
meta.RevisionHash = strPtr(result.Manifest.RevisionHash)
|
||||
}
|
||||
|
||||
if result.Metadata != nil {
|
||||
meta.SchemaName = strPtr(result.Metadata.SchemaName)
|
||||
if result.Metadata.Tags != nil {
|
||||
meta.Tags = result.Metadata.Tags
|
||||
}
|
||||
if result.Metadata.LifecycleState != "" {
|
||||
meta.LifecycleState = result.Metadata.LifecycleState
|
||||
}
|
||||
if result.Metadata.Fields != nil {
|
||||
meta.Fields = result.Metadata.Fields
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.metadata.Upsert(ctx, meta); err != nil {
|
||||
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to upsert metadata")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
||||
"part_number": item.PartNumber,
|
||||
"lifecycle_state": meta.LifecycleState,
|
||||
"updated_by": username,
|
||||
}))
|
||||
|
||||
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to s, or nil if s is empty.
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// fieldKeys returns the keys from a map.
|
||||
func fieldKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -172,6 +172,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
||||
r.Get("/bom/export.ods", server.HandleExportBOMODS)
|
||||
r.Get("/metadata", server.HandleGetMetadata)
|
||||
|
||||
// DAG (gated by dag module)
|
||||
r.Route("/dag", func(r chi.Router) {
|
||||
@@ -209,6 +210,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Post("/bom/merge", server.HandleMergeBOM)
|
||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||
r.Put("/metadata", server.HandleUpdateMetadata)
|
||||
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
||||
r.Patch("/metadata/tags", server.HandleUpdateTags)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
161
internal/db/item_metadata.go
Normal file
161
internal/db/item_metadata.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ItemMetadata represents a row in the item_metadata table.
|
||||
type ItemMetadata struct {
|
||||
ItemID string
|
||||
SchemaName *string
|
||||
Tags []string
|
||||
LifecycleState string
|
||||
Fields map[string]any
|
||||
KCVersion *string
|
||||
ManifestUUID *string
|
||||
SiloInstance *string
|
||||
RevisionHash *string
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy *string
|
||||
}
|
||||
|
||||
// ItemMetadataRepository provides item_metadata database operations.
|
||||
type ItemMetadataRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewItemMetadataRepository creates a new item metadata repository.
|
||||
func NewItemMetadataRepository(db *DB) *ItemMetadataRepository {
|
||||
return &ItemMetadataRepository{db: db}
|
||||
}
|
||||
|
||||
// Get returns metadata for an item, or nil if none exists.
|
||||
func (r *ItemMetadataRepository) Get(ctx context.Context, itemID string) (*ItemMetadata, error) {
|
||||
m := &ItemMetadata{}
|
||||
var fieldsJSON []byte
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT item_id, schema_name, tags, lifecycle_state, fields,
|
||||
kc_version, manifest_uuid, silo_instance, revision_hash,
|
||||
updated_at, updated_by
|
||||
FROM item_metadata
|
||||
WHERE item_id = $1
|
||||
`, itemID).Scan(
|
||||
&m.ItemID, &m.SchemaName, &m.Tags, &m.LifecycleState, &fieldsJSON,
|
||||
&m.KCVersion, &m.ManifestUUID, &m.SiloInstance, &m.RevisionHash,
|
||||
&m.UpdatedAt, &m.UpdatedBy,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting item metadata: %w", err)
|
||||
}
|
||||
if fieldsJSON != nil {
|
||||
if err := json.Unmarshal(fieldsJSON, &m.Fields); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling fields: %w", err)
|
||||
}
|
||||
}
|
||||
if m.Fields == nil {
|
||||
m.Fields = make(map[string]any)
|
||||
}
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Upsert inserts or updates the metadata row for an item.
|
||||
// Used by the commit extraction pipeline.
|
||||
func (r *ItemMetadataRepository) Upsert(ctx context.Context, m *ItemMetadata) error {
|
||||
fieldsJSON, err := json.Marshal(m.Fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling fields: %w", err)
|
||||
}
|
||||
_, err = r.db.pool.Exec(ctx, `
|
||||
INSERT INTO item_metadata
|
||||
(item_id, schema_name, tags, lifecycle_state, fields,
|
||||
kc_version, manifest_uuid, silo_instance, revision_hash,
|
||||
updated_at, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
|
||||
ON CONFLICT (item_id) DO UPDATE SET
|
||||
schema_name = EXCLUDED.schema_name,
|
||||
tags = EXCLUDED.tags,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
fields = EXCLUDED.fields,
|
||||
kc_version = EXCLUDED.kc_version,
|
||||
manifest_uuid = EXCLUDED.manifest_uuid,
|
||||
silo_instance = EXCLUDED.silo_instance,
|
||||
revision_hash = EXCLUDED.revision_hash,
|
||||
updated_at = now(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
`, m.ItemID, m.SchemaName, m.Tags, m.LifecycleState, fieldsJSON,
|
||||
m.KCVersion, m.ManifestUUID, m.SiloInstance, m.RevisionHash,
|
||||
m.UpdatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upserting item metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFields merges the given fields into the existing JSONB fields column.
|
||||
func (r *ItemMetadataRepository) UpdateFields(ctx context.Context, itemID string, fields map[string]any, updatedBy string) error {
|
||||
fieldsJSON, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling fields: %w", err)
|
||||
}
|
||||
tag, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE item_metadata
|
||||
SET fields = fields || $2::jsonb,
|
||||
updated_at = now(),
|
||||
updated_by = $3
|
||||
WHERE item_id = $1
|
||||
`, itemID, fieldsJSON, updatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating metadata fields: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("item metadata not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLifecycle sets the lifecycle_state column.
|
||||
func (r *ItemMetadataRepository) UpdateLifecycle(ctx context.Context, itemID, state, updatedBy string) error {
|
||||
tag, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE item_metadata
|
||||
SET lifecycle_state = $2,
|
||||
updated_at = now(),
|
||||
updated_by = $3
|
||||
WHERE item_id = $1
|
||||
`, itemID, state, updatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating lifecycle state: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("item metadata not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTags replaces the tags array.
|
||||
func (r *ItemMetadataRepository) SetTags(ctx context.Context, itemID string, tags []string, updatedBy string) error {
|
||||
tag, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE item_metadata
|
||||
SET tags = $2,
|
||||
updated_at = now(),
|
||||
updated_by = $3
|
||||
WHERE item_id = $1
|
||||
`, itemID, tags, updatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating tags: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("item metadata not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
106
internal/kc/kc.go
Normal file
106
internal/kc/kc.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package kc extracts and parses the silo/ metadata directory from .kc files.
|
||||
//
|
||||
// A .kc file is a ZIP archive (superset of .fcstd) that contains a silo/
|
||||
// directory with JSON metadata entries. This package handles extraction only —
|
||||
// no database or HTTP dependencies.
|
||||
package kc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest represents the contents of silo/manifest.json.
|
||||
type Manifest struct {
|
||||
UUID string `json:"uuid"`
|
||||
KCVersion string `json:"kc_version"`
|
||||
RevisionHash string `json:"revision_hash"`
|
||||
SiloInstance string `json:"silo_instance"`
|
||||
}
|
||||
|
||||
// Metadata represents the contents of silo/metadata.json.
|
||||
type Metadata struct {
|
||||
SchemaName string `json:"schema_name"`
|
||||
Tags []string `json:"tags"`
|
||||
LifecycleState string `json:"lifecycle_state"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
// ExtractResult holds the parsed silo/ directory contents from a .kc file.
|
||||
type ExtractResult struct {
|
||||
Manifest *Manifest
|
||||
Metadata *Metadata
|
||||
}
|
||||
|
||||
// Extract opens a ZIP archive from data and parses the silo/ directory.
|
||||
// Returns nil, nil if no silo/ directory is found (plain .fcstd file).
|
||||
// Returns nil, error if silo/ entries exist but fail to parse.
|
||||
func Extract(data []byte) (*ExtractResult, error) {
|
||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kc: open zip: %w", err)
|
||||
}
|
||||
|
||||
var manifestFile, metadataFile *zip.File
|
||||
hasSiloDir := false
|
||||
|
||||
for _, f := range r.File {
|
||||
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
|
||||
hasSiloDir = true
|
||||
}
|
||||
switch f.Name {
|
||||
case "silo/manifest.json":
|
||||
manifestFile = f
|
||||
case "silo/metadata.json":
|
||||
metadataFile = f
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSiloDir {
|
||||
return nil, nil // plain .fcstd, no extraction
|
||||
}
|
||||
|
||||
result := &ExtractResult{}
|
||||
|
||||
if manifestFile != nil {
|
||||
m, err := readJSON[Manifest](manifestFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kc: parse manifest.json: %w", err)
|
||||
}
|
||||
result.Manifest = m
|
||||
}
|
||||
|
||||
if metadataFile != nil {
|
||||
m, err := readJSON[Metadata](metadataFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kc: parse metadata.json: %w", err)
|
||||
}
|
||||
result.Metadata = m
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readJSON opens a zip.File and decodes its contents as JSON into T.
|
||||
func readJSON[T any](f *zip.File) (*T, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var v T
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
188
internal/kc/kc_test.go
Normal file
188
internal/kc/kc_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package kc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildZip creates a ZIP archive in memory from a map of filename → content.
|
||||
func buildZip(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
for name, content := range files {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("creating zip entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := f.Write(content); err != nil {
|
||||
t.Fatalf("writing zip entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("closing zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling JSON: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestExtract_PlainFCStd(t *testing.T) {
|
||||
data := buildZip(t, map[string][]byte{
|
||||
"Document.xml": []byte("<xml/>"),
|
||||
"thumbnails/a.png": []byte("png"),
|
||||
})
|
||||
|
||||
result, err := Extract(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result for plain .fcstd, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_ValidKC(t *testing.T) {
|
||||
manifest := Manifest{
|
||||
UUID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
KCVersion: "1.0",
|
||||
RevisionHash: "abc123",
|
||||
SiloInstance: "https://silo.example.com",
|
||||
}
|
||||
metadata := Metadata{
|
||||
SchemaName: "mechanical-part-v2",
|
||||
Tags: []string{"structural", "aluminum"},
|
||||
LifecycleState: "draft",
|
||||
Fields: map[string]any{
|
||||
"material": "6061-T6",
|
||||
"weight_kg": 0.34,
|
||||
},
|
||||
}
|
||||
|
||||
data := buildZip(t, map[string][]byte{
|
||||
"Document.xml": []byte("<xml/>"),
|
||||
"silo/manifest.json": mustJSON(t, manifest),
|
||||
"silo/metadata.json": mustJSON(t, metadata),
|
||||
})
|
||||
|
||||
result, err := Extract(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
if result.Manifest == nil {
|
||||
t.Fatal("expected manifest")
|
||||
}
|
||||
if result.Manifest.UUID != manifest.UUID {
|
||||
t.Errorf("manifest UUID = %q, want %q", result.Manifest.UUID, manifest.UUID)
|
||||
}
|
||||
if result.Manifest.KCVersion != manifest.KCVersion {
|
||||
t.Errorf("manifest KCVersion = %q, want %q", result.Manifest.KCVersion, manifest.KCVersion)
|
||||
}
|
||||
if result.Manifest.RevisionHash != manifest.RevisionHash {
|
||||
t.Errorf("manifest RevisionHash = %q, want %q", result.Manifest.RevisionHash, manifest.RevisionHash)
|
||||
}
|
||||
if result.Manifest.SiloInstance != manifest.SiloInstance {
|
||||
t.Errorf("manifest SiloInstance = %q, want %q", result.Manifest.SiloInstance, manifest.SiloInstance)
|
||||
}
|
||||
|
||||
if result.Metadata == nil {
|
||||
t.Fatal("expected metadata")
|
||||
}
|
||||
if result.Metadata.SchemaName != metadata.SchemaName {
|
||||
t.Errorf("metadata SchemaName = %q, want %q", result.Metadata.SchemaName, metadata.SchemaName)
|
||||
}
|
||||
if result.Metadata.LifecycleState != metadata.LifecycleState {
|
||||
t.Errorf("metadata LifecycleState = %q, want %q", result.Metadata.LifecycleState, metadata.LifecycleState)
|
||||
}
|
||||
if len(result.Metadata.Tags) != 2 {
|
||||
t.Errorf("metadata Tags len = %d, want 2", len(result.Metadata.Tags))
|
||||
}
|
||||
if result.Metadata.Fields["material"] != "6061-T6" {
|
||||
t.Errorf("metadata Fields[material] = %v, want 6061-T6", result.Metadata.Fields["material"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_ManifestOnly(t *testing.T) {
|
||||
manifest := Manifest{
|
||||
UUID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
KCVersion: "1.0",
|
||||
}
|
||||
|
||||
data := buildZip(t, map[string][]byte{
|
||||
"Document.xml": []byte("<xml/>"),
|
||||
"silo/manifest.json": mustJSON(t, manifest),
|
||||
})
|
||||
|
||||
result, err := Extract(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if result.Manifest == nil {
|
||||
t.Fatal("expected manifest")
|
||||
}
|
||||
if result.Metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %+v", result.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_InvalidJSON(t *testing.T) {
|
||||
data := buildZip(t, map[string][]byte{
|
||||
"silo/manifest.json": []byte("{not valid json"),
|
||||
})
|
||||
|
||||
result, err := Extract(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result on error, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_NotAZip(t *testing.T) {
|
||||
result, err := Extract([]byte("this is not a zip file"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-ZIP data")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result on error, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_EmptySiloDir(t *testing.T) {
|
||||
// silo/ directory entry exists but no manifest or metadata files
|
||||
data := buildZip(t, map[string][]byte{
|
||||
"Document.xml": []byte("<xml/>"),
|
||||
"silo/": {},
|
||||
})
|
||||
|
||||
result, err := Extract(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for silo/ dir")
|
||||
}
|
||||
if result.Manifest != nil {
|
||||
t.Errorf("expected nil manifest, got %+v", result.Manifest)
|
||||
}
|
||||
if result.Metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %+v", result.Metadata)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
|
||||
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
TRUNCATE
|
||||
item_metadata, item_dependencies, approval_signatures, item_approvals, item_macros,
|
||||
settings_overrides, module_state,
|
||||
job_log, jobs, job_definitions, runners,
|
||||
dag_cross_edges, dag_edges, dag_nodes,
|
||||
|
||||
Reference in New Issue
Block a user