Changeset 0.34.0 (#208)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 00:02:37 +00:00
committed by xcaliber
parent ed3e9363f2
commit d16bb93177
20 changed files with 5623 additions and 1 deletions

211
server/export/format.go Normal file
View File

@@ -0,0 +1,211 @@
package export
// format.go — v0.34.0 CS0
//
// Defines the .switchboard archive format: a zip file containing a
// manifest.json and per-entity JSON files under data/. File blobs
// live under files/{id}/{filename}.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"time"
)
// ── Constants ────────────────────────────────
const (
FormatVersion = 1
MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total
MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob
MaxExportFiles = 10000
ExportContentType = "application/zip"
ExportExtension = ".switchboard"
)
// ── Manifest ─────────────────────────────────
// Manifest is the top-level metadata written to manifest.json.
type Manifest struct {
Version string `json:"version"` // server version, e.g. "0.34.0"
FormatVersion int `json:"format_version"` // always 1 for now
ExportType string `json:"export_type"` // "user", "team"
CreatedAt time.Time `json:"created_at"`
ExportedBy string `json:"exported_by"` // user ID
EntityCounts map[string]int `json:"entity_counts"`
Scope *ManifestScope `json:"scope,omitempty"`
}
// ManifestScope narrows the export to a user or team.
type ManifestScope struct {
UserID string `json:"user_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
// ── ArchiveWriter ────────────────────────────
// ArchiveWriter wraps zip.Writer with size tracking and convenience
// methods for writing manifest and entity JSON files.
type ArchiveWriter struct {
zw *zip.Writer
written int64
maxBytes int64
}
// NewArchiveWriter creates an ArchiveWriter that streams to w.
func NewArchiveWriter(w io.Writer) *ArchiveWriter {
return &ArchiveWriter{
zw: zip.NewWriter(w),
maxBytes: MaxExportArchiveSize,
}
}
// WriteManifest serializes m as indented JSON into manifest.json.
func (aw *ArchiveWriter) WriteManifest(m *Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("export: marshal manifest: %w", err)
}
return aw.writeEntry("manifest.json", data)
}
// WriteEntityJSON serializes data as indented JSON into data/{name}.json.
func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) {
buf, err := json.MarshalIndent(data, "", " ")
if err != nil {
return 0, fmt.Errorf("export: marshal %s: %w", name, err)
}
if err := aw.writeEntry("data/"+name+".json", buf); err != nil {
return 0, err
}
// Return entity count for manifest
switch v := data.(type) {
case []interface{}:
return len(v), nil
default:
// Use json.RawMessage length heuristic — caller tracks count
return 0, nil
}
}
// WriteFile streams a file blob into files/{zipPath}. Returns bytes
// written or an error if the file exceeds MaxExportFileSize.
func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) {
if aw.written+1 > aw.maxBytes {
return 0, fmt.Errorf("export: archive size limit exceeded")
}
f, err := aw.zw.Create("files/" + zipPath)
if err != nil {
return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err)
}
lr := io.LimitReader(r, MaxExportFileSize+1)
n, err := io.Copy(f, lr)
if err != nil {
return n, fmt.Errorf("export: write file %s: %w", zipPath, err)
}
if n > MaxExportFileSize {
return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize)
}
aw.written += n
return n, nil
}
// Close finalizes the zip archive.
func (aw *ArchiveWriter) Close() error {
return aw.zw.Close()
}
// BytesWritten returns the approximate bytes written so far.
func (aw *ArchiveWriter) BytesWritten() int64 {
return aw.written
}
// ── ArchiveReader ────────────────────────────
// ArchiveReader wraps zip.ReadCloser with validation helpers.
type ArchiveReader struct {
zr *zip.ReadCloser
}
// OpenArchive opens and validates a .switchboard zip archive.
func OpenArchive(path string) (*ArchiveReader, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("export: open archive: %w", err)
}
return &ArchiveReader{zr: zr}, nil
}
// ReadManifest reads and validates manifest.json from the archive.
func (ar *ArchiveReader) ReadManifest() (*Manifest, error) {
for _, f := range ar.zr.File {
if f.Name == "manifest.json" {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("export: open manifest: %w", err)
}
defer rc.Close()
var m Manifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return nil, fmt.Errorf("export: decode manifest: %w", err)
}
if m.FormatVersion > FormatVersion {
return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion)
}
return &m, nil
}
}
return nil, fmt.Errorf("export: manifest.json not found in archive")
}
// ReadEntityJSON reads data/{name}.json into target.
func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error {
path := "data/" + name + ".json"
for _, f := range ar.zr.File {
if f.Name == path {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("export: open %s: %w", path, err)
}
defer rc.Close()
if err := json.NewDecoder(rc).Decode(target); err != nil {
return fmt.Errorf("export: decode %s: %w", path, err)
}
return nil
}
}
// Entity file not present — not an error (sparse archives are valid)
return nil
}
// FileEntries returns zip entries under files/.
func (ar *ArchiveReader) FileEntries() []*zip.File {
var out []*zip.File
for _, f := range ar.zr.File {
if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() {
out = append(out, f)
}
}
return out
}
// Close closes the underlying zip reader.
func (ar *ArchiveReader) Close() error {
return ar.zr.Close()
}
// writeEntry writes raw bytes as a zip entry, tracking total size.
func (aw *ArchiveWriter) writeEntry(name string, data []byte) error {
if aw.written+int64(len(data)) > aw.maxBytes {
return fmt.Errorf("export: archive size limit exceeded writing %s", name)
}
f, err := aw.zw.Create(name)
if err != nil {
return fmt.Errorf("export: create entry %s: %w", name, err)
}
n, err := f.Write(data)
aw.written += int64(n)
return err
}