Skip to content

The SDE and the ESI client ​

Implements: ARCH-10 — see CHARTER.md Part 2. Programme: ROADMAP.md, Track S.

EVE Online has two sources of truth about the game. The ESI API says what is happening right now: who is online, what is for sale in Jita, which systems saw kills in the last hour. The Static Data Export (SDE) says what things are: that type 34 is Tritanium, that system 30000142 is Jita and sits in Caldari space at 0.95 security. ESI.ts ships both, as two independent parts of one package, and this guide explains the role each plays, why they are kept apart, and how a consumer joins them.

Topics with their own document are summarised here and linked:

TopicCanonical document
The HTTP client's layers, request path and middlewareARCHITECTURE.md
The SDE module's internals: load pipeline, storage, entity relationshipssde/ARCHITECTURE.md
Every IStaticDataProvider method, by familysde/REFERENCE.md
Provider patterns and query examplessde/USAGE.md
Adding an entity type or a YAML file to the registrysde/DEVELOPER_GUIDE.md
Error classes on both sides and where to import the guardsERRORS.md
The rule that keeps the two apart and the tests that hold itCHARTER.md, ARCH-10
The eleven overnight runs that bring the SDE up to the core's standardROADMAP.md
What protects the module: suites, coverage, mutation scores and floorssde/TESTING.md

Two sources of truth ​

PropertyESI (the client)SDE (the module)
What it answersState: prices, orders, assets, kills, skills, who is whereDefinitions: types, groups, systems, stargates, blueprints, dogma, lore
FreshnessSeconds to hours, governed by each endpoint's cache TTLOne build per CCP release; the same build answers the same way for months
How it is readHTTPS to esi.evetech.net, one request per question, paginatedOne load into memory at start-up, then synchronous Map lookups
AuthorisationPublic or EVE SSO scopes per endpointNone; the export is a public download
Failure modesNetwork, rate limits, 4xx/5xx, token expiry, circuit openFile missing, YAML malformed, peer not installed, build too old
ErrorsEsiError familySdeError family, extending Error (not EsiError)
CostLatency and error budget per callMemory (the full export is hundreds of megabytes) and load time
IdentityInteger IDs in snake_case fields: type_id, system_idThe same integer IDs in camelCase fields: typeId, systemId

The last row is the whole relationship. CCP assigns one ID space across both. An ESI market order carries type_id: 34; the SDE's getType(34) returns Tritanium. An ESI kill count carries system_id: 30000142; the SDE's getSolarSystem(30000142) returns Jita. The client tells you that something happened to 34 in 30000142; the SDE tells you what 34 and 30000142 are. Neither side needs to know the other exists for this to work, because the join key is CCP's, not ours.

How they complement each other ​

A consumer holds one client and one provider, and joins them with the ID:

typescript
import { EsiClient } from '@lgriffin/esi.ts';
import { SdeDataProvider } from '@lgriffin/esi.ts/sde';

const client = new EsiClient();
const sde = SdeDataProvider.fromDirectory('./sde-data');

Live orders, named:

typescript
const orders = await client.market.getMarketOrders(regionId);

const named = orders.map((order) => ({
  ...order,
  name: sde.getType(order.type_id)?.name ?? `type ${order.type_id}`,
}));

Live kills, placed on the map:

typescript
const kills = await client.universe.getSystemKills();

for (const kill of kills) {
  const system = sde.getSolarSystem(kill.system_id);
  if (!system) continue;
  const region = sde.getRegion(system.regionId);
  log.info(
    `${system.name} (${system.securityStatus.toFixed(1)}, ${region?.name}): ${kill.ship_kills} ship kills`,
  );
}

A character's assets, grouped by category:

typescript
const assets = await client.assets.getCharacterAssets(characterId);

const byCategory = new Map<string, number>();
for (const asset of assets) {
  const type = sde.getType(asset.type_id);
  const group = type ? sde.getGroup(type.groupId) : null;
  const category = group ? sde.getCategory(group.categoryId) : null;
  const key = category?.name ?? 'Unknown';
  byCategory.set(key, (byCategory.get(key) ?? 0) + asset.quantity);
}

Three things to notice. The ESI call is the only await; the SDE side is synchronous, so a join over ten thousand orders costs ten thousand Map lookups and no I/O. Every SDE lookup returns T | null and every collection returns [], so an ID the export does not know (a new type CCP shipped since the build you loaded) degrades to a fallback rather than a throw. And the join is written by the consumer, in the consumer's own terms, which is the deliberate design described next.

