Stage 1: backend foundation (Postgres, sessions, accounts, OTel) #1

Merged
developer merged 2 commits from feature/stage-1-backend-foundation into master 2026-06-02 12:00:46 +00:00
45 changed files with 3462 additions and 93 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Tests · Integration
# Postgres-backed integration tests for the Go backend, gated behind the
# `integration` build tag. They spin a throwaway postgres:17-alpine container via
# testcontainers-go, which reaches the host Docker daemon through the socket the
# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially
# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module
# list grows as new go.work modules are added by later stages.
on:
push:
paths:
- 'backend/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/integration.yaml'
- '!**/*.md'
pull_request:
paths:
- 'backend/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/integration.yaml'
- '!**/*.md'
jobs:
integration:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
# the suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Integration tests
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
# container-backed tests serial; the 15-minute timeout bounds a stuck
# container pull.
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
+11 -5
View File
@@ -60,8 +60,8 @@ conversation memory — is the source of continuity. Keep it that way.
## Stack
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are
added **when first used** (incremental): backend currently uses only `gin` +
`zap`; pgx/goose/jet/OTel arrive with Stage 1+. Client↔gateway is Connect-RPC +
added **when first used** (incremental): backend uses `gin` + `zap` +
`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC +
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite,
packaged to native with Capacitor. Likely no Redis.
@@ -100,9 +100,15 @@ Constraints:
```
go.work # use the existing modules; grows per stage
backend/ # module scrabble/backend
cmd/backend/ # main: boots HTTP listener
internal/config/ # env config
internal/server/ # gin engine, /healthz, /readyz, lifecycle
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
internal/config/ # env config (composes postgres + telemetry)
internal/telemetry/ # OTel providers + request-timing middleware
internal/postgres/ # pgx/database-sql pool, goose migrations/, jet/ (generated)
internal/account/ # durable accounts + identities (store)
internal/session/ # opaque tokens, sessions store, cache, service
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ platform/ # added by their stages
```
+24 -1
View File
@@ -34,7 +34,7 @@ independent (see ARCHITECTURE §9.1).
| # | Stage | Status |
|---|-------|--------|
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | todo |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** |
| 2 | Engine package over scrabble-solver | todo |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo |
@@ -150,3 +150,26 @@ Open details: deployment target/host; dashboards; load expectations.
compose deferred to a stage that has something to deploy. Trunk is `master`
(owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on
`master` by necessity.
- **Stage 1** (interview + implementation):
- Query layer: **go-jet** over `database/sql` (pgx stdlib) + otelsql; a
`cmd/jetgen` tool regenerates the **committed** code from a throwaway
container. Postgres **17** pinned for jetgen, tests and prod.
- Sessions: opaque token stored only as a **SHA-256 hash** (kept as hex
`text`, not `bytea` — avoids jet bytea-literal friction), **revoke-only**
(no TTL); revocation-audit table deferred. Backend keeps a warmed
write-through session cache that gates `/readyz`.
- Data model: **UUIDv7** PKs; one unified `identities` table
(`kind ∈ telegram|email`, widen to `vk`/`max` later); no soft-delete /
actor-audit columns yet.
- HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user,
internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via
`Server` group accessors); the session/account REST handlers land with the
gateway in **Stage 6**. Admin bootstrap deferred to **Stage 9**.
- Telemetry: providers + request-timing middleware + otelsql; exporters
`none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 11**.
- Tests/CI: integration tests behind the `integration` build tag in
`backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk
off, serial), firing on push and PR. Backend now **hard-depends on Postgres
at boot** (migrations at startup) — a deliberate contract change from
Stage 0, documented in both READMEs. All code stays in the existing
`backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched.
+15 -4
View File
@@ -32,14 +32,25 @@ supports English Scrabble, Russian Scrabble and Эрудит.
go build ./backend/... # per module (the workspace spans several modules)
go vet ./backend/...
gofmt -l . # must print nothing
go test -count=1 ./backend/...
go test -count=1 ./backend/... # unit tests
go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker)
```
The `integration`-tagged tests start a throwaway `postgres:17-alpine` container
via testcontainers-go and require a reachable Docker daemon.
## Run the backend locally
The backend now owns persistence, so it needs Postgres and applies its embedded
migrations at startup:
```sh
go run ./backend/cmd/backend # serves /healthz and /readyz on :8080
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
go run ./backend/cmd/backend # serves /healthz and /readyz on :8080
```
Configuration is read from the environment: `BACKEND_HTTP_ADDR` (default
`:8080`), `BACKEND_LOG_LEVEL` (`debug|info|warn|error`, default `info`).
Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL`
(`debug|info|warn|error`, default `info`), `BACKEND_POSTGRES_DSN` (**required**).
The full configuration surface and the go-jet regeneration step live in
[`backend/README.md`](backend/README.md).
+76
View File
@@ -0,0 +1,76 @@
# backend
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
It owns identity/sessions, accounts, and — in later stages — the lobby, game
runtime, robot, chat, history and administration. Its only network consumers are
the `gateway` and the platform side-services; it is never exposed publicly.
As of Stage 1 the backend provides the foundation: configuration, the HTTP
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
and the durable accounts / identities / sessions data model. The session and
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
store/service layer they will call.
## Package layout
```
cmd/backend/ # process entrypoint: telemetry -> db+migrate -> cache -> server
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
internal/config/ # env configuration (composes postgres + telemetry config)
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
migrations/ # embedded *.sql (goose), schema `backend`
jet/ # generated go-jet models + table builders (committed)
internal/account/ # durable accounts + platform/email identities (store)
internal/session/ # opaque tokens, sessions store, write-through cache, service
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
```
## Configuration (environment)
| Variable | Default | Notes |
| --- | --- | --- |
| `BACKEND_HTTP_ADDR` | `:8080` | HTTP listen address. |
| `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. |
| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. |
| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. |
| `BACKEND_POSTGRES_MAX_IDLE_CONNS` | `5` | Pool max idle connections. |
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. |
## Run
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
go run ./cmd/backend
```
On boot the backend opens the pool, creates the `backend` schema if needed, and
applies the embedded migrations. `GET /healthz` reports liveness; `GET /readyz`
reports 200 only when the database answers and the session cache is warmed.
## Migrations & generated code
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
regenerate the committed go-jet code (needs Docker):
```sh
go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container
```
## Tests
```sh
go test -count=1 ./... # unit tests (no Docker)
go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
```
Integration tests are guarded by the `integration` build tag and run against a
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
rather than skipping.
+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
}
+77 -7
View File
@@ -3,39 +3,109 @@ module scrabble/backend
go 1.26.3
require (
github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgtype v1.14.4 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+364 -15
View File
@@ -1,20 +1,75 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jet/jet/v2 v2.14.1 h1:wsfD9e7CGP9h46+IFNlftfncBcmVnKddikbTtapQM3M=
github.com/go-jet/jet/v2 v2.14.1/go.mod h1:dqTAECV2Mo3S2NFjbm4vJ1aDruZjhaJ1RAAR8rGUkkc=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -23,73 +78,367 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8=
github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+203
View File
@@ -0,0 +1,203 @@
// Package account owns durable internal accounts and their platform/email
// identities. First contact from a platform auto-provisions an account bound to
// that identity; guests are session-only and never reach this package.
package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow in a later stage.
const (
KindTelegram = "telegram"
KindEmail = "email"
)
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
const uniqueViolation = "23505"
// ErrNotFound is returned when no account matches the lookup.
var ErrNotFound = errors.New("account: not found")
// Account is a durable internal account.
type Account struct {
ID uuid.UUID
DisplayName string
PreferredLanguage string
TimeZone string
BlockChat bool
BlockFriendRequests bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Store is the Postgres-backed query surface for accounts and identities.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
// a fresh durable account and identity when none exists yet. It is safe under
// concurrent callers: a losing race on the identity's unique constraint is
// resolved by re-reading the winner's account. A platform identity is recorded
// as confirmed; an email identity starts unconfirmed.
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if err == nil {
return acc, nil
}
if !errors.Is(err, ErrNotFound) {
return Account{}, err
}
acc, err = s.create(ctx, kind, externalID)
if err != nil {
if isUniqueViolation(err) {
// A concurrent caller created the identity first; return theirs.
return s.findByIdentity(ctx, kind, externalID)
}
return Account{}, err
}
return acc, nil
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: get by id %s: %w", id, err)
}
return modelToAccount(row), nil
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts.INNER_JOIN(
table.Identities,
table.Identities.AccountID.EQ(table.Accounts.AccountID),
)).
WHERE(
table.Identities.Kind.EQ(postgres.String(kind)).
AND(table.Identities.ExternalID.EQ(postgres.String(externalID))),
).
LIMIT(1)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: find by identity (%s, %s): %w", kind, externalID, err)
}
return modelToAccount(row), nil
}
// create inserts a new account and its first identity inside one transaction
// and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new account id: %w", err)
}
identityID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new identity id: %w", err)
}
var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID).
VALUES(accountID).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := insertAccount.QueryContext(ctx, tx, &row); err != nil {
return err
}
insertIdentity := table.Identities.INSERT(
table.Identities.IdentityID,
table.Identities.AccountID,
table.Identities.Kind,
table.Identities.ExternalID,
table.Identities.Confirmed,
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
return err
}
created = modelToAccount(row)
return nil
})
if err != nil {
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
}
return created, nil
}
// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
return Account{
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
// violation, used to collapse a concurrent-provision race into a re-read.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
+70 -4
View File
@@ -5,6 +5,11 @@ package config
import (
"fmt"
"os"
"strconv"
"time"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/telemetry"
)
// Config holds the backend's runtime configuration.
@@ -13,6 +18,10 @@ type Config struct {
HTTPAddr string
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
LogLevel string
// Postgres configures the primary database pool.
Postgres postgres.Config
// Telemetry configures the OpenTelemetry providers.
Telemetry telemetry.Config
}
// Defaults applied when the corresponding environment variable is unset.
@@ -21,12 +30,35 @@ const (
defaultLogLevel = "info"
)
// Load reads the configuration from the environment, applies defaults for
// unset variables, and validates the result.
// Load reads the configuration from the environment, applies defaults for unset
// variables, and validates the result.
func Load() (Config, error) {
pg := postgres.DefaultConfig()
pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
var err error
if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
return Config{}, err
}
if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
return Config{}, err
}
if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
return Config{}, err
}
if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
return Config{}, err
}
tel := telemetry.DefaultConfig()
tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
Postgres: pg,
Telemetry: tel,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -44,6 +76,12 @@ func (c Config) validate() error {
if c.HTTPAddr == "" {
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
}
if err := c.Postgres.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
}
if err := c.Telemetry.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
return nil
}
@@ -55,3 +93,31 @@ func envOr(key, fallback string) string {
}
return fallback
}
// envInt parses the environment variable named key as an int, returning
// fallback when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
// envDuration parses the environment variable named key as a Go duration,
// returning fallback when it is unset and an error when it is set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}
+80 -4
View File
@@ -1,12 +1,22 @@
package config
import "testing"
import (
"testing"
"time"
// TestLoadDefaults verifies that Load applies defaults when the environment is
// empty.
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/telemetry"
)
// testDSN is a syntactically valid DSN used to satisfy the required-DSN check.
const testDSN = "postgres://u:p@localhost:5432/db?search_path=backend&sslmode=disable"
// TestLoadDefaults verifies that Load applies defaults when only the required
// DSN is set.
func TestLoadDefaults(t *testing.T) {
t.Setenv("BACKEND_HTTP_ADDR", "")
t.Setenv("BACKEND_LOG_LEVEL", "")
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
c, err := Load()
if err != nil {
@@ -18,12 +28,29 @@ func TestLoadDefaults(t *testing.T) {
if c.LogLevel != defaultLogLevel {
t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel)
}
if c.Postgres.DSN != testDSN {
t.Errorf("Postgres.DSN = %q, want %q", c.Postgres.DSN, testDSN)
}
if c.Postgres.MaxOpenConns != postgres.DefaultMaxOpenConns {
t.Errorf("Postgres.MaxOpenConns = %d, want %d", c.Postgres.MaxOpenConns, postgres.DefaultMaxOpenConns)
}
if c.Telemetry.ServiceName != telemetry.DefaultServiceName {
t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, telemetry.DefaultServiceName)
}
if c.Telemetry.TracesExporter != telemetry.ExporterNone {
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterNone)
}
}
// TestLoadOverrides verifies that environment variables override the defaults.
func TestLoadOverrides(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("BACKEND_LOG_LEVEL", "debug")
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "7")
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "3s")
t.Setenv("BACKEND_SERVICE_NAME", "scrabble-test")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "stdout")
c, err := Load()
if err != nil {
@@ -33,14 +60,63 @@ func TestLoadOverrides(t *testing.T) {
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090")
}
if c.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug")
t.Errorf("LogLevel = %q", c.LogLevel)
}
if c.Postgres.MaxOpenConns != 7 {
t.Errorf("Postgres.MaxOpenConns = %d, want 7", c.Postgres.MaxOpenConns)
}
if c.Postgres.OperationTimeout != 3*time.Second {
t.Errorf("Postgres.OperationTimeout = %s, want 3s", c.Postgres.OperationTimeout)
}
if c.Telemetry.ServiceName != "scrabble-test" {
t.Errorf("Telemetry.ServiceName = %q", c.Telemetry.ServiceName)
}
if c.Telemetry.TracesExporter != telemetry.ExporterStdout {
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterStdout)
}
}
// TestLoadRejectsMissingDSN verifies that an empty DSN fails validation.
func TestLoadRejectsMissingDSN(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", "")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a missing DSN, got nil")
}
}
// TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected.
func TestLoadRejectsInvalidLevel(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_LOG_LEVEL", "verbose")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an invalid log level, got nil")
}
}
// TestLoadRejectsMalformedInt verifies that a non-numeric pool size is rejected.
func TestLoadRejectsMalformedInt(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "lots")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a malformed int, got nil")
}
}
// TestLoadRejectsMalformedDuration verifies that a malformed duration is rejected.
func TestLoadRejectsMalformedDuration(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "soon")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a malformed duration, got nil")
}
}
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
// set is rejected.
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
}
}
+91
View File
@@ -0,0 +1,91 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
// accounts per identity, GetByID, and the identity confirmed flag per kind.
func TestAccountProvisionByIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
tgExternal := "tg-" + uuid.NewString()
first, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal)
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if first.ID == uuid.Nil {
t.Fatal("expected a non-nil account id")
}
if first.PreferredLanguage != "en" {
t.Errorf("PreferredLanguage = %q, want default en", first.PreferredLanguage)
}
if first.TimeZone != "UTC" {
t.Errorf("TimeZone = %q, want default UTC", first.TimeZone)
}
// Re-provisioning the same identity returns the same account.
again, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal)
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
if again.ID != first.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, first.ID)
}
// A different identity yields a different account.
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other telegram: %v", err)
}
if other.ID == first.ID {
t.Error("distinct identity must map to a distinct account")
}
// GetByID round-trips, and a random id reports ErrNotFound.
got, err := store.GetByID(ctx, first.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.ID != first.ID {
t.Errorf("get id = %s, want %s", got.ID, first.ID)
}
if _, err := store.GetByID(ctx, uuid.New()); !errors.Is(err, account.ErrNotFound) {
t.Errorf("get missing = %v, want ErrNotFound", err)
}
// A platform identity is confirmed; an email identity starts unconfirmed.
if c := identityConfirmed(t, account.KindTelegram, tgExternal); !c {
t.Error("telegram identity must be confirmed")
}
emailExternal := "e-" + uuid.NewString() + "@example.com"
if _, err := store.ProvisionByIdentity(ctx, account.KindEmail, emailExternal); err != nil {
t.Fatalf("provision email: %v", err)
}
if c := identityConfirmed(t, account.KindEmail, emailExternal); c {
t.Error("email identity must start unconfirmed")
}
}
// identityConfirmed reads the confirmed flag for one identity directly.
func identityConfirmed(t *testing.T, kind, externalID string) bool {
t.Helper()
var confirmed bool
err := testDB.QueryRowContext(context.Background(),
"SELECT confirmed FROM identities WHERE kind = $1 AND external_id = $2",
kind, externalID,
).Scan(&confirmed)
if err != nil {
t.Fatalf("read confirmed for (%s, %s): %v", kind, externalID, err)
}
return confirmed
}
+12
View File
@@ -0,0 +1,12 @@
// Package inttest holds the Postgres-backed integration tests for the backend.
//
// The tests are guarded by the `integration` build tag and run against a
// throwaway postgres:17-alpine container started with testcontainers-go, so a
// reachable Docker daemon is required. Run them with:
//
// go test -tags=integration ./backend/...
//
// They fail loudly when Docker is unavailable rather than skipping, per
// docs/TESTING.md. This file carries no build tag so the package is non-empty
// in the default (no-tag) build.
package inttest
+109
View File
@@ -0,0 +1,109 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"testing"
"time"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/postgres"
)
// testDB is the shared, migrated pool every integration test runs against. It is
// hydrated once by TestMain.
var testDB *sql.DB
const (
pgImage = "postgres:17-alpine"
pgDatabase = "scrabble_backend"
pgUser = "scrabble"
pgPassword = "scrabble"
pgSchema = "backend"
containerStartup = 90 * time.Second
containerShutdown = 30 * time.Second
)
// TestMain starts one Postgres container, applies the migrations, and shares the
// resulting pool with every test. Any setup failure aborts the suite loudly
// (exit 1) rather than skipping coverage.
func TestMain(m *testing.M) {
code, err := runSuite(m)
if err != nil {
fmt.Fprintln(os.Stderr, "inttest:", err)
os.Exit(1)
}
os.Exit(code)
}
func runSuite(m *testing.M) (int, error) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
return 0, fmt.Errorf("start postgres container: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), containerShutdown)
defer cancel()
if termErr := container.Terminate(shutdownCtx); termErr != nil {
fmt.Fprintln(os.Stderr, "inttest: terminate container:", termErr)
}
}()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return 0, fmt.Errorf("resolve container dsn: %w", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
return 0, err
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
return 0, fmt.Errorf("open pool: %w", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
return 0, fmt.Errorf("apply migrations: %w", err)
}
testDB = db
return m.Run(), nil
}
// withSearchPath rewrites dsn so every connection pins search_path to schema.
func withSearchPath(dsn, schema string) (string, error) {
u, err := url.Parse(dsn)
if err != nil {
return "", fmt.Errorf("parse dsn: %w", err)
}
q := u.Query()
q.Set("search_path", schema)
if q.Get("sslmode") == "" {
q.Set("sslmode", "disable")
}
u.RawQuery = q.Encode()
return u.String(), nil
}
+25
View File
@@ -0,0 +1,25 @@
//go:build integration
package inttest
import (
"context"
"testing"
"scrabble/backend/internal/postgres"
)
// TestApplyMigrationsIdempotent re-applies the migrations against the already
// migrated database and confirms the expected tables are queryable.
func TestApplyMigrationsIdempotent(t *testing.T) {
ctx := context.Background()
if err := postgres.ApplyMigrations(ctx, testDB); err != nil {
t.Fatalf("re-apply migrations: %v", err)
}
for _, table := range []string{"accounts", "identities", "sessions"} {
var n int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil {
t.Errorf("count %s: %v", table, err)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// TestReadyzWithRealDatabase exercises the assembled server against the live
// container: /readyz answers 200 once the database pings and the session cache
// is warmed, closing the gap the unit test (nil database) cannot cover.
func TestReadyzWithRealDatabase(t *testing.T) {
svc := session.NewService(session.NewStore(testDB), session.NewCache())
if err := svc.Warm(context.Background()); err != nil {
t.Fatalf("warm session cache: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
PingTimeout: 5 * time.Second,
SessionsReady: svc.Ready,
})
for _, path := range []string{"/healthz", "/readyz"} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s status = %d, want 200", path, rec.Code)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/session"
)
// TestSessionLifecycle covers create, cache-hit resolve, DB-fallback resolve
// after a cold cache warm, idempotent revoke, and post-revoke resolution.
func TestSessionLifecycle(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision account: %v", err)
}
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
token, sess, err := svc.Create(ctx, acc.ID)
if err != nil {
t.Fatalf("create session: %v", err)
}
if sess.AccountID != acc.ID {
t.Errorf("session account = %s, want %s", sess.AccountID, acc.ID)
}
if token == sess.TokenHash {
t.Error("plaintext token must not equal the stored hash")
}
// Resolve via the warm write-through cache.
got, err := svc.Resolve(ctx, token)
if err != nil {
t.Fatalf("resolve (cache): %v", err)
}
if got.ID != sess.ID {
t.Errorf("resolve id = %s, want %s", got.ID, sess.ID)
}
// An unknown token is not found.
if _, err := svc.Resolve(ctx, "not-a-real-token"); !errors.Is(err, session.ErrNotFound) {
t.Errorf("resolve unknown = %v, want ErrNotFound", err)
}
// A fresh service with a cold cache resolves through the DB after Warm.
cold := session.NewCache()
svc2 := session.NewService(store, cold)
if err := svc2.Warm(ctx); err != nil {
t.Fatalf("warm: %v", err)
}
if !cold.Ready() {
t.Error("cache must be ready after Warm")
}
if _, ok := cold.Get(session.HashToken(token)); !ok {
t.Error("Warm must load the active session into the cache")
}
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID {
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID)
}
// Revoke, then the token no longer resolves; revoke again is a no-op.
if err := svc.Revoke(ctx, token); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := svc.Resolve(ctx, token); !errors.Is(err, session.ErrNotFound) {
t.Errorf("resolve after revoke = %v, want ErrNotFound", err)
}
if err := svc.Revoke(ctx, token); err != nil {
t.Errorf("idempotent revoke: %v", err)
}
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Accounts struct {
AccountID uuid.UUID `sql:"primary_key"`
DisplayName string
PreferredLanguage string
TimeZone string
BlockChat bool
BlockFriendRequests bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,22 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Identities struct {
IdentityID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
ExternalID string
Confirmed bool
CreatedAt time.Time
}
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Sessions struct {
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
}
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Accounts = newAccountsTable("backend", "accounts", "")
type accountsTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
DisplayName postgres.ColumnString
PreferredLanguage postgres.ColumnString
TimeZone postgres.ColumnString
BlockChat postgres.ColumnBool
BlockFriendRequests postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountsTable struct {
accountsTable
EXCLUDED accountsTable
}
// AS creates new AccountsTable with assigned alias
func (a AccountsTable) AS(alias string) *AccountsTable {
return newAccountsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountsTable with assigned schema name
func (a AccountsTable) FromSchema(schemaName string) *AccountsTable {
return newAccountsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountsTable with assigned table prefix
func (a AccountsTable) WithPrefix(prefix string) *AccountsTable {
return newAccountsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountsTable with assigned table suffix
func (a AccountsTable) WithSuffix(suffix string) *AccountsTable {
return newAccountsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountsTable(schemaName, tableName, alias string) *AccountsTable {
return &AccountsTable{
accountsTable: newAccountsTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountsTableImpl("", "excluded", ""),
}
}
func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
DisplayNameColumn = postgres.StringColumn("display_name")
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
TimeZoneColumn = postgres.StringColumn("time_zone")
BlockChatColumn = postgres.BoolColumn("block_chat")
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
)
return accountsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
DisplayName: DisplayNameColumn,
PreferredLanguage: PreferredLanguageColumn,
TimeZone: TimeZoneColumn,
BlockChat: BlockChatColumn,
BlockFriendRequests: BlockFriendRequestsColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Identities = newIdentitiesTable("backend", "identities", "")
type identitiesTable struct {
postgres.Table
// Columns
IdentityID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
ExternalID postgres.ColumnString
Confirmed postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type IdentitiesTable struct {
identitiesTable
EXCLUDED identitiesTable
}
// AS creates new IdentitiesTable with assigned alias
func (a IdentitiesTable) AS(alias string) *IdentitiesTable {
return newIdentitiesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new IdentitiesTable with assigned schema name
func (a IdentitiesTable) FromSchema(schemaName string) *IdentitiesTable {
return newIdentitiesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new IdentitiesTable with assigned table prefix
func (a IdentitiesTable) WithPrefix(prefix string) *IdentitiesTable {
return newIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new IdentitiesTable with assigned table suffix
func (a IdentitiesTable) WithSuffix(suffix string) *IdentitiesTable {
return newIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newIdentitiesTable(schemaName, tableName, alias string) *IdentitiesTable {
return &IdentitiesTable{
identitiesTable: newIdentitiesTableImpl(schemaName, tableName, alias),
EXCLUDED: newIdentitiesTableImpl("", "excluded", ""),
}
}
func newIdentitiesTableImpl(schemaName, tableName, alias string) identitiesTable {
var (
IdentityIDColumn = postgres.StringColumn("identity_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
ExternalIDColumn = postgres.StringColumn("external_id")
ConfirmedColumn = postgres.BoolColumn("confirmed")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IdentityIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{ConfirmedColumn, CreatedAtColumn}
)
return identitiesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
IdentityID: IdentityIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
ExternalID: ExternalIDColumn,
Confirmed: ConfirmedColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Sessions = newSessionsTable("backend", "sessions", "")
type sessionsTable struct {
postgres.Table
// Columns
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type SessionsTable struct {
sessionsTable
EXCLUDED sessionsTable
}
// AS creates new SessionsTable with assigned alias
func (a SessionsTable) AS(alias string) *SessionsTable {
return newSessionsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new SessionsTable with assigned schema name
func (a SessionsTable) FromSchema(schemaName string) *SessionsTable {
return newSessionsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new SessionsTable with assigned table prefix
func (a SessionsTable) WithPrefix(prefix string) *SessionsTable {
return newSessionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new SessionsTable with assigned table suffix
func (a SessionsTable) WithSuffix(suffix string) *SessionsTable {
return newSessionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
return &SessionsTable{
sessionsTable: newSessionsTableImpl(schemaName, tableName, alias),
EXCLUDED: newSessionsTableImpl("", "excluded", ""),
}
}
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
var (
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return sessionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,16 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
Accounts = Accounts.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
}
+130
View File
@@ -0,0 +1,130 @@
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io/fs"
"strings"
"sync"
"time"
"github.com/pressly/goose/v3"
"scrabble/backend/internal/postgres/migrations"
)
// schemaName is the Postgres schema owned by the backend service. Every backend
// table lives here, and the DSN pins search_path to it.
const schemaName = "backend"
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
// retry around ApplyMigrations. A freshly started Postgres — notably a test
// container — can reset a pooled connection moments after it reports ready,
// which surfaces as "bad connection" mid-migration; a handful of quick retries
// ride over that without masking real failures.
const (
migrationRetryAttempts = 5
migrationRetryBackoff = 250 * time.Millisecond
)
// gooseMu serialises access to goose's package-level filesystem state so a
// second caller in the same process cannot race on goose.SetBaseFS.
var gooseMu sync.Mutex
// ApplyMigrations runs every pending Up migration embedded in the backend
// binary against db. The schema is created upfront so goose's bookkeeping table
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
// before the first migration runs; migration 00001_init.sql re-asserts the
// schema with IF NOT EXISTS, so the double-create is idempotent.
//
// The apply is retried on transient connection errors. Both steps are
// idempotent, so a retry after a dropped connection resumes from the last
// committed migration.
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
return fmt.Errorf("ensure backend schema: %w", err)
}
if err := runMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
return fmt.Errorf("apply backend migrations: %w", err)
}
return nil
})
}
// runMigrations applies every pending Up migration found under dir inside fsys
// against db. The PostgreSQL dialect is forced; goose's package-level base FS is
// restored on the way out so a second caller in the same process is safe. dir
// is "." when the migration files sit at the embed root.
func runMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, dir string) error {
if db == nil {
return errors.New("run migrations: nil db")
}
if fsys == nil {
return errors.New("run migrations: nil fs")
}
gooseMu.Lock()
defer gooseMu.Unlock()
goose.SetBaseFS(fsys)
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("run migrations: set dialect: %w", err)
}
if err := goose.UpContext(ctx, db, dir); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
// retryOnTransient runs op up to attempts times, retrying only when op fails
// with a transient connection error — a dropped, reset, or refused connection,
// as opposed to a deterministic SQL error. It waits backoff between attempts and
// stops early if ctx is cancelled.
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
var err error
for attempt := 1; attempt <= attempts; attempt++ {
if err = op(); err == nil {
return nil
}
if attempt == attempts || !isTransientConnError(err) {
return err
}
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err())
case <-time.After(backoff):
}
}
return err
}
// isTransientConnError reports whether err is a transient connection-level
// failure worth retrying, leaving deterministic SQL errors (syntax, constraint
// violations) to fail fast.
func isTransientConnError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, driver.ErrBadConn) {
return true
}
msg := strings.ToLower(err.Error())
for _, s := range []string{
"bad connection",
"connection refused",
"connection reset",
"broken pipe",
"server closed the connection",
} {
if strings.Contains(msg, s) {
return true
}
}
return false
}
@@ -0,0 +1,60 @@
-- +goose Up
-- Initial schema for the Scrabble backend service: durable accounts, their
-- platform/email identities, and opaque server sessions.
--
-- Every backend table lives in the `backend` schema. The schema is created here
-- so a fresh database can apply this migration, and search_path is pinned for
-- the rest of the migration so the CREATE statements land in `backend` without
-- qualifying every object. Production also pins search_path via
-- BACKEND_POSTGRES_DSN.
CREATE SCHEMA IF NOT EXISTS backend;
SET search_path = backend, pg_catalog;
-- Durable internal accounts. Guests are session-only and never reach this table.
CREATE TABLE accounts (
account_id uuid PRIMARY KEY,
display_name text NOT NULL DEFAULT '',
preferred_language text NOT NULL DEFAULT 'en',
time_zone text NOT NULL DEFAULT 'UTC',
block_chat boolean NOT NULL DEFAULT false,
block_friend_requests boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru'))
);
-- Platform and email identities attached to an account. external_id is the
-- platform user id (kind='telegram') or the email address (kind='email');
-- confirmed flips true once an email confirm-code is verified (later stages).
CREATE TABLE identities (
identity_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')),
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
);
CREATE INDEX identities_account_idx ON identities (account_id);
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer
-- token; the plaintext token is never stored. Sessions are revoke-only (no
-- TTL): status moves active -> revoked and revoked_at is stamped.
CREATE TABLE sessions (
session_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
token_hash text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz,
revoked_at timestamptz,
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
);
CREATE INDEX sessions_account_idx ON sessions (account_id);
-- +goose Down
DROP TABLE sessions;
DROP TABLE identities;
DROP TABLE accounts;
@@ -0,0 +1,16 @@
// Package migrations exposes the goose migrations applied at backend startup.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var migrationFiles embed.FS
// Migrations returns the embedded goose migration filesystem. The migration
// files sit at the FS root, so callers pass "." as the directory argument.
func Migrations() fs.FS {
return migrationFiles
}
+177
View File
@@ -0,0 +1,177 @@
// Package postgres opens the backend's Postgres pool and applies the embedded
// goose migrations into the `backend` schema at startup.
//
// The pool is a standard library *sql.DB backed by the pgx driver (registered
// through pgx/stdlib) and instrumented with otelsql, so go-jet queries run over
// database/sql while statement spans and connection-pool metrics flow into
// OpenTelemetry. The DSN must pin search_path to the backend schema.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/XSAM/otelsql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
// Default pool tuning applied by DefaultConfig.
const (
DefaultMaxOpenConns = 25
DefaultMaxIdleConns = 5
DefaultConnMaxLifetime = 30 * time.Minute
DefaultOperationTimeout = 5 * time.Second
)
// dbSystemAttribute identifies the wrapped backend in OpenTelemetry spans
// without pinning the package to a specific semconv release.
var dbSystemAttribute = attribute.String("db.system", "postgresql")
// Config describes how to open the backend Postgres pool.
type Config struct {
// DSN is the pgx/libpq connection string. It must pin search_path to the
// backend schema, e.g. "postgres://…/db?search_path=backend&sslmode=disable".
DSN string
// MaxOpenConns bounds the pool's open connections (database/sql).
MaxOpenConns int
// MaxIdleConns bounds the pool's idle connections (database/sql).
MaxIdleConns int
// ConnMaxLifetime caps how long a pooled connection may be reused.
ConnMaxLifetime time.Duration
// OperationTimeout bounds a single connect attempt and the startup Ping.
OperationTimeout time.Duration
}
// DefaultConfig returns a Config carrying the default pool tuning and an empty
// DSN. Callers fill DSN from the environment before opening.
func DefaultConfig() Config {
return Config{
MaxOpenConns: DefaultMaxOpenConns,
MaxIdleConns: DefaultMaxIdleConns,
ConnMaxLifetime: DefaultConnMaxLifetime,
OperationTimeout: DefaultOperationTimeout,
}
}
// Validate reports whether the configuration is usable.
func (c Config) Validate() error {
if strings.TrimSpace(c.DSN) == "" {
return errors.New("postgres: DSN must not be empty")
}
if c.MaxOpenConns <= 0 {
return fmt.Errorf("postgres: MaxOpenConns must be positive, got %d", c.MaxOpenConns)
}
if c.MaxIdleConns < 0 {
return fmt.Errorf("postgres: MaxIdleConns must not be negative, got %d", c.MaxIdleConns)
}
if c.OperationTimeout <= 0 {
return fmt.Errorf("postgres: OperationTimeout must be positive, got %s", c.OperationTimeout)
}
return nil
}
// Option configures the OpenTelemetry providers attached to a pool by Open.
// Unset providers fall back to the OpenTelemetry global providers.
type Option func(*options)
type options struct {
tracerProvider trace.TracerProvider
meterProvider metric.MeterProvider
}
// WithTracerProvider sets the tracer provider used for SQL statement spans.
func WithTracerProvider(tp trace.TracerProvider) Option {
return func(o *options) { o.tracerProvider = tp }
}
// WithMeterProvider sets the meter provider used for connection-pool metrics.
func WithMeterProvider(mp metric.MeterProvider) Option {
return func(o *options) { o.meterProvider = mp }
}
func evalOptions(opts []Option) options {
var resolved options
for _, opt := range opts {
if opt != nil {
opt(&resolved)
}
}
return resolved
}
func (o options) otelsqlOptions() []otelsql.Option {
out := []otelsql.Option{otelsql.WithAttributes(dbSystemAttribute)}
if o.tracerProvider != nil {
out = append(out, otelsql.WithTracerProvider(o.tracerProvider))
}
if o.meterProvider != nil {
out = append(out, otelsql.WithMeterProvider(o.meterProvider))
}
return out
}
// Open opens the instrumented pool described by cfg, registers connection-pool
// metrics, and verifies connectivity with a bounded Ping. Closing the returned
// *sql.DB is the caller's responsibility.
func Open(ctx context.Context, cfg Config, opts ...Option) (*sql.DB, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
resolved := evalOptions(opts)
pgxCfg, err := pgx.ParseConfig(cfg.DSN)
if err != nil {
return nil, fmt.Errorf("open postgres: parse dsn: %w", err)
}
pgxCfg.ConnectTimeout = cfg.OperationTimeout
registeredName := stdlib.RegisterConnConfig(pgxCfg)
db, err := otelsql.Open("pgx", registeredName, resolved.otelsqlOptions()...)
if err != nil {
stdlib.UnregisterConnConfig(registeredName)
return nil, fmt.Errorf("open postgres: otelsql open: %w", err)
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
if _, err := otelsql.RegisterDBStatsMetrics(db, resolved.otelsqlOptions()...); err != nil {
_ = db.Close()
stdlib.UnregisterConnConfig(registeredName)
return nil, fmt.Errorf("open postgres: register db stats: %w", err)
}
if err := Ping(ctx, db, cfg.OperationTimeout); err != nil {
_ = db.Close()
stdlib.UnregisterConnConfig(registeredName)
return nil, err
}
return db, nil
}
// Ping bounds db.PingContext under timeout and wraps the error so startup
// failures are easy to spot in service logs. The same call backs the /readyz
// probe. timeout is typically Config.OperationTimeout.
func Ping(ctx context.Context, db *sql.DB, timeout time.Duration) error {
if db == nil {
return errors.New("ping postgres: nil db")
}
if timeout <= 0 {
return errors.New("ping postgres: timeout must be positive")
}
pingCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return fmt.Errorf("ping postgres: %w", err)
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// headerUserID is the identity header the gateway injects after resolving a
// session to an internal account.
const headerUserID = "X-User-ID"
// contextKey is an unexported type for request-context keys set by this package.
type contextKey string
const userIDContextKey contextKey = "scrabble.user_id"
// RequireUserID returns middleware that requires a valid X-User-ID header and
// stores the parsed account id in the request context. Requests without a
// parseable UUID are rejected with 401. The backend treats X-User-ID as the
// sole identity input and never derives identity from the request body.
func RequireUserID() gin.HandlerFunc {
return func(c *gin.Context) {
id, err := uuid.Parse(c.GetHeader(headerUserID))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid X-User-ID"})
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userIDContextKey, id))
c.Next()
}
}
// UserIDFromContext returns the authenticated account id stored by
// RequireUserID, and whether it was present.
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
return id, ok
}
@@ -0,0 +1,60 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
// exposes it through the request context, and rejects missing or malformed
// headers with 401.
func TestRequireUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
var seen uuid.UUID
var ok bool
r := gin.New()
r.Use(RequireUserID())
r.GET("/x", func(c *gin.Context) {
seen, ok = UserIDFromContext(c.Request.Context())
c.String(http.StatusOK, "ok")
})
t.Run("valid", func(t *testing.T) {
seen, ok = uuid.Nil, false
id := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("X-User-ID", id.String())
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !ok || seen != id {
t.Fatalf("context id = %s (ok=%v), want %s", seen, ok, id)
}
})
t.Run("missing", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
t.Run("malformed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("X-User-ID", "not-a-uuid")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
}
+114 -21
View File
@@ -1,53 +1,146 @@
// Package server wires the backend's HTTP listener: the gin engine, its route
// groups and the start/stop lifecycle. At this stage it serves only the
// infrastructure probes; the domain route groups described in PLAN.md
// (/api/v1/public, /user, /internal, /admin) are added by later stages.
// groups, the per-request telemetry middleware and the start/stop lifecycle.
//
// The /api/v1 route groups (public, user, internal, admin) are created here so
// later stages attach their endpoints to a stable structure; the /user group
// requires the X-User-ID identity header. The probes /healthz (liveness) and
// /readyz (database + session-cache readiness) are unauthenticated.
package server
import (
"context"
"database/sql"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"scrabble/backend/internal/telemetry"
)
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
// during a graceful shutdown.
const shutdownTimeout = 10 * time.Second
// Server owns the gin engine and the underlying HTTP server.
type Server struct {
log *zap.Logger
http *http.Server
// defaultPingTimeout bounds the /readyz database ping when Deps.PingTimeout is
// not set.
const defaultPingTimeout = 5 * time.Second
// Deps carries the runtime dependencies the HTTP layer needs.
type Deps struct {
// Logger receives lifecycle, request and readiness diagnostics.
Logger *zap.Logger
// DB backs the /readyz database ping. A nil DB skips the database check.
DB *sql.DB
// PingTimeout bounds the /readyz database ping.
PingTimeout time.Duration
// SessionsReady reports whether the session cache has been warmed. A nil
// func skips the session-readiness check.
SessionsReady func() bool
}
// New returns a Server that will listen on addr. The logger receives lifecycle
// and request diagnostics.
func New(addr string, log *zap.Logger) *Server {
// Server owns the gin engine, the underlying HTTP server and the readiness
// dependencies.
type Server struct {
log *zap.Logger
http *http.Server
db *sql.DB
pingTimeout time.Duration
sessionsReady func() bool
public *gin.RouterGroup
user *gin.RouterGroup
internal *gin.RouterGroup
admin *gin.RouterGroup
}
// New returns a Server that will listen on addr. It installs the recovery and
// telemetry middleware, the infrastructure probes, and the /api/v1 route groups.
func New(addr string, deps Deps) *Server {
log := deps.Logger
if log == nil {
log = zap.NewNop()
}
pingTimeout := deps.PingTimeout
if pingTimeout <= 0 {
pingTimeout = defaultPingTimeout
}
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery())
registerProbes(engine)
engine.Use(telemetry.Middleware(log))
return &Server{
log: log,
http: &http.Server{Addr: addr, Handler: engine},
s := &Server{
log: log,
db: deps.DB,
pingTimeout: pingTimeout,
sessionsReady: deps.SessionsReady,
http: &http.Server{Addr: addr, Handler: engine},
}
s.registerProbes(engine)
s.registerAPIGroups(engine)
return s
}
// registerProbes installs the unauthenticated infrastructure probes: /healthz
// reports process liveness and /readyz reports readiness to serve traffic.
// Until later stages add real dependencies (Postgres, warmed caches),
// readiness mirrors liveness.
func registerProbes(engine *gin.Engine) {
ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") }
engine.GET("/healthz", ok)
engine.GET("/readyz", ok)
// reports process liveness and /readyz reports readiness to serve traffic
// (database reachable and session cache warmed).
func (s *Server) registerProbes(engine *gin.Engine) {
engine.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
engine.GET("/readyz", s.readyz)
}
// readyz reports 200 only when the database answers a bounded ping and the
// session cache is warmed; otherwise 503.
func (s *Server) readyz(c *gin.Context) {
if s.db != nil {
ctx, cancel := context.WithTimeout(c.Request.Context(), s.pingTimeout)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
s.log.Warn("readiness: database ping failed", zap.Error(err))
c.String(http.StatusServiceUnavailable, "database unavailable")
return
}
}
if s.sessionsReady != nil && !s.sessionsReady() {
c.String(http.StatusServiceUnavailable, "sessions not ready")
return
}
c.String(http.StatusOK, "ok")
}
// registerAPIGroups wires the /api/v1 route groups. They are populated by the
// stages that add their first endpoint; the /user group requires X-User-ID,
// which the gateway injects after resolving a session.
func (s *Server) registerAPIGroups(engine *gin.Engine) {
v1 := engine.Group("/api/v1")
s.public = v1.Group("/public")
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
s.internal = v1.Group("/internal")
s.admin = v1.Group("/admin")
}
// PublicGroup returns the unauthenticated public route group.
func (s *Server) PublicGroup() *gin.RouterGroup { return s.public }
// UserGroup returns the authenticated user route group (requires X-User-ID).
func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
// InternalGroup returns the gateway-facing internal route group.
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
// AdminGroup returns the admin route group (authenticated at the gateway).
func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin }
// Handler returns the underlying HTTP handler. It lets tests drive the server
// without binding a socket and lets later stages compose the backend behind
// another listener.
func (s *Server) Handler() http.Handler { return s.http.Handler }
// Run starts the listener and blocks until ctx is cancelled, then shuts the
// server down gracefully within shutdownTimeout. It returns the first error
// that is not the expected http.ErrServerClosed.
+46 -10
View File
@@ -8,15 +8,51 @@ import (
"go.uber.org/zap/zaptest"
)
// TestProbes verifies that the infrastructure probes answer 200 OK.
func TestProbes(t *testing.T) {
srv := New(":0", zaptest.NewLogger(t))
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want %d", path, rec.Code, http.StatusOK)
}
// get serves a GET request against the server's handler and returns the recorder.
func get(srv *Server, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// TestHealthz verifies that /healthz answers 200 OK.
func TestHealthz(t *testing.T) {
srv := New(":0", Deps{Logger: zaptest.NewLogger(t)})
if rec := get(srv, "/healthz"); rec.Code != http.StatusOK {
t.Fatalf("/healthz status = %d, want %d", rec.Code, http.StatusOK)
}
}
// TestReadyzReadyWithoutDeps verifies that, with no database and no session
// readiness gate wired, /readyz answers 200 OK.
func TestReadyzReadyWithoutDeps(t *testing.T) {
srv := New(":0", Deps{Logger: zaptest.NewLogger(t)})
if rec := get(srv, "/readyz"); rec.Code != http.StatusOK {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK)
}
}
// TestReadyzNotReadyWhenSessionsCold verifies that /readyz answers 503 while the
// session cache reports not-ready.
func TestReadyzNotReadyWhenSessionsCold(t *testing.T) {
srv := New(":0", Deps{
Logger: zaptest.NewLogger(t),
SessionsReady: func() bool { return false },
})
if rec := get(srv, "/readyz"); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
// TestReadyzReadyWhenSessionsWarm verifies that /readyz answers 200 once the
// session cache reports ready (and no database is wired).
func TestReadyzReadyWhenSessionsWarm(t *testing.T) {
srv := New(":0", Deps{
Logger: zaptest.NewLogger(t),
SessionsReady: func() bool { return true },
})
if rec := get(srv, "/readyz"); rec.Code != http.StatusOK {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK)
}
}
+95
View File
@@ -0,0 +1,95 @@
package session
import (
"context"
"sync"
"sync/atomic"
)
// Cache is the in-memory write-through projection of the active rows in
// backend.sessions, keyed by token hash so Resolve avoids a database round-trip
// on the hot path. Reads are RLocked; writes are Locked. Callers commit the
// corresponding database write before invoking Add or Remove so the cache stays
// consistent with the persisted state.
type Cache struct {
mu sync.RWMutex
byHash map[string]Session
ready atomic.Bool
}
// NewCache constructs an empty Cache. It reports Ready() == false until Warm
// completes successfully.
func NewCache() *Cache {
return &Cache{byHash: make(map[string]Session)}
}
// Warm replaces the cache contents with every active session loaded from store.
// It is intended to run once at process boot before the listener accepts
// traffic; success flips Ready to true. Re-warming is supported (useful in
// tests).
func (c *Cache) Warm(ctx context.Context, store *Store) error {
sessions, err := store.ListActive(ctx)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.byHash = make(map[string]Session, len(sessions))
for _, s := range sessions {
c.byHash[s.TokenHash] = s
}
c.ready.Store(true)
return nil
}
// Ready reports whether Warm has completed at least once. The /readyz probe
// wires through this so the backend only reports ready once sessions are
// hydrated.
func (c *Cache) Ready() bool {
if c == nil {
return false
}
return c.ready.Load()
}
// Size returns the number of cached active sessions, for startup logs and tests.
func (c *Cache) Size() int {
if c == nil {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.byHash)
}
// Get returns the session for tokenHash and a presence flag. A miss returns the
// zero Session and false.
func (c *Cache) Get(tokenHash string) (Session, bool) {
if c == nil {
return Session{}, false
}
c.mu.RLock()
defer c.mu.RUnlock()
s, ok := c.byHash[tokenHash]
return s, ok
}
// Add stores s under its token hash. It is safe to call on an existing entry.
func (c *Cache) Add(s Session) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.byHash[s.TokenHash] = s
}
// Remove evicts the entry for tokenHash. Removing a missing entry is a no-op.
func (c *Cache) Remove(tokenHash string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byHash, tokenHash)
}
+52
View File
@@ -0,0 +1,52 @@
package session
import (
"testing"
"github.com/google/uuid"
)
// TestCache exercises the write-through cache's add/get/remove/size/ready cycle.
func TestCache(t *testing.T) {
c := NewCache()
if c.Ready() {
t.Error("a fresh cache must not report ready")
}
if _, ok := c.Get("h1"); ok {
t.Error("get on empty cache must miss")
}
s := Session{ID: uuid.New(), AccountID: uuid.New(), TokenHash: "h1", Status: StatusActive}
c.Add(s)
if got, ok := c.Get("h1"); !ok || got.ID != s.ID {
t.Fatalf("get after add: got %v ok=%v", got.ID, ok)
}
if c.Size() != 1 {
t.Errorf("size = %d, want 1", c.Size())
}
c.Remove("h1")
if _, ok := c.Get("h1"); ok {
t.Error("get after remove must miss")
}
if c.Size() != 0 {
t.Errorf("size = %d, want 0", c.Size())
}
}
// TestCacheNilSafe checks that the cache methods are safe on a nil receiver,
// which the readiness probe relies on before the cache is constructed.
func TestCacheNilSafe(t *testing.T) {
var c *Cache
if c.Ready() {
t.Error("nil cache must not be ready")
}
if _, ok := c.Get("x"); ok {
t.Error("nil cache get must miss")
}
if c.Size() != 0 {
t.Error("nil cache size must be 0")
}
c.Add(Session{TokenHash: "x"}) // must not panic
c.Remove("x") // must not panic
}
+73
View File
@@ -0,0 +1,73 @@
package session
import (
"context"
"time"
"github.com/google/uuid"
)
// Service mints, resolves, and revokes sessions over the store and the
// write-through cache. The gateway is its only caller (from a later stage); the
// HTTP surface is wired then.
type Service struct {
store *Store
cache *Cache
}
// NewService constructs a Service over store and cache.
func NewService(store *Store, cache *Cache) *Service {
return &Service{store: store, cache: cache}
}
// Warm hydrates the cache from the store. Call once before serving traffic.
func (svc *Service) Warm(ctx context.Context) error {
return svc.cache.Warm(ctx, svc.store)
}
// Ready reports whether the session cache has been warmed.
func (svc *Service) Ready() bool {
return svc.cache.Ready()
}
// Create mints a new active session for accountID and returns the plaintext
// token (shown to the caller once) together with the persisted session.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) {
token, tokenHash, err := GenerateToken()
if err != nil {
return "", Session{}, err
}
sess, err := svc.store.Insert(ctx, accountID, tokenHash)
if err != nil {
return "", Session{}, err
}
svc.cache.Add(sess)
return token, sess, nil
}
// Resolve maps a presented token to its active session, consulting the cache
// first and falling back to the store (repopulating the cache on a hit).
// Returns ErrNotFound when no active session matches.
func (svc *Service) Resolve(ctx context.Context, token string) (Session, error) {
hash := HashToken(token)
if sess, ok := svc.cache.Get(hash); ok {
return sess, nil
}
sess, err := svc.store.FindActiveByTokenHash(ctx, hash)
if err != nil {
return Session{}, err
}
svc.cache.Add(sess)
return sess, nil
}
// Revoke revokes the session for the presented token. It is idempotent:
// revoking an unknown or already-revoked token returns nil.
func (svc *Service) Revoke(ctx context.Context, token string) error {
hash := HashToken(token)
if _, _, err := svc.store.RevokeByTokenHash(ctx, hash, time.Now().UTC()); err != nil {
return err
}
svc.cache.Remove(hash)
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package session
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Session lifecycle statuses persisted in the status column.
const (
StatusActive = "active"
StatusRevoked = "revoked"
)
// ErrNotFound is returned when no active session matches the lookup.
var ErrNotFound = errors.New("session: not found")
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
// SHA-256 of the bearer token.
type Session struct {
ID uuid.UUID
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
}
// Store is the Postgres-backed query surface for backend.sessions.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Insert persists a new active session for accountID carrying tokenHash and
// returns the persisted row.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) {
id, err := uuid.NewV7()
if err != nil {
return Session{}, fmt.Errorf("session: new id: %w", err)
}
stmt := table.Sessions.INSERT(
table.Sessions.SessionID,
table.Sessions.AccountID,
table.Sessions.TokenHash,
).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns)
var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Session{}, fmt.Errorf("session: insert: %w", err)
}
return modelToSession(row), nil
}
// FindActiveByTokenHash returns the active session matching tokenHash, or
// ErrNotFound.
func (s *Store) FindActiveByTokenHash(ctx context.Context, tokenHash string) (Session, error) {
stmt := postgres.SELECT(table.Sessions.AllColumns).
FROM(table.Sessions).
WHERE(
table.Sessions.TokenHash.EQ(postgres.String(tokenHash)).
AND(table.Sessions.Status.EQ(postgres.String(StatusActive))),
).
LIMIT(1)
var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Session{}, ErrNotFound
}
return Session{}, fmt.Errorf("session: find by token hash: %w", err)
}
return modelToSession(row), nil
}
// RevokeByTokenHash transitions the active session for tokenHash to revoked and
// returns the post-update row. ok is false with a nil error when no active
// session matched, so revocation is idempotent.
func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time.Time) (Session, bool, error) {
stmt := table.Sessions.
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)).
WHERE(
table.Sessions.TokenHash.EQ(postgres.String(tokenHash)).
AND(table.Sessions.Status.EQ(postgres.String(StatusActive))),
).
RETURNING(table.Sessions.AllColumns)
var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Session{}, false, nil
}
return Session{}, false, fmt.Errorf("session: revoke by token hash: %w", err)
}
return modelToSession(row), true, nil
}
// ListActive loads every active session. Cache.Warm calls this at boot.
func (s *Store) ListActive(ctx context.Context) ([]Session, error) {
stmt := postgres.SELECT(table.Sessions.AllColumns).
FROM(table.Sessions).
WHERE(table.Sessions.Status.EQ(postgres.String(StatusActive)))
var rows []model.Sessions
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("session: list active: %w", err)
}
out := make([]Session, 0, len(rows))
for _, row := range rows {
out = append(out, modelToSession(row))
}
return out, nil
}
// modelToSession projects a generated model row into the public Session struct,
// copying pointer fields so callers cannot mutate the scan buffer.
func modelToSession(row model.Sessions) Session {
s := Session{
ID: row.SessionID,
AccountID: row.AccountID,
TokenHash: row.TokenHash,
Status: row.Status,
CreatedAt: row.CreatedAt,
}
if row.LastSeenAt != nil {
t := *row.LastSeenAt
s.LastSeenAt = &t
}
if row.RevokedAt != nil {
t := *row.RevokedAt
s.RevokedAt = &t
}
return s
}
+35
View File
@@ -0,0 +1,35 @@
// Package session owns opaque server sessions: minting bearer tokens, resolving
// them to accounts through a write-through in-memory cache, and revoking them.
// Only the SHA-256 hash of a token is persisted; the plaintext is returned to
// the caller once and never stored. Sessions are revoke-only (no TTL).
package session
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// tokenBytes is the entropy of an opaque session token: 256 bits.
const tokenBytes = 32
// GenerateToken returns a fresh random opaque token (URL-safe base64, 256-bit)
// together with its hex-encoded SHA-256 hash for storage. The plaintext token
// is handed to the caller once and is never persisted.
func GenerateToken() (token, tokenHash string, err error) {
buf := make([]byte, tokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("session: read random token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, HashToken(token), nil
}
// HashToken returns the hex-encoded SHA-256 of token. Lookups hash the presented
// token and compare against the stored hash.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+44
View File
@@ -0,0 +1,44 @@
package session
import "testing"
// TestGenerateTokenUniqueAndHashed checks that tokens are unique, the stored
// value is the hash (not the plaintext), and the hash is a 64-char SHA-256 hex.
func TestGenerateTokenUniqueAndHashed(t *testing.T) {
tok1, hash1, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
tok2, hash2, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if tok1 == tok2 {
t.Error("tokens must be unique")
}
if hash1 == hash2 {
t.Error("hashes must differ for distinct tokens")
}
if hash1 != HashToken(tok1) {
t.Error("stored hash must equal HashToken(token)")
}
if tok1 == hash1 {
t.Error("stored hash must not equal the plaintext token")
}
if len(hash1) != 64 {
t.Errorf("hash length = %d, want 64 (sha256 hex)", len(hash1))
}
}
// TestHashTokenDeterministic checks that hashing is stable for a given token.
func TestHashTokenDeterministic(t *testing.T) {
first := HashToken("alpha")
second := HashToken("alpha")
if first != second {
t.Error("HashToken must be deterministic")
}
if first == HashToken("beta") {
t.Error("distinct tokens must hash differently")
}
}
+74
View File
@@ -0,0 +1,74 @@
package telemetry
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.uber.org/zap"
)
// tracerName names the instrumentation scope for backend HTTP spans.
const tracerName = "scrabble/backend/server"
// Middleware returns gin middleware that, for every request, opens a server
// span, measures server-side latency, and emits a structured access log
// correlated with the active trace. It uses the globally-registered tracer, so
// spans are exported only when an exporter is configured, while the timing log
// is always emitted. Probe paths (/healthz, /readyz) log at debug level to keep
// the default log clean.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
tracer := otel.Tracer(tracerName)
return func(c *gin.Context) {
start := time.Now()
route := c.FullPath()
if route == "" {
route = c.Request.URL.Path
}
ctx, span := tracer.Start(c.Request.Context(), c.Request.Method+" "+route)
c.Request = c.Request.WithContext(ctx)
c.Next()
status := c.Writer.Status()
elapsed := time.Since(start)
span.SetAttributes(
attribute.String("http.request.method", c.Request.Method),
attribute.String("http.route", route),
attribute.Int("http.response.status_code", status),
)
if status >= http.StatusInternalServerError {
span.SetStatus(codes.Error, http.StatusText(status))
}
span.End()
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", route),
zap.Int("status", status),
zap.Duration("latency", elapsed),
}
fields = append(fields, TraceFieldsFromContext(ctx)...)
if isProbePath(c.Request.URL.Path) {
logger.Debug("http request", fields...)
return
}
logger.Info("http request", fields...)
}
}
// isProbePath reports whether path is one of the unauthenticated infrastructure
// probes, whose access logs are demoted to debug level.
func isProbePath(path string) bool {
return path == "/healthz" || path == "/readyz"
}
+204
View File
@@ -0,0 +1,204 @@
// Package telemetry owns the OpenTelemetry runtime for the backend process.
//
// New constructs the configured tracer and meter providers, registers them as
// the OpenTelemetry globals, and exposes Shutdown for orderly exit. The MVP
// supports the `none` and `stdout` exporters; OTLP export and dashboards arrive
// in a later stage. The per-request timing middleware lives in middleware.go and
// uses the registered global tracer, so requests are timed and logged even when
// the exporter is `none`.
package telemetry
import (
"context"
"errors"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
)
// Exporter selectors supported by the backend.
const (
ExporterNone = "none"
ExporterStdout = "stdout"
)
// DefaultServiceName labels traces and metrics when BACKEND_SERVICE_NAME is
// unset.
const DefaultServiceName = "scrabble-backend"
// Config selects the telemetry providers' service name and exporters.
type Config struct {
// ServiceName is reported as the OpenTelemetry service.name resource.
ServiceName string
// TracesExporter is one of ExporterNone or ExporterStdout.
TracesExporter string
// MetricsExporter is one of ExporterNone or ExporterStdout.
MetricsExporter string
}
// DefaultConfig returns the MVP telemetry configuration: named service, no
// exporters (so no collector is required locally or in CI).
func DefaultConfig() Config {
return Config{
ServiceName: DefaultServiceName,
TracesExporter: ExporterNone,
MetricsExporter: ExporterNone,
}
}
// Validate reports whether the configuration selects supported exporters.
func (c Config) Validate() error {
if c.ServiceName == "" {
return errors.New("telemetry: ServiceName must not be empty")
}
if err := validateExporter("traces", c.TracesExporter); err != nil {
return err
}
return validateExporter("metrics", c.MetricsExporter)
}
func validateExporter(kind, value string) error {
switch value {
case ExporterNone, ExporterStdout:
return nil
default:
return fmt.Errorf("telemetry: unsupported %s exporter %q", kind, value)
}
}
// Runtime owns the shared OpenTelemetry providers.
type Runtime struct {
tracerProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
}
// New constructs the telemetry runtime, registers the global providers and the
// W3C trace-context/baggage propagators, and returns the Runtime. Callers must
// invoke Runtime.Shutdown during process exit.
func New(ctx context.Context, cfg Config) (*Runtime, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
res, err := resource.New(ctx, resource.WithAttributes(
attribute.String("service.name", cfg.ServiceName),
))
if err != nil {
return nil, fmt.Errorf("telemetry: build resource: %w", err)
}
tracerProvider, err := newTracerProvider(cfg, res)
if err != nil {
return nil, fmt.Errorf("telemetry: build tracer provider: %w", err)
}
meterProvider, err := newMeterProvider(cfg, res)
if err != nil {
_ = tracerProvider.Shutdown(ctx)
return nil, fmt.Errorf("telemetry: build meter provider: %w", err)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return &Runtime{tracerProvider: tracerProvider, meterProvider: meterProvider}, nil
}
// TracerProvider returns the runtime tracer provider, or the global one when r
// is not initialised.
func (r *Runtime) TracerProvider() trace.TracerProvider {
if r == nil || r.tracerProvider == nil {
return otel.GetTracerProvider()
}
return r.tracerProvider
}
// MeterProvider returns the runtime meter provider, or the global one when r is
// not initialised.
func (r *Runtime) MeterProvider() metric.MeterProvider {
if r == nil || r.meterProvider == nil {
return otel.GetMeterProvider()
}
return r.meterProvider
}
// Shutdown flushes both providers within ctx.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
var err error
if r.meterProvider != nil {
err = errors.Join(err, r.meterProvider.Shutdown(ctx))
}
if r.tracerProvider != nil {
err = errors.Join(err, r.tracerProvider.Shutdown(ctx))
}
return err
}
// TraceFieldsFromContext returns zap fields identifying the active span, or nil
// when ctx carries no valid span context. Collocated here so callers do not
// import the OpenTelemetry API directly.
func TraceFieldsFromContext(ctx context.Context) []zap.Field {
if ctx == nil {
return nil
}
sc := trace.SpanContextFromContext(ctx)
if !sc.IsValid() {
return nil
}
return []zap.Field{
zap.String("otel_trace_id", sc.TraceID().String()),
zap.String("otel_span_id", sc.SpanID().String()),
}
}
func newTracerProvider(cfg Config, res *resource.Resource) (*sdktrace.TracerProvider, error) {
switch cfg.TracesExporter {
case ExporterNone:
return sdktrace.NewTracerProvider(sdktrace.WithResource(res)), nil
case ExporterStdout:
exporter, err := stdouttrace.New()
if err != nil {
return nil, fmt.Errorf("stdout trace exporter: %w", err)
}
return sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
), nil
default:
return nil, fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
}
}
func newMeterProvider(cfg Config, res *resource.Resource) (*sdkmetric.MeterProvider, error) {
switch cfg.MetricsExporter {
case ExporterNone:
return sdkmetric.NewMeterProvider(sdkmetric.WithResource(res)), nil
case ExporterStdout:
exporter, err := stdoutmetric.New()
if err != nil {
return nil, fmt.Errorf("stdout metric exporter: %w", err)
}
return sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)),
), nil
default:
return nil, fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
}
}
@@ -0,0 +1,82 @@
package telemetry
import (
"context"
"testing"
)
// TestConfigValidate covers the supported and rejected exporter selections.
func TestConfigValidate(t *testing.T) {
if err := DefaultConfig().Validate(); err != nil {
t.Fatalf("default config must be valid: %v", err)
}
otlp := DefaultConfig()
otlp.TracesExporter = "otlp"
if err := otlp.Validate(); err == nil {
t.Error("otlp exporter must be rejected in the MVP set")
}
noName := DefaultConfig()
noName.ServiceName = ""
if err := noName.Validate(); err == nil {
t.Error("empty service name must be rejected")
}
}
// TestNewNoneAndShutdown builds the providers with the none exporter and shuts
// them down.
func TestNewNoneAndShutdown(t *testing.T) {
rt, err := New(context.Background(), DefaultConfig())
if err != nil {
t.Fatalf("New: %v", err)
}
if rt.TracerProvider() == nil {
t.Error("TracerProvider must not be nil")
}
if rt.MeterProvider() == nil {
t.Error("MeterProvider must not be nil")
}
if err := rt.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown: %v", err)
}
}
// TestNewStdoutTraces builds the providers with the stdout trace exporter; no
// spans are recorded, so shutdown flushes nothing to stdout.
func TestNewStdoutTraces(t *testing.T) {
cfg := DefaultConfig()
cfg.TracesExporter = ExporterStdout
rt, err := New(context.Background(), cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := rt.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown: %v", err)
}
}
// TestTraceFieldsFromContextEmpty returns no fields without an active span.
func TestTraceFieldsFromContextEmpty(t *testing.T) {
if TraceFieldsFromContext(context.Background()) != nil {
t.Error("expected nil fields without an active span")
}
var nilCtx context.Context
if TraceFieldsFromContext(nilCtx) != nil {
t.Error("expected nil fields for a nil context")
}
}
// TestNilRuntime checks the nil-receiver fallbacks used before initialisation.
func TestNilRuntime(t *testing.T) {
var rt *Runtime
if rt.TracerProvider() == nil {
t.Error("nil runtime must fall back to the global tracer provider")
}
if rt.MeterProvider() == nil {
t.Error("nil runtime must fall back to the global meter provider")
}
if err := rt.Shutdown(context.Background()); err != nil {
t.Errorf("nil runtime Shutdown: %v", err)
}
}
+34 -14
View File
@@ -71,8 +71,11 @@ arrive from a platform rather than completing a mandatory registration).
(`session_id`).
- The client holds `session_id` in memory for the app session (browser/OS
storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Sessions are
revocable. Session records live in `backend`.
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
records live in `backend`, which stores only a **SHA-256 hash** of the opaque
token (never the plaintext), keeps a warmed in-memory cache for fast
resolution, and treats sessions as **revoke-only** — they have no TTL and live
until explicitly revoked (`status``revoked`).
- **Guest** = ephemeral web session (no platform, no email): session-only,
nothing persisted; restricted to auto-match, with no friends and no
stats/history. Platform users are auto-provisioned **durable** accounts.
@@ -82,6 +85,10 @@ arrive from a platform rather than completing a mandatory registration).
- One internal account may carry several **platform identities**
(`telegram`, `vk`, …) plus an optional **email** identity. First contact from
a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
`confirmed` flag (the confirm-code flow lands later). Accounts and identities
use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the
current account.
@@ -155,9 +162,15 @@ within 10 seconds. Designed to be indistinguishable from a person.
## 9. Persistence
- Single Postgres database, schema `backend`; `backend` is the only writer.
pgx pool; queries via go-jet *(introduced when the first real query lands)*;
migrations embedded and applied with `pressly/goose/v3` at startup *(planned)*.
- Single Postgres database, schema `backend`; `backend` is the only writer. The
"pgx pool" is a `database/sql` handle backed by the pgx stdlib driver and
instrumented with otelsql; type-safe queries use **go-jet** (code generated
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
keys are application-generated **UUIDv7**.
- Stage 1 tables: `accounts` (durable internal accounts), `identities`
(platform/email identities, unique `(kind, external_id)`) and `sessions`
(revoke-only opaque-token hashes).
- **Active game state** is stored structurally with the `dict_version` pinned.
- **Statistics** (computed on finish): wins, losses, max points in a game, max
points for a single word.
@@ -185,12 +198,17 @@ delivery fans out to the appropriate channel.
## 11. Observability
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry traces and
metrics, with a Prometheus pull endpoint where configured *(introduced with
the first real workload)*.
- Per-request server-side timing via middleware from day one. A client-measured
RTT piggybacked on the next request is a later enhancement, not MVP.
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness).
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
meter providers are wired (Stage 1), env-gated by
`BACKEND_OTEL_{TRACES,METRICS}_EXPORTER` with a default of `none` (so no
collector is required locally or in CI); `stdout` is available for debugging
and the Postgres pool is instrumented with otelsql. OTLP export, a Prometheus
pull endpoint, and dashboards arrive with the first real workload.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
## 12. Security boundaries
@@ -219,8 +237,10 @@ is something to deploy.
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
lands on `master`).
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/test on
Go changes; more workflows (ui-test, integration, deploy) are added with the
components they cover.
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
serial). Further workflows (ui-test, deploy) are added with the components they
cover.
- After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
+5 -2
View File
@@ -8,8 +8,11 @@ tests or touching CI.
- **Go unit tests** — table-driven where it helps; `testing` + standard library.
Every functional change ships with regression coverage. Run:
`go test -count=1 ./backend/...` (the module list grows with the workspace).
- **Integration** *(introduced with Postgres in Stage 1)*`testcontainers-go`
spins real dependencies (Postgres). Slow; a separate CI workflow.
- **Integration** *(Stage 1+)*Postgres-backed tests behind the `integration`
build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
live in `backend/internal/inttest` and run with
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain.
- **Engine** — correctness of scoring and move generation is owned by
+69
View File
@@ -0,0 +1,69 @@
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus=
github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/vertica/vertica-sql-go v1.3.6/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA=
github.com/volatiletech/null/v8 v8.1.2/go.mod h1:98DbwNoKEpRrYtGjWFctievIfm4n4MxG0A6EBUcoS5g=
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=