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:
24
PROGRESS.md
24
PROGRESS.md
@@ -22,7 +22,29 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`.
|
|||||||
- `config/config.go`, `config/config_test.go`
|
- `config/config.go`, `config/config_test.go`
|
||||||
- `.golangci.yaml`, `Makefile`
|
- `.golangci.yaml`, `Makefile`
|
||||||
|
|
||||||
## Phase 2: Artifact Repository — IN PROGRESS
|
## Phase 2: Artifact Repository — COMPLETE
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] `artifacts` package: `Artifact`, `Snapshot`, `BlobRef`, `Citation`, `Publisher` types with `Get`/`Store` methods
|
||||||
|
- [x] `MIME` type alias for clarity
|
||||||
|
- [x] Tag and category management: `CreateTag`/`GetTag`/`GetAllTags`, `CreateCategory`/`GetCategory`/`GetAllCategories`, `GetArtifactIDsForTag`
|
||||||
|
- [x] Metadata store/retrieve operations (`StoreMetadata`/`GetMetadata`)
|
||||||
|
- [x] `blob` package: Content-addressable blob store (SHA256 hashing, hierarchical directory layout, read/write/exists)
|
||||||
|
- [x] YAML import: `ArtifactYAML`, `SnapshotYAML`, `CitationYAML` with `ToStd()` conversions and `LoadArtifactFromYAML`
|
||||||
|
- [x] Protobuf message definitions for all artifact types (`proto/exo/v1/common.proto`, `proto/exo/v1/artifacts.proto`)
|
||||||
|
- [x] gRPC service: `ArtifactService` with create/get artifacts, store/retrieve blobs, manage tags/categories, search by tag
|
||||||
|
- [x] `server` package: Full gRPC service implementation with proto-domain conversion helpers
|
||||||
|
- [x] buf.yaml for proto linting, buf.gen.yaml for code generation
|
||||||
|
- [x] Full test coverage for all packages (artifacts, blob, server)
|
||||||
|
|
||||||
|
**Files created:**
|
||||||
|
- `blob/blob.go`, `blob/blob_test.go`
|
||||||
|
- `artifacts/artifact.go`, `artifacts/citation.go`, `artifacts/publisher.go`, `artifacts/snapshot.go`
|
||||||
|
- `artifacts/metadata.go`, `artifacts/tagcat.go`, `artifacts/yaml.go`, `artifacts/artifacts_test.go`
|
||||||
|
- `proto/exo/v1/common.proto`, `proto/exo/v1/artifacts.proto`
|
||||||
|
- `proto/buf.yaml`, `proto/buf.gen.yaml`
|
||||||
|
- `proto/exo/v1/*.pb.go` (generated)
|
||||||
|
- `server/server.go`, `server/server_test.go`
|
||||||
|
|
||||||
## Phase 3: CLI Tools — NOT STARTED
|
## Phase 3: CLI Tools — NOT STARTED
|
||||||
|
|
||||||
|
|||||||
212
artifacts/artifact.go
Normal file
212
artifacts/artifact.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Package artifacts implements the artifact repository pillar — storing,
|
||||||
|
// retrieving, and managing source documents (PDFs, papers, webpages, etc.)
|
||||||
|
// with bibliographic metadata, versioned snapshots, and content-addressable
|
||||||
|
// blob storage.
|
||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
"git.wntrmute.dev/kyle/exo/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactType enumerates the kinds of artifacts.
|
||||||
|
type ArtifactType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactTypeUnknown ArtifactType = "Unknown"
|
||||||
|
ArtifactTypeCustom ArtifactType = "Custom"
|
||||||
|
ArtifactTypeArticle ArtifactType = "Article"
|
||||||
|
ArtifactTypeBook ArtifactType = "Book"
|
||||||
|
ArtifactTypeURL ArtifactType = "URL"
|
||||||
|
ArtifactTypePaper ArtifactType = "Paper"
|
||||||
|
ArtifactTypeVideo ArtifactType = "Video"
|
||||||
|
ArtifactTypeImage ArtifactType = "Image"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Artifact is the top-level container for a knowledge source.
|
||||||
|
type Artifact struct {
|
||||||
|
ID string
|
||||||
|
Type ArtifactType
|
||||||
|
Citation *Citation
|
||||||
|
Latest time.Time
|
||||||
|
History map[time.Time]string // datetime -> snapshot ID
|
||||||
|
Tags map[string]bool
|
||||||
|
Categories map[string]bool
|
||||||
|
Metadata core.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists an Artifact and all its associations (citation, tags,
|
||||||
|
// categories, history, metadata).
|
||||||
|
func (art *Artifact) Store(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
if art.Citation == nil {
|
||||||
|
return fmt.Errorf("artifacts: artifact missing citation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := art.Citation.Store(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store artifact citation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the artifact row first so FK-dependent rows can reference it.
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO artifacts (id, type, citation_id, latest) VALUES (?, ?, ?, ?)`,
|
||||||
|
art.ID, string(art.Type), art.Citation.ID, db.ToDBTime(art.Latest))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := StoreMetadata(ctx, tx, art.ID, art.Metadata); err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store artifact metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store history entries.
|
||||||
|
for t, id := range art.History {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO artifacts_history (artifact_id, snapshot_id, datetime) VALUES (?, ?, ?)`,
|
||||||
|
art.ID, id, db.ToDBTime(t))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store artifact history: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and link tags.
|
||||||
|
for tag := range art.Tags {
|
||||||
|
tagID, err := GetTag(ctx, tx, tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to resolve tag %q: %w", tag, err)
|
||||||
|
}
|
||||||
|
if tagID == "" {
|
||||||
|
return fmt.Errorf("artifacts: unknown tag %q (create it first)", tag)
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO artifact_tags (artifact_id, tag_id) VALUES (?, ?)`,
|
||||||
|
art.ID, tagID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to link tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and link categories.
|
||||||
|
for cat := range art.Categories {
|
||||||
|
catID, err := GetCategory(ctx, tx, cat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to resolve category %q: %w", cat, err)
|
||||||
|
}
|
||||||
|
if catID == "" {
|
||||||
|
return fmt.Errorf("artifacts: unknown category %q (create it first)", cat)
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO artifact_categories (artifact_id, category_id) VALUES (?, ?)`,
|
||||||
|
art.ID, catID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to link category: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves an Artifact by its ID, hydrating citation, history, tags,
|
||||||
|
// categories, and metadata.
|
||||||
|
func (art *Artifact) Get(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
if art.ID == "" {
|
||||||
|
return fmt.Errorf("artifacts: artifact missing ID: %w", core.ErrNoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
art.Citation = &Citation{}
|
||||||
|
var latest, artType string
|
||||||
|
row := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT type, citation_id, latest FROM artifacts WHERE id=?`, art.ID)
|
||||||
|
if err := row.Scan(&artType, &art.Citation.ID, &latest); err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to retrieve artifact: %w", err)
|
||||||
|
}
|
||||||
|
art.Type = ArtifactType(artType)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
art.Latest, err = db.FromDBTime(latest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to parse artifact latest time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := art.Citation.Get(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to load artifact citation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load history.
|
||||||
|
art.History = map[time.Time]string{}
|
||||||
|
rows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT snapshot_id, datetime FROM artifacts_history WHERE artifact_id=?`, art.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to load artifact history: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id, datetime string
|
||||||
|
if err := rows.Scan(&id, &datetime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t, err := db.FromDBTime(datetime, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
art.History[t] = id
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tag IDs, then resolve.
|
||||||
|
var tagIDs []string
|
||||||
|
tagRows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT tag_id FROM artifact_tags WHERE artifact_id=?`, art.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to load artifact tags: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tagRows.Close() }()
|
||||||
|
for tagRows.Next() {
|
||||||
|
var tagID string
|
||||||
|
if err := tagRows.Scan(&tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tagIDs = append(tagIDs, tagID)
|
||||||
|
}
|
||||||
|
if err := tagRows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
art.Tags, err = tagsFromTagIDs(ctx, tx, tagIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load category IDs, then resolve.
|
||||||
|
var catIDs []string
|
||||||
|
catRows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT category_id FROM artifact_categories WHERE artifact_id=?`, art.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to load artifact categories: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = catRows.Close() }()
|
||||||
|
for catRows.Next() {
|
||||||
|
var catID string
|
||||||
|
if err := catRows.Scan(&catID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
catIDs = append(catIDs, catID)
|
||||||
|
}
|
||||||
|
if err := catRows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
art.Categories, err = categoriesFromCategoryIDs(ctx, tx, catIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load metadata.
|
||||||
|
art.Metadata, err = GetMetadata(ctx, tx, art.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
503
artifacts/artifacts_test.go
Normal file
503
artifacts/artifacts_test.go
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/blob"
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
"git.wntrmute.dev/kyle/exo/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustOpenAndMigrate(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
database, err := db.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
t.Fatalf("Migrate failed: %v", err)
|
||||||
|
}
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := db.StartTX(ctx, database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartTX failed: %v", err)
|
||||||
|
}
|
||||||
|
return tx, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag tests ---
|
||||||
|
|
||||||
|
func TestCreateAndGetTag(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
if err := CreateTag(ctx, tx, "golang"); err != nil {
|
||||||
|
t.Fatalf("CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := GetTag(ctx, tx, "golang")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTag failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("tag should exist after creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTagIdempotent(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
if err := CreateTag(ctx, tx, "dup"); err != nil {
|
||||||
|
t.Fatalf("first CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := CreateTag(ctx, tx, "dup"); err != nil {
|
||||||
|
t.Fatalf("second CreateTag should be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllTags(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
for _, tag := range []string{"zebra", "alpha", "mid"} {
|
||||||
|
if err := CreateTag(ctx, tx, tag); err != nil {
|
||||||
|
t.Fatalf("CreateTag %q failed: %v", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := GetAllTags(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllTags failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 3 {
|
||||||
|
t.Fatalf("expected 3 tags, got %d", len(tags))
|
||||||
|
}
|
||||||
|
// Should be sorted.
|
||||||
|
if tags[0] != "alpha" || tags[1] != "mid" || tags[2] != "zebra" {
|
||||||
|
t.Fatalf("tags not sorted: %v", tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTagMissing(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
id, err := GetTag(ctx, tx, "nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTag should not error for missing tag: %v", err)
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
t.Fatalf("missing tag should return empty ID, got %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Category tests ---
|
||||||
|
|
||||||
|
func TestCreateAndGetCategory(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
if err := CreateCategory(ctx, tx, "cs/systems"); err != nil {
|
||||||
|
t.Fatalf("CreateCategory failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := GetCategory(ctx, tx, "cs/systems")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCategory failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("category should exist after creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllCategories(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
for _, cat := range []string{"z/last", "a/first", "m/mid"} {
|
||||||
|
if err := CreateCategory(ctx, tx, cat); err != nil {
|
||||||
|
t.Fatalf("CreateCategory %q failed: %v", cat, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := GetAllCategories(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllCategories failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(cats) != 3 {
|
||||||
|
t.Fatalf("expected 3 categories, got %d", len(cats))
|
||||||
|
}
|
||||||
|
if cats[0] != "a/first" {
|
||||||
|
t.Fatalf("categories not sorted: %v", cats)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Publisher tests ---
|
||||||
|
|
||||||
|
func TestPublisherStoreAndGet(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
pub := &Publisher{Name: "MIT Press", Address: "Cambridge, MA"}
|
||||||
|
if err := pub.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Publisher.Store failed: %v", err)
|
||||||
|
}
|
||||||
|
if pub.ID == "" {
|
||||||
|
t.Fatal("publisher ID should be set after store")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := &Publisher{ID: pub.ID}
|
||||||
|
ok, err := got.Get(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Publisher.Get failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("publisher should be found")
|
||||||
|
}
|
||||||
|
if got.Name != "MIT Press" || got.Address != "Cambridge, MA" {
|
||||||
|
t.Fatalf("publisher data mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublisherStoreDedup(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
pub1 := &Publisher{Name: "ACM", Address: "New York"}
|
||||||
|
if err := pub1.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("first Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub2 := &Publisher{Name: "ACM", Address: "New York"}
|
||||||
|
if err := pub2.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("second Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pub1.ID != pub2.ID {
|
||||||
|
t.Fatalf("duplicate publisher should reuse ID: %q vs %q", pub1.ID, pub2.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Citation tests ---
|
||||||
|
|
||||||
|
func TestCitationStoreAndGet(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
cite := &Citation{
|
||||||
|
Title: "The Art of Computer Programming",
|
||||||
|
DOI: "10.1234/test",
|
||||||
|
Year: 1968,
|
||||||
|
Published: time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
Authors: []string{"Donald Knuth"},
|
||||||
|
Publisher: &Publisher{Name: "Addison-Wesley", Address: "Reading, MA"},
|
||||||
|
Source: "https://example.com",
|
||||||
|
Abstract: "A comprehensive monograph on algorithms.",
|
||||||
|
Metadata: core.Metadata{"edition": core.Val("3rd")},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cite.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Citation.Store failed: %v", err)
|
||||||
|
}
|
||||||
|
if cite.ID == "" {
|
||||||
|
t.Fatal("citation ID should be set after store")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := &Citation{ID: cite.ID}
|
||||||
|
if err := got.Get(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Citation.Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Title != cite.Title {
|
||||||
|
t.Fatalf("title mismatch: got %q, want %q", got.Title, cite.Title)
|
||||||
|
}
|
||||||
|
if got.DOI != cite.DOI {
|
||||||
|
t.Fatalf("DOI mismatch: got %q, want %q", got.DOI, cite.DOI)
|
||||||
|
}
|
||||||
|
if got.Year != cite.Year {
|
||||||
|
t.Fatalf("year mismatch: got %d, want %d", got.Year, cite.Year)
|
||||||
|
}
|
||||||
|
if len(got.Authors) != 1 || got.Authors[0] != "Donald Knuth" {
|
||||||
|
t.Fatalf("authors mismatch: %v", got.Authors)
|
||||||
|
}
|
||||||
|
if got.Publisher.Name != "Addison-Wesley" {
|
||||||
|
t.Fatalf("publisher mismatch: %+v", got.Publisher)
|
||||||
|
}
|
||||||
|
if got.Metadata["edition"].Contents != "3rd" {
|
||||||
|
t.Fatalf("metadata mismatch: %v", got.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Metadata tests ---
|
||||||
|
|
||||||
|
func TestMetadataStoreAndGet(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
id := core.NewUUID()
|
||||||
|
meta := core.Metadata{
|
||||||
|
"key1": core.Val("value1"),
|
||||||
|
"key2": core.Vals("value2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := StoreMetadata(ctx, tx, id, meta); err != nil {
|
||||||
|
t.Fatalf("StoreMetadata failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := GetMetadata(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMetadata failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 metadata entries, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got["key1"].Contents != "value1" {
|
||||||
|
t.Fatalf("key1 mismatch: %+v", got["key1"])
|
||||||
|
}
|
||||||
|
if got["key2"].Type != core.ValueTypeString {
|
||||||
|
t.Fatalf("key2 type mismatch: %+v", got["key2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full artifact round-trip ---
|
||||||
|
|
||||||
|
func TestArtifactStoreAndGet(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
blobStore := blob.NewStore(t.TempDir())
|
||||||
|
|
||||||
|
// Create tags and categories first.
|
||||||
|
for _, tag := range []string{"algorithms", "textbook"} {
|
||||||
|
if err := CreateTag(ctx, tx, tag); err != nil {
|
||||||
|
t.Fatalf("CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CreateCategory(ctx, tx, "cs/fundamentals"); err != nil {
|
||||||
|
t.Fatalf("CreateCategory failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapID := core.NewUUID()
|
||||||
|
artID := core.NewUUID()
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
art := &Artifact{
|
||||||
|
ID: artID,
|
||||||
|
Type: ArtifactTypeBook,
|
||||||
|
Citation: &Citation{
|
||||||
|
Title: "TAOCP",
|
||||||
|
Year: 1968,
|
||||||
|
Published: time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
Authors: []string{"Donald Knuth"},
|
||||||
|
Publisher: &Publisher{Name: "Addison-Wesley", Address: "Boston"},
|
||||||
|
Source: "https://example.com/taocp",
|
||||||
|
},
|
||||||
|
Latest: now,
|
||||||
|
History: map[time.Time]string{now: snapID},
|
||||||
|
Tags: map[string]bool{"algorithms": true, "textbook": true},
|
||||||
|
Categories: map[string]bool{"cs/fundamentals": true},
|
||||||
|
Metadata: core.Metadata{"volume": core.Val("1")},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := art.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Artifact.Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store a snapshot.
|
||||||
|
snap := &Snapshot{
|
||||||
|
ArtifactID: artID,
|
||||||
|
ID: snapID,
|
||||||
|
StoreDate: now,
|
||||||
|
Datetime: now,
|
||||||
|
Citation: art.Citation,
|
||||||
|
Source: "local import",
|
||||||
|
Blobs: map[MIME]*BlobRef{
|
||||||
|
"application/pdf": {
|
||||||
|
Format: "application/pdf",
|
||||||
|
Data: []byte("fake PDF content"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: core.Metadata{},
|
||||||
|
}
|
||||||
|
if err := snap.Store(ctx, tx, blobStore); err != nil {
|
||||||
|
t.Fatalf("Snapshot.Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve and verify the artifact.
|
||||||
|
got := &Artifact{ID: artID}
|
||||||
|
if err := got.Get(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Artifact.Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Type != ArtifactTypeBook {
|
||||||
|
t.Fatalf("type mismatch: got %q, want %q", got.Type, ArtifactTypeBook)
|
||||||
|
}
|
||||||
|
if got.Citation.Title != "TAOCP" {
|
||||||
|
t.Fatalf("citation title mismatch: %q", got.Citation.Title)
|
||||||
|
}
|
||||||
|
if !got.Tags["algorithms"] || !got.Tags["textbook"] {
|
||||||
|
t.Fatalf("tags mismatch: %v", got.Tags)
|
||||||
|
}
|
||||||
|
if !got.Categories["cs/fundamentals"] {
|
||||||
|
t.Fatalf("categories mismatch: %v", got.Categories)
|
||||||
|
}
|
||||||
|
if len(got.History) != 1 {
|
||||||
|
t.Fatalf("expected 1 history entry, got %d", len(got.History))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve and verify the snapshot.
|
||||||
|
gotSnap := &Snapshot{ID: snapID}
|
||||||
|
if err := gotSnap.Get(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Snapshot.Get failed: %v", err)
|
||||||
|
}
|
||||||
|
if gotSnap.ArtifactID != artID {
|
||||||
|
t.Fatalf("snapshot artifact ID mismatch: %q", gotSnap.ArtifactID)
|
||||||
|
}
|
||||||
|
if len(gotSnap.Blobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 blob, got %d", len(gotSnap.Blobs))
|
||||||
|
}
|
||||||
|
pdfBlob, ok := gotSnap.Blobs["application/pdf"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing PDF blob reference")
|
||||||
|
}
|
||||||
|
if pdfBlob.ID == "" {
|
||||||
|
t.Fatal("blob ID should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify blob content via store.
|
||||||
|
data, err := blobStore.Read(pdfBlob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blob store Read failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "fake PDF content" {
|
||||||
|
t.Fatalf("blob content mismatch: %q", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetArtifactIDsForTag(t *testing.T) {
|
||||||
|
database := mustOpenAndMigrate(t)
|
||||||
|
tx, ctx := mustTX(t, database)
|
||||||
|
|
||||||
|
if err := CreateTag(ctx, tx, "search-tag"); err != nil {
|
||||||
|
t.Fatalf("CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artID := core.NewUUID()
|
||||||
|
art := &Artifact{
|
||||||
|
ID: artID,
|
||||||
|
Type: ArtifactTypeArticle,
|
||||||
|
Citation: &Citation{
|
||||||
|
Title: "Test Article",
|
||||||
|
Year: 2024,
|
||||||
|
Published: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
Publisher: &Publisher{Name: "Test", Address: ""},
|
||||||
|
Source: "test",
|
||||||
|
},
|
||||||
|
Latest: time.Now().UTC().Truncate(time.Second),
|
||||||
|
History: map[time.Time]string{},
|
||||||
|
Tags: map[string]bool{"search-tag": true},
|
||||||
|
Categories: map[string]bool{},
|
||||||
|
Metadata: core.Metadata{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := art.Store(ctx, tx); err != nil {
|
||||||
|
t.Fatalf("Artifact.Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := GetArtifactIDsForTag(ctx, tx, "search-tag")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetArtifactIDsForTag failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 1 || ids[0] != artID {
|
||||||
|
t.Fatalf("expected [%s], got %v", artID, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EndTX(tx, nil); err != nil {
|
||||||
|
t.Fatalf("EndTX failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCitationUpdate(t *testing.T) {
|
||||||
|
base := &Citation{
|
||||||
|
DOI: "10.1234/base",
|
||||||
|
Title: "Base Title",
|
||||||
|
Year: 2020,
|
||||||
|
Authors: []string{"Author A"},
|
||||||
|
Metadata: core.Metadata{"key": core.Val("base-val")},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Citation{
|
||||||
|
Title: "Override Title",
|
||||||
|
Metadata: core.Metadata{},
|
||||||
|
}
|
||||||
|
c.Update(base)
|
||||||
|
|
||||||
|
if c.DOI != "10.1234/base" {
|
||||||
|
t.Fatalf("DOI should inherit from base: %q", c.DOI)
|
||||||
|
}
|
||||||
|
if c.Title != "Override Title" {
|
||||||
|
t.Fatalf("Title should not be overridden: %q", c.Title)
|
||||||
|
}
|
||||||
|
if c.Year != 2020 {
|
||||||
|
t.Fatalf("Year should inherit from base: %d", c.Year)
|
||||||
|
}
|
||||||
|
if c.Metadata["key"].Contents != "base-val" {
|
||||||
|
t.Fatalf("Metadata should inherit from base: %v", c.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
42
artifacts/metadata.go
Normal file
42
artifacts/metadata.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoreMetadata persists metadata key-value pairs for the given owner ID.
|
||||||
|
func StoreMetadata(ctx context.Context, tx *sql.Tx, id string, metadata core.Metadata) error {
|
||||||
|
for key, value := range metadata {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT OR REPLACE INTO metadata (id, mkey, contents, type) VALUES (?, ?, ?, ?)`,
|
||||||
|
id, key, value.Contents, value.Type)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store metadata for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata retrieves all metadata key-value pairs for the given owner ID.
|
||||||
|
func GetMetadata(ctx context.Context, tx *sql.Tx, id string) (core.Metadata, error) {
|
||||||
|
metadata := core.Metadata{}
|
||||||
|
rows, err := tx.QueryContext(ctx,
|
||||||
|
`SELECT mkey, contents, type FROM metadata WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to retrieve metadata for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var key, contents, ctype string
|
||||||
|
if err := rows.Scan(&key, &contents, &ctype); err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to scan metadata row: %w", err)
|
||||||
|
}
|
||||||
|
metadata[key] = core.Value{Contents: contents, Type: ctype}
|
||||||
|
}
|
||||||
|
return metadata, rows.Err()
|
||||||
|
}
|
||||||
70
artifacts/publisher.go
Normal file
70
artifacts/publisher.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publisher represents a publishing entity.
|
||||||
|
type Publisher struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPublisher looks up a publisher by name and address, returning its ID.
|
||||||
|
func findPublisher(ctx context.Context, tx *sql.Tx, name, address string) (string, error) {
|
||||||
|
var id string
|
||||||
|
row := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM publishers WHERE name=? AND address=?`, name, address)
|
||||||
|
if err := row.Scan(&id); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists a Publisher. If a publisher with the same name and address
|
||||||
|
// already exists, it reuses that record.
|
||||||
|
func (p *Publisher) Store(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
id, err := findPublisher(ctx, tx, p.Name, p.Address)
|
||||||
|
if err == nil {
|
||||||
|
p.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("artifacts: failed to look up publisher: %w", err)
|
||||||
|
}
|
||||||
|
p.ID = core.NewUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO publishers (id, name, address) VALUES (?, ?, ?)`,
|
||||||
|
p.ID, p.Name, p.Address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifacts: failed to store publisher: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a Publisher by its ID.
|
||||||
|
func (p *Publisher) Get(ctx context.Context, tx *sql.Tx) (bool, error) {
|
||||||
|
if p.ID == "" {
|
||||||
|
return false, fmt.Errorf("artifacts: publisher missing ID: %w", core.ErrNoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT name, address FROM publishers WHERE id=?`, p.ID)
|
||||||
|
err := row.Scan(&p.Name, &p.Address)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("artifacts: failed to look up publisher: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
205
artifacts/tagcat.go
Normal file
205
artifacts/tagcat.go
Normal 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()
|
||||||
|
}
|
||||||
209
artifacts/yaml.go
Normal file
209
artifacts/yaml.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/blob"
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
"git.wntrmute.dev/kyle/exo/db"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetadataYAML is the YAML representation of metadata entries.
|
||||||
|
type MetadataYAML []struct {
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
Contents string `yaml:"contents"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStd converts MetadataYAML to core.Metadata.
|
||||||
|
func (my MetadataYAML) ToStd() core.Metadata {
|
||||||
|
if my == nil {
|
||||||
|
return core.Metadata{}
|
||||||
|
}
|
||||||
|
metadata := core.Metadata{}
|
||||||
|
for _, entry := range my {
|
||||||
|
metadata[entry.Key] = core.Value{Contents: entry.Contents, Type: entry.Type}
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// CitationYAML is the YAML representation of a citation.
|
||||||
|
type CitationYAML struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
DOI string `yaml:"doi"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Year int `yaml:"year"`
|
||||||
|
Published string `yaml:"published"`
|
||||||
|
Authors []string `yaml:"authors"`
|
||||||
|
Publisher *Publisher `yaml:"publisher"`
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
Abstract string `yaml:"abstract"`
|
||||||
|
Metadata MetadataYAML `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStd converts a CitationYAML to a Citation.
|
||||||
|
func (cy *CitationYAML) ToStd() (*Citation, error) {
|
||||||
|
if cy == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cite := &Citation{
|
||||||
|
ID: cy.ID,
|
||||||
|
DOI: cy.DOI,
|
||||||
|
Title: cy.Title,
|
||||||
|
Year: cy.Year,
|
||||||
|
Authors: cy.Authors,
|
||||||
|
Publisher: cy.Publisher,
|
||||||
|
Source: cy.Source,
|
||||||
|
Abstract: cy.Abstract,
|
||||||
|
Metadata: cy.Metadata.ToStd(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cy.Published != "" {
|
||||||
|
var err error
|
||||||
|
cite.Published, err = db.FromDBTime(cy.Published, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to parse citation published date: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobHeaderYAML is the YAML representation of a blob reference.
|
||||||
|
type BlobHeaderYAML struct {
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotYAML is the YAML representation of a snapshot.
|
||||||
|
type SnapshotYAML struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
StoreDate int64 `yaml:"stored"`
|
||||||
|
Datetime string `yaml:"datetime"`
|
||||||
|
Citation *CitationYAML `yaml:"citation"`
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
Blobs []BlobHeaderYAML `yaml:"blobs"`
|
||||||
|
Metadata MetadataYAML `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStd converts a SnapshotYAML to a Snapshot, reading blob data from files.
|
||||||
|
func (syml SnapshotYAML) ToStd(artifactID string, parentCitation *Citation) (*Snapshot, error) {
|
||||||
|
cite, err := syml.Citation.ToStd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := &Snapshot{
|
||||||
|
ArtifactID: artifactID,
|
||||||
|
ID: syml.ID,
|
||||||
|
StoreDate: time.Unix(syml.StoreDate, 0),
|
||||||
|
Citation: cite,
|
||||||
|
Source: syml.Source,
|
||||||
|
Blobs: map[MIME]*BlobRef{},
|
||||||
|
Metadata: syml.Metadata.ToStd(),
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.Datetime, err = db.FromDBTime(syml.Datetime, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit from parent citation if snapshot citation is nil or partial.
|
||||||
|
if snap.Citation == nil {
|
||||||
|
snap.Citation = parentCitation
|
||||||
|
} else if parentCitation != nil {
|
||||||
|
snap.Citation.Update(parentCitation)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bh := range syml.Blobs {
|
||||||
|
data, err := os.ReadFile(bh.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to read blob file %q: %w", bh.Path, err)
|
||||||
|
}
|
||||||
|
id := blob.HashData(data)
|
||||||
|
snap.Blobs[MIME(bh.Format)] = &BlobRef{
|
||||||
|
SnapshotID: syml.ID,
|
||||||
|
ID: id,
|
||||||
|
Format: MIME(bh.Format),
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactYAML is the YAML representation of a complete artifact with snapshots.
|
||||||
|
type ArtifactYAML struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Citation *CitationYAML `yaml:"citation"`
|
||||||
|
Latest string `yaml:"latest"`
|
||||||
|
History map[string]string `yaml:"history"`
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
Categories []string `yaml:"categories"`
|
||||||
|
Metadata MetadataYAML `yaml:"metadata"`
|
||||||
|
Snapshots []SnapshotYAML `yaml:"snapshots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStd converts an ArtifactYAML to an Artifact and its Snapshots.
|
||||||
|
func (ayml *ArtifactYAML) ToStd() (*Artifact, []*Snapshot, error) {
|
||||||
|
cite, err := ayml.Citation.ToStd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
art := &Artifact{
|
||||||
|
ID: ayml.ID,
|
||||||
|
Type: ArtifactType(ayml.Type),
|
||||||
|
Citation: cite,
|
||||||
|
History: map[time.Time]string{},
|
||||||
|
Tags: core.MapFromList(ayml.Tags),
|
||||||
|
Categories: core.MapFromList(ayml.Categories),
|
||||||
|
Metadata: ayml.Metadata.ToStd(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ayml.Latest != "" {
|
||||||
|
art.Latest, err = db.FromDBTime(ayml.Latest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for timestamp, id := range ayml.History {
|
||||||
|
datetime, err := db.FromDBTime(timestamp, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
art.History[datetime] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
var snaps []*Snapshot
|
||||||
|
for _, syml := range ayml.Snapshots {
|
||||||
|
snap, err := syml.ToStd(ayml.ID, art.Citation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
snaps = append(snaps, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return art, snaps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadArtifactFromYAML reads and parses an artifact YAML file.
|
||||||
|
func LoadArtifactFromYAML(path string) (*ArtifactYAML, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // path is a user-provided file for import
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to read YAML file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ay := &ArtifactYAML{}
|
||||||
|
if err := yaml.Unmarshal(data, ay); err != nil {
|
||||||
|
return nil, fmt.Errorf("artifacts: failed to parse YAML file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ay, nil
|
||||||
|
}
|
||||||
80
blob/blob.go
Normal file
80
blob/blob.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Package blob implements a content-addressable store for artifact content.
|
||||||
|
// Files are addressed by their SHA256 hash and stored in a hierarchical
|
||||||
|
// directory layout for filesystem friendliness.
|
||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store manages a content-addressable blob store on the local filesystem.
|
||||||
|
type Store struct {
|
||||||
|
basePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a Store rooted at the given base path.
|
||||||
|
func NewStore(basePath string) *Store {
|
||||||
|
return &Store{basePath: basePath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write computes the SHA256 hash of data, writes it to the store, and returns
|
||||||
|
// the hex-encoded hash (which is the blob ID).
|
||||||
|
func (s *Store) Write(data []byte) (string, error) {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
id := fmt.Sprintf("%x", hash[:])
|
||||||
|
|
||||||
|
p := s.path(id)
|
||||||
|
dir := filepath.Dir(p)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("blob: failed to create directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("blob: failed to write blob %q: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns the content of the blob with the given ID.
|
||||||
|
func (s *Store) Read(id string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(s.path(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("blob: failed to read blob %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists returns true if a blob with the given ID exists in the store.
|
||||||
|
func (s *Store) Exists(id string) bool {
|
||||||
|
_, err := os.Stat(s.path(id))
|
||||||
|
return !errors.Is(err, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the full filesystem path for a blob ID.
|
||||||
|
func (s *Store) Path(id string) string {
|
||||||
|
return s.path(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashData returns the SHA256 hex digest of data without writing it.
|
||||||
|
func HashData(data []byte) string {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
return fmt.Sprintf("%x", hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// path computes the filesystem path for a blob ID. The hex hash is split
|
||||||
|
// into 4-character segments as nested directories.
|
||||||
|
// Example: "a1b2c3d4..." -> basePath/a1b2/c3d4/.../a1b2c3d4...
|
||||||
|
func (s *Store) path(id string) string {
|
||||||
|
parts := []string{s.basePath}
|
||||||
|
for i := 0; i+4 <= len(id); i += 4 {
|
||||||
|
parts = append(parts, id[i:i+4])
|
||||||
|
}
|
||||||
|
parts = append(parts, id)
|
||||||
|
return filepath.Join(parts...)
|
||||||
|
}
|
||||||
145
blob/blob_test.go
Normal file
145
blob/blob_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
return NewStore(t.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAndRead(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
data := []byte("hello, exocortex")
|
||||||
|
|
||||||
|
id, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("Write returned empty ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.Read(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Fatalf("data mismatch: got %q, want %q", got, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteDeterministic(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
data := []byte("deterministic content")
|
||||||
|
|
||||||
|
id1, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id2, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id1 != id2 {
|
||||||
|
t.Fatalf("same content should produce same ID: %q vs %q", id1, id2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
data := []byte("existence check")
|
||||||
|
|
||||||
|
if s.Exists("nonexistent") {
|
||||||
|
t.Fatal("Exists should return false for missing blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.Exists(id) {
|
||||||
|
t.Fatal("Exists should return true after write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadMissing(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
_, err := s.Read("0000000000000000000000000000000000000000000000000000000000000000")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Read of missing blob should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathLayout(t *testing.T) {
|
||||||
|
s := NewStore("/base")
|
||||||
|
// A 64-char hex SHA256 hash split into 4-char segments.
|
||||||
|
id := "a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890"
|
||||||
|
p := s.Path(id)
|
||||||
|
|
||||||
|
// Should contain the 4-char directory segments.
|
||||||
|
if !strings.Contains(p, "a1b2") {
|
||||||
|
t.Fatalf("path should contain 4-char segments: %q", p)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(p, id) {
|
||||||
|
t.Fatalf("path should end with the full hash: %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashData(t *testing.T) {
|
||||||
|
data := []byte("hash me")
|
||||||
|
h := HashData(data)
|
||||||
|
if len(h) != 64 {
|
||||||
|
t.Fatalf("expected 64-char hex hash, got %d chars", len(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same data should produce same hash.
|
||||||
|
h2 := HashData(data)
|
||||||
|
if h != h2 {
|
||||||
|
t.Fatal("HashData is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLargeBlob(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
data := make([]byte, 1<<20) // 1 MiB
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.Read(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Fatal("large blob round-trip failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCreatesDirectories(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
data := []byte("directory creation test")
|
||||||
|
|
||||||
|
id, err := s.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := s.Path(id)
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatalf("blob file should exist at %q: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
go.mod
10
go.mod
@@ -5,4 +5,14 @@ go 1.25.7
|
|||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.37
|
github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
google.golang.org/grpc v1.79.3
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
40
go.sum
40
go.sum
@@ -1,4 +1,44 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
15
proto/buf.gen.yaml
Normal file
15
proto/buf.gen.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: v2
|
||||||
|
|
||||||
|
managed:
|
||||||
|
enabled: true
|
||||||
|
override:
|
||||||
|
- file_option: go_package_prefix
|
||||||
|
value: git.wntrmute.dev/kyle/exo/proto
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-go
|
||||||
|
out: ../proto
|
||||||
|
opt: paths=source_relative
|
||||||
|
- local: protoc-gen-go-grpc
|
||||||
|
out: ../proto
|
||||||
|
opt: paths=source_relative
|
||||||
12
proto/buf.yaml
Normal file
12
proto/buf.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
version: v2
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- path: .
|
||||||
|
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- STANDARD
|
||||||
|
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
1533
proto/exo/v1/artifacts.pb.go
Normal file
1533
proto/exo/v1/artifacts.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
157
proto/exo/v1/artifacts.proto
Normal file
157
proto/exo/v1/artifacts.proto
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package exo.v1;
|
||||||
|
|
||||||
|
option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1";
|
||||||
|
|
||||||
|
import "exo/v1/common.proto";
|
||||||
|
|
||||||
|
// Publisher represents a publishing entity.
|
||||||
|
message Publisher {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string address = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Citation holds bibliographic information.
|
||||||
|
message Citation {
|
||||||
|
string id = 1;
|
||||||
|
string doi = 2;
|
||||||
|
string title = 3;
|
||||||
|
int32 year = 4;
|
||||||
|
string published = 5; // ISO 8601 UTC
|
||||||
|
repeated string authors = 6;
|
||||||
|
Publisher publisher = 7;
|
||||||
|
string source = 8;
|
||||||
|
string abstract = 9;
|
||||||
|
repeated MetadataEntry metadata = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobRef is a reference to content in the blob store.
|
||||||
|
message BlobRef {
|
||||||
|
string snapshot_id = 1;
|
||||||
|
string id = 2; // SHA256 hash
|
||||||
|
string format = 3; // MIME type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot represents content at a specific point in time.
|
||||||
|
message Snapshot {
|
||||||
|
string artifact_id = 1;
|
||||||
|
string id = 2;
|
||||||
|
int64 stored_at = 3;
|
||||||
|
string datetime = 4; // ISO 8601 UTC
|
||||||
|
Citation citation = 5;
|
||||||
|
string source = 6;
|
||||||
|
repeated BlobRef blobs = 7;
|
||||||
|
repeated MetadataEntry metadata = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artifact is the top-level container for a knowledge source.
|
||||||
|
message Artifact {
|
||||||
|
string id = 1;
|
||||||
|
string type = 2;
|
||||||
|
Citation citation = 3;
|
||||||
|
string latest = 4; // ISO 8601 UTC
|
||||||
|
repeated Snapshot snapshots = 5;
|
||||||
|
repeated string tags = 6;
|
||||||
|
repeated string categories = 7;
|
||||||
|
repeated MetadataEntry metadata = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Service messages ---
|
||||||
|
|
||||||
|
message CreateArtifactRequest {
|
||||||
|
Artifact artifact = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateArtifactResponse {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetArtifactRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetArtifactResponse {
|
||||||
|
Artifact artifact = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteArtifactRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteArtifactResponse {}
|
||||||
|
|
||||||
|
message StoreBlobRequest {
|
||||||
|
string snapshot_id = 1;
|
||||||
|
string format = 2; // MIME type
|
||||||
|
bytes data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StoreBlobResponse {
|
||||||
|
string id = 1; // SHA256 hash
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBlobRequest {
|
||||||
|
string id = 1; // SHA256 hash
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBlobResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
string format = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTagsRequest {}
|
||||||
|
|
||||||
|
message ListTagsResponse {
|
||||||
|
repeated string tags = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateTagRequest {
|
||||||
|
string tag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateTagResponse {}
|
||||||
|
|
||||||
|
message ListCategoriesRequest {}
|
||||||
|
|
||||||
|
message ListCategoriesResponse {
|
||||||
|
repeated string categories = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCategoryRequest {
|
||||||
|
string category = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCategoryResponse {}
|
||||||
|
|
||||||
|
message SearchByTagRequest {
|
||||||
|
string tag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchByTagResponse {
|
||||||
|
repeated string artifact_ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactService provides CRUD operations for the artifact repository.
|
||||||
|
service ArtifactService {
|
||||||
|
// Artifacts
|
||||||
|
rpc CreateArtifact(CreateArtifactRequest) returns (CreateArtifactResponse);
|
||||||
|
rpc GetArtifact(GetArtifactRequest) returns (GetArtifactResponse);
|
||||||
|
rpc DeleteArtifact(DeleteArtifactRequest) returns (DeleteArtifactResponse);
|
||||||
|
|
||||||
|
// Blobs
|
||||||
|
rpc StoreBlob(StoreBlobRequest) returns (StoreBlobResponse);
|
||||||
|
rpc GetBlob(GetBlobRequest) returns (GetBlobResponse);
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
rpc ListTags(ListTagsRequest) returns (ListTagsResponse);
|
||||||
|
rpc CreateTag(CreateTagRequest) returns (CreateTagResponse);
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
|
||||||
|
rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
rpc SearchByTag(SearchByTagRequest) returns (SearchByTagResponse);
|
||||||
|
}
|
||||||
477
proto/exo/v1/artifacts_grpc.pb.go
Normal file
477
proto/exo/v1/artifacts_grpc.pb.go
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc (unknown)
|
||||||
|
// source: exo/v1/artifacts.proto
|
||||||
|
|
||||||
|
package exov1
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactService_CreateArtifact_FullMethodName = "/exo.v1.ArtifactService/CreateArtifact"
|
||||||
|
ArtifactService_GetArtifact_FullMethodName = "/exo.v1.ArtifactService/GetArtifact"
|
||||||
|
ArtifactService_DeleteArtifact_FullMethodName = "/exo.v1.ArtifactService/DeleteArtifact"
|
||||||
|
ArtifactService_StoreBlob_FullMethodName = "/exo.v1.ArtifactService/StoreBlob"
|
||||||
|
ArtifactService_GetBlob_FullMethodName = "/exo.v1.ArtifactService/GetBlob"
|
||||||
|
ArtifactService_ListTags_FullMethodName = "/exo.v1.ArtifactService/ListTags"
|
||||||
|
ArtifactService_CreateTag_FullMethodName = "/exo.v1.ArtifactService/CreateTag"
|
||||||
|
ArtifactService_ListCategories_FullMethodName = "/exo.v1.ArtifactService/ListCategories"
|
||||||
|
ArtifactService_CreateCategory_FullMethodName = "/exo.v1.ArtifactService/CreateCategory"
|
||||||
|
ArtifactService_SearchByTag_FullMethodName = "/exo.v1.ArtifactService/SearchByTag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactServiceClient is the client API for ArtifactService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// ArtifactService provides CRUD operations for the artifact repository.
|
||||||
|
type ArtifactServiceClient interface {
|
||||||
|
// Artifacts
|
||||||
|
CreateArtifact(ctx context.Context, in *CreateArtifactRequest, opts ...grpc.CallOption) (*CreateArtifactResponse, error)
|
||||||
|
GetArtifact(ctx context.Context, in *GetArtifactRequest, opts ...grpc.CallOption) (*GetArtifactResponse, error)
|
||||||
|
DeleteArtifact(ctx context.Context, in *DeleteArtifactRequest, opts ...grpc.CallOption) (*DeleteArtifactResponse, error)
|
||||||
|
// Blobs
|
||||||
|
StoreBlob(ctx context.Context, in *StoreBlobRequest, opts ...grpc.CallOption) (*StoreBlobResponse, error)
|
||||||
|
GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (*GetBlobResponse, error)
|
||||||
|
// Tags
|
||||||
|
ListTags(ctx context.Context, in *ListTagsRequest, opts ...grpc.CallOption) (*ListTagsResponse, error)
|
||||||
|
CreateTag(ctx context.Context, in *CreateTagRequest, opts ...grpc.CallOption) (*CreateTagResponse, error)
|
||||||
|
// Categories
|
||||||
|
ListCategories(ctx context.Context, in *ListCategoriesRequest, opts ...grpc.CallOption) (*ListCategoriesResponse, error)
|
||||||
|
CreateCategory(ctx context.Context, in *CreateCategoryRequest, opts ...grpc.CallOption) (*CreateCategoryResponse, error)
|
||||||
|
// Search
|
||||||
|
SearchByTag(ctx context.Context, in *SearchByTagRequest, opts ...grpc.CallOption) (*SearchByTagResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type artifactServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtifactServiceClient(cc grpc.ClientConnInterface) ArtifactServiceClient {
|
||||||
|
return &artifactServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) CreateArtifact(ctx context.Context, in *CreateArtifactRequest, opts ...grpc.CallOption) (*CreateArtifactResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateArtifactResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_CreateArtifact_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) GetArtifact(ctx context.Context, in *GetArtifactRequest, opts ...grpc.CallOption) (*GetArtifactResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetArtifactResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_GetArtifact_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) DeleteArtifact(ctx context.Context, in *DeleteArtifactRequest, opts ...grpc.CallOption) (*DeleteArtifactResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(DeleteArtifactResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_DeleteArtifact_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) StoreBlob(ctx context.Context, in *StoreBlobRequest, opts ...grpc.CallOption) (*StoreBlobResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(StoreBlobResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_StoreBlob_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (*GetBlobResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetBlobResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_GetBlob_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) ListTags(ctx context.Context, in *ListTagsRequest, opts ...grpc.CallOption) (*ListTagsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListTagsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_ListTags_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) CreateTag(ctx context.Context, in *CreateTagRequest, opts ...grpc.CallOption) (*CreateTagResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateTagResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_CreateTag_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) ListCategories(ctx context.Context, in *ListCategoriesRequest, opts ...grpc.CallOption) (*ListCategoriesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListCategoriesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_ListCategories_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) CreateCategory(ctx context.Context, in *CreateCategoryRequest, opts ...grpc.CallOption) (*CreateCategoryResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateCategoryResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_CreateCategory_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *artifactServiceClient) SearchByTag(ctx context.Context, in *SearchByTagRequest, opts ...grpc.CallOption) (*SearchByTagResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SearchByTagResponse)
|
||||||
|
err := c.cc.Invoke(ctx, ArtifactService_SearchByTag_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactServiceServer is the server API for ArtifactService service.
|
||||||
|
// All implementations must embed UnimplementedArtifactServiceServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// ArtifactService provides CRUD operations for the artifact repository.
|
||||||
|
type ArtifactServiceServer interface {
|
||||||
|
// Artifacts
|
||||||
|
CreateArtifact(context.Context, *CreateArtifactRequest) (*CreateArtifactResponse, error)
|
||||||
|
GetArtifact(context.Context, *GetArtifactRequest) (*GetArtifactResponse, error)
|
||||||
|
DeleteArtifact(context.Context, *DeleteArtifactRequest) (*DeleteArtifactResponse, error)
|
||||||
|
// Blobs
|
||||||
|
StoreBlob(context.Context, *StoreBlobRequest) (*StoreBlobResponse, error)
|
||||||
|
GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error)
|
||||||
|
// Tags
|
||||||
|
ListTags(context.Context, *ListTagsRequest) (*ListTagsResponse, error)
|
||||||
|
CreateTag(context.Context, *CreateTagRequest) (*CreateTagResponse, error)
|
||||||
|
// Categories
|
||||||
|
ListCategories(context.Context, *ListCategoriesRequest) (*ListCategoriesResponse, error)
|
||||||
|
CreateCategory(context.Context, *CreateCategoryRequest) (*CreateCategoryResponse, error)
|
||||||
|
// Search
|
||||||
|
SearchByTag(context.Context, *SearchByTagRequest) (*SearchByTagResponse, error)
|
||||||
|
mustEmbedUnimplementedArtifactServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedArtifactServiceServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedArtifactServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedArtifactServiceServer) CreateArtifact(context.Context, *CreateArtifactRequest) (*CreateArtifactResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CreateArtifact not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) GetArtifact(context.Context, *GetArtifactRequest) (*GetArtifactResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetArtifact not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) DeleteArtifact(context.Context, *DeleteArtifactRequest) (*DeleteArtifactResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method DeleteArtifact not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) StoreBlob(context.Context, *StoreBlobRequest) (*StoreBlobResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method StoreBlob not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetBlob not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) ListTags(context.Context, *ListTagsRequest) (*ListTagsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListTags not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) CreateTag(context.Context, *CreateTagRequest) (*CreateTagResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CreateTag not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) ListCategories(context.Context, *ListCategoriesRequest) (*ListCategoriesResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListCategories not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) CreateCategory(context.Context, *CreateCategoryRequest) (*CreateCategoryResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CreateCategory not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) SearchByTag(context.Context, *SearchByTagRequest) (*SearchByTagResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method SearchByTag not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedArtifactServiceServer) mustEmbedUnimplementedArtifactServiceServer() {}
|
||||||
|
func (UnimplementedArtifactServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeArtifactServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to ArtifactServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeArtifactServiceServer interface {
|
||||||
|
mustEmbedUnimplementedArtifactServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterArtifactServiceServer(s grpc.ServiceRegistrar, srv ArtifactServiceServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedArtifactServiceServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&ArtifactService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_CreateArtifact_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateArtifactRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).CreateArtifact(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_CreateArtifact_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).CreateArtifact(ctx, req.(*CreateArtifactRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_GetArtifact_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetArtifactRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).GetArtifact(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_GetArtifact_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).GetArtifact(ctx, req.(*GetArtifactRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_DeleteArtifact_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(DeleteArtifactRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).DeleteArtifact(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_DeleteArtifact_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).DeleteArtifact(ctx, req.(*DeleteArtifactRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_StoreBlob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(StoreBlobRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).StoreBlob(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_StoreBlob_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).StoreBlob(ctx, req.(*StoreBlobRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_GetBlob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetBlobRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).GetBlob(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_GetBlob_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).GetBlob(ctx, req.(*GetBlobRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_ListTags_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListTagsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).ListTags(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_ListTags_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).ListTags(ctx, req.(*ListTagsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_CreateTag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateTagRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).CreateTag(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_CreateTag_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).CreateTag(ctx, req.(*CreateTagRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_ListCategories_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListCategoriesRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).ListCategories(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_ListCategories_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).ListCategories(ctx, req.(*ListCategoriesRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_CreateCategory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateCategoryRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).CreateCategory(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_CreateCategory_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).CreateCategory(ctx, req.(*CreateCategoryRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ArtifactService_SearchByTag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SearchByTagRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ArtifactServiceServer).SearchByTag(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ArtifactService_SearchByTag_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ArtifactServiceServer).SearchByTag(ctx, req.(*SearchByTagRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactService_ServiceDesc is the grpc.ServiceDesc for ArtifactService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var ArtifactService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "exo.v1.ArtifactService",
|
||||||
|
HandlerType: (*ArtifactServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "CreateArtifact",
|
||||||
|
Handler: _ArtifactService_CreateArtifact_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetArtifact",
|
||||||
|
Handler: _ArtifactService_GetArtifact_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DeleteArtifact",
|
||||||
|
Handler: _ArtifactService_DeleteArtifact_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "StoreBlob",
|
||||||
|
Handler: _ArtifactService_StoreBlob_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetBlob",
|
||||||
|
Handler: _ArtifactService_GetBlob_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListTags",
|
||||||
|
Handler: _ArtifactService_ListTags_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateTag",
|
||||||
|
Handler: _ArtifactService_CreateTag_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListCategories",
|
||||||
|
Handler: _ArtifactService_ListCategories_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateCategory",
|
||||||
|
Handler: _ArtifactService_CreateCategory_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SearchByTag",
|
||||||
|
Handler: _ArtifactService_SearchByTag_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "exo/v1/artifacts.proto",
|
||||||
|
}
|
||||||
297
proto/exo/v1/common.pb.go
Normal file
297
proto/exo/v1/common.pb.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc (unknown)
|
||||||
|
// source: exo/v1/common.proto
|
||||||
|
|
||||||
|
package exov1
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Value is a typed key-value entry.
|
||||||
|
type Value struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Contents string `protobuf:"bytes,1,opt,name=contents,proto3" json:"contents,omitempty"`
|
||||||
|
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Value) Reset() {
|
||||||
|
*x = Value{}
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Value) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Value) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Value) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Value.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Value) Descriptor() ([]byte, []int) {
|
||||||
|
return file_exo_v1_common_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Value) GetContents() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Contents
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Value) GetType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Type
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataEntry is a single key-value metadata pair.
|
||||||
|
type MetadataEntry struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
|
||||||
|
Value *Value `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetadataEntry) Reset() {
|
||||||
|
*x = MetadataEntry{}
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetadataEntry) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MetadataEntry) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MetadataEntry) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MetadataEntry.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MetadataEntry) Descriptor() ([]byte, []int) {
|
||||||
|
return file_exo_v1_common_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetadataEntry) GetKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Key
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetadataEntry) GetValue() *Value {
|
||||||
|
if x != nil {
|
||||||
|
return x.Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header is attached to every persistent object.
|
||||||
|
type Header struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
|
Created int64 `protobuf:"varint,3,opt,name=created,proto3" json:"created,omitempty"`
|
||||||
|
Modified int64 `protobuf:"varint,4,opt,name=modified,proto3" json:"modified,omitempty"`
|
||||||
|
Categories []string `protobuf:"bytes,5,rep,name=categories,proto3" json:"categories,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,6,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
Metadata []*MetadataEntry `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) Reset() {
|
||||||
|
*x = Header{}
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Header) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Header) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_exo_v1_common_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Header.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Header) Descriptor() ([]byte, []int) {
|
||||||
|
return file_exo_v1_common_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Type
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetCreated() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Created
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetModified() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Modified
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetCategories() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Categories
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetTags() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Header) GetMetadata() []*MetadataEntry {
|
||||||
|
if x != nil {
|
||||||
|
return x.Metadata
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_exo_v1_common_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_exo_v1_common_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\x13exo/v1/common.proto\x12\x06exo.v1\"7\n" +
|
||||||
|
"\x05Value\x12\x1a\n" +
|
||||||
|
"\bcontents\x18\x01 \x01(\tR\bcontents\x12\x12\n" +
|
||||||
|
"\x04type\x18\x02 \x01(\tR\x04type\"F\n" +
|
||||||
|
"\rMetadataEntry\x12\x10\n" +
|
||||||
|
"\x03key\x18\x01 \x01(\tR\x03key\x12#\n" +
|
||||||
|
"\x05value\x18\x02 \x01(\v2\r.exo.v1.ValueR\x05value\"\xc9\x01\n" +
|
||||||
|
"\x06Header\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||||
|
"\x04type\x18\x02 \x01(\tR\x04type\x12\x18\n" +
|
||||||
|
"\acreated\x18\x03 \x01(\x03R\acreated\x12\x1a\n" +
|
||||||
|
"\bmodified\x18\x04 \x01(\x03R\bmodified\x12\x1e\n" +
|
||||||
|
"\n" +
|
||||||
|
"categories\x18\x05 \x03(\tR\n" +
|
||||||
|
"categories\x12\x12\n" +
|
||||||
|
"\x04tags\x18\x06 \x03(\tR\x04tags\x121\n" +
|
||||||
|
"\bmetadata\x18\a \x03(\v2\x15.exo.v1.MetadataEntryR\bmetadataB\x80\x01\n" +
|
||||||
|
"\n" +
|
||||||
|
"com.exo.v1B\vCommonProtoP\x01Z,git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1\xa2\x02\x03EXX\xaa\x02\x06Exo.V1\xca\x02\x06Exo\\V1\xe2\x02\x12Exo\\V1\\GPBMetadata\xea\x02\aExo::V1b\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_exo_v1_common_proto_rawDescOnce sync.Once
|
||||||
|
file_exo_v1_common_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_exo_v1_common_proto_rawDescGZIP() []byte {
|
||||||
|
file_exo_v1_common_proto_rawDescOnce.Do(func() {
|
||||||
|
file_exo_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_exo_v1_common_proto_rawDesc), len(file_exo_v1_common_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_exo_v1_common_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_exo_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||||
|
var file_exo_v1_common_proto_goTypes = []any{
|
||||||
|
(*Value)(nil), // 0: exo.v1.Value
|
||||||
|
(*MetadataEntry)(nil), // 1: exo.v1.MetadataEntry
|
||||||
|
(*Header)(nil), // 2: exo.v1.Header
|
||||||
|
}
|
||||||
|
var file_exo_v1_common_proto_depIdxs = []int32{
|
||||||
|
0, // 0: exo.v1.MetadataEntry.value:type_name -> exo.v1.Value
|
||||||
|
1, // 1: exo.v1.Header.metadata:type_name -> exo.v1.MetadataEntry
|
||||||
|
2, // [2:2] is the sub-list for method output_type
|
||||||
|
2, // [2:2] is the sub-list for method input_type
|
||||||
|
2, // [2:2] is the sub-list for extension type_name
|
||||||
|
2, // [2:2] is the sub-list for extension extendee
|
||||||
|
0, // [0:2] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_exo_v1_common_proto_init() }
|
||||||
|
func file_exo_v1_common_proto_init() {
|
||||||
|
if File_exo_v1_common_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_exo_v1_common_proto_rawDesc), len(file_exo_v1_common_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 3,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_exo_v1_common_proto_goTypes,
|
||||||
|
DependencyIndexes: file_exo_v1_common_proto_depIdxs,
|
||||||
|
MessageInfos: file_exo_v1_common_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_exo_v1_common_proto = out.File
|
||||||
|
file_exo_v1_common_proto_goTypes = nil
|
||||||
|
file_exo_v1_common_proto_depIdxs = nil
|
||||||
|
}
|
||||||
28
proto/exo/v1/common.proto
Normal file
28
proto/exo/v1/common.proto
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package exo.v1;
|
||||||
|
|
||||||
|
option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1";
|
||||||
|
|
||||||
|
// Value is a typed key-value entry.
|
||||||
|
message Value {
|
||||||
|
string contents = 1;
|
||||||
|
string type = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataEntry is a single key-value metadata pair.
|
||||||
|
message MetadataEntry {
|
||||||
|
string key = 1;
|
||||||
|
Value value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header is attached to every persistent object.
|
||||||
|
message Header {
|
||||||
|
string id = 1;
|
||||||
|
string type = 2;
|
||||||
|
int64 created = 3;
|
||||||
|
int64 modified = 4;
|
||||||
|
repeated string categories = 5;
|
||||||
|
repeated string tags = 6;
|
||||||
|
repeated MetadataEntry metadata = 7;
|
||||||
|
}
|
||||||
434
server/server.go
Normal file
434
server/server.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
// Package server implements the gRPC service for the exo system.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/artifacts"
|
||||||
|
"git.wntrmute.dev/kyle/exo/blob"
|
||||||
|
"git.wntrmute.dev/kyle/exo/core"
|
||||||
|
"git.wntrmute.dev/kyle/exo/db"
|
||||||
|
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactServer implements the ArtifactService gRPC service.
|
||||||
|
type ArtifactServer struct {
|
||||||
|
pb.UnimplementedArtifactServiceServer
|
||||||
|
database *sql.DB
|
||||||
|
blobs *blob.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifactServer creates a new ArtifactServer.
|
||||||
|
func NewArtifactServer(database *sql.DB, blobs *blob.Store) *ArtifactServer {
|
||||||
|
return &ArtifactServer{database: database, blobs: blobs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) CreateArtifact(ctx context.Context, req *pb.CreateArtifactRequest) (*pb.CreateArtifactResponse, error) {
|
||||||
|
if req.Artifact == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "artifact is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
art, snaps, err := protoToArtifact(req.Artifact)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tags and categories idempotently.
|
||||||
|
for tag := range art.Tags {
|
||||||
|
if err := artifacts.CreateTag(ctx, tx, tag); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create tag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for cat := range art.Categories {
|
||||||
|
if err := artifacts.CreateCategory(ctx, tx, cat); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create category: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := art.Store(ctx, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to store artifact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, snap := range snaps {
|
||||||
|
if err := snap.Store(ctx, tx, s.blobs); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to store snapshot: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateArtifactResponse{Id: art.ID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) GetArtifact(ctx context.Context, req *pb.GetArtifactRequest) (*pb.GetArtifactResponse, error) {
|
||||||
|
if req.Id == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
art := &artifacts.Artifact{ID: req.Id}
|
||||||
|
if err := art.Get(ctx, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.NotFound, "artifact not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetArtifactResponse{Artifact: artifactToProto(art)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) DeleteArtifact(_ context.Context, _ *pb.DeleteArtifactRequest) (*pb.DeleteArtifactResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "delete not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) StoreBlob(ctx context.Context, req *pb.StoreBlobRequest) (*pb.StoreBlobResponse, error) {
|
||||||
|
if len(req.Data) == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "data is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.blobs.Write(req.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to write blob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SnapshotId != "" {
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
b := &artifacts.BlobRef{
|
||||||
|
SnapshotID: req.SnapshotId,
|
||||||
|
ID: id,
|
||||||
|
Format: artifacts.MIME(req.Format),
|
||||||
|
}
|
||||||
|
if err := b.Store(ctx, tx, nil); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to store blob ref: %v", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.StoreBlobResponse{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) GetBlob(_ context.Context, req *pb.GetBlobRequest) (*pb.GetBlobResponse, error) {
|
||||||
|
if req.Id == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := s.blobs.Read(req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "blob not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.GetBlobResponse{Data: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) ListTags(ctx context.Context, _ *pb.ListTagsRequest) (*pb.ListTagsResponse, error) {
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := artifacts.GetAllTags(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get tags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListTagsResponse{Tags: tags}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) CreateTag(ctx context.Context, req *pb.CreateTagRequest) (*pb.CreateTagResponse, error) {
|
||||||
|
if req.Tag == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := artifacts.CreateTag(ctx, tx, req.Tag); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create tag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateTagResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) ListCategories(ctx context.Context, _ *pb.ListCategoriesRequest) (*pb.ListCategoriesResponse, error) {
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := artifacts.GetAllCategories(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get categories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListCategoriesResponse{Categories: cats}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) CreateCategory(ctx context.Context, req *pb.CreateCategoryRequest) (*pb.CreateCategoryResponse, error) {
|
||||||
|
if req.Category == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "category is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := artifacts.CreateCategory(ctx, tx, req.Category); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create category: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.CreateCategoryResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactServer) SearchByTag(ctx context.Context, req *pb.SearchByTagRequest) (*pb.SearchByTagResponse, error) {
|
||||||
|
if req.Tag == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.StartTX(ctx, s.database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := artifacts.GetArtifactIDsForTag(ctx, tx, req.Tag)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to search by tag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.SearchByTagResponse{ArtifactIds: ids}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Conversion helpers ---
|
||||||
|
|
||||||
|
func protoToArtifact(p *pb.Artifact) (*artifacts.Artifact, []*artifacts.Snapshot, error) {
|
||||||
|
if p.Id == "" {
|
||||||
|
p.Id = core.NewUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
cite := protoCitationToDomain(p.Citation)
|
||||||
|
|
||||||
|
art := &artifacts.Artifact{
|
||||||
|
ID: p.Id,
|
||||||
|
Type: artifacts.ArtifactType(p.Type),
|
||||||
|
Citation: cite,
|
||||||
|
History: map[time.Time]string{},
|
||||||
|
Tags: core.MapFromList(p.Tags),
|
||||||
|
Categories: core.MapFromList(p.Categories),
|
||||||
|
Metadata: protoMetadataToDomain(p.Metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Latest != "" {
|
||||||
|
var err error
|
||||||
|
art.Latest, err = db.FromDBTime(p.Latest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid latest time: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
art.Latest = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
var snaps []*artifacts.Snapshot
|
||||||
|
for _, sp := range p.Snapshots {
|
||||||
|
snap := protoSnapshotToDomain(sp, p.Id, cite)
|
||||||
|
art.History[snap.Datetime] = snap.ID
|
||||||
|
snaps = append(snaps, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return art, snaps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoCitationToDomain(p *pb.Citation) *artifacts.Citation {
|
||||||
|
if p == nil {
|
||||||
|
return &artifacts.Citation{
|
||||||
|
ID: core.NewUUID(),
|
||||||
|
Metadata: core.Metadata{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cite := &artifacts.Citation{
|
||||||
|
ID: p.Id,
|
||||||
|
DOI: p.Doi,
|
||||||
|
Title: p.Title,
|
||||||
|
Year: int(p.Year),
|
||||||
|
Authors: p.Authors,
|
||||||
|
Source: p.Source,
|
||||||
|
Abstract: p.Abstract,
|
||||||
|
Metadata: protoMetadataToDomain(p.Metadata),
|
||||||
|
}
|
||||||
|
if cite.ID == "" {
|
||||||
|
cite.ID = core.NewUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Published != "" {
|
||||||
|
t, err := db.FromDBTime(p.Published, nil)
|
||||||
|
if err == nil {
|
||||||
|
cite.Published = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Publisher != nil {
|
||||||
|
cite.Publisher = &artifacts.Publisher{
|
||||||
|
ID: p.Publisher.Id,
|
||||||
|
Name: p.Publisher.Name,
|
||||||
|
Address: p.Publisher.Address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cite
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoSnapshotToDomain(p *pb.Snapshot, artifactID string, parentCite *artifacts.Citation) *artifacts.Snapshot {
|
||||||
|
snap := &artifacts.Snapshot{
|
||||||
|
ArtifactID: artifactID,
|
||||||
|
ID: p.Id,
|
||||||
|
StoreDate: time.Unix(p.StoredAt, 0),
|
||||||
|
Source: p.Source,
|
||||||
|
Blobs: map[artifacts.MIME]*artifacts.BlobRef{},
|
||||||
|
Metadata: protoMetadataToDomain(p.Metadata),
|
||||||
|
}
|
||||||
|
if snap.ID == "" {
|
||||||
|
snap.ID = core.NewUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Datetime != "" {
|
||||||
|
t, err := db.FromDBTime(p.Datetime, nil)
|
||||||
|
if err == nil {
|
||||||
|
snap.Datetime = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Citation != nil {
|
||||||
|
snap.Citation = protoCitationToDomain(p.Citation)
|
||||||
|
} else {
|
||||||
|
snap.Citation = parentCite
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range p.Blobs {
|
||||||
|
ref := &artifacts.BlobRef{
|
||||||
|
SnapshotID: snap.ID,
|
||||||
|
ID: b.Id,
|
||||||
|
Format: artifacts.MIME(b.Format),
|
||||||
|
}
|
||||||
|
snap.Blobs[ref.Format] = ref
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifactToProto(art *artifacts.Artifact) *pb.Artifact {
|
||||||
|
p := &pb.Artifact{
|
||||||
|
Id: art.ID,
|
||||||
|
Type: string(art.Type),
|
||||||
|
Latest: db.ToDBTime(art.Latest),
|
||||||
|
Tags: core.ListFromMap(art.Tags),
|
||||||
|
Categories: core.ListFromMap(art.Categories),
|
||||||
|
Metadata: domainMetadataToProto(art.Metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
if art.Citation != nil {
|
||||||
|
p.Citation = domainCitationToProto(art.Citation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainCitationToProto(c *artifacts.Citation) *pb.Citation {
|
||||||
|
p := &pb.Citation{
|
||||||
|
Id: c.ID,
|
||||||
|
Doi: c.DOI,
|
||||||
|
Title: c.Title,
|
||||||
|
Year: int32(c.Year), //nolint:gosec // year values are always small
|
||||||
|
Published: db.ToDBTime(c.Published),
|
||||||
|
Authors: c.Authors,
|
||||||
|
Source: c.Source,
|
||||||
|
Abstract: c.Abstract,
|
||||||
|
Metadata: domainMetadataToProto(c.Metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Publisher != nil {
|
||||||
|
p.Publisher = &pb.Publisher{
|
||||||
|
Id: c.Publisher.ID,
|
||||||
|
Name: c.Publisher.Name,
|
||||||
|
Address: c.Publisher.Address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoMetadataToDomain(entries []*pb.MetadataEntry) core.Metadata {
|
||||||
|
m := core.Metadata{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Value != nil {
|
||||||
|
m[e.Key] = core.Value{Contents: e.Value.Contents, Type: e.Value.Type}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainMetadataToProto(m core.Metadata) []*pb.MetadataEntry {
|
||||||
|
entries := make([]*pb.MetadataEntry, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
entries = append(entries, &pb.MetadataEntry{
|
||||||
|
Key: k,
|
||||||
|
Value: &pb.Value{Contents: v.Contents, Type: v.Type},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
183
server/server_test.go
Normal file
183
server/server_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/exo/blob"
|
||||||
|
"git.wntrmute.dev/kyle/exo/db"
|
||||||
|
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup(t *testing.T) *ArtifactServer {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
database, err := db.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
t.Fatalf("Migrate failed: %v", err)
|
||||||
|
}
|
||||||
|
blobStore := blob.NewStore(t.TempDir())
|
||||||
|
return NewArtifactServer(database, blobStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndGetArtifact(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createResp, err := srv.CreateArtifact(ctx, &pb.CreateArtifactRequest{
|
||||||
|
Artifact: &pb.Artifact{
|
||||||
|
Type: "Article",
|
||||||
|
Citation: &pb.Citation{
|
||||||
|
Title: "Test Article",
|
||||||
|
Year: 2024,
|
||||||
|
Published: "2024-01-15 00:00:00",
|
||||||
|
Authors: []string{"Alice", "Bob"},
|
||||||
|
Publisher: &pb.Publisher{Name: "Test Press", Address: "Testville"},
|
||||||
|
Source: "https://example.com/article",
|
||||||
|
},
|
||||||
|
Tags: []string{"test", "grpc"},
|
||||||
|
Categories: []string{"cs/testing"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateArtifact failed: %v", err)
|
||||||
|
}
|
||||||
|
if createResp.Id == "" {
|
||||||
|
t.Fatal("expected non-empty artifact ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
getResp, err := srv.GetArtifact(ctx, &pb.GetArtifactRequest{Id: createResp.Id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetArtifact failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
art := getResp.Artifact
|
||||||
|
if art.Type != "Article" {
|
||||||
|
t.Fatalf("type mismatch: got %q", art.Type)
|
||||||
|
}
|
||||||
|
if art.Citation.Title != "Test Article" {
|
||||||
|
t.Fatalf("title mismatch: got %q", art.Citation.Title)
|
||||||
|
}
|
||||||
|
if len(art.Citation.Authors) != 2 {
|
||||||
|
t.Fatalf("expected 2 authors, got %d", len(art.Citation.Authors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTagAndList(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := srv.CreateTag(ctx, &pb.CreateTagRequest{Tag: "alpha"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = srv.CreateTag(ctx, &pb.CreateTagRequest{Tag: "beta"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTag failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := srv.ListTags(ctx, &pb.ListTagsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTags failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Tags) != 2 {
|
||||||
|
t.Fatalf("expected 2 tags, got %d", len(resp.Tags))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateCategoryAndList(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := srv.CreateCategory(ctx, &pb.CreateCategoryRequest{Category: "cs/ai"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCategory failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := srv.ListCategories(ctx, &pb.ListCategoriesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCategories failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Categories) != 1 || resp.Categories[0] != "cs/ai" {
|
||||||
|
t.Fatalf("unexpected categories: %v", resp.Categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreBlobAndGet(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
storeResp, err := srv.StoreBlob(ctx, &pb.StoreBlobRequest{
|
||||||
|
Format: "application/pdf",
|
||||||
|
Data: []byte("fake PDF data"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreBlob failed: %v", err)
|
||||||
|
}
|
||||||
|
if storeResp.Id == "" {
|
||||||
|
t.Fatal("expected non-empty blob ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
getResp, err := srv.GetBlob(ctx, &pb.GetBlobRequest{Id: storeResp.Id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBlob failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(getResp.Data) != "fake PDF data" {
|
||||||
|
t.Fatalf("blob data mismatch: %q", getResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchByTag(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := srv.CreateArtifact(ctx, &pb.CreateArtifactRequest{
|
||||||
|
Artifact: &pb.Artifact{
|
||||||
|
Type: "Paper",
|
||||||
|
Citation: &pb.Citation{
|
||||||
|
Title: "Searchable Paper",
|
||||||
|
Year: 2024,
|
||||||
|
Published: "2024-06-01 00:00:00",
|
||||||
|
Publisher: &pb.Publisher{Name: "ACM", Address: "NYC"},
|
||||||
|
Source: "test",
|
||||||
|
},
|
||||||
|
Tags: []string{"searchable"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateArtifact failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := srv.SearchByTag(ctx, &pb.SearchByTagRequest{Tag: "searchable"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchByTag failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.ArtifactIds) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(resp.ArtifactIds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateArtifactNil(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := srv.CreateArtifact(ctx, &pb.CreateArtifactRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil artifact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetArtifactMissing(t *testing.T) {
|
||||||
|
srv := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := srv.GetArtifact(ctx, &pb.GetArtifactRequest{Id: "nonexistent"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing artifact")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user