What is not in the package ​

There is no bridge. No client method calls the SDE, no SDE method calls the client, and no EsiClient option accepts a provider. A StaticDataResolver port inside the client was considered on 2026-09-27 and cut. If an ESI-to-SDE shim is ever wanted (a getMarketOrdersNamed(), say) it is a separate package that depends on both, above both.

Why:

  • Release independence. The SDE tracks CCP's export format, the client tracks CCP's API. Each moves on its own schedule. A wall between them means a new YAML file or a renamed field never forces a client release, and a new endpoint never forces an SDE one.
  • Dependency independence. The SDE reads CCP's files with js-yaml and adm-zip, which are optional peers. A consumer who only wants the client must never be made to install them, and @lgriffin/esi.ts/sde/memory exists for a consumer who wants the SDE types and the in-memory provider without them either.
  • The join is one line. sde.getType(order.type_id)?.name is as short as any helper could make it, and it composes with whatever the consumer already does. A bridge would have to guess which of the 99 provider methods to call for which of the more than 200 endpoints, and the guess would be wrong for someone.

The rule is ARCH-10, and the Isolation section below describes how it is held.


C4 Model ​

The diagrams use the same conventions as ARCHITECTURE.md: consumer in dark blue, this library in blue, generated artifacts in light blue, external systems in grey.

C4 Level 1 — System Context ​

ElementDescription
Consumer ApplicationNode.js (18 or newer) application that needs both what is happening in EVE and what the things involved are
ESI.ts clientThe HTTP SDK: auth, caching, rate limiting, circuit breaking, pagination, validation. Root entry point
ESI.ts SDE moduleThe offline lookup layer: loads CCP's export into memory, answers typed queries. ./sde and ./sde/memory entry points
EVE Online ESI APICCP's REST API at esi.evetech.net, secured by EVE SSO
EVE SSOOAuth2 authorisation server
CCP Static Datadevelopers.eveonline.com/static-data: the latest export as a ZIP of YAML files, and a latest.jsonl naming the current build
mermaid
flowchart TB
    consumer(["Consumer App"])

    subgraph pkg ["@lgriffin/esi.ts"]
        direction LR
        client["ESI client"]
        sde["SDE module"]
    end

    esi[/"ESI API"/]
    sso[/"EVE SSO"/]
    ccp[/"CCP Static Data"/]

    consumer -- "await client.x.y()" --> client
    consumer -- "sde.getType(id)" --> sde
    client -- "HTTPS + JSON" --> esi
    client -- "OAuth2 refresh" --> sso
    ccp -. "ZIP of YAML, downloaded once" .-> sde

    style consumer fill:#08427b,color:#fff,stroke:#073b6f
    style client fill:#1168bd,color:#fff,stroke:#0e5aa7
    style sde fill:#1168bd,color:#fff,stroke:#0e5aa7
    style esi fill:#999,color:#fff,stroke:#888
    style sso fill:#999,color:#fff,stroke:#888
    style ccp fill:#999,color:#fff,stroke:#888
    style pkg fill:none,stroke:#1168bd,stroke-width:2px,stroke-dasharray:5

There is no arrow between client and sde. The consumer holds both and joins their results by ID.

C4 Level 2 — Container Diagram ​

One package, two containers that share nothing but the zod dependency and the ports directory. The client's own containers are in ARCHITECTURE.md; here they are collapsed to show the boundary.

