Skip to content

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.

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 roots starts 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:

ConceptMeaning
MembershipResources assigned to this island during traversal
DependencyDirect 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]

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 / framework

Pair with @xndrjs/contentful-to-zod for typed Contentful payloads and link-field metadata when authoring graph resolution strategies.

Terminal window
pnpm add @xndrjs/resource-graph-resolver @xndrjs/addressable-resources

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().

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.

FieldMeaning
idStable identifier used in observer events and error messages
forARI factories this transport channel handles; each covers one ARI type
batchSizeMax ARIs per load. Omit for no limit
concurrencyLoads this backend tolerates in parallel. Defaults to 1 (serial)
whenOptional routing predicate (rare). Evaluated before for matching
loadFetch one heterogeneous batch; return (payload | undefined)[] same length/order as batch (undefined = miss)

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.

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.

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 }),

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 modeSchedulerWhen to prefer
laneExpand as soon as any batch commits; sources advance independentlyUneven backend latency: a fast CMS should not wait on a slow commercial API
barrierWait for every in-flight batch, then expand togetherReproducible 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.

Resolution is bounded by default. ResourceGraphResolverConfig.budget accepts a partial override; every omitted field retains its safe default:

BudgetDefaultCounts
maxNodes10_000Distinct ARIs, including roots, locators and canonical targets
maxEdges50_000Distinct expansion and redirect edges
maxBatches1_000Datasource load calls started across all sources
maxDurationMs30_000Wall-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:

FieldRole
contentMapResolved payloads keyed by ARI
islandsPer-island resource membership
islandDependenciesDirect edges between islands (child island opened by an island policy)
errorsOne canonical ResolutionError per soft-failed resource
failuresFailure lookup by canonical key and every redirect alias
promotedResourceKeysBacking keys the walk actually reached, in promotion order
redirectsFlattened locator-key → canonical-target map

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.

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 ARIMissingResourceErrorOmit payload, continueResolutionError (code: "missing") in errors, continue
No source’s for list matches the ARINoDataSourceErrorOmit payload, continueResolutionError (code: "no_data_source") in errors
A source’s load rejectedResourceLoadFailedErrorOmit payloads in the batch, continueResolutionError (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.

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.

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.

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:

NamespaceChainSemantics
.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 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 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.

After resolution, inspect direct and transitive island edges:

const pageId = pageRoot.toString();
output.islandDependencies.get(pageId); // direct child islands only
output.islandDependencies.getFlatDependencies(pageId); // transitive, deduped, sorted
output.islandDependencies.snapshot(); // copy of every direct edge

snapshot() 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.

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 from IslandDependencyMap
  • completeness — "complete" or "partial" when errors inherited this island
  • missingResources — 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 null or undefined discards the key, so the walk re-loads it from its source
  • throwing rejects the whole backing build
  1. Infrastructure ARIs — one factory per backend/type (cms.entry, cms.asset, integration.product, …), next to the sources that own them.
  2. ContentRegistry — per-source slices composed with ComposeContentRegistry.
  3. Sources — one DataSource per transport channel: for, batch limit, concurrency, load.
  4. Graph resolution strategy — createGraphResolutionStrategy() with .expansion and .islands actions for child discovery and island boundaries.
  5. Resolver — one createResourceGraphResolver per topology, schedulingMode chosen per route (or fixed to lane).
  6. Orchestration — load backing → resolve (optional signal) → map ContentMap to domain → serializeAllIslands → persist to cache.
  7. Domain mappers — stay outside this package; consume ResolveResourceGraphOutput.

Exported symbols:

  • createResourceGraphResolver — and types ResourceGraphResolver, ResourceGraphResolverConfig
  • createGraphResolutionStrategy — and types GraphResolutionStrategy, GraphResolutionStrategyBuilder
  • defineDataSourceFor — and types DataSource, DataSourceDefinition, ResourceFamily, ResourceOfFamily, ResourceUnionFromFamilies, SourcePayloadSlot, ResourceLoadContext, SourceRouteContext
  • ContentMap, IslandMap, IslandDependencyMap
  • serializeIsland / serializeAllIslands / buildBackingResourcesFromIslands
  • Runtime budgets: DEFAULT_RESOLUTION_BUDGET, ResolutionBudget, ResolutionBudgetOptions, ResolutionBudgetUsage, ResolutionBudgetKind
  • Errors: ResourceGraphError, ResolutionError, MissingResourceError, NoDataSourceError, ResourceLoadFailedError, ResourceBatchLengthError, ResourceGraphAbortedError, ResourceGraphBudgetExceededError, ResourceRedirectCycleError
  • Observability: ResolutionObserver and its event types
  • Types: ContentRegistry, ComposeContentRegistry, ResolveResourceGraphInput, ResolveResourceGraphOutput, SchedulingMode, ResolutionError, OnFailurePolicy, SerializedIsland, ExpansionResult, IslandResult, ExpansionContext, IslandContext, ResolveContext, ResolveResult, ResourceKey, IslandId, RegistryPayloadFor