feat: add typed ESI transport extensions

This commit is contained in:
jb
2026-09-19 15:43:22 -04:00
parent 4d3c7a7521
commit 2675ea8114
14 changed files with 1205 additions and 124 deletions
+77 -18
View File
@@ -1,9 +1,11 @@
# ESI operations
The server ESI adapter uses the fixed trusted base `https://esi.evetech.net` and pins
`X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes only the
character asset GET and global market-price GET needed by the application; callers
cannot provide an arbitrary ESI URL.
`X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes a fixed
allowlist of typed safe reads: character assets (paged GET), character asset names
and locations (bounded read-only POST), character wallet balance (unpaged GET), and
global market prices (unpaged GET). Callers cannot provide an arbitrary ESI
URL, host, method, or POST body.
## Asset synchronization
@@ -12,37 +14,94 @@ reads `X-Pages`, fetches all remaining pages, de-duplicates by `item_id` while
checking for duplicates, and revalidates page one after the other pages. A changed
page count, duplicate ID, malformed page, failed request, or changed first page
fails the operation. The caller must not persist a returned partial result: the
method returns only a complete `{ assets, syncedAt, expiresAt }` snapshot. Asset
IDs are retained as decimal strings, and the response is parsed with
`lossless-json` so an int64 cannot be rounded by JavaScript.
method returns only a complete bounded-read snapshot with
`consistency: "bounded-non-atomic"`. Asset IDs are retained as decimal strings,
and the response is parsed with `lossless-json` so an int64 cannot be rounded by
JavaScript.
This is a stability check, not a transaction. ESI's paginated endpoint does not
promise a true multi-page snapshot while its cache expires between page requests.
A caller should stage pages and replace the persisted Convex snapshot only after
`getAssets` resolves. Convex persistence is intentionally outside this adapter.
Only page one is conditionally revalidated: revalidating every page would multiply
remote calls up to the bounded page cap and still could not make ESI's independently
served pages atomic. The explicit `bounded-non-atomic` coverage state prevents this
adapter from claiming transactional consistency. A caller should stage pages and
replace the persisted Convex snapshot only after `getAssets` resolves. Convex
persistence is intentionally outside this adapter.
The cache is private to an `EsiClient` and keyed by user/authorization context,
character, and scopes. `authorizationKey` should therefore identify the linked
application user and include the character/scopes when available:
The cache is private to an `EsiClient` and keyed by a structured authorization
context: application user/principal, character, sorted scopes, `grantRevision`,
`authorizationEpoch`, and committed `credentialVersion`. Production callers pass
the current counters:
```ts
{ userId, characterId, scopes }
{ userId, principal, characterId, scopes, grantRevision, authorizationEpoch, credentialVersion }
```
A string key is supported for callers that have no scope object, but is treated as
an isolated user key. Tokens are never used as cache keys or emitted in metrics.
Tokens are never used as cache keys or emitted in metrics. Private reads reject
legacy string keys and require all structured fields, including the matching
character, required scope, `grantRevision`, `authorizationEpoch`, and
`credentialVersion`. Changing any authority counter therefore cannot reuse a prior
private body or snapshot.
Responses retain their raw text, ETag, Last-Modified, and cache metadata. Fresh
responses use the server TTL; stale responses send `If-None-Match` and
`If-Modified-Since`, and a 304 reuses the cached body. `Cache-Control`, `Expires`,
`If-Modified-Since`, and a 304 reuses the cached body for both GET and declared
read-only POST operations. Canonical helper bodies are sorted decimal int64 JSON
literals, so ordering variants share one cache identity; operation/method/body
cannot collide. Helper requests accept 1..1000 unique positive signed-int64 IDs
and reject unsafe/duplicate/oversize input without Number coercion. Each operation
also enforces a response `Content-Length`/stream byte cap and result-row cap before
retaining a cache entry; asset pages and aggregate snapshots have separate bounds
(1,000 pages, 5,000 rows/page, 50,000 rows/snapshot). `Cache-Control`, `Expires`,
`Date`, and `Age` are honored. `no-store` responses retain neither body nor
validators. Requests have one deadline covering response-body reads and bounded
retries for transient network/5xx/420/429 responses. `Retry-After` (seconds or
HTTP date) and the legacy ESI error-limit reset are respected. Exhausted new
limiter buckets are cooled down per authorization context and group; the legacy
error limiter is cooled down globally. A server delay that exceeds the remaining
request budget returns a typed rate-limit error rather than retrying early. 401
and 403 are never blindly retried.
error limiter is cooled down globally. Query a private cooldown with
`client.getCooldown(authorizationKey, characterId)` or the public limiter with
`client.getPublicCooldown()`. A server delay that exceeds the remaining request
budget returns a typed rate-limit error with `retryAt`, `retryAfterMs`, and
scheduler-readable `cooldown` rather than retrying early. Response cancellation is
abort-first and non-blocking, so cleanup cannot extend a deadline. Process-local
retention is bounded by weighted response bytes and aggregate snapshot rows;
`client.getCacheStats()` exposes current usage and both budgets. Eviction is
oldest-insertion deterministic. 401 and 403 are never blindly retried. OAuth exchange/refresh remain in `EveSso` and never use
these safe-read retries.
## Asset names and locations
`EsiClient.getAssetNames` and `getAssetLocations` call fixed character helper
routes with the asset scope. Their outbound body is canonicalized and serialized
as numeric JSON literals (including IDs above `2^53`). Typed observations include
requested IDs, returned values, explicit `missing` IDs, `complete`, and
retrieval/expiry provenance. A missing helper result is never silently represented
as complete enrichment. Duplicate or unexpected IDs, malformed names, non-finite
coordinates, and malformed arrays fail closed. Deterministic transport fixtures live
under `tests/fixtures/esi/` so int64 names, finite positions, signed balances,
and versioned route/scope metadata are parsed from raw JSON rather than rounded
JavaScript literals. Asset helper metadata cites pinned OpenAPI evidence; wallet
metadata cites the current official OpenAPI retrieval and explicitly records that
it is not an immutable historical capture.
## Wallet balance
`EsiClient.getWalletBalance` reads the unpaged character wallet route with the
wallet scope. The adapter returns only a transport-level observation with signed
integer cents in `amount` (via T04's half-away-from-zero `decimalIskToCents`
normalization) and `retrievedAt` / `expiresAt`; sub-cent source values are rounded
by that approved policy, not rejected. Persistence, scheduling, and UI/service
orchestration belong to later work. Negative and very large balances remain
lossless, while malformed/non-finite numeric bodies are rejected. The tracked
fixture metadata records the official OpenAPI URL, operation ID, 120-second
TTL, `char-wallet` limiter, response SHA-256
`9a9a0f4af77216300e960cfd2576c9baac085a8170022e40c928c28622d80fc6`, and the
2026-09-19T19:30:12Z retrieval. This is a current retrieval using the pinned
compatibility date, not an immutable historical capture from 2026-08-18. Official
references: [ESI OpenAPI](https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18),
[versioning](https://developers.eveonline.com/docs/services/esi/overview/),
[caching](https://developers.eveonline.com/docs/services/esi/overview/#caching),
and [rate limiting](https://developers.eveonline.com/docs/services/esi/rate-limiting/).
## Market estimates
+12
View File
@@ -8,6 +8,18 @@
**Planning baseline:** `fdcc486` (`Initial Commit`). The roadmap records an earlier 97-test gate; each prototype change reruns the current local suite. Existing `jobs`, `userPreferences` and `referenceSyncMetadata` tables are placeholders, not services.
**T06 evidence:** The centralized ESI client now has fixed typed asset, asset-name,
asset-location, wallet-balance, and market-read strategies with bounded response
bytes/rows/pages, bounded retries, abort-first cleanup, validators, canonical int64
helper bodies, complete scoped authority-context cache identity, cooldown queries,
and runtime schemas. Asset snapshots report bounded non-atomic coverage; page-one
revalidation is not presented as a transaction. T07 durable scheduling/worker
orchestration, T08 location/service orchestration, and T13 wallet persistence/UI/
service orchestration remain unimplemented; production-readiness gates remain
deferred. The narrow `beginSync` expected-authority fence is part of T06's atomic
bridge contract: it prevents transport cache authority from being published under
a different T05 grant/revision/credential fence; it is not T07 durable worker orchestration.
## Execution model
- One task per branch/worktree; branch suggestions below are labels, not created branches.