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() }