Add Phase 2 artifact repository: types, blob store, gRPC service
Build the complete artifact pillar with five packages: - artifacts: Artifact, Snapshot, Citation, Publisher types with Get/Store DB methods, tag/category management, metadata ops, YAML import - blob: content-addressable store (SHA256, hierarchical dir layout) - proto: protobuf definitions (common.proto, artifacts.proto) with buf linting and code generation - server: gRPC ArtifactService implementation (create/get artifacts, store/retrieve blobs, manage tags/categories, search by tag) All FK insertion ordering is correct (parent rows before children). Full test coverage across artifacts, blob, and server packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
artifacts/snapshot.go
Normal file
145
artifacts/snapshot.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/blob"
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
"git.wntrmute.dev/kyle/exo/db"
|
||||
)
|
||||
|
||||
// MIME makes explicit where a MIME type is expected.
|
||||
type MIME string
|
||||
|
||||
// BlobRef is a reference to a blob in the content-addressable store.
|
||||
type BlobRef struct {
|
||||
SnapshotID string
|
||||
ID string // SHA256 hash
|
||||
Format MIME
|
||||
Data []byte // in-memory content (nil when loaded from DB)
|
||||
}
|
||||
|
||||
// Store persists a BlobRef's metadata in the database and writes its data
|
||||
// to the blob store (if data is present).
|
||||
func (b *BlobRef) Store(ctx context.Context, tx *sql.Tx, store *blob.Store) error {
|
||||
if b.Data != nil && store != nil {
|
||||
id, err := store.Write(b.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to write blob to store: %w", err)
|
||||
}
|
||||
b.ID = id
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO blobs (snapshot_id, id, format) VALUES (?, ?, ?)`,
|
||||
b.SnapshotID, b.ID, string(b.Format))
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store blob ref: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot represents content at a specific point in time or format.
|
||||
type Snapshot struct {
|
||||
ArtifactID string
|
||||
ID string
|
||||
StoreDate time.Time
|
||||
Datetime time.Time
|
||||
Citation *Citation
|
||||
Source string
|
||||
Blobs map[MIME]*BlobRef
|
||||
Metadata core.Metadata
|
||||
}
|
||||
|
||||
// Store persists a Snapshot and its blobs.
|
||||
func (snap *Snapshot) Store(ctx context.Context, tx *sql.Tx, store *blob.Store) error {
|
||||
if snap.Citation != nil {
|
||||
if err := snap.Citation.Store(ctx, tx); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store snapshot citation: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
citationID := ""
|
||||
if snap.Citation != nil {
|
||||
citationID = snap.Citation.ID
|
||||
}
|
||||
|
||||
// Insert the snapshot row first so FK-dependent rows (blobs, metadata) can reference it.
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO artifact_snapshots (artifact_id, id, stored_at, datetime, citation_id, source) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
snap.ArtifactID, snap.ID, snap.StoreDate.Unix(), db.ToDBTime(snap.Datetime), citationID, snap.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store snapshot: %w", err)
|
||||
}
|
||||
|
||||
if err := StoreMetadata(ctx, tx, snap.ID, snap.Metadata); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store snapshot metadata: %w", err)
|
||||
}
|
||||
|
||||
for _, b := range snap.Blobs {
|
||||
b.SnapshotID = snap.ID
|
||||
if err := b.Store(ctx, tx, store); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store snapshot blob: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a Snapshot by its ID, including blobs and metadata.
|
||||
func (snap *Snapshot) Get(ctx context.Context, tx *sql.Tx) error {
|
||||
if snap.ID == "" {
|
||||
return fmt.Errorf("artifacts: snapshot missing ID: %w", core.ErrNoID)
|
||||
}
|
||||
|
||||
snap.Citation = &Citation{}
|
||||
var datetime string
|
||||
var stored int64
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT artifact_id, stored_at, datetime, citation_id, source FROM artifact_snapshots WHERE id=?`,
|
||||
snap.ID)
|
||||
err := row.Scan(&snap.ArtifactID, &stored, &datetime, &snap.Citation.ID, &snap.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve snapshot: %w", err)
|
||||
}
|
||||
|
||||
snap.StoreDate = time.Unix(stored, 0)
|
||||
snap.Datetime, err = db.FromDBTime(datetime, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := snap.Citation.Get(ctx, tx); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve snapshot citation: %w", err)
|
||||
}
|
||||
|
||||
snap.Metadata, err = GetMetadata(ctx, tx, snap.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load blob references.
|
||||
snap.Blobs = map[MIME]*BlobRef{}
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, format FROM blobs WHERE snapshot_id=?`, snap.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve snapshot blobs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var id, format string
|
||||
if err := rows.Scan(&id, &format); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to scan blob: %w", err)
|
||||
}
|
||||
snap.Blobs[MIME(format)] = &BlobRef{
|
||||
SnapshotID: snap.ID,
|
||||
ID: id,
|
||||
Format: MIME(format),
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user