zoobzio January 27, 2026 Edit this page

Lifecycle Operations

This guide covers the full lifecycle of data: create, read, update, delete, and list operations.

Store Operations (Key-Value)

Get

Retrieves a value by key.

session, err := store.Get(ctx, "session:abc123")
if errors.Is(err, grub.ErrNotFound) {
    // Key doesn't exist
}

Returns: (*T, error)

  • ErrNotFound if key doesn't exist

Set

Stores a value with optional TTL.

// With TTL (expires in 1 hour)
err := store.Set(ctx, "session:abc123", &session, time.Hour)

// Without TTL (never expires)
err := store.Set(ctx, "config:app", &config, 0)

TTL behavior:

  • ttl > 0: Key expires after duration
  • ttl == 0: No expiration
  • BoltDB: Returns ErrTTLNotSupported if ttl > 0

Delete

Removes a key.

err := store.Delete(ctx, "session:abc123")
if errors.Is(err, grub.ErrNotFound) {
    // Key didn't exist
}

Returns: ErrNotFound if key doesn't exist

Exists

Checks if a key exists without loading the value.

exists, err := store.Exists(ctx, "session:abc123")
if exists {
    // Key exists
}

Returns: (bool, error) — never returns ErrNotFound

List

Lists keys matching a prefix.

// List up to 100 keys with prefix "session:"
keys, err := store.List(ctx, "session:", 100)

// List all keys (no limit)
keys, err := store.List(ctx, "", 0)

Parameters:

  • prefix: Filter keys starting with this string (empty = all)
  • limit: Maximum keys to return (0 = no limit)

GetBatch

Retrieves multiple keys at once.

keys := []string{"user:1", "user:2", "user:3"}
results, err := store.GetBatch(ctx, keys)

for key, user := range results {
    fmt.Println(key, user.Name)
}

Behavior: Missing keys are omitted from the result map (no error).

SetBatch

Stores multiple values at once.

items := map[string]*User{
    "user:1": {Name: "Alice"},
    "user:2": {Name: "Bob"},
}
err := store.SetBatch(ctx, items, time.Hour)

Atomicity varies by provider:

  • Redis: Pipelined (each operation independent)
  • Badger: WriteBatch (atomic)
  • Bolt: Single transaction (atomic)

Bucket Operations (Blob)

Get

Retrieves an object with metadata.

obj, err := bucket.Get(ctx, "docs/report.json")
if errors.Is(err, grub.ErrNotFound) {
    // Object doesn't exist
}

// Access payload
fmt.Println(obj.Data.Title)

// Access metadata
fmt.Println(obj.ContentType)
fmt.Println(obj.Size)
fmt.Println(obj.Metadata["author"])

Put

Stores an object with metadata.

err := bucket.Put(ctx, &grub.Object[Document]{
    Key:         "docs/report.json",
    ContentType: "application/json",
    Metadata:    map[string]string{"author": "alice", "version": "1.0"},
    Data:        Document{Title: "Q4 Report", Content: "..."},
})

Note: Size is computed from encoded data, not set manually.

Delete

Removes an object.

err := bucket.Delete(ctx, "docs/report.json")
if errors.Is(err, grub.ErrNotFound) {
    // Object didn't exist
}

Exists

Checks if an object exists.

exists, err := bucket.Exists(ctx, "docs/report.json")

List

Lists objects matching a prefix (metadata only).

infos, err := bucket.List(ctx, "docs/", 100)

for _, info := range infos {
    fmt.Printf("%s (%d bytes)\n", info.Key, info.Size)
}

Returns: []ObjectInfo — metadata without payload

Database Operations (SQL)

Get

Retrieves a record by primary key.

user, err := db.Get(ctx, "123")
if errors.Is(err, grub.ErrNotFound) {
    // Record doesn't exist
}

Set

Upserts a record (insert or update on conflict).

// Insert new record
err := db.Set(ctx, "123", &User{ID: "123", Name: "Alice"})

// Update existing record (same key)
err := db.Set(ctx, "123", &User{ID: "123", Name: "Alice Smith"})

Behavior: Always upserts. Uses INSERT ... ON CONFLICT DO UPDATE.

Delete

Removes a record by primary key.

err := db.Delete(ctx, "123")
if errors.Is(err, grub.ErrNotFound) {
    // Record didn't exist
}

Exists

Checks if a record exists.

exists, err := db.Exists(ctx, "123")

Query Builder

Returns a query builder for fetching multiple records.

// Using the query builder
users, err := db.Query().
    Where("status", "=", "active").
    Exec(ctx, map[string]any{"active": "enabled"})

// With parameters
users, err := db.Query().
    Where("role", "=", "role").
    Exec(ctx, map[string]any{"role": "admin"})

Select Builder

Returns a select builder for fetching a single record.