ContainerWherePurpose
Entry pointssrc/sde/index.ts, src/sde/memory.ts./sde exports everything; ./sde/memory exports the same types, errors, factory and MemorySdeProvider, but not SdeDataProvider, so it bundles none of the file code
Portsrc/sde/ports/IStaticDataProvider.tsThe 99-method contract every provider implements. Consumers type against it, not against a class
File adaptersrc/sde/providers/yaml/SdeDataProvider.tsfromDirectory(path) and fromZip(path). Reads CCP's YAML through the optional peers, normalises each record, stores it in Maps, builds foreign-key indexes on first use
Memory adaptersrc/sde/providers/memory/MemorySdeProvider.tsTakes typed arrays (MemorySdeData), implements the full port with no I/O. The test double, and the provider for consumers who bring their own data
Domainsrc/sde/domain/<domain>/{types,schemas}.ts109 entity interfaces and 110 z.looseObject schemas, one folder per SDE domain (universe, types, dogma, industry, market, characters, corporations, skins, content, ui), each schema beside the type it validates. Extra fields from a newer export survive validation
Ingestionsrc/sde/ingestion/SdeDownloader (fetch the ZIP, check the latest build), SdeExtractor (ZIP to YAML), transforms (field and locale normalisation), SDE_FILE_REGISTRY (the 102 file specs)
Optional peerssrc/sde/optionalPeers.tsLoads js-yaml and adm-zip on first use, not at import, and turns a missing one into an SdeError naming the install command
Errorssrc/sde/errors.tsSdeError, SdeValidationError, SdeVersionMismatchError and their guards. Extends Error, not EsiError: these are local data faults, not HTTP ones
Test datasrc/sde/testing/SdeTestDataFactory.tsOne create* method per entity with realistic defaults; createHierarchicalTestData() builds a connected universe
mermaid
flowchart TB
    consumer(["Consumer App"])

    subgraph pkg ["@lgriffin/esi.ts"]
        direction LR

        subgraph clientBox ["ESI client (root entry point)"]
            direction TB
            construction["Construction"]
            domain["Domain Clients"]
            pipeline["Request Pipeline"]
            resilience["Resilience"]
        end

        subgraph sdeBox ["SDE module (./sde, ./sde/memory)"]
            direction TB
            entry["Entry points"]
            port["IStaticDataProvider"]
            fileAdapter["SdeDataProvider"]
            memAdapter["MemorySdeProvider"]
            typesSchemas["Types + Schemas"]
            ingestion["Ingestion"]
            peers["Optional peers"]
        end
    end

    esi[/"ESI API"/]
    ccp[/"CCP Static Data"/]
    yaml[/"js-yaml, adm-zip"/]

    consumer --> construction
    consumer --> entry
    construction --> domain
    domain --> pipeline
    pipeline --> resilience
    pipeline -- "HTTPS" --> esi

    entry --> port
    entry --> typesSchemas
    fileAdapter -. implements .-> port
    memAdapter -. implements .-> port
    fileAdapter --> ingestion
    fileAdapter --> peers
    peers -- "require on first use" --> yaml
    ingestion -- "download once" --> ccp

    style consumer fill:#08427b,color:#fff,stroke:#073b6f
    style construction fill:#1168bd,color:#fff,stroke:#0e5aa7
    style domain fill:#1168bd,color:#fff,stroke:#0e5aa7
    style pipeline fill:#1168bd,color:#fff,stroke:#0e5aa7
    style resilience fill:#1168bd,color:#fff,stroke:#0e5aa7
    style entry fill:#1168bd,color:#fff,stroke:#0e5aa7
    style port fill:#1168bd,color:#fff,stroke:#0e5aa7
    style fileAdapter fill:#1168bd,color:#fff,stroke:#0e5aa7
    style memAdapter fill:#1168bd,color:#fff,stroke:#0e5aa7
    style typesSchemas fill:#1168bd,color:#fff,stroke:#0e5aa7
    style ingestion fill:#1168bd,color:#fff,stroke:#0e5aa7
    style peers fill:#1168bd,color:#fff,stroke:#0e5aa7
    style esi fill:#999,color:#fff,stroke:#888
    style ccp fill:#999,color:#fff,stroke:#888
    style yaml fill:#999,color:#fff,stroke:#888
    style clientBox fill:#e8e8e8,stroke:#aaa
    style sdeBox fill:#e8e8e8,stroke:#aaa
    style pkg fill:none,stroke:#1168bd,stroke-width:2px,stroke-dasharray:5

Again, no edge crosses from clientBox to sdeBox or back. npm run lint:layers fails the build if one appears.

C4 Level 3 — Component: the SDE module ​

What happens between fromDirectory() and getType(). The full component diagram, the load pipeline and the entity relationship diagram are in sde/ARCHITECTURE.md; this is the shape.

