From c303dc4c9efe1bae0749c1c37bc73cb837f12755 Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 11 Jun 2026 15:41:53 +0000 Subject: [PATCH] spike: lazy streaming SELECT cursor for JS bindings Add Store.querySolutions(query, options) returning a QuerySolutions cursor that holds the QuerySolutionIter alive instead of draining it into an Array, plus QuerySolutions.nextBatch(count) / .variables. Lets the WASM bindings stream SELECT results in bounded batches rather than materializing the whole result set (the dual of the output-side memory ceiling tracked downstream in dkglab/graph-store#50). Key enabler: on_store() snapshots storage rather than borrowing &Store, so QueryResults/QuerySolutionIter are 'static and can live in a wasm-bindgen struct across JS calls with no self-referential borrow. The snapshot is MVCC-isolated, so an open cursor is unaffected by a concurrent load/clear (and pins that snapshot until closed). Spike only: minimal option parsing (base_iri), SELECT-only, no TS typings, no reuse of query()'s option handling. Verified by building real wasm and streaming a SELECT in batches in Node (lazy batching, stable exhaustion, snapshot isolation across a mid-iteration DELETE). Co-Authored-By: Claude Opus 4.8 --- js/src/store.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/js/src/store.rs b/js/src/store.rs index 04e0b21e9..17d0ddf16 100644 --- a/js/src/store.rs +++ b/js/src/store.rs @@ -6,7 +6,7 @@ use crate::utils::{to_option, to_option_ref}; use js_sys::{Array, Map, try_iter}; use oxigraph::io::{RdfParser, RdfSerializer}; use oxigraph::sparql::results::{QueryResultsFormat, QueryResultsSerializer}; -use oxigraph::sparql::{QueryResults, SparqlEvaluator}; +use oxigraph::sparql::{QueryResults, QuerySolutionIter, SparqlEvaluator, Variable}; use oxigraph::store::Store; use wasm_bindgen::prelude::*; @@ -337,6 +337,84 @@ impl JsStore { .map_err(JsError::from)?; Ok(String::from_utf8(buffer).map_err(JsError::from)?) } + + // SPIKE: lazy streaming SELECT. Returns a cursor that holds the + // QuerySolutionIter alive instead of draining it into an Array, so the + // worker never materializes the whole result set. Minimal option parsing + // (base_iri only) — the production version reuses query()'s full parsing. + #[wasm_bindgen(js_name = querySolutions)] + pub fn query_solutions( + &self, + query: &str, + options: &JsValue, + ) -> Result { + let mut base_iri = None; + if let Some(options) = to_option_ref(options) { + base_iri = convert_base_iri(&reflect_get(options, &BASE_IRI)?)?; + } + let mut evaluator = SparqlEvaluator::new(); + if let Some(base_iri) = base_iri { + evaluator = evaluator.with_base_iri(&base_iri).map_err(JsError::from)?; + } + let results = evaluator + .parse_query(query) + .map_err(JsError::from)? + .on_store(&self.store) + .execute() + .map_err(JsError::from)?; + match results { + QueryResults::Solutions(solutions) => Ok(JsQuerySolutions { + variables: solutions.variables().to_vec(), + iter: solutions, + data_factory: default_data_factory(), + }), + _ => Err(format_err!("querySolutions only supports SELECT queries")), + } + } +} + +// SPIKE: a JS-exposed cursor over a lazy SELECT iterator. The iterator is +// QuerySolutionIter<'static> because on_store() snapshots the storage rather +// than borrowing &Store, so there is no self-referential borrow to fight. +#[wasm_bindgen(js_name = QuerySolutions)] +pub struct JsQuerySolutions { + iter: QuerySolutionIter<'static>, + variables: Vec, + data_factory: DataFactory, +} + +#[wasm_bindgen(js_class = QuerySolutions)] +impl JsQuerySolutions { + #[wasm_bindgen(getter)] + pub fn variables(&self) -> Array { + self.variables + .iter() + .map(|v| JsValue::from_str(v.as_str())) + .collect() + } + + // Pull up to `count` rows. An empty Array signals exhaustion. One + // wasm->JS crossing per batch (not per row) — exactly the shape the + // graph-store batch protocol wants. + #[wasm_bindgen(js_name = nextBatch)] + pub fn next_batch(&mut self, count: usize) -> Result { + let out = Array::new(); + for _ in 0..count { + let Some(solution) = self.iter.next() else { + break; + }; + let solution = solution.map_err(JsError::from)?; + let result = Map::new(); + for (variable, value) in solution.iter() { + result.set( + &variable.as_str().into(), + &from_term(&self.data_factory, value), + ); + } + out.push(&result.into()); + } + Ok(out) + } } fn query_results_format(format: &str) -> Result {