Vault

Grove ORM Integration

How Herald uses Grove as its ORM layer across all database backends.

Herald uses Grove ORM as its unified database abstraction layer. All three relational and document store backends (PostgreSQL, SQLite, MongoDB) share the same store.Store interface, with Grove handling the driver-specific differences under the hood.

Architecture

Grove provides Herald with:

  • Driver auto-detection -- The Forge extension detects the Grove driver name (pg, sqlite, mongo) from the connection and constructs the correct store backend automatically.
  • Migration orchestration -- Each backend registers versioned migrations via migrate.NewGroup("herald"), and Grove's Orchestrator handles version tracking, ordering, and execution.
  • BaseModel embedding -- All model structs embed grove.BaseModel with a grove:"table:xxx" tag to declare the backing table or collection.
  • Struct tag mapping -- Fields use grove:"field_name" tags for column mapping. JSONB columns use grove:"field,type:jsonb". Primary keys use grove:"id,pk".

Driver Auto-Detection

When Herald runs as a Forge extension with WithGroveDatabase, the extension resolves a *grove.DB from the DI container and inspects the driver name:

func (e *Extension) buildStoreFromGroveDB(db *grove.DB) (store.Store, error) {
    driverName := db.Driver().Name()
    switch driverName {
    case "pg":
        return pgstore.New(db), nil
    case "sqlite":
        return sqlitestore.New(db), nil
    case "mongo":
        return mongostore.New(db), nil
    default:
        return nil, fmt.Errorf("herald: unsupported grove driver %q", driverName)
    }
}

You do not need to import individual store packages when using the extension -- the correct backend is selected at runtime.

Migration System

Herald registers all 7 migrations in a single migration group named "herald". Each backend package exports a Migrations variable:

var Migrations = migrate.NewGroup("herald")

Migrations are registered in init() using Migrations.MustRegister(...). Each migration has a unique Name and a Version string (timestamp-based) that determines execution order:

VersionNameDescription
20240201000001create_herald_providersNotification provider configuration
20240201000002create_herald_templatesTemplate definitions
20240201000003create_herald_template_versionsLocale-specific template content
20240201000004create_herald_messagesDelivery log
20240201000005create_herald_inboxIn-app notifications
20240201000006create_herald_preferencesUser notification preferences
20240201000007create_herald_scoped_configsScoped provider overrides

For SQL backends (PostgreSQL, SQLite), the Up function executes DDL via exec.Exec(ctx, sql). For MongoDB, the Up function uses mongomigrate.Executor to create collections and indexes.

Running Migrations

Migrations run automatically when Herald starts as a Forge extension (unless DisableMigrate is set). For standalone usage, call Migrate explicitly:

store := postgres.New(db)
if err := store.Migrate(ctx); err != nil {
    log.Fatal("migration failed:", err)
}

The orchestrator tracks applied migrations and skips those already executed, making Migrate safe to call on every startup.

BaseModel and Struct Tags

Every store model embeds grove.BaseModel to declare the backing table:

type providerModel struct {
    grove.BaseModel `grove:"table:herald_providers"`

    ID          string            `grove:"id,pk"`
    AppID       string            `grove:"app_id"`
    Name        string            `grove:"name"`
    Channel     string            `grove:"channel"`
    Driver      string            `grove:"driver"`
    Credentials map[string]string `grove:"credentials,type:jsonb"`
    Settings    map[string]string `grove:"settings,type:jsonb"`
    Priority    int               `grove:"priority"`
    Enabled     bool              `grove:"enabled"`
    CreatedAt   time.Time         `grove:"created_at"`
    UpdatedAt   time.Time         `grove:"updated_at"`
}

Tag Reference

TagMeaning
grove:"table:herald_xxx"Declares the table/collection name (on BaseModel)
grove:"id,pk"Marks the field as the primary key
grove:"field_name"Maps to a column or document field
grove:"field,type:jsonb"Stores the field as JSONB (PostgreSQL) or JSON text (SQLite)

Model/Mapper Pattern

Each backend follows a consistent model/mapper pattern:

  1. Model struct -- database-level struct with grove tags (and bson tags for MongoDB).
  2. toXxxModel(entity) *xxxModel -- converts a domain entity to a database model.
  3. fromXxxModel(model) (*Entity, error) -- converts a database model back to a domain entity, parsing TypeIDs.

This pattern isolates the domain types from database concerns while keeping the mapping code explicit and testable.

Shared Store Interface

All three backends implement the same composite store.Store interface:

type Store interface {
    provider.Store
    template.Store
    message.Store
    inbox.Store
    preference.Store
    scope.Store

    Migrate(ctx context.Context) error
    Ping(ctx context.Context) error
    Close() error
}

This means you can swap backends without changing any application code -- only the store construction changes.

Choosing a Backend

BackendBest ForDriver Package
PostgreSQLProduction deployments, multi-instance appsgrove/drivers/pgdriver
SQLiteDevelopment, testing, single-node deploymentsgrove/drivers/sqlitedriver
MongoDBDocument-oriented workloads, existing MongoDB infrastructuregrove/drivers/mongodriver
MemoryUnit tests, ephemeral environmentsN/A (no Grove required)

On this page