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)
ErrNotFoundif 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 durationttl == 0: No expiration- BoltDB: Returns
ErrTTLNotSupportedif 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 Type | Save Hooks | Load Hooks | Delete Hooks |
|---|---|---|---|
| StoreT | Set, SetBatch | Get, GetBatch | Delete |
| BucketT | Put | Get | Delete |
| DatabaseT | Set, SetTx, Insert, InsertFull (all builder paths) | Get, GetTx, Query, Select, Modify, ExecQuery, ExecSelect, ExecUpdate (and Tx variants) | Delete, DeleteTx |
| IndexT | Upsert, UpsertBatch | Get, Search, Query, Filter | Delete, DeleteBatch |
Batch Behavior
For batch operations, hooks fire per-item:
- SetBatch:
BeforeSaveruns on each item before encoding. If any fails, the entire batch is aborted.AfterSaveruns on each item after the batch succeeds. - GetBatch:
AfterLoadruns on each decoded item. - UpsertBatch:
BeforeSaveruns 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
}