Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 8s

- internal/postgres: pgx-over-database/sql pool (otelsql), embedded goose
  migrations into schema 'backend', committed go-jet code + cmd/jetgen tool.
- internal/account: durable accounts + unified telegram/email identities
  (UUIDv7 keys), find-or-create provisioning with unique-conflict handling.
- internal/session: opaque 256-bit tokens stored as a SHA-256 hash, revoke-only
  (no TTL); write-through cache gating /readyz; store + service.
- internal/telemetry: OTel tracer/meter providers (none/stdout) + request-timing
  middleware; internal/config gains Postgres + OTel env loading.
- internal/server: /api/v1 {public,user,internal,admin} skeleton + X-User-ID
  middleware; /readyz checks DB ping + cache; main wires
  telemetry -> db+migrate -> warm cache -> server.
- Tests: unit + integration (build tag 'integration', testcontainers
  postgres:17) for migrations, accounts, sessions, readyz; new integration.yaml.
- Docs: ARCHITECTURE, TESTING, PLAN refinements, root + backend READMEs.

Session/account REST handlers deferred to Stage 6 (gateway); OTLP + dashboards
to Stage 11.
This commit is contained in:
Ilia Denisov
2026-06-02 13:52:26 +02:00
parent da079b2bc6
commit eeaad62b10
45 changed files with 3461 additions and 92 deletions
+59 -6
View File
@@ -1,20 +1,30 @@
// Command backend is the Scrabble platform's internal domain service. At this
// stage it boots the HTTP listener with the infrastructure probes only; the
// domain modules described in PLAN.md are added by later stages.
// Command backend is the Scrabble platform's internal domain service. It boots
// the OpenTelemetry runtime, opens the Postgres pool and applies migrations,
// warms the session cache, and serves the HTTP listener with the infrastructure
// probes and the /api/v1 route-group skeleton. Domain endpoints are added by
// later stages described in PLAN.md.
package main
import (
"context"
"fmt"
"log"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/config"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
"scrabble/backend/internal/telemetry"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
func main() {
cfg, err := config.Load()
if err != nil {
@@ -30,12 +40,55 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := server.New(cfg.HTTPAddr, logger)
if err := srv.Run(ctx); err != nil {
logger.Fatal("backend: server terminated", zap.Error(err))
if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("backend: terminated", zap.Error(err))
}
}
// run wires the process dependencies in order — telemetry, database (with
// migrations), session cache, HTTP server — and blocks until ctx is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
tel, err := telemetry.New(ctx, cfg.Telemetry)
if err != nil {
return fmt.Errorf("init telemetry: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
defer cancel()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
db, err := postgres.Open(ctx, cfg.Postgres,
postgres.WithTracerProvider(tel.TracerProvider()),
postgres.WithMeterProvider(tel.MeterProvider()),
)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
logger.Info("database migrations applied")
sessions := session.NewService(session.NewStore(db), session.NewCache())
if err := sessions.Warm(ctx); err != nil {
return fmt.Errorf("warm session cache: %w", err)
}
logger.Info("session cache warmed")
srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger,
DB: db,
PingTimeout: cfg.Postgres.OperationTimeout,
SessionsReady: sessions.Ready,
})
return srv.Run(ctx)
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
+142
View File
@@ -0,0 +1,142 @@
// Command jetgen regenerates the go-jet/v2 query-builder code under
// backend/internal/postgres/jet against a transient PostgreSQL instance.
//
// Invoke as `go run ./cmd/jetgen` from inside the backend module. The tool is
// not part of the runtime binary and requires a reachable Docker daemon.
//
// Steps:
//
// 1. start a postgres:17-alpine container via testcontainers-go
// 2. open it with search_path=backend and apply the embedded goose migrations
// 3. drop goose's bookkeeping table so jet does not generate a model for it
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
package main
import (
"context"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"runtime"
"time"
jetpostgres "github.com/go-jet/jet/v2/generator/postgres"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/postgres"
)
const (
postgresImage = "postgres:17-alpine"
superuserName = "scrabble"
superuserPassword = "scrabble"
superuserDatabase = "scrabble_backend"
backendSchema = "backend"
containerStartup = 90 * time.Second
jetOutputDirSuffix = "internal/postgres/jet"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatalf("jetgen: %v", err)
}
}
func run(ctx context.Context) error {
outputDir, err := jetOutputDir()
if err != nil {
return err
}
container, err := tcpostgres.Run(ctx, postgresImage,
tcpostgres.WithDatabase(superuserDatabase),
tcpostgres.WithUsername(superuserName),
tcpostgres.WithPassword(superuserPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
return fmt.Errorf("start postgres container: %w", err)
}
defer func() {
if termErr := testcontainers.TerminateContainer(container); termErr != nil {
log.Printf("jetgen: terminate container: %v", termErr)
}
}()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return fmt.Errorf("resolve container dsn: %w", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, backendSchema)
if err != nil {
return err
}
cfg := postgres.DefaultConfig()
cfg.DSN = scopedDSN
db, err := postgres.Open(ctx, cfg)
if err != nil {
return fmt.Errorf("open scoped pool: %w", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
// jet's generator wipes <outputDir>/<schema> on every run; ensure the
// parent exists so the first run on a fresh checkout does not fail.
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("ensure jet output dir: %w", err)
}
// Drop goose's bookkeeping table so jet does not generate code for it. The
// container is never reused, so this only affects generation.
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS goose_db_version"); err != nil {
return fmt.Errorf("drop goose_db_version: %w", err)
}
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
return fmt.Errorf("jet generate: %w", err)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
return nil
}
// dsnWithSearchPath rewrites the connection string so every new connection pins
// search_path to the named schema and disables TLS for the local container.
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", fmt.Errorf("parse base dsn: %w", err)
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
// jetOutputDir returns the absolute path jet writes into, anchored to the
// backend module via runtime.Caller so the tool runs from any directory.
func jetOutputDir() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("resolve runtime caller for jet output path")
}
// file = .../backend/cmd/jetgen/main.go
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
return filepath.Join(moduleRoot, jetOutputDirSuffix), nil
}