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/v2Creating 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) *StoreNew 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:
| Collection | Entity | Primary Key Prefix |
|---|---|---|
herald_providers | Provider configuration | hpvd_ |
herald_templates | Template definitions | htpl_ |
herald_template_versions | Locale-specific content | htpv_ |
herald_messages | Delivery log | hmsg_ |
herald_inbox | In-app notifications | hinb_ |
herald_preferences | User preferences | hprf_ |
herald_scoped_configs | Scoped provider overrides | hscf_ |
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
idfield maps to_idin BSON (MongoDB's document primary key). - Maps (
map[string]string) are stored as native BSON documents rather than JSON text. json.RawMessagefields (likevariablesandoverrides) remain asjson.RawMessagebut 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
| Method | Behaviour |
|---|---|
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_idrather than MongoDB's nativeObjectId, ensuring consistent identity across all backends.