Skip to content

Schema federation

json-tology resolves $ref IRIs at registration time. By default, all referenced schemas must be registered before first use. The registry throws GraphError('REF_UNRESOLVED') if a non-fragment IRI points to an unregistered schema.

The loader hook is a single async function that fetches schemas on demand:

/**
 * LoaderType — the loader function signature for JsonTology.prefetch.
 *
 * A loader is any async function that takes an IRI string and returns either
 * the schema object or `null` (IRI unknown). This is the only type required
 * to implement a custom loader.
 *
 * `Loaders.fetch`, `Loaders.memory`, `Loaders.compose`, and `Loaders.cached`
 * all return functions conforming to this signature.
 *
 * Demonstrates: the LoaderType contract — returns the schema for known IRIs,
 * null for unknown IRIs, never throws for expected misses.
 */

import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
  BookSchema,
  CustomerSchema
} from '../bookstore/index.js';

// Loaders.memory returns a LoaderType — callable with (iri: string)
const loader = Loaders.memory(new Map<string, JsonSchemaType>([
  [
    BookSchema.$id,
    BookSchema
  ],
  [
    CustomerSchema.$id,
    CustomerSchema
  ]
]));

// Returns the schema for known IRIs
const resolved = await loader(CustomerSchema.$id);

console.assert(resolved !== null, 'known IRI returns schema (not null)');
console.assert(typeof resolved === 'object', 'resolved schema is an object');

// Returns null for unknown IRIs — no throw
const unknown = await loader('urn:bookstore:Unknown');

console.assert(unknown === null, 'unknown IRI returns null per LoaderType contract');

console.log('LoaderType: resolved schema $id:', (resolved as Record<string, string>).$id, '| unknown IRI returns null:', unknown === null);
Output
Press Execute to run this example against the real library.

Async work is isolated to a single entry point: JsonTology.prefetch, which builds a snapshot. JsonTology.create is synchronous on every call site and consumes the snapshot through the prefetched option.

Prefetch + sync create

JsonTology.prefetch walks transitive $refs via the loader and returns a snapshot. The snapshot is loader-agnostic; pass it to create() via the prefetched option for sync consumption.

/**
 * Bundler pattern — pre-bundle local schemas with network fallback.
 *
 * In a bundler context (Vite, esbuild, webpack), local schemas are imported
 * at build time and seeded into `Loaders.memory`. A `Loaders.compose` chain
 * tries memory first; for any IRI not in the bundle the chain falls back to
 * the next loader (here a stub that returns null so the example runs
 * deterministically offline — in production this slot carries
 * `Loaders.fetch({ base: '…' })`).
 *
 * Demonstrates: Loaders.compose + Loaders.memory seeded from the canonical
 * bookstore schema set + prefetch + synchronous create.
 */

import {
  JsonTology,
  Loaders
} from '../../../src/index.js';
import {
  bookstoreSchemas,
  CustomerSchema,
  OrderSchema
} from '../bookstore/index.js';

// Network-fallback stub — in production replace with
// `Loaders.fetch({ base: 'https://schemas.example/v1/' })`. Returning null
// lets `Loaders.compose` move on to the next loader (none here) and keeps
// the example runnable in offline test environments.
const offlineFallback = (): Promise<null> => {
  return Promise.resolve(null);
};

const snapshot = await JsonTology.prefetch({
  'loader': Loaders.compose(
    // Pre-bundled schemas — memory fast path, no network for known IRIs.
    Loaders.memory(new Map(bookstoreSchemas.map((schema) => {
      return [
        schema.$id,
        schema
      ] as const;
    }))),
    offlineFallback
  ),
  'schemas': [
    CustomerSchema,
    OrderSchema
  ]
});

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'prefetched': snapshot,
  'schemas': [
    CustomerSchema,
    OrderSchema
  ] as const
});

