Files
silo/internal/kc/kc.go
Forbes c216d64702 feat(kc): checkout packing + ETag caching (Phase 2)
Implements issue #142 — .kc checkout pipeline that repacks silo/ entries
with current DB state before serving downloads.

When a client downloads a .kc file via GET /api/items/{pn}/file/{rev},
the server now:
1. Reads the file from storage into memory
2. Checks for silo/ directory (plain .fcstd files bypass packing)
3. Repacks silo/ entries with current item_metadata + revision history
4. Streams the repacked ZIP to the client

New files:
- internal/kc/pack.go: Pack() replaces silo/ entries in ZIP, preserving
  all non-silo entries (FreeCAD files, thumbnails) with original
  compression and timestamps. HasSiloDir() for lightweight detection.
- internal/api/pack_handlers.go: packKCFile server helper, computeETag,
  canSkipRepack lazy optimization.

ETag caching:
- ETag computed from revision_number + metadata.updated_at
- If-None-Match support returns 304 Not Modified before reading storage
- Cache-Control: private, must-revalidate

Lazy packing optimization:
- Skips repack if revision_hash matches and metadata unchanged since upload

Phase 2 packs: manifest.json, metadata.json, history.json,
dependencies.json (empty []). Approvals, macros, jobs deferred to
Phase 3-5.

Closes #142
2026-02-18 17:01:26 -06:00

126 lines
3.2 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 and
// packing — 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
}
// HistoryEntry represents one entry in silo/history.json.
type HistoryEntry struct {
RevisionNumber int `json:"revision_number"`
CreatedAt string `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
Comment *string `json:"comment,omitempty"`
Status string `json:"status"`
Labels []string `json:"labels"`
}
// PackInput holds all the data needed to repack silo/ entries in a .kc file.
// Each field is optional — nil/empty means the entry is omitted from the ZIP.
type PackInput struct {
Manifest *Manifest
Metadata *Metadata
History []HistoryEntry
Dependencies []any // empty [] for Phase 2; structured types in Phase 3+
}
// 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
}