| name | javascript-wasm |
| description | Use the sparq RDF+SPARQL engine from JavaScript/TypeScript (Node >=18 or the browser) via its WebAssembly build and the @jeswr/sparq RDF-JS wrapper — load Turtle/N-Triples/N-Quads/TriG, run SPARQL 1.1 SELECT/ASK, stream large results, count without materialising, apply SPARQL Update / quad deltas, do RDF-JS match()/countQuads(), and ingest gzip/zstd-compressed RDF. Reach for this when wiring sparq into a Node service, browser tab, or RDF-JS pipeline. |
sparq from JavaScript / WebAssembly
sparq is a Rust RDF triplestore + SPARQL engine compiled to a single ~886 KB (314 KB gzip) WebAssembly artifact. The npm package @jeswr/sparq wraps it in an idiomatic RDF/JS surface (SparqStore, Map-like Bindings, spec terms via @rdfjs/types); it runs unchanged in Node >= 18 and the browser. There is also a thin raw wasm class (Store) if you want SPARQL-JSON strings with no JS-side term materialisation.
Use SparqStore (the high-level wrapper) by default — it covers SELECT/ASK (query/queryBindings/queryBoolean) and CONSTRUCT/DESCRIBE (queryQuads, returning RDF/JS Quads). Drop to the raw Store only to skip term materialisation entirely (SPARQL-JSON / N-Triples strings) or for query-plan introspection.
Quickstart
import { SparqStore, DataFactory as DF } from '@jeswr/sparq';
const store = await SparqStore.fromString(`
@prefix ex: <http://ex/> .
ex:alice ex:name "Alice" ; ex:knows ex:bob .
ex:bob ex:name "Bob"@en .
`, 'turtle');
const bindings = await store.queryBindings(
'PREFIX ex: <http://ex/> SELECT ?s ?name WHERE { ?s ex:name ?name }',
);
await new Promise((resolve, reject) => {
bindings.on('error', reject);
bindings.on('end', resolve);
bindings.on('data', (row) => {
console.log(row.get('s').value, '->', row.get('name').value);
});
});
store.queryBoolean('PREFIX ex: <http://ex/> ASK { ex:alice ex:knows ex:bob }');
store.count('PREFIX ex: <http://ex/> SELECT ?s WHERE { ?s ex:name ?o }');
store.free();
Building from a source checkout of the sparq repo (the package ships the wasm prebuilt, but a checkout must build it):
cd js
npm run build
npm test
Key APIs
SparqStore (from @jeswr/sparq) — the high-level store:
static empty(): Promise<SparqStore>
static fromString(data: string, format?: RdfFormat, opts?: SparqStoreOptions): Promise<SparqStore>
static fromQuads(quads: Iterable<RDF.Quad>, opts?: SparqStoreOptions): Promise<SparqStore>
static fromCompressed(bytes: Uint8Array, format?: RdfFormat,
opts?: SparqStoreOptions & { codec?: 'zstd' | 'gzip' }): Promise<SparqStore>
type RdfFormat = 'turtle' | 'ntriples' | 'nquads' | 'trig' | 'jsonld';
interface SparqStoreOptions { compressed?: boolean; dataset?: boolean; baseIri?: string; }
get size(): number
heapBytes(): number
query(sparql): Bindings[] | boolean
queryBindings(sparql, context?): Promise<ResultStream<Bindings>>
querySolutions(sparql): Map<string, Term>[]
querySolutionsStream(sparql): Generator<Map<string,Term>>
queryBoolean(sparql): boolean
queryJson(sparql): string
queryQuads(sparql): Quad[]
queryQuadsString(sparql): string
count(sparql): number
serialize(format?, opts?: SerializeOptions): string
queryBindingsStream(sparql): Generator<Bindings>
queryQuadsStream(sparql, batchSize?): Generator<Quad>
queryJsonChunks(sparql): Generator<string>
validate(data: string, shapes: string, format?: RdfFormat): ValidationReport
match(s?, p?, o?, g?): Quad[]
matchStream(s?, p?, o?, g?): Generator<Quad>
countQuads(s?, p?, o?, g?): number
update(sparql): void
applyDelta(inserts: Iterable<RDF.Quad>, deletes?: Iterable<RDF.Quad>): void
addQuads(quads): void
removeQuads(quads): void
free(): void
Bindings (RDF/JS Query-spec, Map-like): .get('var') -> RDF.Term | undefined, .has, .keys(), .values(), .entries(), .size, .equals, immutable .set/.delete/.filter/.map/.merge, iterable, plus .toMap() -> Map<string, Term> (the Oxigraph-shaped bare-string-keyed view — #1123). Terms: .termType ('NamedNode' | 'Literal' | 'BlankNode' | ...), .value, plus .language / .datatype on literals.
Dataset (named export — the full RDF/JS Dataset over the engine; async factories Dataset.create/fromString/fromQuads): DatasetCore (add/delete/has/match/size/[Symbol.iterator]) PLUS the algebra union/intersection/difference/addAll/deleteMatches/contains/equals/filter/map/forEach/some/every/reduce/import/toStream/toArray/toString/toCanonical. The binary set ops (union/intersection/difference/addAll/contains/equals) are INTEROP-aware — the operand may be another sparq Dataset OR any foreign RDF/JS dataset/store (N3.Store, @rdfjs/dataset), detected via [Symbol.iterator]. Full SPARQL surface one accessor away via dataset.store. (toCanonical/equals/contains are RDFC-1.0 blank-node-ISOMORPHISM-aware — toCanonical emits canonical _:c14nN N-Quads, equals compares canonical forms, contains matches a relabelled subgraph; backed by the engine's RDFC-1.0 surfaced over the opt-in canon wasm feature.)
Other named exports from @jeswr/sparq: DataFactory (RDF/JS factory: namedNode, blankNode, literal, variable, quad, ...) and term classes NamedNode/BlankNode/Literal/Variable/DefaultGraph/Quad; init (idempotent wasm bootstrap); compression helpers decompress / decompressToString / sniffCodec; SPARQL helpers termFromSparqlJson / termToNT / quadsToNQuads / detectQueryForm / SparqlJsonRowsParser; and the SparqDictionaryClient (server dictionary-fetch protocol).
Raw wasm Store (from ../wasm/sparq_wasm.js, re-exported as WasmStore internally) — use only when you need CONSTRUCT/DESCRIBE, batch cursors, or query-plan introspection. Methods return SPARQL-JSON / N-Triples / plan-text strings, not RDF/JS terms: Store.load/loadDataset/loadCompressed(text, format), Store.loadBytes(bytes, format) / Store.loadBytesWithBase(bytes, format, base) (ingest a Uint8Array directly, bytes-ingest bundle only — see below), .query(sparql), .queryChunks(sparql), .queryCursor(sparql, batchSize), .queryQuads(sparql) (CONSTRUCT/DESCRIBE -> N-Triples), .queryQuadsChunks(sparql, batchSize), .count, .ask, .askWithMaxRows(sparql, maxRows), .explain(sparql), .explainAnalyze(sparql), .validate(data, shapes, format) (SHACL report as a JSON string, shacl bundle only), .serialize(format, pretty, indent, abbreviate, prefixes?) (the store's contents as a Turtle / TriG / JSON-LD string, serialize-rdf bundle only — see below), .parseShaclCompact(text, base?) (SHACL Compact Syntax → the shapes graph as a Turtle string, scs bundle only — see below), .update, .updateInPlace, .applyDelta(inserts, deletes), .size, .heapBytes().
Solid server wasm adapter (host integration)
The dedicated sparq-lws-wasm crate is the opt-in request adapter for a Solid-server wasm host; it
is separate from the @jeswr/sparq RDF/JS store package. It owns the real axum LDP routes and WAC
evaluator over CompositeStore<InMemorySparqClient, InMemoryBlobStore>, while excluding the native
listener, Tokio runtime, TLS/PoP/notifications, live OIDC verifier, and non-memory backends.
Build and stage it with
npm --workspace @jeswr/solid-server run build:lws-wasm; use build:lws-wasm-core for the named
core tier. [GPT-5.6] The full build enables sparq-lws-wasm/sparql-endpoint; the core command leaves
that feature off, so the core wasm omits the route and query-engine dependency graph. Construct
SolidServer with the pod base URL and the WebID that owns its provisioned root ACL, then call the
Promise-returning
handleRequest(method, path, headers, body, authenticatedWebid). Header arguments and response
headers are flat name/value arrays. Omit authenticatedWebid for a public request.
const owner = "https://id.example/alice#me";
const pod = new SolidServer("https://pod.example", owner);
const response = await pod.handleRequest(
"PUT", "/card", ["content-type", "text/turtle"], turtleBytes, owner,
);
The host MUST complete OIDC validation before supplying an authenticated WebID; the constructor's
owner parameter only provisions WAC and is not authentication. Do not enable or stub
solid-oidc-verifier inside wasm: its pinned crypto backend is native-only.
[GPT-5.6] The full tier's /sparql is query-only SPARQL 1.1 Protocol: GET uses the query
parameter; POST accepts application/sparql-query or form encoding. SELECT/ASK return
application/sparql-results+json; CONSTRUCT returns application/n-triples; DESCRIBE and UPDATE
are refused in v1. Each request walks authoritative ldp:contains, applies the same per-resource
WAC read decision as LDP GET, and gives the engine only admitted RDF. Each resource is a named graph
at its canonical IRI, the standing default graph is empty, and
FROM <http://www.w3.org/ns/solid/sparql#union-default-graph> opts into the admitted union.
Unreadable, malformed, or indeterminate resources are excluded. Assembly is currently O(pod) per
query; the endpoint does not yet retain a cross-request dataset cache. Within one server instance,
assembly and evaluation hold a shared read barrier while LDP mutations take its write side, so a
query cannot combine resources from an interleaved LDP write.
[GPT-5.6] The in-memory Solid store is bounded by default: its physical blob map admits at most
64 MiB in aggregate and 4,096 stored entries, and its metadata map independently admits at most
4,096 indexed resources. Physical usage includes unreferenced blob versions awaiting reconciliation,
so repeated overwrites cannot bypass the ceiling. A PUT/POST that would exceed either limit fails
closed with HTTP 507; deleting a current blob releases its bytes and entry slot. Rust embedders can
configure both ceilings with InMemoryStoreLimits::new(max_total_bytes, max_resource_count) and
CompositeStore::in_memory_with_limits(limits), then inspect the concrete store's usage() and
quota() views. The current JS SolidServer constructor uses the bounded Rust defaults and does not
yet expose per-instance limit options.
[GPT-5.6] @jeswr/solid-server supplies the local Node host. Install and run it with
npx @jeswr/solid-server --port 3000 --base-url http://127.0.0.1:3000 --owner-webid https://id.example/alice#me, or import startSolidServer({ port, baseUrl, ownerWebid }); it resolves
to a listening Node http.Server with an async closeAsync() helper. The listener binds
127.0.0.1, owns one wasm pod for its lifetime, and preserves repeated request/response headers.
It defaults to deliberately unauthenticated fixed-owner mode: every caller acts as ownerWebid.
For the opt-in Node authentication path, call
startSolidServer({ port, baseUrl, ownerWebid, oidc: true }). The host then requires and verifies a
Solid-OIDC access token plus request-bound DPoP proof with @solid/access-token-verifier before
passing the resolved WebID into wasm; missing, invalid, expired, replayed, bearer-only, or ambiguous
credentials pass no WebID and WAC treats them as anonymous. ownerWebid provisions the root ACL but
is not proof of identity in this mode. baseUrl must be the public request origin used in each DPoP
proof because the host binds the proof to the reconstructed request URL. The verifier dereferences
WebID and issuer/JWKS documents.
Use the package only for local development, do not expose it as a production server, and expect all
data to disappear on shutdown. TLS termination, persistent storage, and notifications remain
absent; OIDC verification runs in Node, not wasm.
[FABLE-5] Transport-agnostic host mode (#2323). For consumers that already run a web
framework (or need a per-request handler with no Node socket), @jeswr/solid-server also exports
createSolidPod({ baseUrl, ownerWebid, oidc }) — the same pod behind a dispatcher instead of a
listener. pod.dispatch({ method, url, rawHeaders, body }) resolves to { status, headers, body }
(url origin-form including any query, headers flat name/value pairs both ways, body in as
string | Buffer | Uint8Array, out as Buffer) and owns the whole host contract: the 2 MiB body
ceiling → the plain-text 413 shape, trap recycle → 503 (a later request is never served by a
poisoned instance), wasm-response copy + free, and repeated-header preservation. baseUrl is
required (no listener port to derive it from; OIDC DPoP proofs bind to it); pod.free() releases
the wasm instance. The first-party Fastify plugin sits on the ./fastify subpath with fastify
as an OPTIONAL peer dependency (the core package keeps its single runtime dep):
import { solidPod } from '@jeswr/solid-server/fastify'; await fastify.register(solidPod, { baseUrl, ownerWebid, oidc }). The plugin is mechanical over dispatch: it replaces all
content-type parsers with a '*' buffer parser (JSON-LD/N3 bytes reach wasm unparsed, capped at
2 MiB with FST_ERR_CTP_BODY_TOO_LARGE mapped to the host 413 shape), registers one catch-all
all('/*') route (exposeHeadRoutes: false) forwarding request.raw.url + request.raw.rawHeaders
(repeated headers and /sparql?query= intact), and sends grouped array-valued response headers
(repeated Link/Vary preserved). Do not put @fastify/cors in front of it — wasm owns CORS.
The building blocks are exported for assembling other hosts (a service-worker fetch handler)
downstream: SolidServer, createPodDispatcher, and the http.js helpers (MAX_BODY_BYTES,
flattenRequestHeaders, readRequestBody, copyWasmResponse, writeNodeResponse) from the
package root, and the raw wasm glue via the ./wasm subpath export.
[SONNET-4.6] Trap recovery (sq-250si). The wasm artifact uses panic=abort (release profile):
a Rust panic or allocation failure at the wasm32 linear-memory ceiling lowers to an unreachable
trap that the host sees as WebAssembly.RuntimeError. Before the abort fires, a console_error_panic_hook
installed at module init emits the panic message to console.error. The Node host catches the
WebAssembly.RuntimeError, frees the poisoned SolidServer, constructs a fresh instance (state
loss is acceptable for the ephemeral dev server), and returns HTTP 503 for the triggering request.
The next request is served by the new instance — a single trap no longer bricks the server process
indefinitely. The attachTrapRecoveryHandler and isWasmTrap helpers are exported from
@jeswr/solid-server/src/trap-recovery.js for testing without a real wasm binary.
Common recipes
Decompress a browser dataset before loading it (@sparq/client). The shared site/GUI client
selects gzip, ZIP, zstd, or bzip2 by payload magic first and filename extension second. gzip and
ZIP use native browser streams; zstd and bzip2 are separate lazy chunks fetched only when invoked.
import { decompressDatasetBytes } from '@sparq/client';
const compressed = new Uint8Array(await file.arrayBuffer());
const { bytes, innerName, codec } = await decompressDatasetBytes(compressed, file.name);
const rdfText = new TextDecoder().decode(bytes);
ZIP selects the first RDF-looking STORED or DEFLATE member and reports its member name. Encrypted
ZIP, ZIP64, unsupported ZIP methods, zstd dictionary frames, and malformed streams reject rather
than returning undecoded bytes. Browser gzip follows DecompressionStream semantics; do not rely
on it to concatenate multiple gzip members.
Stream a large SELECT without holding the whole result. One solution at a time, from ~64 KiB wasm-boundary chunks; break frees the cursor.
for await (const row of store.queryBindingsStream('SELECT ?s ?o WHERE { ?s ?p ?o }')) {
console.log(row.get('s').value);
}
RDF/JS match() / countQuads(). Wildcards are null/undefined/Variable.
store.match(null, DF.namedNode('http://ex/name'), null);
store.countQuads(null, DF.namedNode('http://ex/name'));
Build a store from nothing ([OPUS-4.8] sq-ty78o / #1114). SparqStore.empty() (or the raw new Store()) yields an empty, mutable store; grow it with update() / addQuads(). Named graphs work out of the box — the overlay creates a named graph on the first targeted insert, so an empty store needs no dataset flag to accept INSERT DATA { GRAPH … } (dataset mode only matters when loading a document whose named graphs would fold).
const store = await SparqStore.empty();
store.update('INSERT DATA { GRAPH <http://ex/g> { <http://ex/s> <http://ex/p> <http://ex/o> } }');
const graphBindings = await store.queryBindings('SELECT ?g WHERE { GRAPH ?g { ?s ?p ?o } }');
await new Promise((resolve, reject) => {
graphBindings.on('error', reject);
graphBindings.on('end', resolve);
graphBindings.on('data', (row) => {
console.log(row.get('g').value);
});
});
Resolve relative IRIs against a base ([OPUS-4.8] sq-f66jz / #1115). Pass { baseIri } to fromString (or raw Store.loadWithBase(text, format, base)) for a document fetched from a URL (or a shapes graph / manifest) that carries relative IRIs and no @base. A document-level @base still overrides it; line-based formats ignore it; an invalid base throws. Not combinable with dataset / compressed.
const s = await SparqStore.fromString('<a> <p> <../up/o> .', 'turtle', { baseIri: 'http://ex/dir/' });
Mutate in place (O(batch), no index rebuild).
store.update('PREFIX ex: <http://ex/> INSERT DATA { ex:carol ex:name "Carol" }');
store.addQuads([DF.quad(DF.namedNode('http://ex/d'), DF.namedNode('http://ex/p'), DF.literal('o'))]);
store.removeQuads(store.match(null, DF.namedNode('http://ex/p')));
store.applyDelta(insertQuads, deleteQuads);
Named graphs (load as a dataset). Without dataset: true, all quads fold into the default graph and GRAPH/named-graph lookups see nothing. Applies to N-Quads, TriG, and a JSON-LD @graph (with an outer @id). 'jsonld' parsing is compiled in only when the bundle is built with the opt-in jsonld feature (the published @jeswr/sparq bundle enables it — see the bundle-features note below); the lean default bundle omits it.
const ds = await SparqStore.fromString(nquads, 'nquads', { dataset: true });
const namedBindings = await ds.queryBindings('SELECT ?g ?s WHERE { GRAPH ?g { ?s ?p ?o } }');
await new Promise((resolve, reject) => {
namedBindings.on('error', reject);
namedBindings.on('end', resolve);
namedBindings.on('data', (row) => {
console.log(row.get('g').value, row.get('s').value);
});
});
ds.update('INSERT DATA { GRAPH <http://ex/g> { <http://ex/s> <http://ex/p> "o" } }');
ds.match(null, null, null, DF.namedNode('http://ex/g'));
Compressed ingest + memory-tight index. fromCompressed sniffs the codec by magic number (.nt.zst, .ttl.gz); { compressed: true } halves index memory at a small per-scan decode cost.
const fromZst = await SparqStore.fromCompressed(zstBytes, 'ntriples');
const compact = await SparqStore.fromString(bigTurtle, 'turtle', { compressed: true });
CONSTRUCT / DESCRIBE. SparqStore.queryQuads(sparql) returns the constructed/described graph as RDF/JS Quads in the default graph (template bnodes freshened per solution; illegal slots dropped per §16.2). queryQuadsString(sparql) gives the raw N-Triples (a valid Turtle subset) and queryQuadsStream(sparql, batchSize?) streams the quads for a large graph. A SELECT/ASK routed here throws.
const quads = store.queryQuads('PREFIX ex: <http://ex/> CONSTRUCT { ?s ex:label ?n } WHERE { ?s ex:name ?n }');
quads[0].subject.value;
for (const q of store.queryQuadsStream('DESCRIBE <http://ex/bob>')) { }
const nt = store.queryQuadsString('DESCRIBE <http://ex/bob>');
The raw Store.queryQuads / queryQuadsChunks (returning N-Triples strings) remain available for zero term-materialisation.
Serialise / dump the store ([OPUS-4.8] sq-u78ol / #1117 / #1129, serialize-rdf bundle only). The high-level SparqStore.serialize(format?, opts?) (with a dump(format?, opts?) alias) is the primary entry point — an options-object wrapper over the raw Store.serialize(...) below (opts: { pretty?, indent?, abbreviate?, prefixes? }, all defaulted: pretty Turtle, two-space indent, abbreviated, engine default prefixes). It throws a clear "requires a serialize-rdf-enabled wasm bundle" error on a lean bundle (rather than a cryptic "not a function").
const ttl = store.serialize('turtle');
const trig = store.dump('trig');
const mine = store.serialize('turtle', { prefixes: [['ex', 'http://ex/']] });
const full = store.serialize('turtle', { abbreviate: false });
The raw wasm Store.serialize(format, pretty, indent, abbreviate, prefixes?) (positional) is the same engine writer. Where queryQuads writes a CONSTRUCT/DESCRIBE result graph as flat N-Triples, serialize(...) writes the store itself as a readable Turtle (default graph), TriG (whole dataset, named graphs as GRAPH <g> { … } blocks), or JSON-LD (whole dataset) string. pretty=true indents the output (Turtle/TriG: the sorted, blank-line-separated prettyTurtle shape; JSON-LD: a structurally re-indented document); indent is the indent unit (null/undefined ⇒ two spaces, ignored when pretty=false). For Turtle/TriG, abbreviate=true emits a sorted @prefix header and compacts IRIs to prefix:local, false keeps full <…> IRIs. prefixes is an OPTIONAL [[prefix, iri], …] array selecting the compaction prefix map: omit it (undefined/null) for the engine's well-known defaults (rdf/rdfs/xsd/owl/schema → http://schema.org//foaf/dc/skos/sh) — byte-for-byte the prior behaviour — or pass your OWN policy (the COMMON_PREFIXES registry with https://schema.org/ + dcterms/prov/geo/void/ex → http://example.org/, or a query's declaredPrefixBindings(...)) to drive @prefix/@context compaction under that policy and get byte-parity output. It applies to Turtle/TriG (with abbreviate=true) and the JSON-LD jsonld-compacted @context; a malformed entry throws. format (case-insensitive) is 'turtle' ('ttl'/'text/turtle'), 'trig' ('application/trig'), or JSON-LD: 'jsonld' ('json-ld'/'application/ld+json') emits the expanded form by default, and 'jsonld-expanded' / 'jsonld-flattened' / 'jsonld-compacted' pick the JSON-LD 1.1 document form explicitly. For JSON-LD abbreviate is ignored — IRI abbreviation is chosen by the jsonld-compacted form (which carries a prefix @context). An unrecognised format (or a malformed prefixes) throws. All three formats route through the SAME engine writer the native CLI/HTTP surface uses (so JS consumes the engine writer rather than hand-formatting JSON-LD). Compiled in only with the opt-in serialize-rdf bundle feature (see below); the lean bundle omits it. (JSON-LD serialise-OUT needs no extra feature — only serialize-rdf; the jsonld feature is INGEST-only.) jsonld-compacted vs serializeCompact (full Compaction). The wasm serialize 'jsonld-compacted' form is the prefix-only @context (it abbreviates IRIs to prefix:local CURIEs from the prefixes map). For the full W3C JSON-LD 1.1 Compaction Algorithm against a caller-supplied @context (term definitions / @vocab / type-language-@container coercion / @reverse / @id/@type aliasing / value+node+IRI compaction), use the sibling serializeCompact(context, pretty, indent?) binding (sq-oy1f.5) — context is the @context JSON text (e.g. '{"@vocab":"http://schema.org/"}'), pretty/indent mirror serialize. It routes through sparq_engine::serialize::graph_to_jsonld_compact (still dependency-free, see the data-formats SKILL) and is lossless (a JSON-LD→RDF round-trip reconstructs the triples). A non-object / malformed context throws. Same serialize-rdf bundle feature; absent on the lean bundle.
const doc = store.serializeCompact('{"@vocab":"http://schema.org/"}', true, ' ');
const ttl = store.serialize('turtle', true, ' ', true);
const trig = store.serialize('trig', true, null, true);
const jsonld = store.serialize('jsonld-compacted', true, ' ', true);
const mine = store.serialize('turtle', true, ' ', true, [['ex', 'http://example.org/'], ['schema', 'https://schema.org/']]);
Parse SHACL Compact Syntax (raw wasm Store.parseShaclCompact, scs bundle only). parseShaclCompact(text, base?) parses a SHACL Compact Syntax (SCS) document into the equivalent SHACL shapes graph and returns it as a pretty Turtle string — the SCS input direction (the 'Compact → shapes' mode for the workbench). base (optional) is the base IRI relative IRIs and the owl:Ontology subject resolve against; undefined/null uses the SCS no-BASE convention (urn:x-base:default), and a document-level BASE directive overrides it. It reuses the Store.serialize('turtle', true, ' ', true) engine writer above (no second serialiser), so the bytes match that call; the shapes it yields validate data identically to the equivalent hand-written Turtle shapes (it is the same triples validate consumes), covering the grammar the W3C shacl12-cs corpus exercises. It is stateless (ignores the receiver's triples). A malformed SCS document throws a JsError carrying the 1-based source line.
import init, { Store } from '@jeswr/sparq/wasm/sparq_wasm.js';
await init();
const store = Store.load('', 'turtle');
const shapesTurtle = store.parseShaclCompact(
'PREFIX ex: <http://example.org/>\nshapeClass ex:Person {\n ex:name xsd:string [1..1] .\n}\n');
EXPLAIN / EXPLAIN ANALYZE (query-plan introspection, raw wasm only). Same plan text the Rust API (sparq_engine::explain) and the HTTP endpoint (?explain / ?explain=analyze, or Accept: text/x-sparq-explain) return — returned to JS as a plain string. explain is a planning-only dry run (no execution; every query form); explainAnalyze runs the query (SELECT/ASK only) and appends a per-operator row-count trace.
const plan = raw.explain('PREFIX ex: <http://ex/> SELECT ?n ?a WHERE { ?s ex:name ?n . ?s ex:age ?a }');
const trace = raw.explainAnalyze('PREFIX ex: <http://ex/> SELECT ?n WHERE { ?s ex:name ?n }');
Structured plan tree (explainPlanJson / explainPlanAnalyzeJson, explain-json bundle
only — the published bundle enables it; sq-ixc3.19). The TYPED plan the GUI plan explorer
renders: sparq-engine's explain_json::PlanNode as camelCase JSON — per operator
{"operator", "estimated", "actual", "nanos", "qError", "children"} (the sq-jbqh4 schema
contract, identical to the server's Accept: application/x-sparq-explain+json response).
The dry-run form populates only estimated; the analyze form (SELECT/ASK only) fills
actual rows and qError = max(est/actual, actual/est) exactly — nanos still reads 0 on
wasm32 (the same no-monotonic-clock caveat as explainAnalyze; real per-operator wall
times come from the desktop-native or server paths). @sparq/client exports the matching
PlanNode type + the parsePlanJson defensive parse.
const tree = JSON.parse(raw.explainPlanAnalyzeJson('SELECT ?n WHERE { ?s <http://ex/name> ?n }'));
SHACL validation (SparqStore.validate, shipped by default). validate(data, shapes, format?) validates an RDF data graph against a SHACL shapes graph and returns a typed ValidationReport (the JSON the wasm binding emits, parsed for you). It runs sparq-shacl's SHACL Core + SHACL-SPARQL (sh:sparql) engine inside wasm — a drop-in for rdf-validate-shacl. It is stateless (does not consult the store's own triples). format defaults to 'turtle' and accepts the same set as fromString.
import { SparqStore, type ValidationReport } from '@jeswr/sparq';
const store = await SparqStore.fromString('', 'turtle');
const report: ValidationReport = store.validate(dataTurtle, shapesTurtle);
report.conforms;
const violations = report.results.filter(
r => r.severity === 'http://www.w3.org/ns/shacl#Violation');
The raw Store.validate(data, shapes, format) returns the same report as a JSON string (JSON.parse it yourself). focusNode/value/sourceShape are N-Triples term strings; path is a SHACL Turtle path expression; severity/sourceConstraintComponent are full IRIs; message is the first sh:message text (or a generated default). path/value/message are null when absent. Only a graph parse failure throws; malformed shapes are skipped, not surfaced. For large data graphs validate server-side via the sparq-server HTTP validate endpoint instead (the other half of the #162 decision). See the shacl-validation skill for the engine's SHACL coverage.
Raw wasm Store: init (initSync vs async default), errors, and .free() (#1127). The wasm-pack --target web glue (../wasm/sparq_wasm.js) is a real ESM module that exports a default async init plus a synchronous initSync — one of the two MUST run before any Store.* static (SparqStore/Dataset do this for you via the package's memoised init(); you only call these when using the raw Store directly).
import init, { initSync, Store } from '@jeswr/sparq/wasm/sparq_wasm.js';
await init();
initSync({ module_or_path: wasmModuleOrBytes });
const store = Store.load('<a> <b> <c> .', 'ntriples');
- When to use which. Prefer the high-level
@jeswr/sparq entry (SparqStore/Dataset), whose init() picks the right path per environment (Node reads the bytes off disk; browser/Deno fetches) and memoises it — so the ~MB .wasm is paid at most once. Drop to init/initSync only when you import the raw Store glue directly: async init() for the common fetch-relative case, initSync(...) only when you have the module/bytes in hand and want no await.
init()/initSync() not called first → Store.* throws a wasm-bindgen "must call init" error. Calling init() again is idempotent.
- Format strings for
Store.load/loadDataset/loadCompressed are the same case-sensitive set the engine accepts: turtle (ttl/text/turtle), ntriples (n-triples/nt), nquads (n-quads/nq), trig, and jsonld (json-ld/application/ld+json, opt-in jsonld bundle). An unrecognised format is an Err/throw — it is NOT silently parsed as Turtle (so a 'jsonld' load on the lean bundle throws rather than mis-parsing).
- Errors are JS exceptions. Every fallible
Store method (load*, query, ask, update, applyDelta, validate, …) maps a Rust Err(String) to a thrown Error (wasm-bindgen JsError) carrying the engine message (parse error with position, malformed SPARQL, an unrecognised format, a query form routed to the wrong method — e.g. CONSTRUCT through query). Wrap calls in try/catch; there are no silent failures.
.free() — when and on what. wasm linear memory is NOT GC'd: call .free() on every Store you create, AND on each cursor object (QueryChunks / the queryCursor / queryQuadsChunks handles) once drained or abandoned. After .free() the handle (and any cursor it spawned) must not be touched. In the high-level wrapper use store.free() or using store = await SparqStore.from…() (Symbol.dispose); for the raw Store, breaking out of a streaming for…of over a cursor still requires freeing that cursor. Leaking handles grows the 4 GB wasm heap until the tab/process OOMs.
Gotchas / feature flags / prerequisites
- ESM only, Node >= 18. The package is
"type": "module"; init() is idempotent and runs automatically on the first SparqStore.from* call (in Node it reads the wasm bytes from disk; in the browser/Deno it fetches relative to the module). One runtime dep: fzstd (~8 KB, dynamically imported only when decoding zstd).
SparqStore.query() is SELECT/ASK only. It returns Bindings[] (SELECT) or boolean (ASK); a CONSTRUCT/DESCRIBE routed through it throws. Use queryQuads() (RDF/JS Quads) / queryQuadsString() (N-Triples) / queryQuadsStream() for the graph-valued forms. Federated (SERVICE) queries are still not exposed at the JS wrapper layer.
REGEX / REPLACE are compiled out of the wasm build (the engine's non-default regex cargo feature is off to keep the bundle small). Use CONTAINS/STRSTARTS/STRENDS/... or a custom wasm build with --features regex.
- SHACL is on
SparqStore.validate (typed) AND raw Store.validate (JSON string), shipped by default. The published bundle is built with --features shacl, so validate works out of the box. SHACL is not free in the binary — it pulls in the SHACL engine + regex + the sh:sparql query path, which roughly doubles the .wasm (measured ~1.21 MiB → ~2.19 MiB, +~1.0 MiB / +85%, pre-gzip). Size-sensitive consumers can build the lean variant (npm run build:wasm:lean, equivalently wasm-pack build … --profile release-wasm with no --features), which omits SHACL — SparqStore.validate then throws a clear "requires a SHACL-enabled wasm bundle" error. Use --features shacl-af to also compile sh:rule. Validation is in-process and best for small documents (~10–100 triples); large graphs should use the server-side HTTP validate path.
- JSON-LD ingest (
'jsonld') is shipped in the published bundle but is an OPT-IN cargo feature. The published @jeswr/sparq bundle is built with --features shacl,jsonld (the js build:wasm script), so fromString(_, 'jsonld') works out of the box. JSON-LD is not free in the binary — it links the oxjsonld parser (~0.35 MiB raw cargo build, pre-wasm-opt), so the lean default bundle (build:wasm:lean / cargo build -p sparq-wasm with no --features) omits it to stay under the perf-gate wasm_bundle_bytes floor. On the lean bundle a 'jsonld' load is not recognised as JSON-LD. Turtle / N-Triples / N-Quads / TriG are always present (no feature needed).
serialize / dump need the serialize-rdf bundle feature. Exposed on BOTH the high-level SparqStore.serialize(format?, opts?) (with a dump alias) and the raw Store.serialize(...) ([OPUS-4.8] sq-u78ol / #1117 / #1129). The published @jeswr/sparq bundle is built with --features shacl,jsonld,serialize-rdf (the js build:wasm script), so serialize(...) works out of the box. It is not free in the binary — it links sparq-engine's serializer matrix (the pretty Turtle / TriG writers) — so the lean default bundle (build:wasm:lean / cargo build -p sparq-wasm with no --features) omits it. On the lean bundle SparqStore.serialize/dump throw a clear "requires a serialize-rdf-enabled wasm bundle" error (and the raw Store.serialize method is absent).
Store.parseShaclCompact needs the scs bundle feature. The published @jeswr/sparq bundle is built with --features shacl,jsonld,serialize-rdf,scs (the js build:wasm script), so parseShaclCompact(...) works out of the box. The scs feature implies shacl + serialize-rdf (it REUSES the serialize engine writer to emit the shapes Turtle — no second serialiser) and forwards to sparq-shacl/scs (a hand-rolled parser, no new dependency). The lean default bundle (build:wasm:lean / cargo build -p sparq-wasm with no --features) omits it, so the lean .wasm is unchanged; on the lean bundle the parseShaclCompact method is absent. (Raw Store only for now; the playground "Compact → shapes" input mode and any typed SparqStore wrapper are tracked separately.)
- ODRL policy evaluation is an EXPERIMENTAL probe, NOT in the published bundle ([FABLE-5] sq-586sh, #890 ask A). The opt-in
policy cargo feature compiles sparq-policy's stateless ODRL evaluator to wasm32 and exposes two probe free functions on the raw wasm surface — policyEvaluate(policyRdf, format, action, target?, party?) → decision JSON ({allow, matchedRules, unmetConstraints}, fail-closed: malformed policy throws, unmatched/empty policy denies) and policyConflicts(policyRdf, format) → static conflict/admissibility JSON. build:wasm does not enable it: the full JS API (per-dimension audit statuses, per-duty status, purpose/party taxonomies) awaits a maintainer public-contract decision (the sq-586sh report issue). The stateful count-enforcement feature is deliberately not forwarded (no cross-tab atomicity in a browser). See usage-control-policy for the evaluator semantics.
- Byte-ingest (
Store.loadBytes / loadBytesWithBase) needs the bytes-ingest bundle feature ([FABLE-5] sq-3ul2n.3). The opt-in bytes-ingest cargo feature exposes Store.loadBytes(bytes, format) and Store.loadBytesWithBase(bytes, format, base), which take the Uint8Array you already hold (e.g. new Uint8Array(await response.arrayBuffer())) and feed it to the SAME parse path as load/loadWithBase, skipping the UTF-16 JS-string round-trip. The result is byte-for-byte the store Store.load(new TextDecoder().decode(bytes), format) builds (equal size + probe-query JSON). Invalid UTF-8 is rejected fail-closed with a catchable JsError (RDF text formats are all UTF-8) — never a panic or lossy decode, the same error surface as a malformed document to load. ZERO new dependencies. OFF by default so the lean wasm_bundle_bytes baseline is byte-identical; the published bundle turns it on (the js build:wasm wiring is tracked separately, sq-3ul2n.5). On a bundle without the feature the loadBytes methods are absent.
- SHACL-to-form derivation (
Store.deriveForm) needs the forms bundle feature ([SONNET-4.6] sq-q4apb, #2396). The opt-in forms cargo feature compiles sparq-forms' derivation to wasm32 and exposes Store.deriveForm(data, shapes, focus, format, optionsJson) on the raw wasm surface — the hosted-web half of the GUI forms bridge (gui/app's forms-bridge.ts feature-detects exactly this method; desktop uses the Tauri derive_form command instead, and the adapter never falls through between hosts). Stateless one-shot: data/shapes are serialized workspace snapshots in format (named graphs preserved dataset-style; the workbench sends N-Quads), focus is an absolute IRI or _:label, optionsJson is the snake_case {"mode":"edit"|"view","shape"?:iri-or-_:label} object. Returns the FormDescription serde JSON string verbatim — byte-identical to the desktop bridge for the same inputs; JSON.parse it, never reconstruct keys/groups/widgets. Malformed graphs/focus/options throw a JsError; there is no demo-data fallback. OFF by default (the lean wasm_bundle_bytes baseline is byte-identical) and not yet enabled by build:wasm (the hosted /app bundle wiring is tracked separately). On a bundle without the feature the method is absent. See the shacl-forms skill for the derivation semantics.
options.dataset is not combinable with options.compressed — there is no compressed dataset loader yet (the constructor throws). compact-index (3 permutations, ~half the memory) is auto-selected for wasm32 regardless; compressed adds block compression on top.
size / heapBytes report the DEFAULT graph only. For dataset totals use countQuads() (its graph wildcard spans named graphs).
- Mutation is overlay-based.
update() / applyDelta() write through an append-only delta overlay: the dictionary only grows, and deletes are tombstones until the wasm store is reloaded. Blank nodes in applyDelta/removeQuads are matched by label (so bnode triples can be retracted — impossible via SPARQL DELETE DATA).
- Browser gzip truncation. Browsers silently truncate multi-member gzip to the first member;
fromCompressed uses node:zlib in Node (loops members) and DecompressionStream in the browser (single-member only). Multi-frame zstd decodes fully everywhere via fzstd. fzstd cannot decode zstd dictionary frames — for those supply a dict-capable decoder via SparqDictionaryClient's decodeWithDictionary hook.
- Lifetime. Call
.free() (or use using) to release wasm linear memory; the store and any held cursors must not be used afterward. wasm32 caps linear memory at 4 GB (a real tab is happier under ~2 GB): ~30 M triples raw, ~75 M with compressed.
- Raw
Store budget knobs are wasm-portable only. askWithMaxRows bounds the working set by row count; the engine's wall-clock deadline budget is native-only (std::time::Instant is unusable on wasm32).
explainAnalyze wall times read 0 on wasm32. There is no monotonic clock in the wasm bundle, so the per-operator trace reports 0 for every wall time; the per-operator row counts are exact. explainAnalyze executes the query (SELECT/ASK only) — CONSTRUCT/DESCRIBE/UPDATE are rejected; use explain (a non-executing dry run that accepts every query form) for those.
- The bundle ships under the size-optimised
release-wasm cargo profile ([OPUS-4.8] sq-7d3dj.1). build:wasm{,:lean} build wasm-pack --profile release-wasm (root Cargo.toml): the COLD run-once parse crates (spargebra/oxiri/oxttl) compile at opt-level = "z" and local symbols are stripped (strip = "symbols"), while the HOT engine/core query-eval crates stay opt-level 3 — so the bundle is materially smaller with no runtime-perf change (pure parser code, off every hot query path). The wasm_bundle_bytes perf-gate (scripts/ci-bench.sh) builds under the SAME cargo profile as the shipped bundle — it ratchets a raw cargo build .wasm (not the post-wasm-bindgen/wasm-opt npm artifact), so it tracks the profile the shipped bundle is built under rather than the shipped bytes themselves.
- Benchmarking the bundle vs the JS/WASM ecosystem ([FABLE-5] sq-hmd7l.17).
bench/wasm-compare/run.sh --bundle-only is the DETERMINISTIC shipped-bundle-bytes comparison vs the pinned oxigraph npm artifact (the one wasm metric that can be canonical without a quiet box); bench/wasm-compare/browser/ holds the per-phase browser harness (sq-3ul2n.1: Chromium/Firefox/WebKit + Node, shipped bundle only) and compare.mjs, the cross-library latency comparison (sparq vs oxigraph-npm vs N3.js+quadstore) on the same oracle-checked workload — no latency row without row-count agreement. Competitor packages are gather-only installs; first-read gap record research/gap-wasm-2026-07.md.
See also
fused-decompress-parse, rust-parallel-parsing — server/native ingest internals behind the codecs you feed fromCompressed.
hdt-format — loading .hdt archives (native crate, not in the wasm bundle).
noir-circuit-patterns, noir-optimisation, mpc-protocols, verifiable-credentials-zk, sparql-formal-semantics — the ZK/MPC estate (separate zk feature; not part of the JS/wasm surface).