Files
silo/internal/kc/kc.go
Forbes dd010331c0 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
2026-02-18 16:37:39 -06:00

107 lines
2.5 KiB
Go

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