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:
197
artifacts/citation.go
Normal file
197
artifacts/citation.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
"git.wntrmute.dev/kyle/exo/db"
|
||||
)
|
||||
|
||||
// Citation holds bibliographic information for an artifact.
|
||||
type Citation struct {
|
||||
ID string
|
||||
DOI string
|
||||
Title string
|
||||
Year int
|
||||
Published time.Time
|
||||
Authors []string
|
||||
Publisher *Publisher
|
||||
Source string
|
||||
Abstract string
|
||||
Metadata core.Metadata
|
||||
}
|
||||
|
||||
// Update applies non-zero fields from base into c where c's fields are empty.
|
||||
func (c *Citation) Update(base *Citation) {
|
||||
if c.DOI == "" {
|
||||
c.DOI = base.DOI
|
||||
}
|
||||
if c.Title == "" {
|
||||
c.Title = base.Title
|
||||
}
|
||||
if c.Year == 0 {
|
||||
c.Year = base.Year
|
||||
}
|
||||
if c.Published.IsZero() {
|
||||
c.Published = base.Published
|
||||
}
|
||||
if len(c.Authors) == 0 {
|
||||
c.Authors = base.Authors
|
||||
}
|
||||
if c.Publisher != nil && c.Publisher.Name == "" {
|
||||
c.Publisher.Name = base.Publisher.Name
|
||||
}
|
||||
if c.Publisher != nil && c.Publisher.Address == "" {
|
||||
c.Publisher.Address = base.Publisher.Address
|
||||
}
|
||||
if c.Source == "" {
|
||||
c.Source = base.Source
|
||||
}
|
||||
if c.Abstract == "" {
|
||||
c.Abstract = base.Abstract
|
||||
}
|
||||
for key, value := range base.Metadata {
|
||||
if _, ok := c.Metadata[key]; !ok {
|
||||
c.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Citation) present(ctx context.Context, tx *sql.Tx) (bool, error) {
|
||||
var id string
|
||||
row := tx.QueryRowContext(ctx, `SELECT id FROM citations WHERE id=?`, c.ID)
|
||||
if err := row.Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("artifacts: failed to look up citation: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Store persists a Citation and its associated publisher and authors.
|
||||
func (c *Citation) Store(ctx context.Context, tx *sql.Tx) error {
|
||||
if c.ID == "" {
|
||||
c.ID = core.NewUUID()
|
||||
} else {
|
||||
ok, err := c.present(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: couldn't store citation: %w", err)
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if c.Publisher != nil {
|
||||
if err := c.Publisher.Store(ctx, tx); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store citation publisher: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
publisherID := ""
|
||||
if c.Publisher != nil {
|
||||
publisherID = c.Publisher.ID
|
||||
}
|
||||
|
||||
// Insert the citation row first so FK-dependent rows (authors, metadata) can reference it.
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO citations (id, doi, title, year, published, publisher, source, abstract) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.DOI, c.Title, c.Year, db.ToDBTime(c.Published), publisherID, c.Source, c.Abstract)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store citation: %w", err)
|
||||
}
|
||||
|
||||
if err := storeAuthors(ctx, tx, c.ID, c.Authors); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store citation authors: %w", err)
|
||||
}
|
||||
|
||||
if err := StoreMetadata(ctx, tx, c.ID, c.Metadata); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store citation metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a Citation by its ID, including authors, publisher, and metadata.
|
||||
func (c *Citation) Get(ctx context.Context, tx *sql.Tx) error {
|
||||
if c.ID == "" {
|
||||
return fmt.Errorf("artifacts: citation missing ID: %w", core.ErrNoID)
|
||||
}
|
||||
|
||||
// Get authors.
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT author_name FROM authors WHERE citation_id=?`, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve citation authors: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
c.Authors = nil
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to scan author: %w", err)
|
||||
}
|
||||
c.Authors = append(c.Authors, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get citation fields.
|
||||
c.Publisher = &Publisher{}
|
||||
var published string
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT doi, title, year, published, publisher, source, abstract FROM citations WHERE id=?`, c.ID)
|
||||
if err := row.Scan(&c.DOI, &c.Title, &c.Year, &published, &c.Publisher.ID, &c.Source, &c.Abstract); err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve citation: %w", err)
|
||||
}
|
||||
|
||||
c.Published, err = db.FromDBTime(published, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Publisher.ID != "" {
|
||||
ok, err := c.Publisher.Get(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve citation publisher: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("artifacts: citation references missing publisher %s", c.Publisher.ID)
|
||||
}
|
||||
}
|
||||
|
||||
c.Metadata, err = GetMetadata(ctx, tx, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to retrieve citation metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeAuthors(ctx context.Context, tx *sql.Tx, citationID string, authors []string) error {
|
||||
for _, name := range authors {
|
||||
// Check if this author already exists for this citation.
|
||||
var existing string
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT author_name FROM authors WHERE citation_id=? AND author_name=?`,
|
||||
citationID, name)
|
||||
if err := row.Scan(&existing); err == nil {
|
||||
continue // already exists
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO authors (citation_id, author_name) VALUES (?, ?)`,
|
||||
citationID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artifacts: failed to store author %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user