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:
2026-03-21 09:56:34 -07:00
parent bb2c7f7ef3
commit b64177baa8
22 changed files with 5017 additions and 1 deletions

205
artifacts/tagcat.go Normal file
View File

@@ -0,0 +1,205 @@
package artifacts
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"git.wntrmute.dev/kyle/exo/core"
)
// GetTag returns the tag ID for a given tag string. Returns empty string if
// the tag doesn't exist.
func GetTag(ctx context.Context, tx *sql.Tx, tag string) (string, error) {
var id string
row := tx.QueryRowContext(ctx, `SELECT id FROM tags WHERE tag=?`, tag)
if err := row.Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("artifacts: failed to look up tag %q: %w", tag, err)
}
return id, nil
}
// CreateTag idempotently creates a tag. If the tag already exists, this is a no-op.
func CreateTag(ctx context.Context, tx *sql.Tx, tag string) error {
id, err := GetTag(ctx, tx, tag)
if err != nil {
return fmt.Errorf("artifacts: creating tag failed: %w", err)
}
if id != "" {
return nil
}
id = core.NewUUID()
_, err = tx.ExecContext(ctx, `INSERT INTO tags (id, tag) VALUES (?, ?)`, id, tag)
if err != nil {
return fmt.Errorf("artifacts: creating tag %q failed: %w", tag, err)
}
return nil
}
// GetAllTags returns all tag strings, sorted alphabetically.
func GetAllTags(ctx context.Context, tx *sql.Tx) ([]string, error) {
rows, err := tx.QueryContext(ctx, `SELECT tag FROM tags`)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to get all tags: %w", err)
}
defer func() { _ = rows.Close() }()
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, fmt.Errorf("artifacts: failed to scan tag: %w", err)
}
tags = append(tags, tag)
}
sort.Strings(tags)
return tags, rows.Err()
}
// GetCategory returns the category ID for a given category string.
// Returns empty string if the category doesn't exist.
func GetCategory(ctx context.Context, tx *sql.Tx, category string) (string, error) {
var id string
row := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE category=?`, category)
if err := row.Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("artifacts: failed to look up category %q: %w", category, err)
}
return id, nil
}
// CreateCategory idempotently creates a category.
func CreateCategory(ctx context.Context, tx *sql.Tx, category string) error {
id, err := GetCategory(ctx, tx, category)
if err != nil {
return fmt.Errorf("artifacts: creating category failed: %w", err)
}
if id != "" {
return nil
}
id = core.NewUUID()
_, err = tx.ExecContext(ctx, `INSERT INTO categories (id, category) VALUES (?, ?)`, id, category)
if err != nil {
return fmt.Errorf("artifacts: creating category %q failed: %w", category, err)
}
return nil
}
// GetAllCategories returns all category strings, sorted alphabetically.
func GetAllCategories(ctx context.Context, tx *sql.Tx) ([]string, error) {
rows, err := tx.QueryContext(ctx, `SELECT category FROM categories`)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to get all categories: %w", err)
}
defer func() { _ = rows.Close() }()
var categories []string
for rows.Next() {
var category string
if err := rows.Scan(&category); err != nil {
return nil, fmt.Errorf("artifacts: failed to scan category: %w", err)
}
categories = append(categories, category)
}
sort.Strings(categories)
return categories, rows.Err()
}
// tagsFromTagIDs resolves a list of tag UUIDs to their string values.
func tagsFromTagIDs(ctx context.Context, tx *sql.Tx, idList []string) (map[string]bool, error) {
if len(idList) == 0 {
return map[string]bool{}, nil
}
placeholders := make([]string, len(idList))
args := make([]any, len(idList))
for i, id := range idList {
placeholders[i] = "?"
args[i] = id
}
query := `SELECT tag FROM tags WHERE id IN (` + strings.Join(placeholders, ",") + `)` //nolint:gosec // placeholders are literal "?" strings, not user input
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to resolve tag IDs: %w", err)
}
defer func() { _ = rows.Close() }()
tags := map[string]bool{}
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, err
}
tags[tag] = true
}
return tags, rows.Err()
}
// categoriesFromCategoryIDs resolves a list of category UUIDs to their string values.
func categoriesFromCategoryIDs(ctx context.Context, tx *sql.Tx, idList []string) (map[string]bool, error) {
if len(idList) == 0 {
return map[string]bool{}, nil
}
placeholders := make([]string, len(idList))
args := make([]any, len(idList))
for i, id := range idList {
placeholders[i] = "?"
args[i] = id
}
query := `SELECT category FROM categories WHERE id IN (` + strings.Join(placeholders, ",") + `)` //nolint:gosec // placeholders are literal "?" strings, not user input
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to resolve category IDs: %w", err)
}
defer func() { _ = rows.Close() }()
categories := map[string]bool{}
for rows.Next() {
var category string
if err := rows.Scan(&category); err != nil {
return nil, err
}
categories[category] = true
}
return categories, rows.Err()
}
// GetArtifactIDsForTag returns artifact IDs that have the given tag.
func GetArtifactIDsForTag(ctx context.Context, tx *sql.Tx, tag string) ([]string, error) {
tagID, err := GetTag(ctx, tx, tag)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to look up tag ID: %w", err)
}
if tagID == "" {
return nil, nil
}
rows, err := tx.QueryContext(ctx, `SELECT artifact_id FROM artifact_tags WHERE tag_id=?`, tagID)
if err != nil {
return nil, fmt.Errorf("artifacts: failed to get artifact IDs for tag: %w", err)
}
defer func() { _ = rows.Close() }()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("artifacts: failed to scan artifact ID: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}