1- # sparql/ — SPARQL SELECT over a pod, read-time only
1+ # sparql/ — SPARQL SELECT + UPDATE (ground data) over a pod
22
33Exploration of [ issue #509 ] ( https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/509 )
44("SPARQL endpoint + write-time index — make pods queryable") as a #206
55loader plugin. It ships the ** read-time half** of that issue honestly —
6- ` POST /sparql ` over a pod's JSON-LD, WAC-filtered by construction — and
7- documents why the ** write-time index half cannot be built as a plugin
8- today** (see Findings).
6+ ` POST /sparql ` over a pod's JSON-LD, WAC-filtered by construction — plus
7+ ** SPARQL UPDATE for the ground-data forms** (` INSERT DATA ` / ` DELETE DATA ` ,
8+ one resource per update, applied over loopback with the caller's own
9+ credentials and the host's own ` If-Match ` ), and documents why the
10+ ** write-time index half still cannot be built as a plugin today** (see
11+ Findings — owning a write endpoint does not change that).
912
10- No npm dependencies: the SPARQL tokenizer/parser, the JSON-LD → triples
11- flattener, and the BGP evaluator are hand-rolled on node builtins.
13+ No npm dependencies: the SPARQL tokenizer/parser, the JSON-LD ⇄ triples
14+ mapping (both directions), and the BGP evaluator are hand-rolled on node
15+ builtins.
1216
1317## Usage
1418
@@ -64,14 +68,80 @@ the resources visited.
6468disagree with the server because the server itself answers every fetch
6569(the notifications plugin's loopback pattern, applied to a whole dataset).
6670
71+ ## SPARQL UPDATE
72+
73+ ``` bash
74+ curl -X POST " http://localhost:3000/sparql" \
75+ -H ' Content-Type: application/sparql-update' \
76+ -H " Authorization: Bearer $TOKEN " \
77+ --data '
78+ PREFIX schema: <https://schema.org/>
79+ INSERT DATA { GRAPH <http://localhost:3000/alice/photos/photo-1.jsonld> {
80+ <http://localhost:3000/alice/photos/photo-1.jsonld> schema:keywords "vintage" .
81+ } }'
82+ ```
83+
84+ ` POST <prefix> ` with ` Content-Type: application/sparql-update ` accepts
85+ exactly two forms — ** ` INSERT DATA ` ** and ** ` DELETE DATA ` ** with ground
86+ triples (no variables, no blank nodes). Success is ` 200 ` with a small JSON
87+ summary (matching the endpoint's JSON-body style):
88+ `{ "ok": true, "op": "INSERT DATA", "resource": …, "inserted": n,
89+ "removed": n, "triples": total, "retried": bool }`.
90+
91+ ** Target** — an update names exactly ONE resource (the read side's
92+ "a JSON-LD resource is a graph" model, inverted): a ` GRAPH <iri> { … } `
93+ block in the data block (same-origin, no federation) or ` ?resource=/path `
94+ on the URL; 400 without one, 400 if both are given and disagree, 400 for a
95+ container path or an ` .acl ` /` .meta ` sidecar (the read side skips those
96+ too).
97+
98+ ** How a write happens** — the exact inverse of the read lens:
99+ ` GET ` the target over loopback with the caller's own ` Authorization `
100+ (capturing the host's ETag) → flatten with the same JSON-LD subset the
101+ SELECT side reads → merge (` INSERT DATA ` , deduplicated) or filter
102+ (` DELETE DATA ` , same datatype/lang-insensitive term matching the evaluator
103+ uses) → serialize back (` @graph ` of nodes with absolute-IRI keys,
104+ ` @type ` , ` {"@id"} ` / ` {"@value","@type","@language"} ` objects) → ` PUT `
105+ with ` If-Match ` (or ` If-None-Match: * ` when creating). On ` 412 ` the whole
106+ read-transform-write is retried once, then ` 409 ` to the caller. WAC
107+ decides both legs: a caller who can't read the target gets the host's
108+ 401/403 back verbatim; one who can read but not write gets the PUT's.
109+
110+ Semantics that follow (all deliberate, all tested):
111+
112+ - ` INSERT DATA ` into a missing resource ** creates** it
113+ (` PUT If-None-Match: * ` ); ` DELETE DATA ` against a missing resource is
114+ ` 404 ` .
115+ - ` DELETE DATA ` removing the last triple leaves an ** empty resource**
116+ (` {"@graph":[]} ` ), not a 404 — LDP resource existence and graph
117+ emptiness are different things; ` DELETE ` the resource itself to remove
118+ it.
119+ - Inserting a triple that's already present / deleting one that isn't is a
120+ no-op (` inserted ` /` removed ` say so) and issues ** no write at all** .
121+ - ** An update REWRITES the target through the plugin's triple lens.**
122+ Anything the documented flattening subset drops — terms no ` @context `
123+ maps (and no ` @vocab ` catches), ` @list ` order, the original ` @context `
124+ and formatting — does ** not survive** an update. Data invisible to
125+ SELECT does not survive an UPDATE; don't point this endpoint at JSON-LD
126+ that leans on the unhandled features.
127+
128+ ** Refused honestly, not half-implemented** : the pattern forms
129+ (` DELETE ` /` INSERT … WHERE ` , ` WITH ` , ` USING ` ), graph management (` LOAD ` ,
130+ ` CLEAR ` , ` CREATE ` , ` DROP ` , ` COPY ` , ` MOVE ` , ` ADD ` ) and multi-operation
131+ requests (` ; ` ) are ** 501** with a clear error. A faithful ` … WHERE ` needs
132+ bnode-safe template instantiation against the store, which the read
133+ engine's per-document bnode relabelling cannot round-trip — a documented
134+ wall, not a parser gap. Variables or blank nodes inside a ` DATA ` block are
135+ 400 .
136+
67137## Supported SPARQL subset
68138
69139Deliberately small and exactly this — a documented subset beats a broken
70140full parser:
71141
72142| Feature | Supported |
73143| ---| ---|
74- | Query form | ` SELECT ` only ( no ` ASK ` /` CONSTRUCT ` /` DESCRIBE ` / ` UPDATE ` ) |
144+ | Query form | ` SELECT ` (queries); ` INSERT DATA ` / ` DELETE DATA ` (updates — see above); no ` ASK ` /` CONSTRUCT ` /` DESCRIBE ` |
75145| Projection | ` SELECT ?a ?b ` , ` SELECT * ` , ` SELECT DISTINCT ` |
76146| Prologue | ` PREFIX ns: <iri> ` (with ` rdf: ` /` xsd: ` predefined) |
77147| Dataset | one optional ` FROM <iri> ` (same-origin scope container) |
@@ -82,7 +152,8 @@ full parser:
82152| Modifiers | ` LIMIT n ` |
83153| Comments | ` # to end of line ` |
84154
85- ** Not supported** (parse error, by design): ` OPTIONAL ` , ` UNION ` , ` GRAPH ` ,
155+ ** Not supported in queries** (parse error, by design): ` OPTIONAL ` , ` UNION ` ,
156+ ` GRAPH ` (update data blocks use it to name their target; queries don't),
86157` ORDER BY ` , ` OFFSET ` , ` GROUP BY ` /aggregates, property paths, subqueries,
87158` BIND ` /` VALUES ` , blank-node syntax (` [] ` ) in patterns, ` && ` /` || ` inside a
88159FILTER (write two FILTERs for AND), functions beyond CONTAINS/REGEX, and
@@ -115,6 +186,18 @@ BGP joins, `FILTER` comparison/`CONTAINS`/`REGEX`, projection, `LIMIT`,
115186` DISTINCT ` , nested ` @id ` objects, and the WAC property (owner sees 3
116187photos; anonymous and a second registered agent see 0).
117188
189+ The update tests cover: ` INSERT DATA ` then ` SELECT ` sees the triple (and
190+ the pre-existing triples survive the rewrite, and re-inserting is a
191+ no-op); create-on-insert via ` ?resource= ` ; ` DELETE DATA ` then ` SELECT ` no
192+ longer sees it; deleting every triple leaves an empty resource; the WAC
193+ 401/403 passthrough (and that a refused update does not land); the 400 and
194+ 501 families; the ** 412 retry path, deterministically** — the plugin runs
195+ in-process and reaches the host through global ` fetch ` , so the test wraps
196+ ` globalThis.fetch ` to slip an interfering external write into the window
197+ between the plugin's GET and its ` If-Match ` PUT, then asserts the update
198+ retried and merged against the * new* state; and a concurrent-writers test
199+ that asserts exactly the invariants the host guarantees (see Findings 7).
200+
118201## Findings
119202
1202031 . ** Read-time query works as a plugin; write-time indexing does not — and
@@ -130,6 +213,19 @@ photos; anonymous and a second registered agent see 0).
130213 notifications workaround) is not good enough here — a watcher can drop
131214 or debounce events, and a stale index silently lies. Until the seam
132215 exists, the honest plugin answer is what this is: crawl at read time.
216+
217+ ** Sharpened by the update surface: owning a write endpoint does not
218+ buy the index either.** The plugin now * has* a write path — every
219+ ` INSERT DATA ` /` DELETE DATA ` flows through code that could update an
220+ index synchronously, transactionally, for free. And it still can't
221+ maintain one, because writes through plain LDP (` PUT ` /` POST ` /` PATCH ` /
222+ ` DELETE ` on the pod, which every Solid app uses) bypass this endpoint
223+ entirely: the plugin would index its own writes and silently miss
224+ everyone else's — a * wrong-results* index, worse than none.
225+ Partial-write-path visibility is not enough; the index needs the
226+ HOST's event stream, because only the host sees all writes. This is a
227+ new sharpening of the ` api.events ` finding: ** a plugin that owns a
228+ write endpoint still can't see sibling writes.**
1332292 . ** Performance is O(N) per query, without the index.** Every query
134230 re-walks the scope container over loopback — one HTTP round-trip per
135231 resource, bounded by ` maxDepth ` /` maxResources ` and surfaced via the
@@ -156,3 +252,47 @@ photos; anonymous and a second registered agent see 0).
156252 boots a second server (even one that fails activation on purpose)
157253 silently redirects the first server's key lookups. The suite orders the
158254 failing boot first; notifications/ happens to do the same.
255+ 6 . ** Optimistic concurrency via ` If-Match ` over loopback WORKS for the
256+ stale-writer case (measured)** — this plugin is the ** second consumer**
257+ of the conditional-write pass-through remotestorage/ measured. Every
258+ update is GET (capture the host's ETag) → transform → ` PUT If-Match `
259+ (or ` If-None-Match: * ` on create); the headers pass through loopback
260+ verbatim and the host honors them. The retry path is proven end-to-end
261+ and * deterministically* : the test injects a real external write between
262+ the plugin's GET and PUT, the host answers the now-stale ` If-Match ` PUT
263+ with ** 412** , and the plugin's single re-read-and-retry merges against
264+ the new state — the interfering write's triple and the update's triple
265+ both survive. A second consecutive 412 becomes a 409 to the caller.
266+ Loopback WAC also composes with it for free: the GET leg bounces
267+ readers the pod refuses, the PUT leg bounces writers, both as the
268+ host's own 401/403 passed through verbatim.
269+ 7 . ** …but the host's conditional write is check-then-write, NOT atomic
270+ under overlap (measured — sharpens remotestorage/ finding 2).**
271+ remotestorage/ measured sequential staleness ("stale ` If-Match ` PUT →
272+ 412 … atomic at the host") and this repo has been citing it as such.
273+ Driving it * concurrently* says otherwise: two overlapping PUTs carrying
274+ the SAME currently-valid ` If-Match ` both return ** 2xx** — the handler
275+ ` stat ` s (ETag check) and writes with awaits in between, so both checks
276+ can pass before either write lands, and the loser is silently
277+ overwritten (measured against the npm host: ` 204 ` /` 204 ` , second body
278+ wins; the same PUTs issued sequentially 412 correctly). For this
279+ plugin that means two simultaneous updates to one resource can lose
280+ one update with both callers told 200 — the suite's concurrency test
281+ asserts exactly the invariants that DO hold (both 200, no torn state,
282+ seeded triples survive, result ⊆ union). No plugin can fix this:
283+ the race is inside the host's PUT handler, between requests the plugin
284+ has already sent. The host-side fix is making the ETag check and the
285+ write atomic (serialize per path, or compare-and-swap at the storage
286+ layer).
287+ 8 . ** The rewrite lens is honest but lossy, by construction.** The write
288+ side is the exact inverse of the read side's documented JSON-LD subset,
289+ so an update round-trips every triple SELECT can see — and rewrites the
290+ stored document into normalized form (` @graph ` + absolute IRIs),
291+ dropping exactly what the read side already ignored: unmapped terms,
292+ ` @list ` order, the original ` @context ` . "Data invisible to the query
293+ engine does not survive its updates" is the sharpest way to say it;
294+ the alternative (surgical JSON edits preserving unknown structure)
295+ would mean updates whose effects SELECT couldn't confirm. Documented
296+ loudly above instead. (Also mildly convenient: the loader's wildcard
297+ ` parseAs: 'buffer' ` content-type parser passed `application/
298+ sparql-update` bodies through untouched — no parser gap this time.)
0 commit comments