ComponentFileResponsibility
fromDirectoryproviders/yaml/SdeDataProvider.tsReads _sde.yaml for the build's version info, then walks SDE_FILE_REGISTRY; a file the directory lacks is skipped, so a partial extract loads
fromZipproviders/yaml/SdeDataProvider.tsThe same walk over the entries of the ZIP, through SdeExtractor, with no extraction to disk
loadRecordsproviders/yaml/SdeDataProvider.tsFor one file: yaml.load, then for each [key, record] pair transformRecordNative, then entities.set(tableName, Map<id, record>)
transformRecordNativeingestion/transforms.tsgroupID to groupId (recursively, into nested objects and arrays), {en: "Jita"} to "Jita", the YAML key injected as the primary key where the registry says so
entitiesproviders/yaml/SdeDataProvider.tsMap<tableName, Map<id, record>>. Every getX(id) is one get on the inner map
fkIndexesproviders/yaml/SdeDataProvider.tsMap<"table:field", Map<fkValue, record[]>>, built on the first getXByY that needs it and cached. Tables never filtered by a foreign key never pay for its index
99 typed methodsproviders/yaml/SdeDataProvider.ts, providers/memory/MemorySdeProvider.tsEach is a one-line call to getById, getByFk, getAllRecords, search or filterBy with the table name and the entity type fixed
requireOptionalPeeroptionalPeers.tscreateRequire(__filename) so the peer resolves from wherever the package is installed; a MODULE_NOT_FOUND for the peer itself becomes an SdeError with the install command
mermaid
flowchart LR
    subgraph load ["Load (once, at start-up)"]
        direction TB
        dir["fromDirectory / fromZip"]
        registry["SDE_FILE_REGISTRY<br/>102 file specs"]
        parse["yaml.load"]
        transform["transformRecordNative"]
        dir --> registry --> parse --> transform
    end

    subgraph store ["Store"]
        direction TB
        entities["entities<br/>Map&lt;table, Map&lt;id, record&gt;&gt;"]
        fk["fkIndexes<br/>built on first use"]
        entities -. "scan once" .-> fk
    end

    subgraph query ["Query (synchronous)"]
        direction TB
        byId["getType(34)"]
        byFk["getTypesByGroup(18)"]
        scan["searchTypesByName('trit')"]
    end

    transform --> entities
    byId --> entities
    byFk --> fk
    scan --> entities

    style dir fill:#1168bd,color:#fff,stroke:#0e5aa7
    style registry fill:#438dd5,color:#fff,stroke:#3c7fc0
    style parse fill:#1168bd,color:#fff,stroke:#0e5aa7
    style transform fill:#1168bd,color:#fff,stroke:#0e5aa7
    style entities fill:#1168bd,color:#fff,stroke:#0e5aa7
    style fk fill:#1168bd,color:#fff,stroke:#0e5aa7
    style byId fill:#08427b,color:#fff,stroke:#073b6f
    style byFk fill:#08427b,color:#fff,stroke:#073b6f
    style scan fill:#08427b,color:#fff,stroke:#073b6f
    style load fill:#e8e8e8,stroke:#aaa
    style store fill:#e8e8e8,stroke:#aaa
    style query fill:#e8e8e8,stroke:#aaa

SDE_FILE_REGISTRY is shown in the generated colour although it is hand-written today: it is the one table that says which of CCP's files exist and what their keys are, and nightly-sde.yml checks it against the real export every night (npm run sde:drift).


Entry points ​

Entry pointExportsNeedsFor
@lgriffin/esi.ts/sdeSdeDataProvider, MemorySdeProvider, IStaticDataProvider, every entity type, SdeError family, SdeTestDataFactoryjs-yaml when a YAML file is read; adm-zip when a ZIP is readAn application that loads CCP's export
@lgriffin/esi.ts/sde/memoryThe same, minus SdeDataProviderNothing beyond the packageTests, and applications that already have the data in another store and want the typed contract

The peers are loaded on first use, not at import, so import { SdeDataProvider } from '@lgriffin/esi.ts/sde' succeeds in an install that has neither; the first fromDirectory() then throws an SdeError that names the package and the npm install command. tests/tdd/sde/memory-entry-bundle.test.ts bundles src/sde/memory.ts and the built dist/sde/memory.{mjs,js} and fails if node:fs, js-yaml, adm-zip or better-sqlite3 (the SQLite driver an earlier design used, kept on the list as a guard) reaches either.

Getting the data ​

bash
npm install js-yaml adm-zip
npx ts-node scripts/sde/sde-ingest.ts --output sde-data

The script resolves the current build, downloads the ZIP from CCP, extracts the YAML files to ./sde-data/ and deletes the ZIP. sde-data/ is gitignored. fromZip() skips the extraction step and reads the archive directly.


The port ​

Consumers hold an IStaticDataProvider, not a SdeDataProvider, so the adapter is a start-up decision rather than a type that spreads through the application:

typescript
import type { IStaticDataProvider } from '@lgriffin/esi.ts/sde';

export function describeType(sde: IStaticDataProvider, typeId: number): string {
  const type = sde.getType(typeId);
  if (!type) return `Unknown type ${typeId}`;
  const group = sde.getGroup(type.groupId);
  return group ? `${type.name} (${group.name})` : type.name;
}

