diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index f7666d5..bdcceb6 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -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))
}
diff --git a/internal/api/metadata_handlers.go b/internal/api/metadata_handlers.go
new file mode 100644
index 0000000..82850e4
--- /dev/null
+++ b/internal/api/metadata_handlers.go
@@ -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
+}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 178999d..14f3b3d 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -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)
})
})
})
diff --git a/internal/db/item_metadata.go b/internal/db/item_metadata.go
new file mode 100644
index 0000000..41d107b
--- /dev/null
+++ b/internal/db/item_metadata.go
@@ -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
+}
diff --git a/internal/kc/kc.go b/internal/kc/kc.go
new file mode 100644
index 0000000..f1d02ec
--- /dev/null
+++ b/internal/kc/kc.go
@@ -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
+}
diff --git a/internal/kc/kc_test.go b/internal/kc/kc_test.go
new file mode 100644
index 0000000..9c740d4
--- /dev/null
+++ b/internal/kc/kc_test.go
@@ -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(""),
+ "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(""),
+ "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(""),
+ "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(""),
+ "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)
+ }
+}
diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go
index 4029cdc..6381a1c 100644
--- a/internal/testutil/testutil.go
+++ b/internal/testutil/testutil.go
@@ -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,