// validate is synchronous — async work was isolated to prefetch
const result = jt.validate(CustomerSchema.$id, {
  'addresses': [],
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'email': 'bastian.bux@bookstore.example',
  'name': 'Bastian Balthazar Bux'
});

console.assert(result.ok, 'Customer validates against prefetched registry');

console.log('Prefetch + sync create: snapshot schemas:', snapshot.schemas.size, '| validate ok:', result.ok);
Output
Press Execute to run this example against the real library.

prefetch accepts:

  • loader: required.
  • schemas: seed schemas whose refs are followed.
  • rootIds: IRIs to load directly from the loader (no local seed required).
  • baseIri: used by the ephemeral walker; defaults to a static placeholder when omitted.

How the resolution walk works

  1. prefetch registers any schemas provided as seeds, then loads each rootIds IRI.
  2. The walker iterates registered schemas and collects every non-fragment cross-schema $ref IRI not yet present.
  3. Each unresolved IRI is passed to the loader. If the loader returns null, the walk throws GraphError('REF_UNRESOLVED') with the offending IRI in err.pointer.
  4. Returned schemas are registered and recursed into; their own $refs are added to the queue.
  5. A Set<string> of visited IRIs prevents calling the loader twice for the same IRI.
  6. The walker captures every resolved schema into snapshot.schemas keyed by $id.

Built-in loader helpers

The Loaders namespace ships four universal helpers that work in Node ≥ 18, Bun, Deno, and browsers.

Loaders.fetch

Uses globalThis.fetch. Works anywhere. 4xx/5xx → null. Network errors propagate.

/**
 * Loaders.fetch — universal HTTP loader with base URL and header options.
 *
 * `Loaders.fetch` uses `globalThis.fetch` and works in Node ≥ 18, Bun,
 * Deno, and browsers. Options:
 * - No options: fetches the IRI directly.
 * - `base`: resolves relative IRIs against the base URL.
 * - `init`: passes RequestInit overrides (headers, credentials, etc.).
 *
 * Demonstrates: Loaders.fetch is callable with all three option shapes;
 * all three return a valid LoaderType function.
 */

import { Loaders } from '../../../src/index.js';

// No options — fetch from the IRI directly
const direct = Loaders.fetch();

// With base URL — resolve relative IRIs against the base
const withBase = Loaders.fetch({ 'base': 'https://schemas.example/v1/' });

// With RequestInit — add auth headers
const withHeaders = Loaders.fetch({ 'init': { 'headers': { 'X-Api-Key': 'demo-key' } } });

// All three produce LoaderType functions
console.assert(typeof direct === 'function', 'Loaders.fetch() returns a function');
console.assert(typeof withBase === 'function', 'Loaders.fetch({ base }) returns a function');
console.assert(typeof withHeaders === 'function', 'Loaders.fetch({ init }) returns a function');

console.log('Loaders.fetch variants: direct:', typeof direct, '| withBase:', typeof withBase, '| withHeaders:', typeof withHeaders);
Output
Press Execute to run this example against the real library.

Loaders.memory

In-memory lookup. Accepts a Map or plain object. Zero I/O.

/**
 * Loaders.memory — in-memory loader for local schema bundles.
 *
 * `Loaders.memory` accepts a `Map` or plain object keyed by $id IRI. It
 * returns a `LoaderType` that resolves schemas with zero I/O — useful for
 * pre-bundled schemas at build time and for testing.
 *
 * Demonstrates: Loaders.memory with bookstore schemas as the lookup map.
 */

import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
  BookSchema,
  CustomerSchema,
  IsbnSchema
} from '../bookstore/index.js';

// Build an in-memory Map keyed by $id IRI — explicit type annotation satisfies Loaders.memory
const memLoader = Loaders.memory(new Map<string, JsonSchemaType>([
  [
    BookSchema.$id,
    BookSchema
  ],
  [
    CustomerSchema.$id,
    CustomerSchema
  ],
  [
    IsbnSchema.$id,
    IsbnSchema
  ]
]));

