Files
BountyBoard/internal/store/store.go
T
etalon f2c534f636 feat: store layer with indexes, optimistic concurrency, GridFS, credential crypto (phase 2)
- mongo-driver v2 connection with bounded startup retry + /readyz gate
- idempotent creation of all §4.9 indexes
- UpdateVersioned: version-filtered updates, conflict vs not-found errors
- AES-256-GCM Seal/Open for ticketing credentials, base64(nonce|ct)
- GridFS file store: MIME sniffing, MAX_UPLOAD_MB cap, sha256 metadata
- HMAC-signed short-lived file URL tokens (§5.1, 1h TTL)
- integration tests against compose Mongo via docker-compose.test.yml overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:17:26 +02:00

74 lines
2.0 KiB
Go

// 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)
}