user, err := db.Select().
    Where("email", "=", "email").
    Exec(ctx, map[string]any{"email": "alice@example.com"})

Modify Builder

Returns an update builder for modifying records.

user, err := db.Modify().
    Set("status", "new_status").
    Where("id", "=", "user_id").
    Exec(ctx, map[string]any{"new_status": "inactive", "user_id": "123"})

Statement Execution

Execute pre-defined edamame statements.

// Query all records
users, err := db.ExecQuery(ctx, grub.QueryAll, nil)

// Count records
count, err := db.ExecAggregate(ctx, grub.CountAll, nil)

Lifecycle Hooks

Types can opt into lifecycle hooks that fire automatically during CRUD operations. Implement one or more hook interfaces on your type to add validation, normalization, or side-effects.

Hook Interfaces

type BeforeSave interface {
    BeforeSave(ctx context.Context) error
}

type AfterSave interface {
    AfterSave(ctx context.Context) error
}

type AfterLoad interface {
    AfterLoad(ctx context.Context) error
}

type BeforeDelete interface {
    BeforeDelete(ctx context.Context) error
}

type AfterDelete interface {
    AfterDelete(ctx context.Context) error
}

Example: Validation and Normalization

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

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

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

These hooks fire automatically on all typed operations:

// BeforeSave fires before the write — returns error if email is empty
err := store.Set(ctx, "user:1", &User{Name: "Alice"}, 0)
// err: "email is required"

// AfterLoad fires after decode — email is normalized
user, err := store.Get(ctx, "user:1")
// user.Email is lowercase

Hook Firing Points

Storage TypeSave HooksLoad HooksDelete Hooks
StoreTSet, SetBatchGet, GetBatchDelete
BucketTPutGetDelete
DatabaseTSet, SetTx, Insert, InsertFull (all builder paths)Get, GetTx, Query, Select, Modify, ExecQuery, ExecSelect, ExecUpdate (and Tx variants)Delete, DeleteTx
IndexTUpsert, UpsertBatchGet, Search, Query, FilterDelete, DeleteBatch

Batch Behavior

For batch operations, hooks fire per-item:

  • SetBatch: BeforeSave runs on each item before encoding. If any fails, the entire batch is aborted. AfterSave runs on each item after the batch succeeds.
  • GetBatch: AfterLoad runs on each decoded item.
  • UpsertBatch: BeforeSave runs on each metadata item before encoding.

Delete Hooks

Delete hooks are invoked on a zero-value T because delete operations only receive a key/ID. They act as static guards or side-effects:

func (u *User) BeforeDelete(ctx context.Context) error {
    // No instance state available — use for static guards
    return nil
}

What Hooks Don't Cover

  • Atomic views do not trigger hooks (they operate below the type-aware layer)
  • ExecAggregate does not trigger hooks (returns float64, not *T)
  • List/Exists operations do not trigger hooks (no T instance involved)
  • Remove (delete builder) does not trigger hooks (returns int64, not *T)

Common Patterns

Check-Then-Act

exists, _ := store.Exists(ctx, key)
if !exists {
    // Create default
    store.Set(ctx, key, defaultValue, 0)
}

Warning: Not atomic. For atomic operations, use provider-specific features.

Get-Or-Create

func GetOrCreate[T any](ctx context.Context, store *grub.Store[T], key string, create func() *T) (*T, error) {
    val, err := store.Get(ctx, key)
    if err == nil {
        return val, nil
    }
    if !errors.Is(err, grub.ErrNotFound) {
        return nil, err
    }

    val = create()
    if err := store.Set(ctx, key, val, 0); err != nil {
        return nil, err
    }
    return val, nil
}

Batch Processing

// Process in batches to avoid memory issues
const batchSize = 100

keys, _ := store.List(ctx, "user:", 0)

for i := 0; i < len(keys); i += batchSize {
    end := min(i+batchSize, len(keys))
    batch := keys[i:end]

    results, _ := store.GetBatch(ctx, batch)
    for key, user := range results {
        // Process user
    }
}

Conditional Delete

// Delete only if value matches condition
val, err := store.Get(ctx, key)
if err != nil {
    return err
}
if val.Status == "expired" {
    return store.Delete(ctx, key)
}

Error Handling

Standard Error Checks

val, err := store.Get(ctx, key)
switch {
case err == nil:
    // Success
case errors.Is(err, grub.ErrNotFound):
    // Key doesn't exist
case errors.Is(err, context.DeadlineExceeded):
    // Timeout
case errors.Is(err, context.Canceled):
    // Canceled
default:
    // Provider error (network, etc.)
}

Wrapping Errors

val, err := store.Get(ctx, key)
if err != nil {
    return fmt.Errorf("loading config %s: %w", key, err)
}

The wrapped error preserves errors.Is behavior:

if errors.Is(err, grub.ErrNotFound) {
    // Still works
}