Exploration of issue #509
("SPARQL endpoint + write-time index — make pods queryable") as a #206
loader plugin. It ships the read-time half of that issue honestly —
POST /sparql over a pod's JSON-LD, WAC-filtered by construction — plus
SPARQL UPDATE for the ground-data forms (INSERT DATA / DELETE DATA,
one resource per update, applied over loopback with the caller's own
credentials and the host's own If-Match), and documents why the
write-time index half still cannot be built as a plugin today (see
Findings — owning a write endpoint does not change that).
No npm dependencies: the SPARQL tokenizer/parser, the JSON-LD ⇄ triples mapping (both directions), and the BGP evaluator are hand-rolled on node builtins.
plugins: [{
module: 'sparql/plugin.js',
prefix: '/sparql',
config: {
baseUrl: 'http://localhost:3000', // required: the server's public origin
// loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches its host
// defaultContainer: '/alice/', // scope when the query names none
// maxDepth: 3, // container recursion below the scope
// maxResources: 200, // loopback fetches per query
// resultCap: 1000, // bindings cap even without LIMIT
// maxRegexLength: 512, // reject FILTER(REGEX) patterns longer than this
// maxRegexInput: 10000, // test a REGEX against at most this many chars
},
}]curl -X POST http://localhost:3000/sparql \
-H 'Content-Type: application/sparql-query' \
-H "Authorization: Bearer $TOKEN" \
--data '
PREFIX schema: <https://schema.org/>
SELECT ?s ?d
FROM <http://localhost:3000/alice/photos/>
WHERE {
?s a schema:Photo .
?s schema:dateCreated ?d .
FILTER(?d > "2025-01-01")
}
LIMIT 10'Returns the SPARQL 1.1 JSON results format:
{ "head": { "vars": [...] }, "results": { "bindings": [...] } } as
application/sparql-results+json. GET /sparql returns a small JSON
description of the endpoint and its limits.
Scope — the caller names which container subtree to query, one of:
FROM <iri> in the query (must be this server's origin — no federation),
?container=/path/ on the URL, or config.defaultContainer. The plugin
walks it recursively over loopback: containers via their ldp:contains
listing, other resources parsed only when the server serves them as JSON
(.acl/.meta sidecars skipped), bounded by maxDepth/maxResources.
When the walk is cut short the response carries
X-SPARQL-Scope-Truncated: true; X-SPARQL-Scope-Resources always counts
the resources visited.
Authorization — every loopback fetch carries the caller's own
Authorization header, so the dataset is exactly what the caller could
GET themselves. Anonymous callers query only public data; WAC can never
disagree with the server because the server itself answers every fetch
(the notifications plugin's loopback pattern, applied to a whole dataset).
curl -X POST "http://localhost:3000/sparql" \
-H 'Content-Type: application/sparql-update' \
-H "Authorization: Bearer $TOKEN" \
--data '
PREFIX schema: <https://schema.org/>
INSERT DATA { GRAPH <http://localhost:3000/alice/photos/photo-1.jsonld> {
<http://localhost:3000/alice/photos/photo-1.jsonld> schema:keywords "vintage" .
} }'POST <prefix> with Content-Type: application/sparql-update accepts
exactly two forms — INSERT DATA and DELETE DATA with ground
triples (no variables, no blank nodes). Success is 200 with a small JSON
summary (matching the endpoint's JSON-body style):
{ "ok": true, "op": "INSERT DATA", "resource": …, "inserted": n, "removed": n, "triples": total, "retried": bool }.
Target — an update names exactly ONE resource (the read side's
"a JSON-LD resource is a graph" model, inverted): a GRAPH <iri> { … }
block in the data block (same-origin, no federation) or ?resource=/path
on the URL; 400 without one, 400 if both are given and disagree, 400 for a
container path or an .acl/.meta sidecar (the read side skips those
too).
How a write happens — the exact inverse of the read lens:
GET the target over loopback with the caller's own Authorization
(capturing the host's ETag) → flatten with the same JSON-LD subset the
SELECT side reads → merge (INSERT DATA, deduplicated) or filter
(DELETE DATA, same datatype/lang-insensitive term matching the evaluator
uses) → serialize back (@graph of nodes with absolute-IRI keys,
@type, {"@id"} / {"@value","@type","@language"} objects) → PUT
with If-Match (or If-None-Match: * when creating). On 412 the whole
read-transform-write is retried once, then 409 to the caller. WAC
decides both legs: a caller who can't read the target gets the host's
401/403 back verbatim; one who can read but not write gets the PUT's.
Semantics that follow (all deliberate, all tested):
INSERT DATAinto a missing resource creates it (PUT If-None-Match: *);DELETE DATAagainst a missing resource is404.DELETE DATAremoving the last triple leaves an empty resource ({"@graph":[]}), not a 404 — LDP resource existence and graph emptiness are different things;DELETEthe resource itself to remove it.- Inserting a triple that's already present / deleting one that isn't is a
no-op (
inserted/removedsay so) and issues no write at all. - An update REWRITES the target through the plugin's triple lens.
Anything the documented flattening subset drops — terms no
@contextmaps (and no@vocabcatches),@listorder, the original@contextand formatting — does not survive an update. Data invisible to SELECT does not survive an UPDATE; don't point this endpoint at JSON-LD that leans on the unhandled features.
Refused honestly, not half-implemented: the pattern forms
(DELETE/INSERT … WHERE, WITH, USING), graph management (LOAD,
CLEAR, CREATE, DROP, COPY, MOVE, ADD) and multi-operation
requests (;) are 501 with a clear error. A faithful … WHERE needs
bnode-safe template instantiation against the store, which the read
engine's per-document bnode relabelling cannot round-trip — a documented
wall, not a parser gap. Variables or blank nodes inside a DATA block are
400.
Deliberately small and exactly this — a documented subset beats a broken full parser:
| Feature | Supported |
|---|---|
| Query form | SELECT (queries); INSERT DATA/DELETE DATA (updates — see above); no ASK/CONSTRUCT/DESCRIBE |
| Projection | SELECT ?a ?b, SELECT *, SELECT DISTINCT |
| Prologue | PREFIX ns: <iri> (with rdf:/xsd: predefined) |
| Dataset | one optional FROM <iri> (same-origin scope container) |
| Patterns | Basic Graph Patterns: any mix of variables, <iri>s, prefixed names, literals; joined by conjunction |
| Abbreviations | a for rdf:type; predicate lists with ;; object lists with , |
| Literals | "strings" (\", \\, \n, \t escapes), integers/decimals, true/false, "…"^^dt, "…"@lang (datatype/lang stored, ignored by matching) |
| FILTER | FILTER(expr op expr) with = != < > <= >= (numeric when both sides look numeric, else string comparison); FILTER(CONTAINS(?v, "str")); FILTER(REGEX(?v, "pat", "flags")); operands are variables or constants; multiple FILTERs conjoin |
| Modifiers | LIMIT n |
| Comments | # to end of line |
Not supported in queries (parse error, by design): OPTIONAL, UNION,
GRAPH (update data blocks use it to name their target; queries don't),
ORDER BY, OFFSET, GROUP BY/aggregates, property paths, subqueries,
BIND/VALUES, blank-node syntax ([]) in patterns, &&/|| inside a
FILTER (write two FILTERs for AND), functions beyond CONTAINS/REGEX, and
SERVICE federation. Matching is datatype-insensitive: "5" matches
"5"^^xsd:integer.
Hand-rolled expansion of each fetched document into triples: @context as
an object (prefix maps, term maps, {"@id": …, "@type": "@id"} defs,
@vocab) or an array; a string @context is treated as @vocab — the
schema.org heuristic, since a plugin fetching remote contexts would be a
new network dependency; @id resolved against the resource URL (subject =
resource IRI, or a blank node when absent); @type → rdf:type; arrays;
nested objects (named or blank) recursed; @value/@type/@language
literal objects; @list flattened to repeated triples (order lost);
@graph. Not handled: remote context fetching, @reverse, @container
maps, @nest, @index. Terms that no context maps (and no @vocab
catches) are dropped rather than guessed.
node --test --test-concurrency=1 sparql/test.jsBoots a real JSS from npm with idp: true, registers two pods, seeds
schema:Photo JSON-LD resources over authenticated PUT, then exercises
BGP joins, FILTER comparison/CONTAINS/REGEX, projection, LIMIT,
DISTINCT, nested @id objects, and the WAC property (owner sees 3
photos; anonymous and a second registered agent see 0).
The update tests cover: INSERT DATA then SELECT sees the triple (and
the pre-existing triples survive the rewrite, and re-inserting is a
no-op); create-on-insert via ?resource=; DELETE DATA then SELECT no
longer sees it; deleting every triple leaves an empty resource; the WAC
401/403 passthrough (and that a refused update does not land); the 400 and
501 families; the 412 retry path, deterministically — the plugin runs
in-process and reaches the host through global fetch, so the test wraps
globalThis.fetch to slip an interfering external write into the window
between the plugin's GET and its If-Match PUT, then asserts the update
retried and merged against the new state; and a concurrent-writers test
that asserts exactly the invariants the host guarantees (see Findings 7).
-
Read-time query works as a plugin; write-time indexing does not — and the index is #509's real ask. The issue wants a
/.index/maintained on everyPUT/POST/PATCH/DELETEso queries are O(1). A plugin has no way to see writes: there is noapi.events.onResourceChange— this is exactly the top candidate seam already in NOTES.md ("the seam any future 'react to pod writes' app (webhooks, indexing, sync) wants"). This port is that seam's second concrete consumer after notifications/, and the stronger one: an index that misses a write doesn't just fire a late notification, it returns wrong query results.fs.watchon a config-supplied path (the notifications workaround) is not good enough here — a watcher can drop or debounce events, and a stale index silently lies. Until the seam exists, the honest plugin answer is what this is: crawl at read time.Sharpened by the update surface: owning a write endpoint does not buy the index either. The plugin now has a write path — every
INSERT DATA/DELETE DATAflows through code that could update an index synchronously, transactionally, for free. And it still can't maintain one, because writes through plain LDP (PUT/POST/PATCH/DELETEon the pod, which every Solid app uses) bypass this endpoint entirely: the plugin would index its own writes and silently miss everyone else's — a wrong-results index, worse than none. Partial-write-path visibility is not enough; the index needs the HOST's event stream, because only the host sees all writes. This is a new sharpening of theapi.eventsfinding: a plugin that owns a write endpoint still can't see sibling writes. -
Performance is O(N) per query, without the index. Every query re-walks the scope container over loopback — one HTTP round-trip per resource, bounded by
maxDepth/maxResourcesand surfaced via theX-SPARQL-Scope-*headers.api.storage.pluginDir()could cache the crawl, but without change events, invalidation is guesswork (finding 1 again). Fine for a few hundred resources; the thousand-resource pod the issue opens with is exactly where the write-time index becomes the feature. -
Loopback answers #509's ACL open question for free. The issue asks how to filter results by the requesting agent's read access without leaking private resources. Forwarding the caller's
Authorizationon every loopback fetch makes the dataset definitionally WAC-correct — the server itself decides every resource, per caller, per query. Cost: it's per-resource HTTP, which is finding 2's latency. A core implementation with an index would need real per-agent filtering; the plugin gets correctness now and slowness with it. -
config.baseUrlagain — sameapi.serverInfofinding as notifications/: the plugin needs its host's public origin (FROM origin check, loopback target, IRI normalization) and must be told it in config; the test does the probe-port-then-boot dance. -
Process-global
DATA_ROOTbites multi-boot tests (host-side quirk, not a plugin-api gap): JSS resolves IdP keys through theDATA_ROOTenv var, which everycreateServercall repoints — so a test that boots a second server (even one that fails activation on purpose) silently redirects the first server's key lookups. The suite orders the failing boot first; notifications/ happens to do the same. -
Optimistic concurrency via
If-Matchover loopback WORKS for the stale-writer case (measured) — this plugin is the second consumer of the conditional-write pass-through remotestorage/ measured. Every update is GET (capture the host's ETag) → transform →PUT If-Match(orIf-None-Match: *on create); the headers pass through loopback verbatim and the host honors them. The retry path is proven end-to-end and deterministically: the test injects a real external write between the plugin's GET and PUT, the host answers the now-staleIf-MatchPUT with 412, and the plugin's single re-read-and-retry merges against the new state — the interfering write's triple and the update's triple both survive. A second consecutive 412 becomes a 409 to the caller. Loopback WAC also composes with it for free: the GET leg bounces readers the pod refuses, the PUT leg bounces writers, both as the host's own 401/403 passed through verbatim. -
…but the host's conditional write is check-then-write, NOT atomic under overlap (measured — sharpens remotestorage/ finding 2). remotestorage/ measured sequential staleness ("stale
If-MatchPUT → 412 … atomic at the host") and this repo has been citing it as such. Driving it concurrently says otherwise: two overlapping PUTs carrying the SAME currently-validIf-Matchboth return 2xx — the handlerstats (ETag check) and writes with awaits in between, so both checks can pass before either write lands, and the loser is silently overwritten (measured against the npm host:204/204, second body wins; the same PUTs issued sequentially 412 correctly). For this plugin that means two simultaneous updates to one resource can lose one update with both callers told 200 — the suite's concurrency test asserts exactly the invariants that DO hold (both 200, no torn state, seeded triples survive, result ⊆ union). No plugin can fix this: the race is inside the host's PUT handler, between requests the plugin has already sent. The host-side fix is making the ETag check and the write atomic (serialize per path, or compare-and-swap at the storage layer). -
The rewrite lens is honest but lossy, by construction. The write side is the exact inverse of the read side's documented JSON-LD subset, so an update round-trips every triple SELECT can see — and rewrites the stored document into normalized form (
@graph+ absolute IRIs), dropping exactly what the read side already ignored: unmapped terms,@listorder, the original@context. "Data invisible to the query engine does not survive its updates" is the sharpest way to say it; the alternative (surgical JSON edits preserving unknown structure) would mean updates whose effects SELECT couldn't confirm. Documented loudly above instead. (Also mildly convenient: the loader's wildcardparseAs: 'buffer'content-type parser passedapplication/ sparql-updatebodies through untouched — no parser gap this time.) -
User-supplied
FILTER(REGEX(...))is a ReDoS surface, mitigated by bounding not by a real timeout. The pattern is caller-controlled and run withRegExp.testagainst strings the caller can plant in their own pod, so a catastrophic-backtracking pattern like(a+)+$over a long crafted string would pin Node's single event loop and hang the whole server. Node has no built-in regex timeout and the import rule forbids a new dep (no RE2), so the worst case is bounded honestly instead, in three layers: (a) cap the pattern length (config.maxRegexLength, default 512) → 400; (b) cap the input length the pattern is tested against (config.maxRegexInput, default 10 000 chars — oversized inputs are truncated beforetest, so normal-length strings are unaffected), which bounds the backtracking exponent × input; (c) a best-effort static heuristic (\(.*[+*].*\)[+*{]) that rejects the obvious nested-unbounded- quantifier shapes ((a+)+,(a*)*,(a|b*)+…) with a 400 before ever compiling them. The guard runs at BOTH parse time (literal patterns) and eval time (patterns bound from a variable, which are never seen at parse time). This is deliberately conservative — it can over-reject and is not a complete safe-regex analyzer — and the real bound is the two length caps; the finding is that without a host- or runtime-level regex timeout, a plugin exposing user regex can only bound the worst case, not eliminate it. A genuine per-request CPU/time budget (or RE2) is the seam a core implementation would want.