// Resolve a known IRI — returns the schema
const resolved = await memLoader(CustomerSchema.$id);

console.assert(resolved !== null, 'known IRI resolves from memory');
console.assert(
  (resolved as Record<string, string>).$id === CustomerSchema.$id,
  'resolved schema carries the expected $id'
);

// Resolve an unknown IRI — returns null (no throw)
const unknown = await memLoader('urn:bookstore:NoSuchSchema');

console.assert(unknown === null, 'unknown IRI returns null');

console.log('Loaders.memory: resolved $id:', (resolved as Record<string, string>).$id, '| unknown IRI:', unknown);
Output
Press Execute to run this example against the real library.

Loaders.compose

Chains multiple loaders. Returns the first non-null result.

/**
 * Loaders.compose — chain multiple loaders with first-non-null wins.
 *
 * `Loaders.compose` tries each loader in order and returns the first non-null
 * result. The fast path is an in-memory bundle of locally-known schemas;
 * the fallback is a network loader.
 *
 * Demonstrates: Loaders.compose + Loaders.memory + Loaders.fetch for a
 * "local-first, network-fallback" resolution strategy.
 */

import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
  BookSchema,
  CustomerSchema,
  IsbnSchema
} from '../bookstore/index.js';

const composed = Loaders.compose(
  // Locally-known schemas served from memory — no network for known IRIs
  Loaders.memory(new Map<string, JsonSchemaType>([
    [
      BookSchema.$id,
      BookSchema
    ],
    [
      CustomerSchema.$id,
      CustomerSchema
    ],
    [
      IsbnSchema.$id,
      IsbnSchema
    ]
  ])),
  // Fallback for IRIs not in the local bundle
  Loaders.fetch({ 'base': 'https://schemas.example/v1/' })
);

// The composed loader resolves from memory — no network needed for known IRIs
const customer = await composed(CustomerSchema.$id);

console.assert(customer !== null, 'composed loader resolves known IRI from memory');
console.assert(
  (customer as Record<string, string>).$id === CustomerSchema.$id,
  'resolved schema is the CustomerSchema'
);

console.log('Loaders.compose: resolved from memory, $id:', (customer as Record<string, string>).$id);
Output
Press Execute to run this example against the real library.

Loaders.cached

Wraps any loader with an LRU cache (default: 1024 entries). Both resolved schemas and null results are cached so the inner loader is called at most once per IRI.

/**
 * Loaders.cached — LRU-cached loader wrapper.
 *
 * `Loaders.cached` wraps any loader and caches both resolved schemas and `null`
 * results so the inner loader is called at most once per IRI. Both positive and
 * negative results are cached (negative caching avoids repeated failed fetches).
 *
 * Demonstrates: Loaders.cached wrapping Loaders.memory, with custom maxSize.
 */

import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
  BookSchema,
  CustomerSchema
} from '../bookstore/index.js';

const inner = Loaders.memory(new Map<string, JsonSchemaType>([
  [
    BookSchema.$id,
    BookSchema
  ],
  [
    CustomerSchema.$id,
    CustomerSchema
  ]
]));

// Wrap with a small LRU cache (256 entries)
const cached = Loaders.cached(inner, { 'maxSize': 256 });

// First call populates the cache
const first = await cached(CustomerSchema.$id);

console.assert(first !== null, 'first call resolves');

// Second call returns from cache — inner loader not called again
const second = await cached(CustomerSchema.$id);

console.assert(
  (second as Record<string, string>).$id === CustomerSchema.$id,
  'cached result carries the same $id'
);

// Null results are also cached
const miss = await cached('urn:bookstore:NoSuchSchema');

console.assert(miss === null, 'unknown IRI returns null');

console.log('Loaders.cached: first call $id:', (first as Record<string, string>).$id, '| miss (null):', miss);
Output
Press Execute to run this example against the real library.