The 99 methods fall into families; each single-entity lookup returns T | null, each collection T[], and each search* takes an optional limit:

FamilyExamplesJoins to ESI on
Types and groupsgetType, getTypesByGroup, getGroup, getCategory, searchTypesByNametype_id on orders, assets, killmails, fittings, skills
GeographygetRegion, getConstellation, getSolarSystem, getStargate, searchSolarSystemsByNameregion_id, system_id on orders, kills, jumps, routes
Universe bodiesgetStar, getPlanet, getMoon, getAsteroidBelt, getLandmarkplanet_id, moon_id on structures and PI
Character and loregetFaction, getRace, getBloodline, getAncestry, getSchoolrace_id, bloodline_id, faction_id on characters
NPC infrastructuregetNpcCorporation, getNpcStation, getNpcCharacter, getCorporationActivitycorporation_id, station_id on locations and agents
MarketgetMarketGroup, getRootMarketGroups, getTypesByMarketGrouptype_id on orders and history
DogmagetDogmaAttribute, getDogmaEffect, getTypeDogma, getDogmaUnitattribute_id, effect_id on the dogma endpoints
IndustrygetBlueprint, getIndustryActivity, getPlanetSchematicblueprint_type_id, activity_id on industry jobs
Version and lifecyclegetVersion, closeSee Freshness

The complete list, family by family, is in sde/REFERENCE.md.


Errors ​

The two families do not share a base class, and that is the point. An EsiError carries HTTP semantics: a status, retryability, the error-limit headers. An SdeError is a local fault: the directory is missing, a YAML file did not parse, the build is older than the code expects, a peer is not installed. Nothing about retrying, rate limits or circuit breakers applies to it.

typescript
import { isEsiError } from '@lgriffin/esi.ts/errors';
import { isSdeError } from '@lgriffin/esi.ts/sde';
import { createConsoleLogger } from '@lgriffin/esi.ts';

const log = createConsoleLogger('info');

try {
  const orders = await client.market.getMarketOrders(regionId);
  const named = orders.map((o) => sde.getType(o.type_id)?.name);
  log.info(`${named.length} orders named`);
} catch (err) {
  if (isEsiError(err)) {
    // transient or not, per ERRORS.md; the pipeline has already retried what it should
  } else if (isSdeError(err)) {
    // the export is unusable: fix the install or the data, do not retry
  } else {
    throw err;
  }
}

In practice the SDE side throws at load time, not at query time: once fromDirectory() has returned, the query methods return null or [] for anything they cannot find and never throw. The hierarchy and the guards are in ERRORS.md.


Testing with both ​

MemorySdeProvider takes the same typed arrays SdeTestDataFactory produces, and implements the same port, so a test builds the universe it needs in a few lines and the code under test cannot tell the difference:

typescript
import {
  MemorySdeProvider,
  SdeTestDataFactory,
} from '@lgriffin/esi.ts/sde/memory';
import { createConsoleLogger } from '@lgriffin/esi.ts';

const log = createConsoleLogger('info');

const sde = new MemorySdeProvider({
  categories: [
    SdeTestDataFactory.createEveCategory({ categoryId: 4, name: 'Material' }),
  ],
  groups: [
    SdeTestDataFactory.createEveGroup({
      groupId: 18,
      categoryId: 4,
      name: 'Mineral',
    }),
  ],
  types: [
    SdeTestDataFactory.createEveType({
      typeId: 34,
      groupId: 18,
      name: 'Tritanium',
    }),
    SdeTestDataFactory.createEveType({
      typeId: 35,
      groupId: 18,
      name: 'Pyerite',
    }),
  ],
});

log.info(
  sde
    .getTypesByGroup(18)
    .map((t) => t.name)
    .join(', '),
); // Tritanium, Pyerite

The ESI side is stubbed at the transport seam the same way it is in the library's own tests (createMockTransport() from ./testing, see TESTING.md). A test of a join therefore has two doubles that know nothing of each other, mirroring production, and no third thing to mock.

The library's own SDE tests run at three tiers: unit tests against MemorySdeProvider, BDD scenarios under tests/bdd/features/sde/, and integration tests that load a real export from sde-data/ when it is present. Track S added the mutation, fuzz, type-test, benchmark and nightly-drift tiers the core already has; the plan is in ROADMAP.md and the measured state of every tier, with its floors, in sde/TESTING.md.


Isolation ​

ARCH-10 states the rule. Three mechanisms hold it, and each has a test that proves it fires:

