zoobzio January 27, 2026 Edit this page

Core Concepts

Grub has four storage modes, each with a type-safe wrapper and provider interface.

Storage Modes

ModeWrapperProvider InterfaceUse Case
Key-ValueStore[T]StoreProviderSessions, cache, config
BlobBucket[T]BucketProviderFiles, media, documents
SQLDatabase[T]edamame.FactoryStructured records
VectorIndex[T]VectorProviderSimilarity search, RAG

Stores (Key-Value)

Stores map string keys to typed values with optional TTL.

StoreProvider Interface

Providers implement raw byte operations:

type StoreProvider interface {
    Get(ctx context.Context, key string) ([]byte, error)
    Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
    Delete(ctx context.Context, key string) error
    Exists(ctx context.Context, key string) (bool, error)
    List(ctx context.Context, prefix string, limit int) ([]string, error)
    GetBatch(ctx context.Context, keys []string) (map[string][]byte, error)
    SetBatch(ctx context.Context, items map[string][]byte, ttl time.Duration) error
}

StoreT Wrapper

The wrapper adds type safety and serialization:

store := grub.NewStore[Session](redis.New(client))

// All operations are type-safe
session, err := store.Get(ctx, "key")      // Returns *Session
err = store.Set(ctx, "key", &session, ttl) // Accepts *Session
exists, err := store.Exists(ctx, "key")    // Returns bool
keys, err := store.List(ctx, "prefix:", 0) // Returns []string

TTL Behavior

  • ttl > 0 — Key expires after duration
  • ttl == 0 — No expiration
  • BoltDB does not support TTL (returns ErrTTLNotSupported)

Buckets (Blob Storage)

Buckets store objects with metadata (content type, size, custom headers).

BucketProvider Interface

type BucketProvider interface {
    Get(ctx context.Context, key string) ([]byte, *ObjectInfo, error)
    Put(ctx context.Context, key string, data []byte, info *ObjectInfo) error
    Delete(ctx context.Context, key string) error
    Exists(ctx context.Context, key string) (bool, error)
    List(ctx context.Context, prefix string, limit int) ([]ObjectInfo, error)
}

ObjectT Structure

Objects combine metadata with typed payload:

type Object[T any] struct {
    Key         string            // Required: object path
    ContentType string            // MIME type
    Size        int64             // Computed from encoded data
    ETag        string            // Version identifier
    Metadata    map[string]string // Custom headers
    Data        T                 // Your typed payload
}

BucketT Wrapper

bucket := grub.NewBucket[Document](s3.New(client, "bucket"))

// Put stores the object
err := bucket.Put(ctx, &grub.Object[Document]{
    Key:         "docs/file.json",
    ContentType: "application/json",
    Metadata:    map[string]string{"author": "alice"},
    Data:        Document{Title: "Hello"},
})

// Get returns Object with typed Data
obj, err := bucket.Get(ctx, "docs/file.json")
fmt.Println(obj.Data.Title)     // "Hello"
fmt.Println(obj.Metadata["author"]) // "alice"

ObjectInfo (Metadata Only)

For listing without loading payload:

type ObjectInfo struct {
    Key         string
    ContentType string
    Size        int64
    ETag        string
    Metadata    map[string]string
}

// List returns metadata only
infos, _ := bucket.List(ctx, "docs/", 100)
for _, info := range infos {
    fmt.Println(info.Key, info.Size)
}

Databases (SQL)

Databases provide CRUD operations on SQL tables with query capabilities.

DatabaseT Constructor

db := grub.NewDatabase[User](
    sqlxDB,        // *sqlx.DB connection
    "users",       // Table name
    sqlite.New(),  // SQL dialect renderer
)

The primary key column is derived automatically from struct tags:

type User struct {
    ID    int    `db:"id" constraints:"primarykey"`
    Email string `db:"email"`
}

Operations

// CRUD via key
user, err := db.Get(ctx, "123")
err = db.Set(ctx, "123", &User{ID: "123", Name: "Alice"})
err = db.Delete(ctx, "123")
exists, err := db.Exists(ctx, "123")

// Query builders for ad-hoc queries
users, err := db.Query().Where("status", "=", "active").Exec(ctx, map[string]any{"active": "enabled"})
user, err := db.Select().Where("email", "=", "email").Exec(ctx, map[string]any{"email": "alice@example.com"})

// Pre-defined statements via edamame
users, err := db.ExecQuery(ctx, grub.QueryAll, nil)

Set Behavior

Set performs an upsert (insert or update on conflict):

// First call inserts
db.Set(ctx, "1", &User{ID: "1", Name: "Alice"})

// Second call updates
db.Set(ctx, "1", &User{ID: "1", Name: "Alice Smith"})

Indexes (Vector)

Indexes store vectors with typed metadata for similarity search.

VectorProvider Interface

Providers implement vector storage and search:

