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:

/**
 * LoaderInterface — 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 LoaderInterface 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 LoaderInterface — 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 LoaderInterface contract');

console.log('LoaderInterface: 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 LoaderInterface 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 LoaderInterface 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 `LoaderInterface` 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.