API Contracts & Interface Specs

The public surface is small and stable: one engine class implementing one interface, typed request/response envelopes, one in-repo store implementation, and a term-utility layer. The complete export list (verified against deno doc --json of src/mod.ts) is at the end of this page.

The engine interface

// src/sparql-engine-interface.ts
export interface SparqlEngineInterface {
  execute(request: SparqlRequest): Promise<SparqlResponse>;
}

export interface SparqlRequest {
  query: string; // raw SPARQL query or update string
  baseIri?: string; // base for relative IRIs; the query's BASE directive wins (Fork B, #117)
  timeoutMs?: number; // aborts execution after this many milliseconds (defaults to 30 seconds)
  signal?: AbortSignal; // aborts the request; execute() rejects with the signal's reason
}

execute() is the entry point. It parses the request, dispatches queries to SparqlEvaluator.evaluateQuery() and updates to UpdateEvaluator.executeUpdate(), and returns a discriminated union:

baseIri is the base for relative IRIs (IRI()/URI(), relative PREFIX IRIs, and relative LOAD sources). The query’s own BASE directive wins when both are present (decision #117, Fork B — the directive is authoritative per SPARQL §4.2, matching Comunica).

export type SparqlResponse =
  | { kind: "select"; data: SparqlSelectResults }
  | { kind: "ask"; data: SparqlAskResults }
  | { kind: "construct"; data: SparqlConstructResults }
  | { kind: "void" }; // SPARQL updates always resolve to void

Result shapes

// SELECT
interface SparqlSelectResults {
  head: { vars: string[]; link?: string[] | null };
  results: { bindings: SparqlBinding[] };
}
type SparqlBinding = Record<string, SparqlValue>; // variable name → value

// ASK
interface SparqlAskResults {
  head: { link?: string[] | null };
  boolean: boolean;
}

// CONSTRUCT / DESCRIBE
interface SparqlConstructResults {
  quads: rdfjs.Quad[]; // RDF/JS quads, graph deduped
}

SparqlValue is the wire value format (SPARQL 1.1 results JSON shape, plus RDF 1.2 extensions):

type SparqlValue =
  | { type: "uri"; value: string }
  | { type: "bnode"; value: string }
  | {
    type: "literal";
    value: string;
    "xml:lang"?: string;
    "its:dir"?: "ltr" | "rtl";
    datatype?: string;
  }
  | {
    type: "triple";
    value: {
      subject: SparqlValue;
      predicate: SparqlValue;
      object: SparqlValue;
    };
  };

rdfTermToSparqlValue() (src/term/convert.ts) is the only place RDF/JS terms become SparqlValues. Plain string literals carry no datatype (xsd:string is implicit); lang-tagged literals carry xml:lang (+ its:dir for RDF 1.2 directional literals); RDF 1.2 triple terms serialize as type: "triple".

Constructing the engine

// src/wazoo-sparql-engine.ts
export interface WazooSparqlEngineOptions {
  store: rdfjs.Store; // required: any RDF/JS store
  createTransaction?: () => WazooSparqlTransaction; // optional: atomic updates
  reorderPatterns?: boolean; // default true: dynamic join ordering
  functions?: IriFunctionMap; // optional: custom IRI function registry
  estimator?: JoinCostEstimator; // default: baseline greedy formula
}

store is the only required option. reorderPatterns: false preserves the written order of BGP triple patterns. createTransaction upgrades updates from direct addQuad/removeQuad calls to one atomic transaction per request. functions registers custom IRI functions (SPARQL 1.1 §17.4.3.1); see Custom functions & operators. estimator swaps the join-cost model behind the greedy reorderer (the default is the baseline greedy formula); see Join-cost estimator.

Supported SPARQL surface

Query forms

Form Notes Entry point
SELECT (incl. DISTINCT, REDUCED, *, AS ?v projection) REDUCED ≡ DISTINCT by decision SparqlEvaluator.evaluateSelect
ASK bindings.length > 0 evaluateAsk
CONSTRUCT template × solutions, fresh bnodes per solution, graph dedup evaluateConstruct
DESCRIBE (<iri>, ?var, *) outgoing-arc description (Comunica shape) evaluateDescribe

Graph patterns (WHERE)

bgp (incl. reified << s p o >> and annotations), FILTER, BIND, VALUES, OPTIONAL, MINUS, UNION, GRAPH <g> / GRAPH ?g, nested { } groups, subqueries, SERVICE (evaluated locally; SILENT swallows errors). Property paths: ^ / | ? * + ! (negated property sets).

Expressions (FILTER / BIND / ORDER BY / HAVING / projection)

Unsupported expression kinds (e.g. unknown functionCall IRIs) raise a clear error rather than silently mis-evaluating.

Aggregates (GROUP BY / HAVING)

COUNT (incl. COUNT(*)), SUM, AVG, MIN, MAX, SAMPLE, GROUP_CONCAT (with SEPARATOR), each with DISTINCT. Semantics are pinned differentially against Comunica: empty SUM/AVG/COUNT are 0; SUM/AVG are unbound when any bound argument is non-numeric; MIN/MAX use term ordering; decimal SUM is exact (BigInt). See src/evaluator/aggregate.ts.

Updates

INSERT DATA, DELETE DATA, INSERT WHERE, DELETE WHERE, DELETE/INSERT (with WITH/USING/GRAPH), LOAD (incl. INTO GRAPH, http(s) + local file sources), CLEAR/DROP (DEFAULT/NAMED/ALL/GRAPH), CREATE, ADD, COPY, MOVE — all with SILENT where the grammar allows.

Extensibility interfaces

1. Custom quad stores (the data boundary)

Any rdfjs.Store with match() is a valid read source. To support updates without a transaction, the store must additionally expose addQuad / removeQuad — the QuadWriteStore shape:

// src/evaluator/update-evaluator.ts
export type QuadWriteStore = rdfjs.Store & {
  addQuad(item: rdfjs.Quad): unknown;
  removeQuad(item: rdfjs.Quad): unknown;
};

MemoryStore, @worlds/sqlite’s SqliteStore, and @worlds/libsql’s LibsqlRdfjsStore all satisfy it. If neither createTransaction nor addQuad/removeQuad is available, updates throw a clear error.

2. Durable transactions

Provide createTransaction to make updates atomic and durable:

// src/wazoo-sparql-engine.ts
export interface WazooSparqlTransaction {
  add(quad: rdfjs.Quad): unknown; // buffer an insert
  delete(quad: rdfjs.Quad): unknown; // buffer a delete
  commit(): Promise<void>; // persist the patch atomically
  rollback(): void; // discard the patch
}

One transaction is created per update request; every operation’s writes are routed through it; commit() runs once; any error triggers rollback() and rethrows. The reference implementation is @worlds/sqlite’s SqliteStore.createTransaction() (BEGIN IMMEDIATE … COMMIT, WAL journaling) — see docs/durable-transactions.md. The same shape is compatible with @worlds/sdk’s Transaction, so existing durable backends pass their transaction objects through unchanged.

3. Pattern-evaluation hook (EXISTS)

The expression layer is deliberately pure. EXISTS/NOT EXISTS evaluate through an injected context:

// src/evaluator/expression-evaluator.ts
export interface ExpressionEvaluationContext {
  evaluateExists?: (pattern: Pattern, solution: TermBinding) => boolean;
  evaluateNotExists?: (pattern: Pattern, solution: TermBinding) => boolean;
  baseIri?: string;
  bnodeMap?: Map<string, rdfjs.BlankNode>;
}

BgpEvaluator binds these hooks at every expression call site (FILTER, BIND, ORDER BY, HAVING, projection) with the current graph scope, so EXISTS works uniformly in every expression position — nested &&, inside OPTIONAL, EXISTS-inside-EXISTS, and subqueries inside EXISTS.

The hooks evaluate against a per-call snapshot: prepareExistsIndex() returns Promise<ExistsSnapshot> (an internal quads + QuadIndex + version record; previously Promise<void>), and the private context builders — scopedExistsContext(store, snapshot), pipelineExistsContext(snapshot) — capture it once so a concurrent execute()’s cache rebuild is never observable mid-evaluation (issue #72).

4. Custom functions & operatorsCustom IRI functions are registered through WazooSparqlEngineOptions.functions:

a map from function IRI to evaluator, injected like Comunica’s function factory. An IriFunction receives the function’s evaluated arguments (undefined for unbound variables and runtime type errors) and returns a result term, or undefined to signal a type error — the same contract as the builtin surface: FILTER drops the row, ORDER BY sorts it lowest. Registered functions win over the builtin XSD-constructor mapping; unregistered IRIs keep raising Unsupported SPARQL expression: functionCall <iri>.

export type IriFunction = (
  args: ReadonlyArray<rdfjs.Term | undefined>,
) => rdfjs.Term | undefined;

export type IriFunctionMap = Readonly<Record<string, IriFunction>>;

const engine = new WazooSparqlEngine({
  store,
  functions: {
    "http://example.org/startsWithA": (args) => {
      const name = args[0];
      if (name === undefined || name.termType !== "Literal") return undefined;
      return literal(
        String(name.value.startsWith("a")),
        namedNode("http://www.w3.org/2001/XMLSchema#boolean"),
      );
    },
  },
});

The rest of the expression surface (operators, XSD constructors, aggregates, update operation types) lives in ExpressionEvaluator.evaluateOperation() / evaluateFunctionCall() (src/evaluator/expression-evaluator.ts) and aggregateValue / applyOperation (src/evaluator/update-evaluator.ts).

5. Join-cost estimator

The greedy BGP reorderer (BgpEvaluator.evaluateWithReordering) picks the next pattern to join by estimated cost. That estimate comes from an injectable JoinCostEstimator (src/planner/join-cost-estimator.ts):

export interface JoinCostEstimator {
  estimateJoinCost(
    entry: ScanEntry,
    bindings: TermBinding[],
    stats?: PatternStats,
  ): number;
}

The default is BaselineJoinCostEstimator — the shipped greedy formula: no bound pattern variable costs bindings.length × candidates.length; a bound pattern variable costs bindings.length × average bucket size (candidates.length ÷ distinct values, bounded below by 1), using the bound variable with the fewest distinct values. Swap it via WazooSparqlEngineOptions.estimator.

Contract: the estimator receives the pattern’s ScanEntry (whose candidates array holds the pattern’s true store cardinality from the up-front scan), the current bindings, and the pattern’s optional PatternStats — and nothing else: no store access. It must be pure and deterministic. Crucially, the estimate affects only join order, never results (SPARQL 1.1 §18.2.2: joins commute, so every order yields the same solution multiset); the W3C differential gate is the guard. With the default estimator, small BGPs get the globally optimal order from the DP join-order search (searchBestJoinOrder, piece 3, issue #130), which optimizes exactly this cost model over the pattern statistics; an injected custom estimator keeps the greedy loop.

5a. Statistics source

The greedy loop’s bucket-size arithmetic is only as good as its distinct-value counts. BaselineJoinCostEstimator therefore takes per-pattern statistics (planner piece 2, issue #129) from PatternStatistics (src/planner/pattern-statistics.ts), the per-query statistics source:

export interface PatternStats {
  candidates: number;
  distinctByVar: Partial<Record<string, number>>;
}

export class PatternStatistics {
  constructor(store: rdfjs.Source<rdfjs.Quad>);
  statsFor(storeView, entry: ScanEntry): Promise<PatternStats>;
}

PatternStatistics is created once per query evaluation and threaded through every group, so statistics are computed once per query — keyed by pattern signature — never re-derived inside the greedy per-step loop. It resolves each pattern’s stats in this order:

  1. Store hook — a store exposing estimateStats(pattern) (mirroring the countQuads capability, decision #12) supplies its own numbers; the engine uses them when present. GraphScopedStore forwards the hook like it forwards version.
  2. Scoped fallback — named-graph scopes (GRAPH) always derive from the pattern’s own scoped candidate scan; the hook cannot see the scope, and the scoped candidates are exact for that graph.
  3. Bounded fallback — otherwise the same shape is derived from the pattern’s already-scanned candidates: exact cardinality plus a distinct-value pass capped at DISTINCT_SAMPLE_CAP candidates (exact below the cap, a deterministic evenly-spaced sample above it), so large stores never pay an unbounded counting pass. On identical data the fallback agrees exactly with a hook answering the same numbers.

Because stats only ever influence join order — never results — a store providing estimateStats (or not) is fully transparent to query output.

Term utilities (exported from src/mod.ts)

The engine also exports its term algebra for consumers that need RDF/JS term handling or differential testing:

Export File Purpose
DataFactory, dataFactory src/term/data-factory.ts zero-dependency RDF/JS factory (namedNode, blankNode, literal, variable, defaultGraph, quad, fromTerm, fromQuad)
termKey src/term/identity.ts sound RDF-term hash key
sameRdfTerm src/term/identity.ts structural term equality (incl. triple terms)
sparqlTermToRdfTerm / rdfTermToSparqlValue src/term/convert.ts AST ⇄ RDF/JS ⇄ wire conversion
canonicalizeRdfTerm / canonicalizeSparqlValue / CanonicalTerm src/term/canonical.ts serialization-stable projection for cross-engine parity
numericValue, compareNumericValues, formatNumber, NUMERIC_DATATYPES, XSD* src/term/numeric.ts numeric value semantics
compareRdfTerms src/term/ordering.ts SPARQL §12.4 term ordering (ORDER BY, MIN/MAX)

Public export inventory (from deno doc --json)

Types: SparqlEngineInterface, SparqlRequest, SparqlResponse, SparqlSelectResults, SparqlAskResults, SparqlConstructResults, SparqlValue, SparqlBinding, WazooSparqlEngineOptions, WazooSparqlTransaction, IriFunction, IriFunctionMap (link on JSR once published),JoinCostEstimator (link on JSR once published), PatternStats, StoreStatisticsHook (link on JSR once published), EstimatedJoinState (link on JSR once published), CanonicalTerm, TurtleFormat, TurtleWriterOptions.

Values/classes: WazooSparqlEngine, SparqlSyntaxError, BaselineJoinCostEstimator (link on JSR once published), PatternStatistics, DISTINCT_SAMPLE_CAP (link on JSR once published), searchBestJoinOrder, DP_MAX_PATTERNS (link on JSR once published), MemoryStore, MemoryStream, DataFactory, dataFactory, canonicalizeRdfTerm, canonicalizeSparqlValue, compareNumericValues, compareRdfTerms, formatNumber, NUMERIC_DATATYPES, numericValue, rdfTermToSparqlValue, sameRdfTerm, sparqlTermToRdfTerm, serializeJsonResults, serializeXmlResults, serializeTurtle (the writers are also re-exported from the ./serialize subpath entrypoint), termKey, XSD, XSD_BOOLEAN, XSD_DECIMAL, XSD_DOUBLE, XSD_FLOAT, XSD_INTEGER, XSD_STRING.

Not exported from the root entrypoint (deep-import or subpath only): SparqlParser, the AST types (src/parser/ast.ts), and the evaluator internals — all reachable from source but outside the public surface, keeping the published package’s runtime dependency graph empty. The durable node:sqlite store that used to ship behind a ./sqlite subpath moved to @worlds/sqlite (2026-08-17) — no engine entrypoint pulls node:sqlite anymore.