Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:
- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
/character-stats/{c} (stats_data JSONB merged to top level),
/combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
/vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
/me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
/inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
exact-match check for /character-stats and /combat-stats on OFFLINE chars
(online chars legitimately differ — Python serves a richer live overlay that
Phase-1 Go lacks until ingest).
Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
4 KiB
Go
141 lines
4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// newPool creates a pgx pool against the dereth TimescaleDB.
|
|
//
|
|
// Phase 1 is strictly read-only. As defense-in-depth we force every pooled
|
|
// connection into read-only transaction mode, so even a buggy or future write
|
|
// statement cannot mutate the live production data the Python service owns.
|
|
func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
|
cfg, err := pgxpool.ParseConfig(dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
|
|
}
|
|
cfg.MaxConns = 10
|
|
cfg.MaxConnIdleTime = 5 * time.Minute
|
|
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
|
if _, err := conn.Exec(ctx, "SET default_transaction_read_only = on"); err != nil {
|
|
return fmt.Errorf("set read-only: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create pool: %w", err)
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
// queryRowsAsMaps runs a query and returns each row as a column-name->value map,
|
|
// mirroring how the Python service builds response dicts directly from rows.
|
|
// A nil result is coerced to an empty (non-nil) slice so JSON encodes "[]".
|
|
func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) ([]map[string]any, error) {
|
|
rows, err := pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := pgx.CollectRows(rows, pgx.RowToMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
out = []map[string]any{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// queryRowAsMap runs a query expected to return at most one row. It returns
|
|
// (nil, nil) when there are no rows, so callers can map that to a 404.
|
|
func queryRowAsMap(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) (map[string]any, error) {
|
|
rows, err := pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m, err := pgx.CollectExactlyOneRow(rows, pgx.RowToMap)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// asJSONMap coerces a value that may be JSON bytes, a JSON string, or an
|
|
// already-decoded map into a map[string]any. Used for JSONB columns where pgx's
|
|
// decoding can vary. Returns nil if the value can't be interpreted as an object.
|
|
func asJSONMap(v any) map[string]any {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return nil
|
|
case map[string]any:
|
|
return x
|
|
case []byte:
|
|
var m map[string]any
|
|
if json.Unmarshal(x, &m) == nil {
|
|
return m
|
|
}
|
|
case string:
|
|
var m map[string]any
|
|
if json.Unmarshal([]byte(x), &m) == nil {
|
|
return m
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeJSONValue coerces a JSON/JSONB column value into its natural Go value
|
|
// (map, slice, scalar). Bytes/strings are unmarshaled; anything else is
|
|
// returned unchanged.
|
|
func decodeJSONValue(v any) any {
|
|
switch x := v.(type) {
|
|
case []byte:
|
|
var out any
|
|
if json.Unmarshal(x, &out) == nil {
|
|
return out
|
|
}
|
|
case string:
|
|
var out any
|
|
if json.Unmarshal([]byte(x), &out) == nil {
|
|
return out
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
|
|
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
|
|
// - no fractional part when microseconds are zero
|
|
// - otherwise exactly 6 fractional digits
|
|
// - "+00:00" offset (not "Z")
|
|
// Postgres timestamptz has microsecond resolution, so ns is always a multiple
|
|
// of 1000.
|
|
func pyISO(t time.Time) string {
|
|
t = t.UTC()
|
|
if t.Nanosecond() == 0 {
|
|
return t.Format("2006-01-02T15:04:05+00:00")
|
|
}
|
|
return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d+00:00", t.Nanosecond()/1000)
|
|
}
|
|
|
|
// formatTimes rewrites the named time.Time columns in-place to pyISO strings.
|
|
// Missing or NULL (nil) values are left untouched, so they encode as JSON null.
|
|
func formatTimes(rows []map[string]any, keys ...string) {
|
|
for _, m := range rows {
|
|
for _, k := range keys {
|
|
if t, ok := m[k].(time.Time); ok {
|
|
m[k] = pyISO(t)
|
|
}
|
|
}
|
|
}
|
|
}
|