Resource graph resolver
@xndrjs/resource-graph-resolver resolves a resource graph from one or more seed Addressable Resource Identifiers (ARIs). It walks child resources discovered by your expansion rules, loads payloads through the backends you declare, tracks island membership and dependencies, and returns a typed ContentMap you can serialize for cache or map into domain aggregates.
What is an island?
Section titled “What is an island?”An island is a subgraph of the resolution walk that has its own identity in the graph — a unit you treat as distinct from the island that reached it. You declare that identity with an island policy (startIsland) on a resolved resource. That resource’s ARI (resource.toString()) becomes the island id unless you supply a custom id; every resource reached while expanding from that point — until the next island boundary — belongs to the same island.
Typical reasons to mark an island: a fragment with a different lifecycle than its parent (global header, shared footer, …), a reusable slice referenced from multiple places, or a boundary where membership should not collapse into the parent’s aggregate.
In practice:
- Each seed in
rootsstarts as an island (its own id). - When an island policy returns
startIsland: true, traversal forks: the resource starts a new island; its parent records a dependency on it. - Each island is a first-class node in the output (
IslandMap,SerializedIsland) — cache TTL, warm paths, and invalidation are downstream concerns your infrastructure may attach to that model; they are not what defines an island.
Membership (which resources live inside an island) and dependencies (which other islands one island needs) are separate:
| Concept | Meaning |
|---|---|
| Membership | Resources assigned to this island during traversal |
| Dependency | Direct edge to a child island opened by an island policy |
Example: a page island may depend on menu and footer islands without containing their payloads. The page’s direct dependencies are only its immediate island children; getFlatDependencies(page) adds transitive ones when nested islands exist deeper in the graph.
Islands let you name and partition a large graph by application meaning — what is “the page”, what is “the menu”, what is shared — before you decide how to cache, invalidate, or aggregate each part. The demo app shows one possible downstream use (tiered LRU + manifests); the resolver only tracks identity, membership, and dependencies. It does not invalidate caches for you.
Islands are meant for macro-grouping. A resource reachable from many islands is tracked in all of them, so marking hundreds of fine-grained islands over a shared subgraph multiplies membership entries — model islands around lifecycle boundaries, not around individual nodes.
The resolver is schema-agnostic: you supply a ContentRegistry (ARI type → payload shape), one DataSource per transport channel, and a GraphResolutionStrategy built with createGraphResolutionStrategy(). Frameworks, CMS clients, and cache stores stay in your infrastructure layer.
For a full wiring example, see the resource-graph-resolver-demo app: demo-resolver.ts wires sources and demo-strategy.ts defines the graph resolution strategy; resolveDemoPage is the single integration path (defaults to lane; flip DEMO_SCHEDULING_MODE in demo-resolver.ts to try barrier). Timed lane-vs-barrier comparisons live in @xndrjs/resource-graph-resolver-bench.
%%{init: {'flowchart': {'curve': 'stepAfter'}}}%%
flowchart TD
root[Root ARI] --> resolver[Resource graph resolver]
resolver --> strategy[GraphResolutionStrategy]
strategy --> expand[Expansion policies]
strategy --> islandPolicies[Island policies]
expand --> route[Route ARI to first matching source]
route --> batch[Chunk each source queue to batchSize, throttle to concurrency]
batch --> sources[DataSource load batch]
sources --> contentMap[ContentMap]
resolver --> islands[IslandMap]
resolver --> deps[IslandDependencyMap]
contentMap --> serialize[serializeAllIslands]
serialize --> cache[Island cache optional]
Where it fits
Section titled “Where it fits”The resolver sits in infrastructure because the split between external systems (CMS, commercial API, …) is an infrastructure concern. The application asks for a domain aggregate; it should not have to know how that aggregate is assembled from backends.
Addressable Resource Identifiers still provide the identity vocabulary — but the ARIs that name nodes in this graph (cms.entry, integration.product, …) are infrastructure resources. Infrastructure may know where a product lives today; the domain should not. Mapping from the resolved ContentMap into domain shapes happens above this package:
Transport ↓Infrastructure resources (ARIs) ↓Graph resolution (this package) ↓Domain aggregate ↓UI / frameworkPair with @xndrjs/contentful-to-zod for typed Contentful payloads and link-field metadata when authoring graph resolution strategies.
Install
Section titled “Install”pnpm add @xndrjs/resource-graph-resolver @xndrjs/addressable-resourcesContentRegistry and ContentMap
Section titled “ContentRegistry and ContentMap”Define which ARI type literals your project resolves and what each payload looks like:
type CmsContentRegistry = { "cms.entry": ContentfulResolvedLocalizedEntry; "cms.asset": ContentfulAsset;};
type IntegrationContentRegistry = { "integration.product": ProductDto;};Compose the project registry from per-source slices. Use ComposeContentRegistry so hovers and type errors show one flat object rather than a chain of intersections:
import type { ComposeContentRegistry } from "@xndrjs/resource-graph-resolver";
type DemoContentRegistry = ComposeContentRegistry<[CmsContentRegistry, IntegrationContentRegistry]>;Do not intersect with ContentRegistry itself ({ ... } & ContentRegistry): ContentRegistry is Record<string, unknown>, so intersecting widens keyof back to string and payload narrowing collapses. ContentRegistry is a constraint, not a base type to mix in.
ContentMap<R> keys entries by resource.toString() (canonical ARI identity). get(resource) narrows the return type from resource.type; getByKey stays weakly typed for cache and JSON paths. It also exposes size, keys(), entries(), iteration, and toJSON().
DataSource
Section titled “DataSource”A source is one backend transport channel. It declares which ARI types it handles (for), the channel’s batch limit, how many requests it tolerates in parallel, and how to fetch one batch.
With Contentful Delivery, entries and assets travel on separate endpoints, so they are usually two sources:
import { defineDataSourceFor } from "@xndrjs/resource-graph-resolver";
const defineSource = defineDataSourceFor<DemoContentRegistry, DemoExecutionContext>();
export const cmsEntrySource = defineSource({ id: "cms-entries", for: [cmsEntryAri], batchSize: 50, // ids per GET /entries — your client chunk size, not a resolver default load: (batch, { signal }) => deliveryClient.getEntries({ "sys.id[in]": idsFrom(batch), signal }),});
export const cmsAssetSource = defineSource({ id: "cms-assets", for: [cmsAssetAri], batchSize: 50, load: (batch, { signal }) => deliveryClient.getAssets({ "sys.id[in]": idsFrom(batch), signal }),});If a CMS exposed one batch API for mixed types, a single source is enough:
export const cmsSource = defineSource({ id: "cms", for: [cmsEntryAri, cmsAssetAri], batchSize: 100, async load(batch, { signal }) { return cmsClient.fetchBatch(batch, { signal }); },});The demo app uses this second shape: Contentful-shaped payloads, but one in-memory channel for the workshop.
The definer is curried (defineDataSourceFor<R, Ctx>() then the config) because TypeScript has no partial type-argument inference: currying keeps for inferred while the registry stays explicit.
R is the whole project registry, not the source’s own slice — payload shapes are a project-wide contract, and for is what scopes a source to the ARI types it may be asked for. Identity hops (e.g. CustomReference → Entry) belong in strategy .resolve, not in load.
| Field | Meaning |
|---|---|
id | Stable identifier used in observer events and error messages |
for | ARI factories this transport channel handles; each covers one ARI type |
batchSize | Max ARIs per load. Omit for no limit |
concurrency | Loads this backend tolerates in parallel. Defaults to 1 (serial) |
when | Optional routing predicate (rare). Evaluated before for matching |
load | Fetch one heterogeneous batch; return (payload | undefined)[] same length/order as batch (undefined = miss) |
Routing
Section titled “Routing”The resolver walks sources in order. For each ARI, the first source whose optional when passes and whose for list contains a matching family wins. Overlapping sources are not detected — declare one owner per ARI type.
Who owns what
Section titled “Who owns what”The resolver owns routing, chunking to batchSize, throttling to concurrency, scheduling, deduplication and island bookkeeping. A source owns one backend’s transport — and its retry/backoff policy: a source has at most concurrency loads in flight, so awaiting inside load throttles that backend and nothing else.
A source signals “no data” by omitting an ARI from its result. Never throw for a single missing row: a rejected load fails the whole batch.
Cancellation inside a source
Section titled “Cancellation inside a source”load receives the resolution’s signal. Forward it into fetch (or your client’s equivalent) so an aborted resolution cancels in-flight IO instead of merely ignoring the result:
load: (batch, { signal }) => fetch(url, { method: "POST", body: JSON.stringify({ skus: batch.map((r) => r.key.sku) }), signal }),Resolver and scheduling modes
Section titled “Resolver and scheduling modes”Build one resolver per source topology and reuse it across requests:
import { createGraphResolutionStrategy, createResourceGraphResolver,} from "@xndrjs/resource-graph-resolver";
const resolver = createResourceGraphResolver<DemoContentRegistry, DemoExecutionContext>({ sources: [cmsEntrySource, cmsAssetSource, integrationSource], strategy: createDemoStrategy(), schedulingMode: "lane", // or "barrier" budget: { maxNodes: 2_000, maxDurationMs: 5_000 }, // optional partial override observer, // optional});
const output = await resolver.resolve({ roots: [pageRoot], executionContext: { locale: "en-US" }, // backingResources: cachedPayloadsByKey, // signal: AbortSignal.timeout(5_000),});Both scheduling modes produce identical graph output — same ContentMap, island membership, dependencies, promotions and errors. They differ only in when expansion runs relative to in-flight loads:
| Scheduling mode | Scheduler | When to prefer |
|---|---|---|
lane | Expand as soon as any batch commits; sources advance independently | Uneven backend latency: a fast CMS should not wait on a slow commercial API |
barrier | Wait for every in-flight batch, then expand together | Reproducible rounds for tracing and tests; backends of similar latency |
Under lane, a fast source keeps walking its own subgraph while a slow peer’s request is still open, so wall clock stops tracking the slowest backend in every wave.
When several sources can handle the same ARI type, the first match in sources order wins; declare one owner per type.
Runtime budgets
Section titled “Runtime budgets”Resolution is bounded by default. ResourceGraphResolverConfig.budget accepts a partial override; every omitted field retains its safe default:
| Budget | Default | Counts |
|---|---|---|
maxNodes | 10_000 | Distinct ARIs, including roots, locators and canonical targets |
maxEdges | 50_000 | Distinct expansion and redirect edges |
maxBatches | 1_000 | Datasource load calls started across all sources |
maxDurationMs | 30_000 | Wall-clock time for one resolution |
All values must be positive integers. Crossing a limit throws ResourceGraphBudgetExceededError, regardless of per-edge onFailure. The error identifies budget, limit, actual, and the final usage counters. The resolver also emits onBudgetExceeded once and does not emit onResolutionEnd for the failed walk.
The deadline aborts the signal passed to datasource loaders and stops waiting even if a loader ignores it. Forward context.signal to the underlying transport so in-flight I/O is cancelled too.
resolve returns:
| Field | Role |
|---|---|
contentMap | Resolved payloads keyed by ARI |
islands | Per-island resource membership |
islandDependencies | Direct edges between islands (child island opened by an island policy) |
errors | One canonical ResolutionError per soft-failed resource |
failures | Failure lookup by canonical key and every redirect alias |
promotedResourceKeys | Backing keys the walk actually reached, in promotion order |
redirects | Flattened locator-key → canonical-target map |
Redirects and canonical identity
Section titled “Redirects and canonical identity”Strategy .resolve policies run after a locator payload is decoded and before expansion. The locator is not expanded; its target is enqueued instead. Chains are flattened, so A → B → C produces A → C and B → C in output.redirects. When C resolves, its payload is available from contentMap through A, B, and C.
The same rules apply when either the locator decode payload or canonical target comes from backingResources. Several aliases converging on one canonical target do not duplicate its load.
A soft target failure occurs once in output.errors, attributed to the canonical target. output.failures also indexes that same ResolutionError instance under every alias, which lets generated projectors implement on failure set error without losing redirect information. Temporary locator decode payloads are removed when the canonical target fails.
Redirect cycles are invalid. Self-cycles, two-node cycles, and longer cycles throw ResourceRedirectCycleError regardless of the edge’s onFailure policy.
Missing resources and termination
Section titled “Missing resources and termination”Roots always throw on failure. Children inherit onFailure from the expansion that discovered them ("throw" by default). When the same ARI is reached by several edges, the strictest policy wins (throw > setError > setNull).
| Situation | "throw" (default) | "setNull" | "setError" |
|---|---|---|---|
| A source omitted a requested ARI | MissingResourceError | Omit payload, continue | ResolutionError (code: "missing") in errors, continue |
No source’s for list matches the ARI | NoDataSourceError | Omit payload, continue | ResolutionError (code: "no_data_source") in errors |
A source’s load rejected | ResourceLoadFailedError | Omit payloads in the batch, continue | ResolutionError (code: "load_failed") per ARI, continue |
Set onFailure on ExpansionResult (Ziel: on failure set null / set error / throw after an expand target). All thrown errors extend ResourceGraphError. ResourceLoadFailedError carries sourceId, resourceKeys and the original rejection as cause. Datasources may also throw new ResolutionError(code, message, cause) — the resolver preserves the instance.
Cancellation
Section titled “Cancellation”Pass signal: AbortSignal on the resolve input. Sources receive a composite signal covering both caller cancellation and the runtime deadline. Caller abort throws ResourceGraphAbortedError independent of per-edge onFailure, and outstanding loads are always observed first, so a cancellation never leaves unhandled rejections behind.
Optional backing resources
Section titled “Optional backing resources”Pass backingResources: ReadonlyMap<ResourceKey, unknown> to hydrate hits before any source is asked. A backing entry is promoted the moment the walk reaches that ARI, so unreached keys cost nothing. The map is never mutated; the keys actually promoted come back as promotedResourceKeys. Use this for partial warm paths — for example dependency islands still valid while the root island expired.
Observability
Section titled “Observability”Pass an optional observer to trace batches, expansions and promotions without wrapping your sources or policies:
const observer: ResolutionObserver = { onBatchStart: ({ sourceId, batchNumber, resources, resourceCount }) => { /* resources is the flat batch handed to load */ }, onBatchEnd: ({ sourceId, durationMs, resolvedCount }) => { /* … */ }, onExpand: ({ resource, islandId, isIsland, children }) => { /* … */ }, onBackingPromote: ({ resource, islandIds }) => { /* … */ }, onMissingResource: ({ resourceKey, message }) => { /* … */ }, onBudgetExceeded: ({ budget, limit, actual, usage }) => { /* record bounded-failure metrics */ },};Every hook is optional, and a hook that throws never affects resolution — observers are diagnostics, so a logging bug cannot corrupt a walk.
Graph resolution strategy (createGraphResolutionStrategy)
Section titled “Graph resolution strategy (createGraphResolutionStrategy)”A graph resolution strategy bundles expansion and island policies into one object you pass to the resolver. Author it with the fluent createGraphResolutionStrategy() builder: each .expand() or .startIsland() registers one policy and returns the builder so you can chain further actions.
import { createGraphResolutionStrategy } from "@xndrjs/resource-graph-resolver";import { cmsEntryAri } from "./cms/ari";
const islandContentTypes = ["menu", "footer"] as const;
export function createDemoStrategy() { const s = createGraphResolutionStrategy<DemoExecutionContext, DemoContentRegistry>();
s.expansion .on(cmsEntryAri) .when(({ resource, executionContext }) => resource.key.locale === executionContext.locale) .expand(({ payload, executionContext }) => ({ resources: collectChildArisFromEntry(payload, executionContext.locale), }));
s.islands .on(cmsEntryAri) .when(({ payload }) => islandContentTypes.includes(payload.sys.contentType.sys.id)) .startIsland();
return s.build();}createGraphResolutionStrategy<ExecutionContext, ContentRegistry>() returns a builder with three namespaces:
| Namespace | Chain | Semantics |
|---|---|---|
.expansion | .on(ari).when(…).expand(…) | Every matching policy contributes children; duplicates removed by ARI key |
.islands | .on(ari).when(…).startIsland(…) | Any matching policy may open an island boundary |
.resolve | .on(ari).when(…).to(…) | First match redirects a decoded locator to a canonical ARI |
.on(ari) narrows both resource and payload to the matched ARI family. .when(…) is optional on both namespaces.
Expansion policies
Section titled “Expansion policies”Expansion discovers child ARIs for an already-resolved resource. Island boundaries are declared separately in the .islands namespace (below).
Expansion = current resource + its own payload + execution context. A policy cannot look up other nodes in a shared map, cannot see the island it was reached from, and must not depend on which peers happened to land in the same batch. That constraint is what keeps expansion deterministic: the edges of the graph depend on content, not on traversal order or batch sizes.
A resource reachable from several islands is expanded once per island, so it joins the membership of all of them while being fetched only once.
Island policies
Section titled “Island policies”Island policies decide whether a resolved resource opens a new island boundary. They observe the same scoped context as expansion policies.
When startIsland resolves to a boundary, the resource becomes a new island id (resource.toString() unless you return a custom id). The parent island records a direct dependency on that child island. Children discovered from the new island inherit its id until another island boundary appears.
Dependencies ≠ membership. A child island is a dependency of its parent, not necessarily a direct dependency of the page root — but nested islands appear in the transitive flat closure (below). Resources resolved inside an island are members of that island, not separate islands, unless an island policy opens another boundary.
IslandDependencyMap
Section titled “IslandDependencyMap”After resolution, inspect direct and transitive island edges:
const pageId = pageRoot.toString();
output.islandDependencies.get(pageId); // direct child islands onlyoutput.islandDependencies.getFlatDependencies(pageId); // transitive, deduped, sortedoutput.islandDependencies.snapshot(); // copy of every direct edgesnapshot() is a method, not a getter, because its cost is proportional to islands × edges.
Use getFlatDependencies when you need the full transitive dependency closure from a root island (for example a manifest of every dependency island reachable from a page). The starting island is never included, even if dependency cycles point back to it.
Serialization
Section titled “Serialization”Materialize portable island payloads for JSON or LRU cache:
import { serializeIsland, serializeAllIslands } from "@xndrjs/resource-graph-resolver";
const islands = serializeAllIslands(output);const pageIsland = serializeIsland(pageRoot.toString(), output);Each SerializedIsland (schema v1) includes:
resources— payloads for members of that island (not dependency-only roots)dependencies— direct child island ids fromIslandDependencyMapcompleteness—"complete"or"partial"when errors inherited this islandmissingResources— unresolved keys attributed to this island
buildBackingResourcesFromIslands reverses complete islands back into backing resources for the next resolve call:
buildBackingResourcesFromIslands(islands, { policy, onResourceConflict });policy controls which islands contribute resources (only-complete or all).
Two cached islands can legitimately hold the same resourceKey with different payloads — a shared logo cached at two different times, for example. The library does not pick a winner for you, because the right answer depends on your freshness model. onResourceConflict (required) is invoked with:
existing/existingIslandId(already in the map)incoming/incomingIslandId(new island payload)
Return values:
- returning a value keeps that payload in
backingResources - returning
nullorundefineddiscards the key, so the walk re-loads it from its source - throwing rejects the whole backing build
Typical project wiring
Section titled “Typical project wiring”- Infrastructure ARIs — one factory per backend/type (
cms.entry,cms.asset,integration.product, …), next to the sources that own them. - ContentRegistry — per-source slices composed with
ComposeContentRegistry. - Sources — one
DataSourceper transport channel:for, batch limit, concurrency,load. - Graph resolution strategy —
createGraphResolutionStrategy()with.expansionand.islandsactions for child discovery and island boundaries. - Resolver — one
createResourceGraphResolverper topology,schedulingModechosen per route (or fixed tolane). - Orchestration — load backing →
resolve(optionalsignal) → mapContentMapto domain →serializeAllIslands→ persist to cache. - Domain mappers — stay outside this package; consume
ResolveResourceGraphOutput.
Exported symbols:
createResourceGraphResolver— and typesResourceGraphResolver,ResourceGraphResolverConfigcreateGraphResolutionStrategy— and typesGraphResolutionStrategy,GraphResolutionStrategyBuilderdefineDataSourceFor— and typesDataSource,DataSourceDefinition,ResourceFamily,ResourceOfFamily,ResourceUnionFromFamilies,SourcePayloadSlot,ResourceLoadContext,SourceRouteContextContentMap,IslandMap,IslandDependencyMapserializeIsland/serializeAllIslands/buildBackingResourcesFromIslands- Runtime budgets:
DEFAULT_RESOLUTION_BUDGET,ResolutionBudget,ResolutionBudgetOptions,ResolutionBudgetUsage,ResolutionBudgetKind - Errors:
ResourceGraphError,ResolutionError,MissingResourceError,NoDataSourceError,ResourceLoadFailedError,ResourceBatchLengthError,ResourceGraphAbortedError,ResourceGraphBudgetExceededError,ResourceRedirectCycleError - Observability:
ResolutionObserverand its event types - Types:
ContentRegistry,ComposeContentRegistry,ResolveResourceGraphInput,ResolveResourceGraphOutput,SchedulingMode,ResolutionError,OnFailurePolicy,SerializedIsland,ExpansionResult,IslandResult,ExpansionContext,IslandContext,ResolveContext,ResolveResult,ResourceKey,IslandId,RegistryPayloadFor
See also
Section titled “See also”- Addressable resources — identity vocabulary (
toString()keys); graph ARIs for this package are infrastructure-scoped factories - Contentful to Zod — transport schemas and link-field metadata for expansion authoring
- Demo app