diff --git a/appview/CACHING.md b/appview/CACHING.md new file mode 100644 index 0000000..077e3b7 --- /dev/null +++ b/appview/CACHING.md @@ -0,0 +1,189 @@ +# Effem AppView — Caching Strategy + +Three caching layers, each with a clear contract and a bounded blast radius. + +``` + client ┐ + ▼ + CDN (optional) ──► shared cache keyed on URL + (optionally) Authorization + ▼ + AppView origin ──► responses carry Cache-Control on public endpoints + ▼ + pi_cache ────► Postgres-backed cache of Podcast Index upstream calls + ▼ + Podcast Index +``` + +## Layer 1 — `pi_cache` (always on) + +Already implemented in `appview/podcastindex/cache.go`. Every outbound +Podcast Index call checks this Postgres-backed cache first. Per-endpoint +TTLs live next to each `getOrFetch` call: 15 min for episode lists, 1 h +for searches, 24 h for podcast metadata, 7 d for categories. + +Metrics: `effem_pi_cache_hits_total{endpoint}` / +`effem_pi_cache_misses_total{endpoint}` (added in Phase 1). + +No operator action needed — this is internal. + +## Layer 2 — HTTP `Cache-Control` (on the origin today) + +Set by `setPublicCache(c, maxAgeSeconds)` in +`appview/handlers/handlers.go`. Applied only on responses that **do not +vary by caller identity**: + +| Endpoint | max-age | Why | +|---|---|---| +| `GetCategories` | 3600 s | Category list is effectively static. | +| `GetStats` | 300 s | Counts shift slowly. | +| `GetTrending` | 300 s | Trending shifts gradually. | +| `GetPodcast` | 300 s | Upstream metadata is stable; social counts mutate but are feed-level. | +| `GetEpisodes` | 120 s | New episodes appear any time. | +| `GetEpisode` | 120 s | Same. | +| `GetRecentEpisodes` | 60 s | Freshness > hit rate. | +| `GetPopular` | 300 s | Popularity rankings change slowly. | +| `SearchPodcasts` | 300 s | Query-deterministic. | +| `SearchEpisodes` | 300 s | Same. | + +### Why `public` and not `private` + +Most requests carry an `Authorization` header (AppView requires auth). +RFC 7234 §3.2 says shared caches must not cache responses to requests +with `Authorization` **unless** the response explicitly allows it with +`Cache-Control: public`, `s-maxage`, or `must-revalidate`. We use +`public` so Cloudflare and similar edges can cache these responses +despite the token in the request. + +Browser caches (which are private) can cache responses with either +`public` or `private`. The `public` directive is strictly more +permissive, so we use that. + +### Endpoints that are deliberately NOT cached + +Anything that block-filters or depends on the caller's identity: + +- `GetComments`, `GetCommentThread`, `GetRecommendations` — + `excludeBlockedDIDs` makes each response per-caller. +- `GetSubscriptions`, `GetSubscribers`, `GetBookmarks`, + `GetEpisodeStates`, `GetBlocks`, `GetLists`, `GetList`, + `GetProfile`, `GetInbox` — user-scoped by DID. +- Admin endpoints — never cache moderation state. + +If you change any of those to skip the block filter for service tokens +and want a cache, prefer adding a separate unfiltered route rather than +toggling the header conditionally. + +## Layer 3 — CDN (operator-configured) + +Beyond the hobby tier, put Cloudflare or equivalent in front of the +AppView so the edge honors the `Cache-Control` headers above. + +### Cloudflare page rule recipe + +For each of the endpoints in the table above, add a rule that **caches +everything** (Cloudflare calls this "Cache Everything" + "Edge Cache TTL +respect origin"): + +``` +# URL match: +/xrpc/xyz.effem.podcast.* +/xrpc/xyz.effem.search.* +/xrpc/xyz.effem.feed.getPopular + +# Settings: +Cache Level: Cache Everything +Edge Cache TTL: Respect Existing Headers +Browser Cache TTL: Respect Existing Headers +``` + +### Cache key + +By default Cloudflare includes the URL and query string in the cache +key but **not** the `Authorization` header, which is exactly right for +these endpoints (the response doesn't depend on who asked). Do not add +Authorization to the cache key — it would shatter the cache into one +entry per token and defeat the point. + +### Blocking the auth-gated paths + +Safety: add a "Cache Bypass" rule covering everything the page rules +above don't match, so a misconfigured header on +`GetComments` / `GetCommentThread` doesn't leak one user's block list to +another. Order rules so bypass wins for anything outside the allowlist. + +### Purging + +When a migration or hotfix changes response shape, purge via Cloudflare +API keyed on the affected paths. The TTLs are short enough (≤ 1 h) that +cached-but-stale responses self-expire quickly, but purge is faster for +user-visible breakage. + +## Database layer — pool tuning + +Controlled by four knobs (Phase 6 Step 19): + +| Flag / env | Default | Notes | +|---|---|---| +| `--db-max-open-conns` / `EFFEM_DB_MAX_OPEN_CONNS` | 25 | Railway hobby Postgres caps ~50 connections; 25 leaves headroom for tools. | +| `--db-max-idle-conns` / `EFFEM_DB_MAX_IDLE_CONNS` | 5 | Warm pool size. Must be ≤ open. | +| `--db-conn-max-lifetime` / `EFFEM_DB_CONN_MAX_LIFETIME` | 5m | Rotate connections so Railway host moves don't surface as client-side errors. | +| `--db-conn-max-idle-time` / `EFFEM_DB_CONN_MAX_IDLE_TIME` | 5m | Close long-idle connections. | + +Validation in `Config.Validate` rejects MaxIdleConns > MaxOpenConns and +any negative lifetime. + +### Sizing guide + +- **Local dev**: defaults are fine. +- **Small Railway instance (~50 connection cap)**: 25 / 5. Leave + headroom for the firehose consumer's writes and a human psql session. +- **Larger Postgres (≥ 200 cap)**: 50 / 10 or higher. Watch + `sqlDB.Stats().WaitCount` — non-zero means requests are queuing on + the pool. + +## Database layer — read replicas + +Controlled by `--database-reader-url` / `EFFEM_DATABASE_READER_URL` +(Phase 6 Step 20). When the flag is set, `registerReadReplica` installs +`gorm.io/plugin/dbresolver` so reads route to the replica and writes +(and anything wrapped in a transaction) stay on the primary. + +### When to enable it + +- Reads dominate the workload — check + `effem_http_requests_total{method="GET"}` vs. the firehose write rate. +- The primary's CPU is the bottleneck, not the network. +- Replication lag is bounded (Railway replicas are usually within + seconds). + +Before flipping it on, exercise the code path in staging: write a +record, read it back immediately, and confirm eventual consistency +doesn't break any flow. `GetProfile` right after an +identity-event index is the classic failure mode. + +### Avoiding replica-lag surprises + +Any handler that must read-its-own-write should wrap the read and write +in a transaction so dbresolver pins both to the primary. Today no +handler does that — it's a future concern when write-then-read +sequences show up. + +## Observability + +- `effem_http_request_duration_seconds{path}` — per-route latency. + Cache hits at Cloudflare **don't** reach the origin, so a sudden drop + here after CDN rollout is the expected signal of success. +- `effem_pi_cache_hits_total` / `effem_pi_cache_misses_total` — inner + cache health. If the hit rate craters, upstream is returning varying + responses (something new in the cache key) or TTLs are too tight. + +## Policy summary + +- Inner PI cache: Postgres-backed, always on. +- HTTP Cache-Control: `public, max-age=…` on non-personalized endpoints, + absent everywhere else. The absence is the contract — adding an + explicit `no-store` is noise. +- CDN: configure to respect origin headers and to bypass for the + personalized paths. +- DB pool: four tunables, sensible defaults for Railway hobby tier. +- Read replica: opt-in via env var; no-op without it. diff --git a/appview/config.go b/appview/config.go index ab0a99c..d4cd902 100644 --- a/appview/config.go +++ b/appview/config.go @@ -3,11 +3,32 @@ package appview import ( "fmt" "strings" + "time" ) type Config struct { - Bind string - DatabaseURL string + Bind string + DatabaseURL string + // DatabaseReaderURL is an optional connection string for a read + // replica. When set, reads route to the replica and writes stay on + // the primary (DatabaseURL) via the dbresolver plugin. Leave empty + // for a single-DB deployment. + DatabaseReaderURL string + + // DBMaxOpenConns caps the number of simultaneously-open connections + // to the primary. Railway's hobby Postgres limits ~50 concurrent + // connections; 25 leaves headroom for tools and ops queries. + DBMaxOpenConns int + // DBMaxIdleConns is the warm-pool size. Too low means cold-start + // latency on every request; too high wastes backend slots. + DBMaxIdleConns int + // DBConnMaxLifetime rotates connections defensively so Railway's + // load balancer can shed them on host moves without upstream errors. + DBConnMaxLifetime time.Duration + // DBConnMaxIdleTime closes connections that have been idle for this + // duration. Keeps the warm pool honest in long-running processes. + DBConnMaxIdleTime time.Duration + RelayHost string PLCHost string PIKey string @@ -85,6 +106,21 @@ func (c Config) Validate() error { return fmt.Errorf("admin DID %q must start with \"did:\"", did) } } + if c.DBMaxOpenConns <= 0 { + return fmt.Errorf("db max open connections must be positive") + } + if c.DBMaxIdleConns < 0 { + return fmt.Errorf("db max idle connections must be >= 0") + } + if c.DBMaxIdleConns > c.DBMaxOpenConns { + return fmt.Errorf("db max idle (%d) cannot exceed max open (%d)", c.DBMaxIdleConns, c.DBMaxOpenConns) + } + if c.DBConnMaxLifetime < 0 { + return fmt.Errorf("db conn max lifetime must be >= 0 (0 disables rotation)") + } + if c.DBConnMaxIdleTime < 0 { + return fmt.Errorf("db conn max idle time must be >= 0 (0 keeps connections forever)") + } return nil } diff --git a/appview/config_test.go b/appview/config_test.go index 929464a..e45a4f3 100644 --- a/appview/config_test.go +++ b/appview/config_test.go @@ -1,6 +1,9 @@ package appview -import "testing" +import ( + "testing" + "time" +) func TestParseTokenSubjectMap(t *testing.T) { tokens, err := ParseTokenSubjectMap("tokenA=did:plc:alice, tokenB=*") @@ -45,10 +48,38 @@ func TestConfigValidateAcceptsValidConfig(t *testing.T) { } } +func TestConfigValidateRejectsMissingOpenConns(t *testing.T) { + cfg := validConfig() + cfg.DBMaxOpenConns = 0 + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for zero max-open-conns") + } +} + +func TestConfigValidateRejectsIdleAboveOpen(t *testing.T) { + cfg := validConfig() + cfg.DBMaxIdleConns = cfg.DBMaxOpenConns + 1 + if err := cfg.Validate(); err == nil { + t.Fatal("expected error when idle exceeds open") + } +} + +func TestConfigValidateRejectsNegativeLifetime(t *testing.T) { + cfg := validConfig() + cfg.DBConnMaxLifetime = -1 + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for negative lifetime") + } +} + func validConfig() Config { return Config{ Bind: ":8080", DatabaseURL: "postgres://effem:effem@localhost:5432/effem?sslmode=disable", + DBMaxOpenConns: 25, + DBMaxIdleConns: 5, + DBConnMaxLifetime: 5 * time.Minute, + DBConnMaxIdleTime: 5 * time.Minute, RelayHost: "wss://bsky.network", PLCHost: "https://plc.directory", PIKey: "key", diff --git a/appview/handlers/episodes.go b/appview/handlers/episodes.go index a8068fd..b48513a 100644 --- a/appview/handlers/episodes.go +++ b/appview/handlers/episodes.go @@ -57,6 +57,7 @@ func (h *Handlers) GetEpisodes(c echo.Context) error { } payload["items"] = items + setPublicCache(c, 120) // Episode lists shift on every new upload; 2 min balances freshness. return c.JSON(http.StatusOK, payload) } @@ -80,5 +81,6 @@ func (h *Handlers) GetEpisode(c echo.Context) error { feedID2 := parseInt64(c.QueryParam("feedId"), 0) payload["social"] = h.episodeSocialCounts(c.Request().Context(), feedID2, episodeID) + setPublicCache(c, 120) return c.JSON(http.StatusOK, payload) } diff --git a/appview/handlers/handlers.go b/appview/handlers/handlers.go index a45cf7f..c09feb9 100644 --- a/appview/handlers/handlers.go +++ b/appview/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "log/slog" "net/http" "strconv" @@ -84,6 +85,24 @@ func writeJSONBlob(c echo.Context, payload json.RawMessage) error { return c.Blob(http.StatusOK, echo.MIMEApplicationJSONCharsetUTF8, payload) } +// setPublicCache marks the response cacheable by any cache (browser + +// shared CDN) for maxAgeSeconds. Only use on responses whose body does +// not depend on the caller's identity — e.g. Podcast Index proxy results +// plus feed-level social counts, but not comment/recommendation lists +// that carry a per-caller block filter. +// +// `public` is required (not just `max-age=`) because the request usually +// carries an Authorization header; per RFC 7234 §3.2 shared caches treat +// authenticated responses as uncacheable unless the directive opts in. +// See appview/CACHING.md for the full strategy. +func setPublicCache(c echo.Context, maxAgeSeconds int) { + if maxAgeSeconds <= 0 { + return + } + c.Response().Header().Set("Cache-Control", + fmt.Sprintf("public, max-age=%d", maxAgeSeconds)) +} + // requestingDID returns the authenticated user's DID, or empty string // for unauthenticated or service/admin tokens with wildcard subjects. func requestingDID(c echo.Context) string { diff --git a/appview/handlers/podcast.go b/appview/handlers/podcast.go index f556608..128e2d6 100644 --- a/appview/handlers/podcast.go +++ b/appview/handlers/podcast.go @@ -38,6 +38,7 @@ func (h *Handlers) GetPodcast(c echo.Context) error { } payload["social"] = h.podcastSocialCounts(c.Request().Context(), feedID) + setPublicCache(c, 300) // 5 min — upstream TTL is 24h; social counts mutate on every new sub/comment. return c.JSON(http.StatusOK, payload) } @@ -70,6 +71,7 @@ func (h *Handlers) GetTrending(c echo.Context) error { } storeFeedItems(payload, key, items) + setPublicCache(c, 300) // Trending shifts slowly; 5 min is a good edge-TTL. return c.JSON(http.StatusOK, payload) } @@ -79,6 +81,7 @@ func (h *Handlers) GetCategories(c echo.Context) error { h.logger.Warn("get categories failed", "err", err) return writeError(c, http.StatusBadGateway, "UpstreamError", "podcast index request failed") } + setPublicCache(c, 3600) // Categories are effectively static — hour-long cache is safe. return writeJSONBlob(c, payload) } @@ -90,6 +93,7 @@ func (h *Handlers) GetRecentEpisodes(c echo.Context) error { h.logger.Warn("get recent episodes failed", "err", err) return writeError(c, http.StatusBadGateway, "UpstreamError", "podcast index request failed") } + setPublicCache(c, 60) // Freshness matters more than hit rate here. return writeJSONBlob(c, raw) } @@ -99,6 +103,7 @@ func (h *Handlers) GetStats(c echo.Context) error { h.logger.Warn("get stats failed", "err", err) return writeError(c, http.StatusBadGateway, "UpstreamError", "podcast index request failed") } + setPublicCache(c, 300) return writeJSONBlob(c, raw) } diff --git a/appview/handlers/recommendation.go b/appview/handlers/recommendation.go index a7da0be..342d1d5 100644 --- a/appview/handlers/recommendation.go +++ b/appview/handlers/recommendation.go @@ -115,5 +115,6 @@ func (h *Handlers) GetPopular(c echo.Context) error { return h.internalError(c, "GetPopular.scan", err) } + setPublicCache(c, 300) // Popularity moves slowly; 5 min is generous without being stale. return c.JSON(http.StatusOK, map[string]any{"items": rows}) } diff --git a/appview/handlers/search.go b/appview/handlers/search.go index ea13dac..feec1e8 100644 --- a/appview/handlers/search.go +++ b/appview/handlers/search.go @@ -47,6 +47,7 @@ func (h *Handlers) SearchPodcasts(c echo.Context) error { } storeFeedItems(payload, key, items) + setPublicCache(c, 300) // Search results are query-deterministic; 5 min gives repeat queries a free ride. return c.JSON(http.StatusOK, payload) } @@ -62,5 +63,6 @@ func (h *Handlers) SearchEpisodes(c echo.Context) error { h.logger.Warn("episode search failed", "err", err) return writeError(c, http.StatusBadGateway, "UpstreamError", "podcast index request failed") } + setPublicCache(c, 300) return writeJSONBlob(c, payload) } diff --git a/appview/replica.go b/appview/replica.go new file mode 100644 index 0000000..a663bcc --- /dev/null +++ b/appview/replica.go @@ -0,0 +1,42 @@ +package appview + +import ( + "fmt" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/plugin/dbresolver" +) + +// registerReadReplica wires the dbresolver plugin to route reads to +// cfg.DatabaseReaderURL when that is non-empty. Writes always stay on the +// primary opened by the caller. When the config is empty the function is +// a no-op so single-DB deployments keep their existing behavior. +// +// Transactions and locked reads (FOR UPDATE) route to the primary via +// dbresolver's default policy. Replica lag is a real concern — callers +// that must read-their-own-write should wrap the sequence in a +// transaction, which pins the whole block to the primary. +func registerReadReplica(db *gorm.DB, cfg Config) error { + if cfg.DatabaseReaderURL == "" { + return nil + } + + resolver := dbresolver.Register(dbresolver.Config{ + Replicas: []gorm.Dialector{postgres.Open(cfg.DatabaseReaderURL)}, + Policy: dbresolver.RandomPolicy{}, + }) + + // Apply the same pool shape to the replica so saturation behavior + // stays predictable across both databases. + resolver = resolver. + SetMaxOpenConns(cfg.DBMaxOpenConns). + SetMaxIdleConns(cfg.DBMaxIdleConns). + SetConnMaxLifetime(cfg.DBConnMaxLifetime). + SetConnMaxIdleTime(cfg.DBConnMaxIdleTime) + + if err := db.Use(resolver); err != nil { + return fmt.Errorf("install dbresolver: %w", err) + } + return nil +} diff --git a/appview/server.go b/appview/server.go index 54a18d8..dca16bb 100644 --- a/appview/server.go +++ b/appview/server.go @@ -59,6 +59,22 @@ func NewServer(cfg Config) (*Server, error) { return nil, fmt.Errorf("connecting to database: %w", err) } + // Apply pool settings before migrations so the migration runner sees + // the same shape as live traffic. go-sql's defaults are unbounded on + // MaxOpenConns, which can saturate Railway's Postgres instance. + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("resolving sql.DB for pool tuning: %w", err) + } + sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns) + sqlDB.SetConnMaxLifetime(cfg.DBConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(cfg.DBConnMaxIdleTime) + + if err := registerReadReplica(db, cfg); err != nil { + return nil, fmt.Errorf("registering read replica: %w", err) + } + if err := database.RunMigrations(db); err != nil { return nil, fmt.Errorf("running database migrations: %w", err) } diff --git a/cmd/effem-appview/main.go b/cmd/effem-appview/main.go index 677d315..711a148 100644 --- a/cmd/effem-appview/main.go +++ b/cmd/effem-appview/main.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/urfave/cli/v2" "tangled.org/sparrowtek.com/effem-AppView/appview" @@ -27,7 +28,36 @@ func main() { Name: "database-url", Value: "postgres://effem:effem@localhost:5432/effem?sslmode=disable", EnvVars: []string{"EFFEM_DATABASE_URL"}, - Usage: "PostgreSQL connection string", + Usage: "PostgreSQL connection string (primary; receives writes)", + }, + &cli.StringFlag{ + Name: "database-reader-url", + EnvVars: []string{"EFFEM_DATABASE_READER_URL"}, + Usage: "Optional read-replica connection string; when set, reads route here", + }, + &cli.IntFlag{ + Name: "db-max-open-conns", + Value: 25, + EnvVars: []string{"EFFEM_DB_MAX_OPEN_CONNS"}, + Usage: "Maximum concurrently-open DB connections", + }, + &cli.IntFlag{ + Name: "db-max-idle-conns", + Value: 5, + EnvVars: []string{"EFFEM_DB_MAX_IDLE_CONNS"}, + Usage: "Warm-pool size for DB connections", + }, + &cli.DurationFlag{ + Name: "db-conn-max-lifetime", + Value: 5 * time.Minute, + EnvVars: []string{"EFFEM_DB_CONN_MAX_LIFETIME"}, + Usage: "Maximum age of a DB connection before it is rotated (0 disables)", + }, + &cli.DurationFlag{ + Name: "db-conn-max-idle-time", + Value: 5 * time.Minute, + EnvVars: []string{"EFFEM_DB_CONN_MAX_IDLE_TIME"}, + Usage: "Maximum idle duration before a DB connection is closed (0 keeps forever)", }, &cli.StringFlag{ Name: "relay-host", @@ -146,6 +176,11 @@ func run(cctx *cli.Context) error { cfg := appview.Config{ Bind: cctx.String("bind"), DatabaseURL: cctx.String("database-url"), + DatabaseReaderURL: cctx.String("database-reader-url"), + DBMaxOpenConns: cctx.Int("db-max-open-conns"), + DBMaxIdleConns: cctx.Int("db-max-idle-conns"), + DBConnMaxLifetime: cctx.Duration("db-conn-max-lifetime"), + DBConnMaxIdleTime: cctx.Duration("db-conn-max-idle-time"), RelayHost: cctx.String("relay-host"), PLCHost: cctx.String("plc-host"), PIKey: cctx.String("podcast-index-key"), diff --git a/go.mod b/go.mod index 450c7e0..5894d98 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/urfave/cli/v2 v2.27.6 golang.org/x/time v0.5.0 gorm.io/driver/postgres v1.5.11 - gorm.io/gorm v1.25.12 + gorm.io/gorm v1.26.0 ) require ( @@ -95,11 +95,12 @@ require ( go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect - golang.org/x/sync v0.7.0 // indirect + golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect lukechampine.com/blake3 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index c3a7c14..73d944a 100644 --- a/go.sum +++ b/go.sum @@ -327,6 +327,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 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= @@ -341,6 +342,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -358,6 +361,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -372,6 +377,7 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 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= @@ -401,6 +407,10 @@ gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs= +gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=