type VectorProvider interface {
    Upsert(ctx context.Context, id string, vector []float32, metadata map[string]any) error
    UpsertBatch(ctx context.Context, vectors []VectorRecord) error
    Get(ctx context.Context, id string) ([]float32, *VectorInfo, error)
    Delete(ctx context.Context, id string) error
    DeleteBatch(ctx context.Context, ids []string) error
    Search(ctx context.Context, vector []float32, k int, filter map[string]any) ([]VectorResult, error)
    Query(ctx context.Context, vector []float32, k int, filter *vecna.Filter) ([]VectorResult, error)
    List(ctx context.Context, prefix string, limit int) ([]string, error)
    Exists(ctx context.Context, id string) (bool, error)
}

IndexT Wrapper

The wrapper adds type safety for metadata:

index := grub.NewIndex[Embedding](qdrant.New(client, qdrant.Config{
    Collection: "documents",
}))

// All operations are type-safe
err := index.Upsert(ctx, "id", vector, &Embedding{Category: "tech"})
result, err := index.Get(ctx, "id")        // Returns *Vector[Embedding]
results, err := index.Search(ctx, vec, 10, nil)  // Returns []*Vector[Embedding]

VectorT Structure

Search results include the vector, score, and typed metadata:

type Vector[T any] struct {
    ID       string    // Vector identifier
    Vector   []float32 // The embedding
    Score    float32   // Similarity score (distance)
    Metadata T         // Your typed payload
}

Query with Filters

Use vecna for type-safe filter building:

import "github.com/zoobz-io/vecna"

// Build filter
filter := vecna.And(
    vecna.Eq("category", "tech"),
    vecna.Gte("score", 0.8),
)

// Query with filter
results, err := index.Query(ctx, queryVector, 10, filter)

Note: Filter operator support varies by provider. See Provider Reference for the operator support matrix.

Lifecycle Hooks

Types can opt into lifecycle hooks by implementing one or more hook interfaces. When present, grub calls these hooks at specific points during CRUD operations across all four storage types.

Hook Interfaces

InterfaceWhen CalledEffect of Error
BeforeSaveBefore persisting TAborts the write
AfterSaveAfter successful persistSignals post-save failure
AfterLoadAfter T is loaded and decodedSignals broken data
BeforeDeleteBefore deletionAborts the delete
AfterDeleteAfter successful deletionSignals post-delete failure

All hooks receive context.Context and return error.

Usage

Implement the interface on your type with a pointer receiver:

type User struct {
    ID    string `db:"id" constraints:"primarykey"`
    Name  string `db:"name"`
    Email string `db:"email"`
}

func (u *User) BeforeSave(ctx context.Context) error {
    if u.Email == "" {
        return errors.New("email is required")
    }
    return nil
}

func (u *User) AfterLoad(ctx context.Context) error {
    u.Email = strings.ToLower(u.Email)
    return nil
}

Hooks are opt-in. Types that don't implement any hook interface work unchanged with zero overhead.

Delete Hooks

Delete hooks are invoked on a zero-value T since delete operations only have a key/ID, not a loaded instance. They act as static guards or side-effects on the type:

func (u *User) BeforeDelete(ctx context.Context) error {
    // Guard or side-effect — no instance state available
    return nil
}

Scope

Hooks fire on all typed interactions:

  • StoreT: Get, Set, Delete, GetBatch, SetBatch
  • BucketT: Get, Put, Delete (hooks fire on the T payload, not Object[T])
  • DatabaseT: Get, Set, Delete, and all query builder/statement execution results
  • IndexT: Upsert, UpsertBatch, Get, Delete, DeleteBatch, Search, Query, Filter

Atomic views do not trigger hooks — they operate below the type-aware layer.

Codecs

Codecs handle serialization between typed values and bytes.

Built-in Codecs

CodecFormatUse Case
JSONCodecJSONDefault, portable, human-readable
GobCodecGobGo-specific, more compact

Custom Codec

// Use Gob instead of JSON
store := grub.NewStoreWithCodec[Config](
    provider,
    grub.GobCodec{},
)

Codec Interface

type Codec interface {
    Encode(v any) ([]byte, error)
    Decode(data []byte, v any) error
}

Semantic Errors

All providers return consistent error types:

ErrorMeaning
ErrNotFoundRecord does not exist
ErrDuplicateKey already exists
ErrConflictConcurrent modification
ErrConstraintConstraint violation
ErrInvalidKeyKey malformed or empty
ErrReadOnlyWrite on read-only connection
ErrTTLNotSupportedProvider doesn't support TTL
ErrDimensionMismatchVector dimension doesn't match index
ErrInvalidVectorVector is malformed (nil, empty, NaN)
ErrIndexNotReadyIndex not loaded or initialized
ErrInvalidQueryFilter contains validation errors
ErrOperatorNotSupportedProvider doesn't support filter operator

Check errors with errors.Is:

user, err := store.Get(ctx, "missing")
if errors.Is(err, grub.ErrNotFound) {
    // Handle missing record
}

Atomic Views

For framework internals needing field-level access:

// Get atomic view (lazily cached)
atomicStore := store.Atomic()
atomicIndex := index.Atomic()

// Access raw atom structure
atom, err := atomicStore.Get(ctx, "key")
spec := atomicStore.Spec() // Field metadata

Warning: Atomic() panics if T is not atomizable. Check at startup.

See Architecture for details on atomic views.