Write your own loader

Any function with the signature (iri: string) => Promise<JsonSchemaType | null> is a valid loader. Node fs example:

/**
 * Custom in-memory loader — resolve schemas from a pre-built map.
 *
 * Any function with signature `(iri: string) => Promise<JsonSchemaType | null>`
 * is a valid loader. This example builds a small in-memory map of schema IRI
 * to schema object and a loader function that resolves from it — no disk I/O,
 * no Node built-ins, runs identically in browsers, workers, and Node.
 *
 * In a real setup the map would be populated from a bundled import or a prior
 * fetch; here we use the bookstore schemas directly so the example is
 * self-contained and verifiable.
 *
 * Demonstrates: custom loader function, `null` on miss, in-memory pattern.
 */

import {
  CustomerSchema,
  IsbnSchema
} from '../bookstore/index.js';

/** In-memory schema store keyed by schema $id. */
const schemaMap = new Map<string, Record<string, unknown>>([
  [
    CustomerSchema.$id,
    CustomerSchema
  ],
  [
    IsbnSchema.$id,
    IsbnSchema
  ]
]);

/**
 * Loader: resolves a schema IRI from the in-memory map.
 * Returns `null` for IRIs that have no registered entry.
 */
const memoryLoader = async (iri: string): Promise<null | Record<string, unknown>> => {
  return schemaMap.get(iri) ?? null;
};

// Loader resolves a known schema IRI
const customerResult = await memoryLoader(CustomerSchema.$id);

console.assert(customerResult !== null, 'known schema IRI resolves via memory loader');
console.assert(
  customerResult !== null && customerResult.$id === CustomerSchema.$id,
  'resolved schema carries correct $id'
);
console.log('customerResult.$id:', customerResult?.$id);

// IsbnSchema also resolves
const isbnResult = await memoryLoader(IsbnSchema.$id);

console.assert(isbnResult !== null, 'Isbn schema resolves from map');
console.log('isbnResult.$id:', isbnResult?.$id);

// Unknown IRI returns null without throwing
const unknown = await memoryLoader('urn:bookstore:NoSuchThing');

console.assert(unknown === null, 'unknown IRI returns null');
console.log('unknown IRI returns null:', unknown === null);
Output
Press Execute to run this example against the real library.

Adding schemas after construction

jt.set(schema) is synchronous and throws REF_UNRESOLVED for any unregistered cross-schema refs. For schemas whose transitive refs are not yet known locally, build a fresh snapshot with JsonTology.prefetch and pass it through prefetched on a new JsonTology.create call, or merge the new schemas into the existing registry by calling set() once every dependency is in scope.

Performance notes

  • Cache the loader. Wrap with Loaders.cached() so schemas fetched in one walk are not re-fetched if prefetch is called again.
  • Bundle critical schemas. Loaders.compose(Loaders.memory(bundled), Loaders.fetch(...)) serves critical schemas from memory with the network as fallback.
  • Snapshot at build time. Run prefetch in a build step and persist the resulting snapshot.schemas map; consume it at runtime with zero network calls.

Error handling

ConditionResult
Loader returns null for a required IRIGraphError('REF_UNRESOLVED') with the IRI in err.pointer
Loader throws (network error)Error propagates; callers see the real failure
Loader returns a schema with new unresolved $refsThose IRIs are queued and resolved transitively
Same IRI encountered twice in one walkLoader called at most once (visited-set dedup)

Comparison with similar mechanisms

Featurejson-tologyAJV loadSchemaSPARQL SERVICE
Protocol-agnosticYesYesHTTP only
Sync create() after prefetchYesNo (async compile)N/A
Cycle detectionVisited-set dedupManualDepends on engine
Universal (Node/browser)YesYesServer-side only
Built-in cachingLoaders.cachedNoneEndpoint-level

Released under the MIT License.