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>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user