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
This commit is contained in:
Forbes
2026-02-18 17:01:26 -06:00
parent dd010331c0
commit c216d64702
5 changed files with 517 additions and 24 deletions

View File

@@ -0,0 +1,97 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/kc"
)
// packKCFile gathers DB state and repacks silo/ entries in a .kc file.
func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev *db.Revision, meta *db.ItemMetadata) ([]byte, error) {
manifest := &kc.Manifest{
UUID: item.ID,
KCVersion: derefStr(meta.KCVersion, "1.0"),
RevisionHash: derefStr(meta.RevisionHash, ""),
SiloInstance: derefStr(meta.SiloInstance, ""),
}
metadata := &kc.Metadata{
SchemaName: derefStr(meta.SchemaName, ""),
Tags: meta.Tags,
LifecycleState: meta.LifecycleState,
Fields: meta.Fields,
}
// Build history from last 20 revisions.
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err != nil {
return nil, fmt.Errorf("getting revisions: %w", err)
}
limit := 20
if len(revisions) < limit {
limit = len(revisions)
}
history := make([]kc.HistoryEntry, limit)
for i, r := range revisions[:limit] {
labels := r.Labels
if labels == nil {
labels = []string{}
}
history[i] = kc.HistoryEntry{
RevisionNumber: r.RevisionNumber,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
CreatedBy: r.CreatedBy,
Comment: r.Comment,
Status: r.Status,
Labels: labels,
}
}
input := &kc.PackInput{
Manifest: manifest,
Metadata: metadata,
History: history,
Dependencies: []any{}, // empty for Phase 2
}
return kc.Pack(data, input)
}
// computeETag generates a quoted ETag from the revision number and metadata freshness.
func computeETag(rev *db.Revision, meta *db.ItemMetadata) string {
var ts int64
if meta != nil {
ts = meta.UpdatedAt.UnixNano()
} else {
ts = rev.CreatedAt.UnixNano()
}
raw := fmt.Sprintf("%d:%d", rev.RevisionNumber, ts)
h := sha256.Sum256([]byte(raw))
return `"` + hex.EncodeToString(h[:8]) + `"`
}
// canSkipRepack returns true if the stored blob already has up-to-date silo/ data.
func canSkipRepack(rev *db.Revision, meta *db.ItemMetadata) bool {
if meta == nil {
return true // no metadata row = plain .fcstd
}
if meta.RevisionHash != nil && rev.FileChecksum != nil &&
*meta.RevisionHash == *rev.FileChecksum &&
meta.UpdatedAt.Before(rev.CreatedAt) {
return true
}
return false
}
// derefStr returns the value of a *string pointer, or fallback if nil.
func derefStr(p *string, fallback string) string {
if p != nil {
return *p
}
return fallback
}