Vault

MongoDB Store

Document-oriented MongoDB backend using Grove ORM with mongodriver.

The MongoDB store (herald/store/mongo) provides a document-oriented backend for Herald using Grove ORM with mongodriver. It maps Herald entities to 7 MongoDB collections with compound indexes, uses dual grove/bson struct tags, and supports upsert via $setOnInsert/$set for idempotent writes.

Installation

go get github.com/xraph/herald
go get github.com/xraph/grove
go get github.com/xraph/grove/drivers/mongodriver
go get go.mongodb.org/mongo-driver/v2

Creating a Store

import (
    "github.com/xraph/grove"
    "github.com/xraph/grove/drivers/mongodriver"
    "github.com/xraph/herald/store/mongo"
)

// Open a MongoDB connection via Grove.
db := grove.Open(mongodriver.New(
    mongodriver.WithURI("mongodb://localhost:27017"),
    mongodriver.WithDatabase("herald"),
))

// Create the Herald store.
store := mongo.New(db)

// Run migrations (creates indexes).
if err := store.Migrate(ctx); err != nil {
    log.Fatal("migration failed:", err)
}

Constructor

func New(db *grove.DB) *Store

New accepts a *grove.DB backed by mongodriver and returns a *Store that satisfies the store.Store interface. Internally it calls mongodriver.Unwrap(db) to access the MongoDB-specific operations.

Collections

The store uses 7 collections, defined as constants:

CollectionEntityPrimary Key Prefix
herald_providersProvider configurationhpvd_
herald_templatesTemplate definitionshtpl_
herald_template_versionsLocale-specific contenthtpv_
herald_messagesDelivery loghmsg_
herald_inboxIn-app notificationshinb_
herald_preferencesUser preferenceshprf_
herald_scoped_configsScoped provider overrideshscf_

Indexes

The Migrate method creates the following indexes on each collection:

herald_providers

  • Compound index: {app_id: 1, channel: 1}

herald_templates

  • Unique compound index: {app_id: 1, slug: 1, channel: 1}

herald_template_versions

  • Unique compound index: {template_id: 1, locale: 1}

herald_messages

  • Compound index: {app_id: 1, status: 1}
  • Descending index: {created_at: -1}

herald_inbox

  • Compound index: {user_id: 1, read: 1, created_at: -1}
  • Compound index: {app_id: 1, user_id: 1}

herald_preferences

  • Unique compound index: {app_id: 1, user_id: 1}

herald_scoped_configs

  • Unique compound index: {app_id: 1, scope: 1, scope_id: 1}

BSON Document Mapping

MongoDB model structs carry dual struct tags -- grove for the ORM layer and bson for the MongoDB driver:

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

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

Key differences from the SQL stores:

  • The id field maps to _id in BSON (MongoDB's document primary key).
  • Maps (map[string]string) are stored as native BSON documents rather than JSON text.
  • json.RawMessage fields (like variables and overrides) remain as json.RawMessage but are stored as raw BSON.
  • Optional time fields use bson:"field,omitempty" to avoid storing zero timestamps.

Upsert Patterns

The MongoDB store uses $setOnInsert/$set for upsert operations on preferences and scoped configs:

// Preference upsert
_, err := s.mdb.NewUpdate(m).
    Filter(bson.M{"app_id": m.AppID, "user_id": m.UserID}).
    SetUpdate(bson.M{
        "$setOnInsert": bson.M{
            "_id":        m.ID,
            "app_id":     m.AppID,
            "user_id":    m.UserID,
            "created_at": m.CreatedAt,
        },
        "$set": bson.M{
            "overrides":  m.Overrides,
            "updated_at": m.UpdatedAt,
        },
    }).
    Upsert().
    Exec(ctx)

This ensures that on insert, the immutable fields (_id, app_id, user_id, created_at) are set once, while mutable fields are always updated.

Migration System

Unlike the SQL backends that use exec.Exec(ctx, sql), the MongoDB migrations use mongomigrate.Executor with collection-aware operations:

Up: func(ctx context.Context, exec migrate.Executor) error {
    mexec, ok := exec.(*mongomigrate.Executor)
    if !ok {
        return fmt.Errorf("expected mongomigrate executor, got %T", exec)
    }
    if err := mexec.CreateCollection(ctx, (*providerModel)(nil)); err != nil {
        return err
    }
    return mexec.CreateIndexes(ctx, "herald_providers", []mongo.IndexModel{
        {Keys: bson.D{{Key: "app_id", Value: 1}, {Key: "channel", Value: 1}}},
    })
},

The Migrate method on the store also provides a simpler path that creates indexes directly using the MongoDB driver, without going through the migration orchestrator.

Lifecycle Methods

MethodBehaviour
Migrate(ctx)Creates all 7 collections with their indexes
Ping(ctx)Calls db.Ping(ctx) to verify MongoDB connectivity
Close()Calls db.Close() to disconnect from MongoDB

Connection Setup Example

package main

import (
    "context"
    "log"
    "os"

    "github.com/xraph/grove"
    "github.com/xraph/grove/drivers/mongodriver"
    "github.com/xraph/herald"
    mongostore "github.com/xraph/herald/store/mongo"
)

func main() {
    ctx := context.Background()

    uri := os.Getenv("MONGODB_URI")
    if uri == "" {
        uri = "mongodb://localhost:27017"
    }

    db := grove.Open(mongodriver.New(
        mongodriver.WithURI(uri),
        mongodriver.WithDatabase("herald"),
    ))

    store := mongostore.New(db)
    if err := store.Migrate(ctx); err != nil {
        log.Fatal("migrate:", err)
    }
    if err := store.Ping(ctx); err != nil {
        log.Fatal("ping:", err)
    }

    h, err := herald.New(
        herald.WithStore(store),
    )
    if err != nil {
        log.Fatal("herald:", err)
    }
    defer store.Close()

    _ = h
}

When to Use

  • Document-oriented workloads -- flexible schemas that evolve with your application.
  • Existing MongoDB infrastructure -- leverage your current MongoDB cluster.
  • Horizontal scaling -- MongoDB sharding for large-scale notification workloads.
  • Cloud-native deployments -- MongoDB Atlas for fully managed document storage.

Considerations

  • No foreign key cascades -- the store manually deletes template versions before deleting templates.
  • Eventual consistency -- MongoDB's default read/write concern may not provide immediate consistency for all operations. Configure write concern as needed.
  • TypeID as _id -- Herald uses string TypeIDs as _id rather than MongoDB's native ObjectId, ensuring consistent identity across all backends.

On this page