// Package store owns all MongoDB access: connection lifecycle, index // bootstrap, optimistic-concurrency updates, and (via internal/files) GridFS. // No other package talks to Mongo directly. package store import ( "context" "fmt" "log/slog" "time" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/readpref" "bountyboard/internal/config" ) type Store struct { Client *mongo.Client DB *mongo.Database } // Connect builds the client (the driver dials lazily) and verifies // connectivity with a bounded retry so a compose race at boot does not kill // the app, then ensures all indexes. func Connect(ctx context.Context, cfg *config.Config, log *slog.Logger) (*Store, error) { opts := options.Client(). ApplyURI(cfg.MongoURI). SetAppName("bountyboard"). SetServerSelectionTimeout(5 * time.Second) if cfg.MongoUsername != "" { opts.SetAuth(options.Credential{Username: cfg.MongoUsername, Password: cfg.MongoPassword}) } client, err := mongo.Connect(opts) if err != nil { return nil, fmt.Errorf("mongo connect: %w", err) } s := &Store{Client: client, DB: client.Database(cfg.MongoDB)} const attempts = 6 for i := 1; ; i++ { err = s.Ping(ctx) if err == nil { break } if i == attempts || ctx.Err() != nil { return nil, fmt.Errorf("mongo ping (%d attempts): %w", i, err) } log.Warn("mongo not ready, retrying", "attempt", i, "err", err) select { case <-time.After(time.Duration(i) * time.Second): case <-ctx.Done(): return nil, ctx.Err() } } if err := EnsureIndexes(ctx, s.DB); err != nil { return nil, fmt.Errorf("ensure indexes: %w", err) } log.Info("mongo connected", "db", cfg.MongoDB) return s, nil } func (s *Store) Ping(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() return s.Client.Ping(ctx, readpref.Primary()) } func (s *Store) Close(ctx context.Context) error { return s.Client.Disconnect(ctx) }