FigNet.Persistence - Architecture Overview
This document is the on-ramp for a developer who has never seen the codebase. Read it once and you should know what runs where, what talks to what, and where to look next.
It is the top of the documentation tree for the BaaS (Backend-as-a-Service) layer.
1. What this is
FigNet.Persistence is a self-hosted, multi-tenant BaaS layer that sits next to FigNet's real-time game servers (EntangleServer for gameplay, FnVoiceServer for voice). It owns the slow, durable, cross-session state that a game wants but doesn't want to keep in-memory in a room process: accounts, profiles, wallets, IAPs, leaderboards, friends, presence, chat, notifications, remote config, analytics, server-side scripts, scheduled jobs.
It is shaped like Talo / Nakama / PlayFab, but is intentionally:
- Self-hosted. You run it on your own VPS. No external SaaS dependency. The same image runs on Hetzner, DigitalOcean, AWS, or a laptop.
- Multi-tenant from day one. Every player-facing row carries an
app_id. One Persistence instance can serve N games. - Opinionated about the data layer. PostgreSQL is the system of record. Redis is the hot-path cache + realtime pub/sub. Object storage (S3-compatible: S3, R2, Spaces, MinIO) is for user-generated content + backups.
- Source-gen JSON only. Reflection-based JSON is disabled. Every DTO registers in
PersistenceJsonContext.cs. This is non-negotiable - it's how the trimmed self-contained binary stays AOT-friendly and fast. - No EF Core. Data access is linq2db - a thin LINQ-to-SQL provider with explicit
Insert/Update/InsertWithIdentityand no change-tracking magic. See section 10 for the ground-rules.
2. The shipping fleet
The product runs as a fixed set of processes. The minimum cluster is six containers.
| Process | Project | Public port | Internal port | Purpose |
|---|---|---|---|---|
fignet-persistence-api | FigNet.Persistence.Api | 8090 (HTTP/1.1, WSS) | 8091 (h2c) | Player REST + WSS; S2S admin endpoints over h2c |
fignet-persistence-worker | FigNet.Persistence.Worker | - | - | Background hosted services (heartbeat, restart listener). Cron/jobs currently run in the API, not the worker - the worker is a placeholder for future stream consumers. |
entangleserver | EntangleServer | 8080 (admin HTTP+WS), 9000 (game WSS), 5559 (UDP RUDP) | - | Game runtime. Hosts the admin dashboard + the PersistenceProxy that S2S-signs into 8091. |
postgres | postgres:16-alpine | - | 5432 | System of record. |
redis | redis:7-alpine | - | 6379 | Cache, pub/sub, presence, locks, in-process snapshots. |
minio (or external S3) | minio/minio | 9000/9001 dev | - | UGC content + PG backups + Redis snapshots. |
In front of the public traffic is a reverse proxy - Caddy in the reference deployment - that terminates TLS and routes by hostname. The Persistence API does not terminate TLS itself.
How the boxes talk
Browser / Unity SDK ──TLS──> Caddy ──HTTP/1.1──> Persistence.Api :8090 ─┐
──WSS────────> Persistence.Api :8090 ─┤
├─> PG (5432)
Admin dashboard browser ─TLS──> Caddy ──HTTP/1.1──> EntangleServer :8080├─> Redis (6379)
│ ├─> S3/MinIO (9000)
│ h2c │
└────────────> Persistence.Api :8091
(PersistenceProxy + S2S HMAC)
The dashboard never speaks directly to Persistence.Api. Every admin call goes EntangleServer -> :8091. This is deliberate - see section 4.
3. The trust + auth model
There are exactly three credential types on the wire. Mixing them up is the most common cause of "why is this 401?".
3a. Player JWT - Bearer token on /v1/*
Issued by AuthFlow on login. Signed HS256 with PERSIST_JWT_SECRET. Validated by ASP.NET's AddJwtBearer (Program.cs:449). The same secret signs and validates - there is no public-key story here, which is fine because only Persistence issues + Persistence validates.
iss=fignet-persistence,aud=fignet(configurable, seePersistence.yaml).sub= account id (canonical primary key - UUID).accessTtldefaults 15 min,refreshTtl30 days. Refresh tokens live in PG (sessionstable) so revocation is a DELETE.- For the WSS endpoint
/v1/realtime, the token comes through?token=in the URL (browsers can't set custom headers on the upgrade request). SeeProgram.cs:466-478.
3b. Admin JWT - Bearer token on /v1/admin/*
Issued by AdminAuthEndpoints (login flow, separate from player flow). Same signing key, different audience. Carries role (super / admin / support / readonly) and sub (admin user id from admin_users).
Scopes are derived from role + extra_scopes union, not stored in the JWT body. See AdminScopes.cs. Every admin endpoint declares its required scope; the check happens inside the handler (no global authorization filter).
3c. Server-to-server HMAC - X-FigNet-S2S-* headers on :8091
The proxy hop. EntangleServer's PersistenceProxy signs every request with HMAC-SHA256(timestamp + "." + body) using PERSIST_S2S_SECRET. The header set:
X-FigNet-S2S-Ts: <unix-millis>
X-FigNet-S2S-Sig: <base64 hmac>
X-Admin-Sub: <admin user id> # propagated from admin JWT
X-Admin-Role: <super|admin|support|readonly>
X-Admin-Username: <admin username>
X-Admin-Scopes: <comma-separated extra scopes>
Validation happens in S2sGuard. The timestamp is rejected if more than 5 minutes off (clock-skew tolerance). The body is streamed through IncrementalHash rather than buffered.
Why two hops instead of one? The dashboard already authenticates to EntangleServer (the existing admin surface). Letting EntangleServer mint S2S to Persistence means the admin JWT verification stays in one place + the admin session cookie stays single-origin. The cost is the extra TCP hop - which is why :8091 uses h2c (HTTP/2 cleartext) for multiplexing. See section 4.
4. Two ports / two protocols
The API listens on two ports (Program.cs:80-112).
| Port | Protocol | Listener | Used by |
|---|---|---|---|
| 8090 | HTTP/1.1 + WebSocket | HttpProtocols.Http1 | Player SDK, browser game client. WSS upgrade only works over HTTP/1.1 (RFC 6455). |
| 8091 | HTTP/2 cleartext (h2c) | HttpProtocols.Http2 | PersistenceProxy only. Never mapped to the host in compose - internal Docker network only. |
The h2c port exists because every admin click in the dashboard becomes an S2S call. h2 gives multiplexing (many concurrent admin calls reuse one TCP connection) + HPACK header compression (the five X-Admin-* headers repeat on every call). On the client side (PersistenceProxy.cs), getting h2c to actually engage was a two-part trap:
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true)must run before theHttpClientstatic field initializes. This is done via an ordered static field initializer so the textual order is the runtime order.HttpClient.DefaultRequestVersionis ignored byHttpRequestMessagebecause the message ctor pre-populatesVersion = 1.1. Override per-request:req.Version = HttpVersion.Version20; req.VersionPolicy = RequestVersionExact;.
If you're debugging "why is this admin call 400/500", the first thing to check is that the request actually arrived as HTTP/2.
5. The data layer
5a. PostgreSQL
System of record. One database, one schema (public). Schema is defined by forward-only DbUp migrations under FigNet.Persistence.Core/Migrations/. They are embedded resources (.sql files), run in numeric order at API startup (Program.cs:508-519). Failed migrations abort startup - there is no graceful fallback.
Rules:
- Migrations are forward-only. Never edit a shipped one - write a new one that fixes it.
- No destructive change on a populated table without an
*_archivedmirror. See migrations 023 / 025. - Every player-facing table has
app_id. Composite indexes start withapp_idso the query planner can prune by tenant. - Analytics tables are PG-declarative-partitioned by day.
analytics_events_YYYYMMDDchild tables are created daily byAnalyticsPartitionManagerand dropped on retention by thearchive-cleanupjob.
5b. linq2db (not EF)
linq2db is a thin LINQ-to-SQL provider with explicit CRUD. There is no change-tracking, no lazy loading, no migrations from the ORM (that's DbUp's job).
The persistent connection pattern is always:
using var db = _dbFactory.Create(); // PersistenceDbFactory; opens NpgsqlConnection + wraps in DataConnection
// ... .InsertAsync / .UpdateAsync / .DeleteAsync / .ToListAsync
Disposal footgun - any read method that returns a Task without await will dispose the DataConnection before the query runs. Always async + await on linq2db reads.
Nested-transaction footgun - BeginTransaction on a connection that already has an open transaction will throw. Services that might be called inside a parent transaction (e.g. WalletService.DebitAsync called from ShopService.PurchaseAsync) check db.Transaction != null and join rather than open a fresh one. This pattern is standard for any service that participates in a multi-write atomic flow.
5c. Redis
Hot path for everything that isn't a system-of-record write. Configured with an explicit ConfigurationOptions (Program.cs:161-171):
AbortOnConnectFail = false- process boots even if Redis is briefly unreachable; cache reads fall through to PG.KeepAlive = 60- PING every minute so middle-boxes don't drop the connection.ConnectRetry = 3, 5s timeouts.
Surfaces using Redis:
| Surface | Use |
|---|---|
Cache | Cache-aside for accounts, profiles, shop catalog, remote-config bundles. Pre-rendered JSON byte[] to skip a second serialize. |
Pubsub | cache:invalidate for cross-replica cache busting, rt:user:{id} for realtime push, scripts:{appId}:invalidate for hot-reload. |
Presence | presence:{appId}:{accountId} short-TTL keys; PresenceService sets + clears. |
SchedulerLock | lock:job:{appId}:{name} - distributed at-most-once for the cron tick. |
BanBlocklist | bans:active SET - fast SISMEMBER check on every authenticated request. |
Telemetry | In-process counter snapshot mirror of OTEL meters (so dashboards don't have to scrape Prometheus). |
ScriptKv | Per-app, per-script KV available to TS scripts via fignet.kv.*. |
Every one of those surfaces is wrapped in a per-surface circuit breaker - see RedisBreaker.cs. When the breaker opens, the surface's fail-open / fail-closed policy decides what happens. The dashboard's /ops/redis-breakers page surfaces the live state. Specifically:
BanBlocklist,Cache,Presence,Pubsub,Telemetry,ScriptKvare fail-open.SchedulerLockis fail-closed - better to miss a cron tick than double-fire it on every replica.
5d. Object storage (S3-compatible)
Abstracted behind IContentStore. The implementation is S3ContentStore - works against AWS S3, Cloudflare R2, DigitalOcean Spaces, Hetzner Object Storage, MinIO, and Firebase Storage (which exposes an S3 gateway). Only endpoint + keys differ.
Two responsibilities:
- UGC -
/v1/content/*issues presigned PUT URLs that point at the public endpoint, players upload directly, server records the metadata row incontent_items. - Backups -
PgBackupServicespawnspg_dump | gzipand streams tobackups/pg/YYYY/MM/DD/....PgRestoreServicereverses it. The Dockerfile installspostgresql-client-16for exactly this.
There are two endpoints in the config: Endpoint (server-side, Docker DNS) and PublicEndpoint (the URL embedded into presigned URLs handed to browsers). In prod, these should be the same URL.
6. The HTTP pipeline
Order matters. From Program.cs:612-641:
UseRouting
└─> UseWebSockets
└─> MaintenanceMiddleware (503 if maintenance_mode != off, before any other work)
└─> LoadShedMiddleware (ConcurrencyLimiter, 503 + Retry-After if cap hit)
└─> UseRateLimiter (per-policy fixed-window limiter)
└─> UseAuthentication (JWT validation)
└─> BanCheckMiddleware (SISMEMBER bans:active)
└─> UseAuthorization
└─> endpoint
Maintenance + load-shed sit before auth and rate-limit on purpose: a 503 cycle should cost nothing. Admin / internal / health paths bypass both middleware via BypassPathPrefixes in PersistenceConfig.cs so operators can always reach the dashboard during an incident.
Rate-limit policies are config-driven (OpsEndpoints.ResolveActivePolicies). Adding a new policy is two lines: a row in the config and .RequireRateLimiting("name") on the endpoint group. Operator overrides via /ops/rate-limits are stored in system_flags and applied on next restart.
7. Realtime model
A single WSS endpoint, /v1/realtime, served on port 8090 by RealtimeEndpoint. The SDK opens one connection per logged-in account. The token is passed via ?token=<jwt> (browsers can't set headers on the upgrade).
The wire is JSON envelopes:
{ "kind": "wallet.changed", "topic": "wallet:abc-123", "ts": 1716200000000, "payload": { ... } }
Producers publish via IEventPublisher. There are two flavours:
- Per-user push -
PublishToUserAsync(accountId, envelope)on Redis channelrt:user:{accountId}. The connected SDK receives it. - Topic fanout -
PublishFanoutAsync(topic, envelope)on Redis channelrt:topic:{topic}. Anyone subscribed to that topic receives it. Subscribe ACL (SubscriptionAcl.cs) enforces that a caller can only subscribe to their own topics + their friends' presence.
Topic-emitting surfaces today: profile.changed, metadata.changed, wallet.changed, leaderboard.changed, friends.*, chat.dm, chat.group.*, presence.*, notification.received, and the moderation system.kick / system.banned.
Worth knowing: Redis pub/sub is at-most-once - if the SDK is offline at the moment of publish, the message is dropped on the floor. Anything that has to survive offline is also persisted (chat DMs to chat_dm_messages, notifications to the inbox).
8. Hooks + server-side scripts
Game teams who can't ship server code register TS/JS scripts that hook into business events.
8a. Hook registry
HookRegistry holds the dispatch table. Two flavours:
before.*- runs inside the service transaction. Can mutate the payload (abefore.wallet.debithook can adjust the amount). Can abort by throwing - the parent action rolls back. Used for: validation, business-rule injection, anti-cheat checks.after.*- runs after commit. Cannot mutate. Failures are logged and swallowed. Used for: notifications, side effects, downstream grants.
Hook kinds are enumerated in HookKind.cs.
8b. After-hook fanout to CLR listeners
after.* hooks also fan out to any C# implementation of IHookAfterListener. Today, ProfileTemplateService implements it - that's how event-triggered profile templates ("grant a welcome pack on first purchase") work without a TS script in between. The listener is resolved lazily via IServiceProvider rather than constructor-injected as IEnumerable<IHookAfterListener> - that avoids a DI cycle that previously hung the boot on a 400-cascade.
8c. The JS host
ScriptEngine is a Jint engine pool with a compiled-program cache. Source is TS, compiled to JS at save time, cached in Redis. Hot-reload watches the scripts:{appId}:invalidate channel and evicts on signal.
The runtime API (RuntimeApi/) is the "nakama runtime" - what scripts can call:
fignet.accounts.get(id)
fignet.wallet.debit(accountId, currency, amount, reason)
fignet.wallet.credit(...)
fignet.metadata.read(accountId, namespace, key)
fignet.metadata.write(accountId, namespace, key, value)
fignet.notifications.send(accountId, kind, payload)
fignet.analytics.track(accountId, eventName, payload)
fignet.kv.get(scriptKey, k) / fignet.kv.set(scriptKey, k, v)
fignet.log.info(msg) / .warn / .error
Sandboxing: no eval, no arbitrary require, no network outside fignet.*, no FS, memory cap, per-script CPU budget. Scripts that exceed the budget consecutive times get auto-disabled (ScriptTelemetry) - there's a wired threshold callback in Program.cs:583-592.
There's also an RPC surface, /v1/rpc/{name} (player JWT) and /v1/internal/rpc/{name} (S2S). Each registered RPC declares a permission (public / authenticated / admin) and an optional rate-limit policy.
9. Background work
9a. Worker process
FigNet.Persistence.Worker is a Host.CreateApplicationBuilder process that today hosts two services:
HeartbeatService- a placeholder. Future stream consumers (analytics shipping, durable outbox) will live here.WorkerRestartListener- subscribes to Redisworker:control:restart. On signal, callsIHostApplicationLifetime.StopApplication()thenEnvironment.Exit(0)after a 2s grace; Docker'sunless-stoppedpolicy resurrects.
9b. JobScheduler (in the API process)
Despite the name, the cron tick runs in the API, not the worker - see Program.cs:357. 1-minute Cronos tick + Redis lock per (app_id, name) for distributed at-most-once. Each tick consults scheduled_jobs, picks rows whose cron is due, and dispatches via JobRunner.
Built-in job handlers (each implements IBuiltInJobHandler):
refresh-token-gc- purges expiredsessionsrows.archive-cleanup- purges old*_archivedmirror rows.notification-broadcast- schedules a broadcast notification.daily-reward-grant- applies a profile template to a cohort.apply-template- generic scheduled template apply with audience filter.backup-pg-pg_dump | gzip -> S3.
A kind=script job invokes a registered TS RPC instead.
Operators see this in the dashboard's /jobs page; CRUD via /v1/admin/jobs/*.
9c. Analytics rollup
AnalyticsRollupScheduler is its own hosted service - runs hourly + daily rollups from the partitioned raw event tables into the smaller summary tables that the dashboard reads.
10. Observability
Three layers.
10a. Logs - ZLogger
Same choice as EntangleServer. Two sinks:
- Console - JSON-per-line. This is what
docker logsshows and what log shippers consume. - Rolling file - plain text, day-rolled, 50MB/file, under
PERSIST_LOG_DIR(default/app/logs). The dashboard's/logspage reads these via/v1/admin/ops/logs/*.
Minimum log level is mutable at runtime - RuntimeLogLevel.cs. The dashboard's /ops/log-level page flips it; effect is immediate, no restart needed. Resets to Information on next process boot.
10b. Metrics - OpenTelemetry -> Prometheus
ASP.NET Core + HttpClient + cache meters are exported on :8091/metrics (Prometheus scrape format). The persistence-specific meter is FigNet.Persistence.Cache (CacheMetrics.cs) - fignet_cache_hit_total{key_prefix=...} etc.
OTLP export is optional - set OTEL_EXPORTER_OTLP_ENDPOINT to ship traces + metrics to an external collector (Tempo, Honeycomb, etc).
The dashboard's /ops/cache page reads the in-process snapshot (CacheMetrics.Snapshot) rather than scraping Prometheus, so the in-product view works even without a Prometheus deployment.
10c. Audit log
AdminAuditService writes a row to admin_audit for every admin write. Schema: (admin_id, action, target_kind, target_id, payload_hash, result, ts). The dashboard's /audit page reads this through a paginated endpoint.
The non-negotiable rule: every admin write emits an audit row, including the failure path. If you add an admin endpoint, you add an audit emission.
11. Hot-path conventions
These are conventions baked into the codebase for the hot path. Follow them or the bench regresses.
- Source-gen JSON only. Every DTO + every collection of DTOs has an entry in
PersistenceJsonContext.cs. Reflection-based JSON throws at runtime. - linq2db nested-tx detection. If your service might be called inside someone else's transaction, check
db.Transaction != nulland join. Don't start a fresh one. ValueTaskonly where it pays. Specifically, cache-aside hot paths -AccountService.GetByIdAsync,ProfileService.GetAsync,ShopService.GetCatalogAsync,RemoteConfigService.ResolveAsync. Not blanket - the rest stayTaskbecauseValueTaskdoesn't help on the cold path and complicatesawaitpatterns.ArrayPool<byte>.Sharedfor hot byte-buffer allocations - HMAC composition inS2sHmac.csand Redis Streams payload composition inEventPublisher.cs. Alwaystry { ... } finally { ArrayPool.Return(buf); }.- Cache-aside with explicit invalidation. Every mutating service post-commits an
_invalidator.InvalidateAsync(keys)call. Keys are centralised inCacheKeys.cs- never hand-format a cache key inline. - Cross-replica cache fanout.
RedisCacheInvalidatorpublishes tocache:invalidateso other API replicas drop the entry too. - Streaming HMAC. S2S body validation is chunked through
IncrementalHash, not buffered. The request body is not consumed by the guard for downstream handlers - handlers that re-read the body (e.g. multipart upload) must re-wrap it from the buffer they got back, seeBackupsEndpoints.cs. - Idempotency keys. Payment receipt validation + shop purchase + wallet ledger all key by an opaque
idempotency_keyso a retried client request can't double-charge. Stored in PG with a unique constraint. [SkipLocalsInit]on tight inner loops (e.g. parsers, codec primitives). Documented per file.
12. The middleware-free admin auth path
One quirk worth flagging. The admin pipeline does not use ASP.NET's AddAuthentication for the admin role. The admin JWT is on the EntangleServer side; what arrives at Persistence :8091 is only S2S HMAC + the X-Admin-* headers. Each admin endpoint manually calls S2sGuard.CheckAdminScopeAsync(http, requiredScope) at the top of the handler and the handler bails out with the returned reject result if it fails. This is intentional - it keeps the admin-scope check next to the endpoint code that reads the X-Admin-Sub and writes the audit row.
13. Multi-tenancy
Every player-facing request carries an X-App-Id header (canonical) or the legacy X-FigNet-App header (still accepted for older SDK builds). The Unity SDK is constructed with an app id and adds it automatically; admin calls pass it explicitly via the EntangleServer proxy. Once a player has a JWT, the app claim baked into the token is the canonical source - the SDK doesn't need to keep sending the header on authed calls.
Without an app id, requests 400 with app_id_required. There is no silent "default" bucket - that fallback was removed because it let misconfigured clients accidentally blend into a fake shared tenant. The single resolver lives at FigNet.Persistence.Api/Internal/AppIdResolver.cs.
Segregation guarantees:
- Every player-facing row carries
app_id. Indexes lead withapp_idso the query planner prunes per tenant. - Friendships are app-scoped (
friendshipsPK is(app_id, account_id, other_id)). - Bans are per-app (
account_bans.app_id). Banning a cheater in App A doesn't kick them from App B. Redis blocklist SETs are per-app (bans:active:<appId>). - Sessions are per-app - revoke on ban scopes to one app.
- Payment dedupe is app-scoped - same Apple tx_id can legitimately exist in two apps (rare but architecturally clean).
- Realtime topic format for shared-name resources is
leaderboard:<appId>:<slug>. Account-keyed topics (wallet:<accountId>,profile:<accountId>,metadata:<accountId>:<ns>) use the globally-unique UUID directly, so no app segment is needed. - Cross-app target = 404, not 403. No existence-side-channel across tenants.
App ids are still arbitrary strings - there's no apps table today. A future phase is expected to introduce one as part of a customer-portal capability; existing rows with app_id='default' would be associated with a sentinel default app owned by the platform operator.
14. Configuration
There are three layers, in order of precedence (later overrides earlier):
Persistence.yaml- defaults baked into the image. Mounted to/app/api/Persistence.yaml. Override the whole file by mounting a different one.- Environment variables - every secret-bearing field has a
*_Envcounterpart that names the env var to read. Set in compose / k8s secrets /.env. The canonical list:
| Env var | Purpose |
|---|---|
PG_PASSWORD / PG_CONNECTION_STRING | Postgres credentials |
REDIS_CONNECTION_STRING | Redis (e.g. redis:6379) |
PERSIST_JWT_SECRET | Signs player + admin JWTs |
PERSIST_S2S_SECRET | EntangleServer -> Persistence HMAC |
PERSIST_S3_BUCKET / PERSIST_S3_ACCESS_KEY / PERSIST_S3_SECRET_KEY | Object storage |
PERSIST_ADMIN_BOOTSTRAP_USERNAME / _PASSWORD | Seed first admin on empty admin_users (no-op after) |
PERSIST_CONFIG_PATH | Override the YAML path |
PERSIST_LOG_DIR | Where the rolling files write (default /app/logs) |
PERSIST_MODE | api or worker (Dockerfile dispatch) |
OTEL_EXPORTER_OTLP_ENDPOINT | Optional OTLP target |
SMTP_DSN | Email-verify provider (if Email auth enabled) |
GOOGLE_CLIENT_ID / APPLE_TEAM_ID / etc. | External auth providers |
system_flagstable - runtime-mutable settings that the dashboard edits.maintenance_mode,ban_blocklist_enabled,rate_limit_overrides. Read at request time (with in-process caching + Redis fanout for cross-replica freshness).
The dashboard also has a direct YAML editor at /persistence-config - it edits the file on disk and triggers a restart. Use it sparingly; treat the file as the source of truth.
15. Development workflow
One command:
docker compose -f docker-compose.dev.yml -f docker-compose.persistence.yml --profile embedded-db up --build
This brings up: EntangleServer + dashboard + Persistence.Api + Persistence.Worker + Postgres + Redis + MinIO. The dashboard is at http://localhost:8080. The bootstrap admin is whatever is set in the dev environment file (which is never committed to source control).
Running a single test project:
dotnet test FigNet.Persistence.Tests/FigNet.Persistence.Tests.csproj
There is no aggregating solution that runs every test - each project stands alone. CI does not currently run persistence tests.
Running the bench:
FigNet.Persistence.Bench is a BenchmarkDotNet project separate from xUnit. It is not run in CI. Use it locally before / after a perf-sensitive change.
Adding a new endpoint:
- DTO records in the matching
Api/{Feature}/{Feature}Dtos.cs. Register every record inPersistenceJsonContext.cs- both the record itself and anyList<>/ array shapes you return. - Endpoint method in
Api/{Feature}/{Feature}Endpoints.cswith[Map{Feature}Endpoints]. - Service method in
Core/{Feature}/{Feature}Service.cs. If you cache, register aCacheKeys.*entry and invalidate on writes. - Migration
.sqlfile underCore/Migrations/(next number, embedded in csproj). - If admin-facing: declare scope, write audit. If player-facing: declare rate-limit policy.
- Add an SDK module + a demo controller.
Adding a new migration:
FigNet.Persistence.Core/Migrations/034_my_change.sql
DbUp picks it up automatically on next startup. Never edit a shipped migration. If you need to fix a mistake, write a new migration.