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)
- Comparisons
= != < > <= >=; logical&& || !; arithmetic+ - * /with XPath numeric promotion (integer stays exact via BigInt). - Term tests:
bound,isIRI/isURI,isBLANK,isLITERAL,isNUMERIC,isTRIPLE,sameTerm. - Strings:
STR,STRLEN,UCASE,LCASE,CONCAT,SUBSTR,STRDT,STRLANG,REPLACE,REGEX,CONTAINS,STRSTARTS,STRENDS,STRBEFORE,STRAFTER,ENCODE_FOR_URI,LANG,LANGMATCHES. - Misc:
COALESCE,IF,IN/NOT IN,IRI/URI,TZ,BNODE,STRUUID,UUID,RAND,NOW,ABS/CEIL/FLOOR/ROUND,YEAR/MONTH/DAY/HOURS/MINUTES/SECONDS/TIMEZONE,MD5/SHA1/SHA256/SHA384/SHA512,DATATYPE. - XSD constructors:
xsd:integer,xsd:decimal,xsd:double,xsd:float,xsd:string,xsd:boolean,xsd:dateTime. - RDF-star / RDF 1.2:
TRIPLE,SUBJECT,PREDICATE,OBJECT, triple-term expressions<<( ?s ?p ?o )>>, andEXISTS/NOT EXISTS(correlated, graph-scoped). - SPARQL 1.2 direction functions:
LANGDIR,STRLANGDIR,hasLang,hasLangDir.hasLangis variadic — all three arities parse and evaluate:hasLang(langString),hasLang(langString, language), andhasLang(simpleLiteral, language, direction). The binary/ternary forms are a documented superset extension (the published SPARQL 1.2 grammar defines only the unary form); arity-2 matches the language tag case-insensitively and arity-3 additionally matches the base direction (canonicalized to lowercase, consistent withSTRLANGDIR).
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:
- Store hook — a store exposing
estimateStats(pattern)(mirroring thecountQuadscapability, decision #12) supplies its own numbers; the engine uses them when present.GraphScopedStoreforwards the hook like it forwardsversion. - 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.
- 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_CAPcandidates (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.