ce8b5026c1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
150 lines
4.8 KiB
Go
150 lines
4.8 KiB
Go
// 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 the backend and payments schemas into
|
|
// internal/postgres/jet (one subdirectory per schema)
|
|
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"
|
|
paymentsSchema = "payments"
|
|
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)
|
|
}
|
|
|
|
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
|
// generator wipes only that subtree), so the two calls do not clobber each
|
|
// other. GenerateDB takes the schema explicitly, so the connection's
|
|
// search_path=backend does not affect the payments introspection.
|
|
for _, schema := range []string{backendSchema, paymentsSchema} {
|
|
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
|
|
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
|
|
}
|
|
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
|
|
}
|
|
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
|
|
}
|