Add Phase 2 artifact repository: types, blob store, gRPC service

Build the complete artifact pillar with five packages:
- artifacts: Artifact, Snapshot, Citation, Publisher types with Get/Store
  DB methods, tag/category management, metadata ops, YAML import
- blob: content-addressable store (SHA256, hierarchical dir layout)
- proto: protobuf definitions (common.proto, artifacts.proto) with buf
  linting and code generation
- server: gRPC ArtifactService implementation (create/get artifacts,
  store/retrieve blobs, manage tags/categories, search by tag)

All FK insertion ordering is correct (parent rows before children).
Full test coverage across artifacts, blob, and server packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:56:34 -07:00
parent bb2c7f7ef3
commit b64177baa8
22 changed files with 5017 additions and 1 deletions

View File

@@ -22,7 +22,29 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`.
- `config/config.go`, `config/config_test.go`
- `.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

212
artifacts/artifact.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

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

209
artifacts/yaml.go Normal file
View 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
View 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
View 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
View File

@@ -5,4 +5,14 @@ go 1.25.7
require (
github.com/google/uuid v1.6.0
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
View File

@@ -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/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/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
View 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
View File

@@ -0,0 +1,12 @@
version: v2
modules:
- path: .
lint:
use:
- STANDARD
breaking:
use:
- FILE

1533
proto/exo/v1/artifacts.pb.go Normal file

File diff suppressed because it is too large Load Diff

View 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);
}

View 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
View 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
View 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
View 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
View 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")
}
}