RuleHeld byProven by
src/sde imports only Node built-ins, its own files, zod, js-yaml, adm-zip and src/core/portsnpm run lint:layers, the sde message of layers/inward-imports in config/eslint/layers.rules.cjstests/tdd/layers/layers-lint.test.ts
No file under src/ outside src/sde imports it, by relative path, by the package's own name or through any sde/ segmentnpm run lint:layers, the sideModule messagetests/tdd/layers/layers-lint.test.ts
The ./sde/memory bundle contains no file-system, YAML, ZIP or SQLite code, in the esbuild graph and in the shipped dist/sde/memory.{mjs,js} and their chunkstests/tdd/sde/memory-entry-bundle.test.tsThe same test checks itself against a known string

src/core/ports is the one directory of src/ the SDE reaches, and only for Clock (Track S Run 2): a port imports nothing itself, so taking one from there shares an interface without sharing the pipeline.

What the rule forbids in practice: a domain client that calls sde.getType() to name its results, an EsiClient option that accepts a provider, an SDE method that fetches from ESI to fill a gap in the export, and a shared error base class. Each was possible before 2026-09-27 and each is a lint failure now.

Layers inside the module ​

Since Track S Run 12 the module has the shape the core has: a reader finds a port, a domain, a provider or an ingestion step by its folder, and the same ESLint rule (sdeLayer message of layers/inward-imports) keeps the folders pointing inward. The entry points index.ts and memory.ts import anything below and are imported by nothing inside the module.

src/sde/
  index.ts, memory.ts       Entry points (./sde, ./sde/memory)
  errors.ts, version.ts,    Root support files: import one another only
  clock.ts, optionalPeers.ts
  ports/                    IStaticDataProvider: imports domain/ and version
  domain/<domain>/          types.ts and schemas.ts per SDE domain: import domain/ and zod only
  providers/yaml/           SdeDataProvider: imports ports/, domain/, ingestion/ and the root files
  providers/memory/         MemorySdeProvider: the same allowance, uses none of ingestion/
  providers/order.ts        ID ordering shared by both providers
  ingestion/                Download, extract, transform: imports the root files, never a port or a provider
  testing/                  SdeTestDataFactory: imported by the entry points only
LayerMay import
domain/domain/, zod
ports/domain/, the root files
ingestion/ingestion/, the root files
providers/ports/, domain/, ingestion/, providers/, the root files
testing/ports/, domain/, providers/, the root files
the root filesthe root files
the entry pointseverything but each other

tests/tdd/layers/layers-lint.test.ts has a failing case for each row. The provider folder is providers/yaml/, not the roadmap's providers/sqlite/: the provider reads CCP's YAML and holds Maps, and nothing in it is SQLite.


Freshness and versions ​

The two sides age differently, and a consumer should know which it is looking at.

The client pins a compatibility date on every request: CCP serves the API shape as it was on that date, and the generated types (ARCH-01 in the charter) match the specification that date describes. The SDE has a build number: SdeDownloader reads latest.jsonl from CCP to find it, the ZIP's _sde.yaml records it, and getVersion() returns it:

typescript
const version = sde.getVersion();
log.info(
  `SDE build ${version.version}, built ${version.buildDate}, loaded ${version.importedAt}`,
);

Nothing ties the two together, because nothing in CCP's data does: an ESI response from today can name a type that entered the game after your export was built, which is exactly the case the T | null returns exist for. Log the SDE build at start-up, refresh the export when CCP publishes a new one, and treat a null from the SDE for an ID ESI just returned as "newer than my export", not as an error.

nightly-sde.yml (Track S Run 9) downloads the current build every night, loads it, runs the integration tests and the SDE examples, and compares the export's file list and each file's keys with SDE_FILE_REGISTRY and the Zod schemas (npm run sde:drift), keeping one issue open while they disagree.


Where this is going ​

Track S is twelve overnight runs, each a PR, that bring the SDE to the standard of the core: layer lint (done, Run 1), coverage ratchet, step-library BDD, per-method spec coverage, error contracts, property tests, type tests, real-data nightly, benchmarks and soak, security review, the documentation move, and last the restructuring. Run 11 (landed 2026-09-28) moved the module's doc set under guides/sde/ with this file as the index and src/sde/README.md as a pointer, so the whole set is published on the documentation site. Run 12, after every other run, splits src/sde/ into ports/, domain/, providers/, ingestion/ and testing/ with layer rules inside the module, keeping the ./sde and ./sde/memory exports as they are; the level 3 diagram above is redrawn then.

Released under the GPL-3.0-or-later license.