diff --git a/PROGRESS.md b/PROGRESS.md index 15f2556..8568253 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/artifacts/artifact.go b/artifacts/artifact.go new file mode 100644 index 0000000..952cb34 --- /dev/null +++ b/artifacts/artifact.go @@ -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 +} diff --git a/artifacts/artifacts_test.go b/artifacts/artifacts_test.go new file mode 100644 index 0000000..4b3b027 --- /dev/null +++ b/artifacts/artifacts_test.go @@ -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) + } +} diff --git a/artifacts/citation.go b/artifacts/citation.go new file mode 100644 index 0000000..fca42b9 --- /dev/null +++ b/artifacts/citation.go @@ -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 +} diff --git a/artifacts/metadata.go b/artifacts/metadata.go new file mode 100644 index 0000000..accd303 --- /dev/null +++ b/artifacts/metadata.go @@ -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() +} diff --git a/artifacts/publisher.go b/artifacts/publisher.go new file mode 100644 index 0000000..9c470e0 --- /dev/null +++ b/artifacts/publisher.go @@ -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 +} diff --git a/artifacts/snapshot.go b/artifacts/snapshot.go new file mode 100644 index 0000000..70dcd69 --- /dev/null +++ b/artifacts/snapshot.go @@ -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() +} diff --git a/artifacts/tagcat.go b/artifacts/tagcat.go new file mode 100644 index 0000000..daacad4 --- /dev/null +++ b/artifacts/tagcat.go @@ -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() +} diff --git a/artifacts/yaml.go b/artifacts/yaml.go new file mode 100644 index 0000000..ca31bcd --- /dev/null +++ b/artifacts/yaml.go @@ -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 +} diff --git a/blob/blob.go b/blob/blob.go new file mode 100644 index 0000000..a241580 --- /dev/null +++ b/blob/blob.go @@ -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...) +} diff --git a/blob/blob_test.go b/blob/blob_test.go new file mode 100644 index 0000000..d9bd6a5 --- /dev/null +++ b/blob/blob_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod index c453dee..8aa6790 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 9bf32e5..3913c45 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,44 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/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= diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 0000000..054a77e --- /dev/null +++ b/proto/buf.gen.yaml @@ -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 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..aed963e --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,12 @@ +version: v2 + +modules: + - path: . + +lint: + use: + - STANDARD + +breaking: + use: + - FILE diff --git a/proto/exo/v1/artifacts.pb.go b/proto/exo/v1/artifacts.pb.go new file mode 100644 index 0000000..f93dc75 --- /dev/null +++ b/proto/exo/v1/artifacts.pb.go @@ -0,0 +1,1533 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: exo/v1/artifacts.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) +) + +// Publisher represents a publishing entity. +type Publisher struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Publisher) Reset() { + *x = Publisher{} + mi := &file_exo_v1_artifacts_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Publisher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Publisher) ProtoMessage() {} + +func (x *Publisher) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_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 Publisher.ProtoReflect.Descriptor instead. +func (*Publisher) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{0} +} + +func (x *Publisher) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Publisher) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Publisher) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// Citation holds bibliographic information. +type Citation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Doi string `protobuf:"bytes,2,opt,name=doi,proto3" json:"doi,omitempty"` + Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"` + Year int32 `protobuf:"varint,4,opt,name=year,proto3" json:"year,omitempty"` + Published string `protobuf:"bytes,5,opt,name=published,proto3" json:"published,omitempty"` // ISO 8601 UTC + Authors []string `protobuf:"bytes,6,rep,name=authors,proto3" json:"authors,omitempty"` + Publisher *Publisher `protobuf:"bytes,7,opt,name=publisher,proto3" json:"publisher,omitempty"` + Source string `protobuf:"bytes,8,opt,name=source,proto3" json:"source,omitempty"` + Abstract string `protobuf:"bytes,9,opt,name=abstract,proto3" json:"abstract,omitempty"` + Metadata []*MetadataEntry `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Citation) Reset() { + *x = Citation{} + mi := &file_exo_v1_artifacts_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Citation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Citation) ProtoMessage() {} + +func (x *Citation) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_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 Citation.ProtoReflect.Descriptor instead. +func (*Citation) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{1} +} + +func (x *Citation) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Citation) GetDoi() string { + if x != nil { + return x.Doi + } + return "" +} + +func (x *Citation) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Citation) GetYear() int32 { + if x != nil { + return x.Year + } + return 0 +} + +func (x *Citation) GetPublished() string { + if x != nil { + return x.Published + } + return "" +} + +func (x *Citation) GetAuthors() []string { + if x != nil { + return x.Authors + } + return nil +} + +func (x *Citation) GetPublisher() *Publisher { + if x != nil { + return x.Publisher + } + return nil +} + +func (x *Citation) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *Citation) GetAbstract() string { + if x != nil { + return x.Abstract + } + return "" +} + +func (x *Citation) GetMetadata() []*MetadataEntry { + if x != nil { + return x.Metadata + } + return nil +} + +// BlobRef is a reference to content in the blob store. +type BlobRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` // SHA256 hash + Format string `protobuf:"bytes,3,opt,name=format,proto3" json:"format,omitempty"` // MIME type + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlobRef) Reset() { + *x = BlobRef{} + mi := &file_exo_v1_artifacts_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlobRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlobRef) ProtoMessage() {} + +func (x *BlobRef) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_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 BlobRef.ProtoReflect.Descriptor instead. +func (*BlobRef) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{2} +} + +func (x *BlobRef) GetSnapshotId() string { + if x != nil { + return x.SnapshotId + } + return "" +} + +func (x *BlobRef) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *BlobRef) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +// Snapshot represents content at a specific point in time. +type Snapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + ArtifactId string `protobuf:"bytes,1,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + StoredAt int64 `protobuf:"varint,3,opt,name=stored_at,json=storedAt,proto3" json:"stored_at,omitempty"` + Datetime string `protobuf:"bytes,4,opt,name=datetime,proto3" json:"datetime,omitempty"` // ISO 8601 UTC + Citation *Citation `protobuf:"bytes,5,opt,name=citation,proto3" json:"citation,omitempty"` + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + Blobs []*BlobRef `protobuf:"bytes,7,rep,name=blobs,proto3" json:"blobs,omitempty"` + Metadata []*MetadataEntry `protobuf:"bytes,8,rep,name=metadata,proto3" json:"metadata,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Snapshot) Reset() { + *x = Snapshot{} + mi := &file_exo_v1_artifacts_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Snapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Snapshot) ProtoMessage() {} + +func (x *Snapshot) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[3] + 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 Snapshot.ProtoReflect.Descriptor instead. +func (*Snapshot) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{3} +} + +func (x *Snapshot) GetArtifactId() string { + if x != nil { + return x.ArtifactId + } + return "" +} + +func (x *Snapshot) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Snapshot) GetStoredAt() int64 { + if x != nil { + return x.StoredAt + } + return 0 +} + +func (x *Snapshot) GetDatetime() string { + if x != nil { + return x.Datetime + } + return "" +} + +func (x *Snapshot) GetCitation() *Citation { + if x != nil { + return x.Citation + } + return nil +} + +func (x *Snapshot) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *Snapshot) GetBlobs() []*BlobRef { + if x != nil { + return x.Blobs + } + return nil +} + +func (x *Snapshot) GetMetadata() []*MetadataEntry { + if x != nil { + return x.Metadata + } + return nil +} + +// Artifact is the top-level container for a knowledge source. +type Artifact 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"` + Citation *Citation `protobuf:"bytes,3,opt,name=citation,proto3" json:"citation,omitempty"` + Latest string `protobuf:"bytes,4,opt,name=latest,proto3" json:"latest,omitempty"` // ISO 8601 UTC + Snapshots []*Snapshot `protobuf:"bytes,5,rep,name=snapshots,proto3" json:"snapshots,omitempty"` + Tags []string `protobuf:"bytes,6,rep,name=tags,proto3" json:"tags,omitempty"` + Categories []string `protobuf:"bytes,7,rep,name=categories,proto3" json:"categories,omitempty"` + Metadata []*MetadataEntry `protobuf:"bytes,8,rep,name=metadata,proto3" json:"metadata,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Artifact) Reset() { + *x = Artifact{} + mi := &file_exo_v1_artifacts_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Artifact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Artifact) ProtoMessage() {} + +func (x *Artifact) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[4] + 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 Artifact.ProtoReflect.Descriptor instead. +func (*Artifact) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{4} +} + +func (x *Artifact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Artifact) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Artifact) GetCitation() *Citation { + if x != nil { + return x.Citation + } + return nil +} + +func (x *Artifact) GetLatest() string { + if x != nil { + return x.Latest + } + return "" +} + +func (x *Artifact) GetSnapshots() []*Snapshot { + if x != nil { + return x.Snapshots + } + return nil +} + +func (x *Artifact) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *Artifact) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +func (x *Artifact) GetMetadata() []*MetadataEntry { + if x != nil { + return x.Metadata + } + return nil +} + +type CreateArtifactRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Artifact *Artifact `protobuf:"bytes,1,opt,name=artifact,proto3" json:"artifact,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateArtifactRequest) Reset() { + *x = CreateArtifactRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactRequest) ProtoMessage() {} + +func (x *CreateArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[5] + 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 CreateArtifactRequest.ProtoReflect.Descriptor instead. +func (*CreateArtifactRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateArtifactRequest) GetArtifact() *Artifact { + if x != nil { + return x.Artifact + } + return nil +} + +type CreateArtifactResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateArtifactResponse) Reset() { + *x = CreateArtifactResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactResponse) ProtoMessage() {} + +func (x *CreateArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[6] + 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 CreateArtifactResponse.ProtoReflect.Descriptor instead. +func (*CreateArtifactResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{6} +} + +func (x *CreateArtifactResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetArtifactRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetArtifactRequest) Reset() { + *x = GetArtifactRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetArtifactRequest) ProtoMessage() {} + +func (x *GetArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[7] + 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 GetArtifactRequest.ProtoReflect.Descriptor instead. +func (*GetArtifactRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{7} +} + +func (x *GetArtifactRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetArtifactResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Artifact *Artifact `protobuf:"bytes,1,opt,name=artifact,proto3" json:"artifact,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetArtifactResponse) Reset() { + *x = GetArtifactResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetArtifactResponse) ProtoMessage() {} + +func (x *GetArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[8] + 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 GetArtifactResponse.ProtoReflect.Descriptor instead. +func (*GetArtifactResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{8} +} + +func (x *GetArtifactResponse) GetArtifact() *Artifact { + if x != nil { + return x.Artifact + } + return nil +} + +type DeleteArtifactRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteArtifactRequest) Reset() { + *x = DeleteArtifactRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactRequest) ProtoMessage() {} + +func (x *DeleteArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[9] + 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 DeleteArtifactRequest.ProtoReflect.Descriptor instead. +func (*DeleteArtifactRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteArtifactRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeleteArtifactResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteArtifactResponse) Reset() { + *x = DeleteArtifactResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactResponse) ProtoMessage() {} + +func (x *DeleteArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[10] + 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 DeleteArtifactResponse.ProtoReflect.Descriptor instead. +func (*DeleteArtifactResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{10} +} + +type StoreBlobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"` + Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` // MIME type + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreBlobRequest) Reset() { + *x = StoreBlobRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreBlobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreBlobRequest) ProtoMessage() {} + +func (x *StoreBlobRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[11] + 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 StoreBlobRequest.ProtoReflect.Descriptor instead. +func (*StoreBlobRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{11} +} + +func (x *StoreBlobRequest) GetSnapshotId() string { + if x != nil { + return x.SnapshotId + } + return "" +} + +func (x *StoreBlobRequest) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +func (x *StoreBlobRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type StoreBlobResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // SHA256 hash + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreBlobResponse) Reset() { + *x = StoreBlobResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreBlobResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreBlobResponse) ProtoMessage() {} + +func (x *StoreBlobResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[12] + 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 StoreBlobResponse.ProtoReflect.Descriptor instead. +func (*StoreBlobResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{12} +} + +func (x *StoreBlobResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetBlobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // SHA256 hash + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBlobRequest) Reset() { + *x = GetBlobRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBlobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBlobRequest) ProtoMessage() {} + +func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[13] + 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 GetBlobRequest.ProtoReflect.Descriptor instead. +func (*GetBlobRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{13} +} + +func (x *GetBlobRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetBlobResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBlobResponse) Reset() { + *x = GetBlobResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBlobResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBlobResponse) ProtoMessage() {} + +func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[14] + 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 GetBlobResponse.ProtoReflect.Descriptor instead. +func (*GetBlobResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{14} +} + +func (x *GetBlobResponse) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *GetBlobResponse) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +type ListTagsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTagsRequest) Reset() { + *x = ListTagsRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTagsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTagsRequest) ProtoMessage() {} + +func (x *ListTagsRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[15] + 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 ListTagsRequest.ProtoReflect.Descriptor instead. +func (*ListTagsRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{15} +} + +type ListTagsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tags []string `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTagsResponse) Reset() { + *x = ListTagsResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTagsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTagsResponse) ProtoMessage() {} + +func (x *ListTagsResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[16] + 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 ListTagsResponse.ProtoReflect.Descriptor instead. +func (*ListTagsResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{16} +} + +func (x *ListTagsResponse) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +type CreateTagRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTagRequest) Reset() { + *x = CreateTagRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTagRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTagRequest) ProtoMessage() {} + +func (x *CreateTagRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[17] + 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 CreateTagRequest.ProtoReflect.Descriptor instead. +func (*CreateTagRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{17} +} + +func (x *CreateTagRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type CreateTagResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTagResponse) Reset() { + *x = CreateTagResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTagResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTagResponse) ProtoMessage() {} + +func (x *CreateTagResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[18] + 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 CreateTagResponse.ProtoReflect.Descriptor instead. +func (*CreateTagResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{18} +} + +type ListCategoriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCategoriesRequest) Reset() { + *x = ListCategoriesRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCategoriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCategoriesRequest) ProtoMessage() {} + +func (x *ListCategoriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[19] + 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 ListCategoriesRequest.ProtoReflect.Descriptor instead. +func (*ListCategoriesRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{19} +} + +type ListCategoriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Categories []string `protobuf:"bytes,1,rep,name=categories,proto3" json:"categories,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCategoriesResponse) Reset() { + *x = ListCategoriesResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCategoriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCategoriesResponse) ProtoMessage() {} + +func (x *ListCategoriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[20] + 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 ListCategoriesResponse.ProtoReflect.Descriptor instead. +func (*ListCategoriesResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{20} +} + +func (x *ListCategoriesResponse) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +type CreateCategoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Category string `protobuf:"bytes,1,opt,name=category,proto3" json:"category,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCategoryRequest) Reset() { + *x = CreateCategoryRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCategoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCategoryRequest) ProtoMessage() {} + +func (x *CreateCategoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[21] + 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 CreateCategoryRequest.ProtoReflect.Descriptor instead. +func (*CreateCategoryRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{21} +} + +func (x *CreateCategoryRequest) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +type CreateCategoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCategoryResponse) Reset() { + *x = CreateCategoryResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCategoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCategoryResponse) ProtoMessage() {} + +func (x *CreateCategoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[22] + 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 CreateCategoryResponse.ProtoReflect.Descriptor instead. +func (*CreateCategoryResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{22} +} + +type SearchByTagRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchByTagRequest) Reset() { + *x = SearchByTagRequest{} + mi := &file_exo_v1_artifacts_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchByTagRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchByTagRequest) ProtoMessage() {} + +func (x *SearchByTagRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[23] + 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 SearchByTagRequest.ProtoReflect.Descriptor instead. +func (*SearchByTagRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{23} +} + +func (x *SearchByTagRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type SearchByTagResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ArtifactIds []string `protobuf:"bytes,1,rep,name=artifact_ids,json=artifactIds,proto3" json:"artifact_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchByTagResponse) Reset() { + *x = SearchByTagResponse{} + mi := &file_exo_v1_artifacts_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchByTagResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchByTagResponse) ProtoMessage() {} + +func (x *SearchByTagResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_artifacts_proto_msgTypes[24] + 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 SearchByTagResponse.ProtoReflect.Descriptor instead. +func (*SearchByTagResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_artifacts_proto_rawDescGZIP(), []int{24} +} + +func (x *SearchByTagResponse) GetArtifactIds() []string { + if x != nil { + return x.ArtifactIds + } + return nil +} + +var File_exo_v1_artifacts_proto protoreflect.FileDescriptor + +const file_exo_v1_artifacts_proto_rawDesc = "" + + "\n" + + "\x16exo/v1/artifacts.proto\x12\x06exo.v1\x1a\x13exo/v1/common.proto\"I\n" + + "\tPublisher\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + + "\aaddress\x18\x03 \x01(\tR\aaddress\"\xa6\x02\n" + + "\bCitation\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03doi\x18\x02 \x01(\tR\x03doi\x12\x14\n" + + "\x05title\x18\x03 \x01(\tR\x05title\x12\x12\n" + + "\x04year\x18\x04 \x01(\x05R\x04year\x12\x1c\n" + + "\tpublished\x18\x05 \x01(\tR\tpublished\x12\x18\n" + + "\aauthors\x18\x06 \x03(\tR\aauthors\x12/\n" + + "\tpublisher\x18\a \x01(\v2\x11.exo.v1.PublisherR\tpublisher\x12\x16\n" + + "\x06source\x18\b \x01(\tR\x06source\x12\x1a\n" + + "\babstract\x18\t \x01(\tR\babstract\x121\n" + + "\bmetadata\x18\n" + + " \x03(\v2\x15.exo.v1.MetadataEntryR\bmetadata\"R\n" + + "\aBlobRef\x12\x1f\n" + + "\vsnapshot_id\x18\x01 \x01(\tR\n" + + "snapshotId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12\x16\n" + + "\x06format\x18\x03 \x01(\tR\x06format\"\x94\x02\n" + + "\bSnapshot\x12\x1f\n" + + "\vartifact_id\x18\x01 \x01(\tR\n" + + "artifactId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12\x1b\n" + + "\tstored_at\x18\x03 \x01(\x03R\bstoredAt\x12\x1a\n" + + "\bdatetime\x18\x04 \x01(\tR\bdatetime\x12,\n" + + "\bcitation\x18\x05 \x01(\v2\x10.exo.v1.CitationR\bcitation\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x12%\n" + + "\x05blobs\x18\a \x03(\v2\x0f.exo.v1.BlobRefR\x05blobs\x121\n" + + "\bmetadata\x18\b \x03(\v2\x15.exo.v1.MetadataEntryR\bmetadata\"\x8b\x02\n" + + "\bArtifact\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12,\n" + + "\bcitation\x18\x03 \x01(\v2\x10.exo.v1.CitationR\bcitation\x12\x16\n" + + "\x06latest\x18\x04 \x01(\tR\x06latest\x12.\n" + + "\tsnapshots\x18\x05 \x03(\v2\x10.exo.v1.SnapshotR\tsnapshots\x12\x12\n" + + "\x04tags\x18\x06 \x03(\tR\x04tags\x12\x1e\n" + + "\n" + + "categories\x18\a \x03(\tR\n" + + "categories\x121\n" + + "\bmetadata\x18\b \x03(\v2\x15.exo.v1.MetadataEntryR\bmetadata\"E\n" + + "\x15CreateArtifactRequest\x12,\n" + + "\bartifact\x18\x01 \x01(\v2\x10.exo.v1.ArtifactR\bartifact\"(\n" + + "\x16CreateArtifactResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"$\n" + + "\x12GetArtifactRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"C\n" + + "\x13GetArtifactResponse\x12,\n" + + "\bartifact\x18\x01 \x01(\v2\x10.exo.v1.ArtifactR\bartifact\"'\n" + + "\x15DeleteArtifactRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x18\n" + + "\x16DeleteArtifactResponse\"_\n" + + "\x10StoreBlobRequest\x12\x1f\n" + + "\vsnapshot_id\x18\x01 \x01(\tR\n" + + "snapshotId\x12\x16\n" + + "\x06format\x18\x02 \x01(\tR\x06format\x12\x12\n" + + "\x04data\x18\x03 \x01(\fR\x04data\"#\n" + + "\x11StoreBlobResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\" \n" + + "\x0eGetBlobRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"=\n" + + "\x0fGetBlobResponse\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\x16\n" + + "\x06format\x18\x02 \x01(\tR\x06format\"\x11\n" + + "\x0fListTagsRequest\"&\n" + + "\x10ListTagsResponse\x12\x12\n" + + "\x04tags\x18\x01 \x03(\tR\x04tags\"$\n" + + "\x10CreateTagRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\"\x13\n" + + "\x11CreateTagResponse\"\x17\n" + + "\x15ListCategoriesRequest\"8\n" + + "\x16ListCategoriesResponse\x12\x1e\n" + + "\n" + + "categories\x18\x01 \x03(\tR\n" + + "categories\"3\n" + + "\x15CreateCategoryRequest\x12\x1a\n" + + "\bcategory\x18\x01 \x01(\tR\bcategory\"\x18\n" + + "\x16CreateCategoryResponse\"&\n" + + "\x12SearchByTagRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\"8\n" + + "\x13SearchByTagResponse\x12!\n" + + "\fartifact_ids\x18\x01 \x03(\tR\vartifactIds2\xe4\x05\n" + + "\x0fArtifactService\x12O\n" + + "\x0eCreateArtifact\x12\x1d.exo.v1.CreateArtifactRequest\x1a\x1e.exo.v1.CreateArtifactResponse\x12F\n" + + "\vGetArtifact\x12\x1a.exo.v1.GetArtifactRequest\x1a\x1b.exo.v1.GetArtifactResponse\x12O\n" + + "\x0eDeleteArtifact\x12\x1d.exo.v1.DeleteArtifactRequest\x1a\x1e.exo.v1.DeleteArtifactResponse\x12@\n" + + "\tStoreBlob\x12\x18.exo.v1.StoreBlobRequest\x1a\x19.exo.v1.StoreBlobResponse\x12:\n" + + "\aGetBlob\x12\x16.exo.v1.GetBlobRequest\x1a\x17.exo.v1.GetBlobResponse\x12=\n" + + "\bListTags\x12\x17.exo.v1.ListTagsRequest\x1a\x18.exo.v1.ListTagsResponse\x12@\n" + + "\tCreateTag\x12\x18.exo.v1.CreateTagRequest\x1a\x19.exo.v1.CreateTagResponse\x12O\n" + + "\x0eListCategories\x12\x1d.exo.v1.ListCategoriesRequest\x1a\x1e.exo.v1.ListCategoriesResponse\x12O\n" + + "\x0eCreateCategory\x12\x1d.exo.v1.CreateCategoryRequest\x1a\x1e.exo.v1.CreateCategoryResponse\x12F\n" + + "\vSearchByTag\x12\x1a.exo.v1.SearchByTagRequest\x1a\x1b.exo.v1.SearchByTagResponseB\x83\x01\n" + + "\n" + + "com.exo.v1B\x0eArtifactsProtoP\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_artifacts_proto_rawDescOnce sync.Once + file_exo_v1_artifacts_proto_rawDescData []byte +) + +func file_exo_v1_artifacts_proto_rawDescGZIP() []byte { + file_exo_v1_artifacts_proto_rawDescOnce.Do(func() { + file_exo_v1_artifacts_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_exo_v1_artifacts_proto_rawDesc), len(file_exo_v1_artifacts_proto_rawDesc))) + }) + return file_exo_v1_artifacts_proto_rawDescData +} + +var file_exo_v1_artifacts_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_exo_v1_artifacts_proto_goTypes = []any{ + (*Publisher)(nil), // 0: exo.v1.Publisher + (*Citation)(nil), // 1: exo.v1.Citation + (*BlobRef)(nil), // 2: exo.v1.BlobRef + (*Snapshot)(nil), // 3: exo.v1.Snapshot + (*Artifact)(nil), // 4: exo.v1.Artifact + (*CreateArtifactRequest)(nil), // 5: exo.v1.CreateArtifactRequest + (*CreateArtifactResponse)(nil), // 6: exo.v1.CreateArtifactResponse + (*GetArtifactRequest)(nil), // 7: exo.v1.GetArtifactRequest + (*GetArtifactResponse)(nil), // 8: exo.v1.GetArtifactResponse + (*DeleteArtifactRequest)(nil), // 9: exo.v1.DeleteArtifactRequest + (*DeleteArtifactResponse)(nil), // 10: exo.v1.DeleteArtifactResponse + (*StoreBlobRequest)(nil), // 11: exo.v1.StoreBlobRequest + (*StoreBlobResponse)(nil), // 12: exo.v1.StoreBlobResponse + (*GetBlobRequest)(nil), // 13: exo.v1.GetBlobRequest + (*GetBlobResponse)(nil), // 14: exo.v1.GetBlobResponse + (*ListTagsRequest)(nil), // 15: exo.v1.ListTagsRequest + (*ListTagsResponse)(nil), // 16: exo.v1.ListTagsResponse + (*CreateTagRequest)(nil), // 17: exo.v1.CreateTagRequest + (*CreateTagResponse)(nil), // 18: exo.v1.CreateTagResponse + (*ListCategoriesRequest)(nil), // 19: exo.v1.ListCategoriesRequest + (*ListCategoriesResponse)(nil), // 20: exo.v1.ListCategoriesResponse + (*CreateCategoryRequest)(nil), // 21: exo.v1.CreateCategoryRequest + (*CreateCategoryResponse)(nil), // 22: exo.v1.CreateCategoryResponse + (*SearchByTagRequest)(nil), // 23: exo.v1.SearchByTagRequest + (*SearchByTagResponse)(nil), // 24: exo.v1.SearchByTagResponse + (*MetadataEntry)(nil), // 25: exo.v1.MetadataEntry +} +var file_exo_v1_artifacts_proto_depIdxs = []int32{ + 0, // 0: exo.v1.Citation.publisher:type_name -> exo.v1.Publisher + 25, // 1: exo.v1.Citation.metadata:type_name -> exo.v1.MetadataEntry + 1, // 2: exo.v1.Snapshot.citation:type_name -> exo.v1.Citation + 2, // 3: exo.v1.Snapshot.blobs:type_name -> exo.v1.BlobRef + 25, // 4: exo.v1.Snapshot.metadata:type_name -> exo.v1.MetadataEntry + 1, // 5: exo.v1.Artifact.citation:type_name -> exo.v1.Citation + 3, // 6: exo.v1.Artifact.snapshots:type_name -> exo.v1.Snapshot + 25, // 7: exo.v1.Artifact.metadata:type_name -> exo.v1.MetadataEntry + 4, // 8: exo.v1.CreateArtifactRequest.artifact:type_name -> exo.v1.Artifact + 4, // 9: exo.v1.GetArtifactResponse.artifact:type_name -> exo.v1.Artifact + 5, // 10: exo.v1.ArtifactService.CreateArtifact:input_type -> exo.v1.CreateArtifactRequest + 7, // 11: exo.v1.ArtifactService.GetArtifact:input_type -> exo.v1.GetArtifactRequest + 9, // 12: exo.v1.ArtifactService.DeleteArtifact:input_type -> exo.v1.DeleteArtifactRequest + 11, // 13: exo.v1.ArtifactService.StoreBlob:input_type -> exo.v1.StoreBlobRequest + 13, // 14: exo.v1.ArtifactService.GetBlob:input_type -> exo.v1.GetBlobRequest + 15, // 15: exo.v1.ArtifactService.ListTags:input_type -> exo.v1.ListTagsRequest + 17, // 16: exo.v1.ArtifactService.CreateTag:input_type -> exo.v1.CreateTagRequest + 19, // 17: exo.v1.ArtifactService.ListCategories:input_type -> exo.v1.ListCategoriesRequest + 21, // 18: exo.v1.ArtifactService.CreateCategory:input_type -> exo.v1.CreateCategoryRequest + 23, // 19: exo.v1.ArtifactService.SearchByTag:input_type -> exo.v1.SearchByTagRequest + 6, // 20: exo.v1.ArtifactService.CreateArtifact:output_type -> exo.v1.CreateArtifactResponse + 8, // 21: exo.v1.ArtifactService.GetArtifact:output_type -> exo.v1.GetArtifactResponse + 10, // 22: exo.v1.ArtifactService.DeleteArtifact:output_type -> exo.v1.DeleteArtifactResponse + 12, // 23: exo.v1.ArtifactService.StoreBlob:output_type -> exo.v1.StoreBlobResponse + 14, // 24: exo.v1.ArtifactService.GetBlob:output_type -> exo.v1.GetBlobResponse + 16, // 25: exo.v1.ArtifactService.ListTags:output_type -> exo.v1.ListTagsResponse + 18, // 26: exo.v1.ArtifactService.CreateTag:output_type -> exo.v1.CreateTagResponse + 20, // 27: exo.v1.ArtifactService.ListCategories:output_type -> exo.v1.ListCategoriesResponse + 22, // 28: exo.v1.ArtifactService.CreateCategory:output_type -> exo.v1.CreateCategoryResponse + 24, // 29: exo.v1.ArtifactService.SearchByTag:output_type -> exo.v1.SearchByTagResponse + 20, // [20:30] is the sub-list for method output_type + 10, // [10:20] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name +} + +func init() { file_exo_v1_artifacts_proto_init() } +func file_exo_v1_artifacts_proto_init() { + if File_exo_v1_artifacts_proto != nil { + return + } + file_exo_v1_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_exo_v1_artifacts_proto_rawDesc), len(file_exo_v1_artifacts_proto_rawDesc)), + NumEnums: 0, + NumMessages: 25, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_exo_v1_artifacts_proto_goTypes, + DependencyIndexes: file_exo_v1_artifacts_proto_depIdxs, + MessageInfos: file_exo_v1_artifacts_proto_msgTypes, + }.Build() + File_exo_v1_artifacts_proto = out.File + file_exo_v1_artifacts_proto_goTypes = nil + file_exo_v1_artifacts_proto_depIdxs = nil +} diff --git a/proto/exo/v1/artifacts.proto b/proto/exo/v1/artifacts.proto new file mode 100644 index 0000000..c57603e --- /dev/null +++ b/proto/exo/v1/artifacts.proto @@ -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); +} diff --git a/proto/exo/v1/artifacts_grpc.pb.go b/proto/exo/v1/artifacts_grpc.pb.go new file mode 100644 index 0000000..d0ccfb5 --- /dev/null +++ b/proto/exo/v1/artifacts_grpc.pb.go @@ -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", +} diff --git a/proto/exo/v1/common.pb.go b/proto/exo/v1/common.pb.go new file mode 100644 index 0000000..3ef2ff1 --- /dev/null +++ b/proto/exo/v1/common.pb.go @@ -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 +} diff --git a/proto/exo/v1/common.proto b/proto/exo/v1/common.proto new file mode 100644 index 0000000..0893c7e --- /dev/null +++ b/proto/exo/v1/common.proto @@ -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; +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..c52790b --- /dev/null +++ b/server/server.go @@ -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 +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..bf20443 --- /dev/null +++ b/server/server_test.go @@ -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") + } +}