Core Concepts
Grub has four storage modes, each with a type-safe wrapper and provider interface.
Storage Modes
| Mode | Wrapper | Provider Interface | Use Case |
|---|---|---|---|
| Key-Value | Store[T] | StoreProvider | Sessions, cache, config |
| Blob | Bucket[T] | BucketProvider | Files, media, documents |
| SQL | Database[T] | edamame.Factory | Structured records |
| Vector | Index[T] | VectorProvider | Similarity 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 durationttl == 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
| Interface | When Called | Effect of Error |
|---|---|---|
BeforeSave | Before persisting T | Aborts the write |
AfterSave | After successful persist | Signals post-save failure |
AfterLoad | After T is loaded and decoded | Signals broken data |
BeforeDelete | Before deletion | Aborts the delete |
AfterDelete | After successful deletion | Signals 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
Tpayload, notObject[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
| Codec | Format | Use Case |
|---|---|---|
JSONCodec | JSON | Default, portable, human-readable |
GobCodec | Gob | Go-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:
| Error | Meaning |
|---|---|
ErrNotFound | Record does not exist |
ErrDuplicate | Key already exists |
ErrConflict | Concurrent modification |
ErrConstraint | Constraint violation |
ErrInvalidKey | Key malformed or empty |
ErrReadOnly | Write on read-only connection |
ErrTTLNotSupported | Provider doesn't support TTL |
ErrDimensionMismatch | Vector dimension doesn't match index |
ErrInvalidVector | Vector is malformed (nil, empty, NaN) |
ErrIndexNotReady | Index not loaded or initialized |
ErrInvalidQuery | Filter contains validation errors |
ErrOperatorNotSupported | Provider 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.