Skip to content

Commit 59696f0

Browse files
sparql: SPARQL UPDATE (INSERT DATA / DELETE DATA) over loopback
Updates name one resource (GRAPH block or ?resource=), are applied as the exact inverse of the read lens (GET → merge/filter → PUT with If-Match, If-None-Match:* on create, one retry on 412 then 409), and pattern forms are honest 501s. Findings: (1) owning a write endpoint does NOT buy the write-time index — plain-LDP writes bypass it, so it would silently index only its own writes; only the host event stream sees everything (api.events, sharpened). (2) Second consumer of the conditional-write pass-through — stale writers proven to 412-and-merge deterministically. (3) NEW measurement amending remotestorage's finding: the host's conditional write is check-then-write, not atomic — two concurrent PUTs with the same valid If-Match both 2xx and one silently loses (sequentially the same PUTs 412 correctly); unfixable plugin-side, needs an atomic stat+write in the host. (4) Data invisible to SELECT does not survive UPDATE — the rewrite lens drops unmapped terms, documented loudly.
1 parent b97f8c0 commit 59696f0

3 files changed

Lines changed: 805 additions & 67 deletions

File tree

sparql/README.md

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
# sparql/ — SPARQL SELECT over a pod, read-time only
1+
# sparql/ — SPARQL SELECT + UPDATE (ground data) over a pod
22

33
Exploration of [issue #509](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/509)
44
("SPARQL endpoint + write-time index — make pods queryable") as a #206
55
loader 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.
6468
disagree 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

69139
Deliberately small and exactly this — a documented subset beats a broken
70140
full 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
88159
FILTER (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
116187
photos; 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

120203
1. **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.**
133229
2. **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

Comments
 (0)