diff --git a/.gitignore b/.gitignore
index 92110e5..8999c64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ CHANGELOG.md
*.ipynb
/site
.direnv
+# Pyodide wheels copied in to test the browser demo locally
+docs/demo/*.whl
diff --git a/docs/browser.md b/docs/browser.md
new file mode 100644
index 0000000..267fcf2
--- /dev/null
+++ b/docs/browser.md
@@ -0,0 +1,153 @@
+# Run in the browser
+
+xarray-sql runs in [Pyodide](https://pyodide.org), Python compiled to
+WebAssembly. The demo below runs SQL over NOAA GFS forecasts read straight
+from [dynamical.org](https://dynamical.org)'s public Zarr store. xarray-sql
+and DuckDB run in a Web Worker in your browser, so there is no server. Each
+query downloads only the chunks that its filters on `init_time`, `latitude`,
+and `longitude` reach.
+
+The demo needs Chrome or Edge 137 or newer. It takes about 15 seconds to
+start, most of it downloading and loading Pyodide, pyarrow, DuckDB, and
+pandas.
+
+
+
+[Open the demo in its own page](demo/){ #demo-link target="_blank" }
+
+
+
+## Install in Pyodide
+
+Releases after 0.5.0 publish a Pyodide wheel to PyPI. The DuckDB engine
+adapter works in Pyodide, including the round-trip back to a Dataset with
+[to_dataset][xarray_sql.to_dataset]. DataFusion does not yet, because its
+Python bindings have no Pyodide build, so [Context][xarray_sql.Context] is
+unavailable there.
+
+Copy [`fetch_store.py`](https://github.com/xqlsystems/xarray-sql/tree/main/docs/demo/fetch_store.py)
+from the demo to open Zarr stores over HTTP in Pyodide, since fsspec's HTTP
+filesystem needs sockets. Then open a window of the dataset, read it into
+memory, and query it:
+
+```python
+import micropip
+await micropip.install(["xarray-sql", "duckdb", "zarr"])
+
+import duckdb
+import xarray as xr
+import xarray_sql as xql
+from fetch_store import FetchStore
+
+store = FetchStore("https://data.dynamical.org/noaa/gfs/forecast/latest.zarr")
+ds = (
+ xr.open_zarr(store, chunks=None)[["temperature_2m"]]
+ .isel(init_time=-1, lead_time=slice(0, 24))
+ .sel(latitude=slice(50, 25), longitude=slice(-100, -75))
+)
+ds = await ds.load_async()
+
+con = duckdb.connect()
+xql.register(con, "gfs", ds)
+hottest = con.sql("""
+ SELECT latitude, longitude, max(temperature_2m) AS t2m_max
+ FROM gfs
+ GROUP BY latitude, longitude
+""")
+out = xql.to_dataset(hottest, template=ds) # dims latitude, longitude
+```
+
+The window is read into memory first because a DuckDB scan cannot wait on
+the network in Pyodide (see the next section). Pyodide also cannot start
+threads, so xarray-sql loads chunks one at a time there. To query a larger
+store, read it through a cache like the demo's.
+
+## How lazy reads work in the browser
+
+Browsers can only read over the network asynchronously. zarr's synchronous
+API waits on those reads with Pyodide's JavaScript Promise Integration
+(JSPI), but JSPI cannot suspend while DuckDB or pyarrow is calling back into
+Python: their C++ frames sit between the suspension point and Python. So a
+query cannot read chunks in the middle of a scan.
+
+The demo's `ChunkCache` runs each query against a cache of decoded chunks
+instead:
+
+1. Run the query. A chunk that is not cached is recorded as missing and
+ scanned as fill values, so one run records every chunk that survives
+ xarray-sql's chunk pruning.
+2. Fetch the missing chunks concurrently with `Dataset.load_async`, then run
+ the query again.
+3. A run with no missing chunks used only real data, so its result is
+ exact.
+
+Most queries need two runs. The stats line under the editor shows the runs,
+chunks, requests, and bytes for each query.
+
+The demo's files are in
+[`docs/demo/`](https://github.com/xqlsystems/xarray-sql/tree/main/docs/demo):
+
+| File | Role |
+|------|------|
+| `index.html` | The page: example queries, SQL editor, results table, and an interactive map ([deck.gl](https://deck.gl) over a [MapLibre](https://maplibre.org) basemap) for `latitude, longitude, value` results. |
+| `worker.js` | Loads Pyodide, installs xarray-sql, DuckDB, and zarr, and runs queries off the main thread. |
+| `app.py` | Opens the dataset, registers it on DuckDB as `gfs`, and runs queries. |
+| `chunk_cache.py` | Lazy reads that work in the browser, as described above. |
+| `fetch_store.py` | A read-only Zarr store that uses `fetch` with `Range` requests. |
+| `smoke.mjs` | Runs every example query headlessly in Node. |
+
+## Run it locally
+
+Serve `docs/demo` over HTTP and open it in Chrome or Edge:
+
+```shell
+python -m http.server 8000 --directory docs/demo
+```
+
+By default the worker installs xarray-sql from PyPI. To test a local build,
+copy the wheel from `pyodide build` (see "Building for WebAssembly" in the
+[contributing guide](contributing.md)) into `docs/demo` and pass it in the
+URL:
+
+```
+http://localhost:8000/?wheel=./xarray_sql-0.5.0-cp310-abi3-pyemscripten_2026_0_wasm32.whl
+```
+
+On this docs page, `?wheel=` is passed through to the embedded demo, so a
+locally built docs site can use a local wheel too (for example,
+`/browser/?wheel=../demo/xarray_sql-...whl` after copying the wheel into the
+built `demo/` directory).
+
+To run the examples without a browser:
+
+```shell
+cd docs/demo
+npm install --no-save pyodide@314.0.7
+node smoke.mjs path/to/xarray_sql-*-pyemscripten_*.whl
+```
+
+## Known limits
+
+- **`lead_time` filters do not prune chunks.** DuckDB does not push
+ `INTERVAL` predicates down to Arrow scans. The demo registers only the
+ first lead-time chunk (forecast hours 0–104), so every query reads at most
+ that one.
+- **Queries that touch the whole grid are slow.** For example,
+ `SELECT max(init_time) FROM gfs` builds every coordinate row (about 440
+ million) even though it downloads nothing. Use the literal timestamps that
+ the example queries use.
+- **The cache holds 768 MiB of decoded chunks.** A query that needs more
+ fails with a message to narrow its filters.
diff --git a/docs/demo/app.py b/docs/demo/app.py
new file mode 100644
index 0000000..8ca6bc6
--- /dev/null
+++ b/docs/demo/app.py
@@ -0,0 +1,217 @@
+"""The Python half of the browser demo: open a remote Zarr, query it with SQL.
+
+``worker.js`` loads this module into Pyodide and calls :func:`setup` once
+and :func:`run` per query. Both return JSON strings so the worker can hand
+results to the page without converting Python objects.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from decimal import Decimal
+from typing import Any
+
+import duckdb
+import numpy as np
+import pandas as pd
+import pyarrow as pa
+import xarray as xr
+from chunk_cache import ChunkCache
+from fetch_store import FetchStore
+
+import xarray_sql as xql
+
+GFS_URL = "https://data.dynamical.org/noaa/gfs/forecast/latest.zarr"
+GFS_VARIABLES = [
+ "temperature_2m",
+ "precipitation_surface",
+ "wind_u_10m",
+ "wind_v_10m",
+]
+# The store's inner chunks; one cached chunk is then one range request.
+GFS_CHUNKS = {
+ "init_time": 1,
+ "lead_time": 105,
+ "latitude": 121,
+ "longitude": 121,
+}
+
+_state: dict[str, Any] = {}
+
+
+def _ts(value: Any) -> str:
+ return str(pd.Timestamp(value))[:19]
+
+
+def _examples(ds: xr.Dataset) -> list[dict[str, str]]:
+ latest = _ts(ds.init_time.values[-1])
+ earliest = _ts(ds.init_time.values[0])
+ return [
+ {
+ "title": "San Francisco: 2 m temperature for the next 3 days",
+ "sql": f"""SELECT init_time + lead_time AS valid_time,
+ round(avg(temperature_2m), 1) AS temperature_c
+FROM gfs
+WHERE init_time = TIMESTAMP '{latest}'
+ AND lead_time <= INTERVAL 72 HOUR
+ AND latitude BETWEEN 37.5 AND 38 AND longitude BETWEEN -122.75 AND -122.25
+GROUP BY ALL ORDER BY valid_time""",
+ },
+ {
+ "title": "Map: Europe 2 m temperature, 24 h ahead",
+ "sql": f"""SELECT latitude, longitude, temperature_2m
+FROM gfs
+WHERE init_time = TIMESTAMP '{latest}'
+ AND lead_time = INTERVAL 24 HOUR
+ AND latitude BETWEEN 35 AND 70 AND longitude BETWEEN -10 AND 40""",
+ },
+ {
+ "title": "Map: contiguous US wind speed, 12 h ahead",
+ "sql": f"""SELECT latitude, longitude,
+ round(sqrt(wind_u_10m ^ 2 + wind_v_10m ^ 2), 1) AS wind_speed
+FROM gfs
+WHERE init_time = TIMESTAMP '{latest}'
+ AND lead_time = INTERVAL 12 HOUR
+ AND latitude BETWEEN 24 AND 50 AND longitude BETWEEN -125 AND -66""",
+ },
+ {
+ "title": "Wettest places in the contiguous US, next 24 h",
+ "sql": f"""SELECT latitude, longitude,
+ round(sum(precipitation_surface) * 3600, 1) AS approx_precip_mm
+FROM gfs
+WHERE init_time = TIMESTAMP '{latest}'
+ AND lead_time BETWEEN INTERVAL 1 HOUR AND INTERVAL 24 HOUR
+ AND latitude BETWEEN 24 AND 50 AND longitude BETWEEN -125 AND -66
+GROUP BY ALL ORDER BY approx_precip_mm DESC LIMIT 10""",
+ },
+ {
+ "title": "How the forecast for San Francisco changed across runs",
+ "sql": f"""SELECT init_time,
+ round(max(temperature_2m), 1) AS max_temperature_c
+FROM gfs
+WHERE init_time BETWEEN TIMESTAMP '{earliest}' AND TIMESTAMP '{latest}'
+ AND init_time + lead_time BETWEEN TIMESTAMP '{latest}' + INTERVAL 24 HOUR
+ AND TIMESTAMP '{latest}' + INTERVAL 48 HOUR
+ AND lead_time <= INTERVAL 72 HOUR
+ AND latitude BETWEEN 37.5 AND 38 AND longitude BETWEEN -122.75 AND -122.25
+GROUP BY ALL ORDER BY init_time""",
+ },
+ ]
+
+
+async def setup(
+ url: str = GFS_URL,
+ variables: list[str] | None = None,
+ runs: int = 4,
+ max_cache_mib: int = 768,
+) -> str:
+ """Open the dataset and register it on a DuckDB connection as ``gfs``."""
+ store = FetchStore(url)
+ ds = xr.open_zarr(store, chunks=None)
+ # DuckDB does not push INTERVAL predicates down to Arrow scans, so a
+ # lead_time filter cannot prune chunks. Keeping only the first lead-time
+ # chunk (forecast hours 0-104) halves what every query downloads.
+ ds = ds[variables or GFS_VARIABLES].isel(
+ init_time=slice(-runs, None),
+ lead_time=slice(0, GFS_CHUNKS["lead_time"]),
+ )
+ cache = ChunkCache(ds, GFS_CHUNKS, max_bytes=max_cache_mib * 2**20)
+ con = duckdb.connect()
+ con.sql("SET enable_progress_bar = false")
+ xql.register(con, "gfs", cache.dataset(), chunks=GFS_CHUNKS)
+ _state.update(store=store, cache=cache, con=con)
+ return json.dumps(
+ {
+ "url": url,
+ "variables": {
+ name: {
+ "dims": list(var.dims),
+ "units": var.attrs.get("units", ""),
+ "long_name": var.attrs.get("long_name", name),
+ }
+ for name, var in ds.data_vars.items()
+ },
+ "sizes": dict(ds.sizes),
+ "init_times": [_ts(t) for t in ds.init_time.values],
+ "examples": _examples(ds),
+ }
+ )
+
+
+def _jsonable(value: Any) -> Any:
+ """A Python value from Arrow, made safe for JSON (which has no NaN)."""
+ if isinstance(value, float):
+ return value if math.isfinite(value) else None
+ if isinstance(value, Decimal):
+ return float(value)
+ # Exactly tuple: Arrow intervals are named tuples, shown as text below.
+ if isinstance(value, list) or type(value) is tuple:
+ return [_jsonable(v) for v in value]
+ if isinstance(value, dict):
+ return {str(k): _jsonable(v) for k, v in value.items()}
+ if value is None or isinstance(value, (str, int)):
+ return value
+ return str(value) # Timestamps, intervals, bytes, ...
+
+
+def _grid(table: pa.Table) -> dict[str, Any] | None:
+ """Columns for the page's map: latitude, longitude, and one number."""
+ names = table.column_names
+ if "latitude" not in names or "longitude" not in names:
+ return None
+ values = [
+ n
+ for n in names
+ if n not in ("latitude", "longitude")
+ and (
+ pa.types.is_floating(table[n].type)
+ or pa.types.is_integer(table[n].type)
+ or pa.types.is_decimal(table[n].type)
+ )
+ ]
+ if len(values) != 1 or table.num_rows < 4:
+ return None
+ lat = table["latitude"].to_numpy().astype("float64")
+ lon = table["longitude"].to_numpy().astype("float64")
+ # One pixel per point: with several rows per point (e.g. every lead
+ # time), the table is the honest view.
+ if len(np.unique(np.stack([lat, lon]), axis=1)[0]) != table.num_rows:
+ return None
+ value = table[values[0]].to_numpy().astype("float64")
+ finite = np.isfinite(value)
+ if not finite.any():
+ return None
+ return {
+ "name": values[0],
+ "latitude": lat[finite].tolist(),
+ "longitude": lon[finite].tolist(),
+ "value": value[finite].tolist(),
+ }
+
+
+async def run(sql: str, max_rows: int = 500) -> str:
+ """Run ``sql``, fetching whatever chunks it needs, and return the result."""
+ store, cache, con = _state["store"], _state["cache"], _state["con"]
+ requests, nbytes = store.requests, store.bytes_read
+ table, stats = await cache.query(lambda: con.sql(sql).to_arrow_table())
+ shown = table.slice(0, max_rows)
+ return json.dumps(
+ {
+ "columns": shown.column_names,
+ "rows": _jsonable(
+ list(zip(*(shown[c].to_pylist() for c in shown.column_names)))
+ ),
+ "num_rows": table.num_rows,
+ "grid": _grid(table),
+ "stats": {
+ "runs": stats.rounds,
+ "chunks_fetched": stats.chunks_fetched,
+ "requests": store.requests - requests,
+ "mb_downloaded": round((store.bytes_read - nbytes) / 1e6, 1),
+ "seconds_fetching": round(stats.seconds_fetching, 2),
+ "seconds_querying": round(stats.seconds_querying, 2),
+ "cached_chunks": len(cache),
+ },
+ }
+ )
diff --git a/docs/demo/chunk_cache.py b/docs/demo/chunk_cache.py
new file mode 100644
index 0000000..42daf1b
--- /dev/null
+++ b/docs/demo/chunk_cache.py
@@ -0,0 +1,238 @@
+"""Lazy SQL over remote Zarr in Pyodide, by faulting chunks in between runs.
+
+In the browser, Zarr reads are asynchronous (``fetch``). zarr's
+synchronous API blocks on them with Pyodide's JSPI support, but that
+cannot suspend while the engine is calling back into Python: DuckDB and
+pyarrow pull record batches through C++ frames that JSPI cannot unwind.
+So a query cannot read chunks in the middle of a scan.
+
+Instead, every scan reads from a cache of decoded chunks:
+
+1. Run the query. A chunk that is not cached is recorded as missing and
+ scanned as fill values, so the scan keeps going and records every
+ chunk the query touches in one pass. xarray-sql's chunk pruning still
+ applies, so only chunks that survive the query's coordinate filters
+ are touched.
+2. If anything was missing, fetch those chunks concurrently with
+ ``Dataset.load_async`` (plain top-level ``await``, no JSPI), then run
+ the query again.
+3. A run with no misses saw only real data, so its result is exact.
+
+Placeholder data can change data-dependent filters in the discarded
+runs, which may fetch a few extra chunks, or make a run fail outright
+(casting NaN to an integer, say). A failed run that missed chunks is
+treated like any other: fetch, then run again. Neither changes the final
+answer.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import itertools
+import time
+from collections import OrderedDict
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+import xarray as xr
+
+ChunkKey = tuple[str, tuple[tuple[int, int], ...]]
+
+
+def _fill_value(dtype: np.dtype) -> Any:
+ if dtype.kind in "fc":
+ return np.nan
+ if dtype.kind == "M":
+ return np.datetime64("NaT")
+ if dtype.kind == "m":
+ return np.timedelta64("NaT")
+ return 0
+
+
+class _CachedVariable(xr.backends.BackendArray):
+ """A lazily indexed variable whose reads are served from the cache."""
+
+ def __init__(self, cache: ChunkCache, name: str, source: xr.Variable):
+ self._cache = cache
+ self._name = name
+ self.shape = source.shape
+ self.dtype = source.dtype
+ self._chunks = tuple(
+ cache.chunks.get(str(d), n)
+ for d, n in zip(source.dims, source.shape)
+ )
+
+ def __getitem__(self, key: Any) -> np.ndarray:
+ return xr.core.indexing.explicit_indexing_adapter(
+ key,
+ self.shape,
+ xr.core.indexing.IndexingSupport.BASIC,
+ self._raw_getitem,
+ )
+
+ def _raw_getitem(self, key: tuple) -> np.ndarray:
+ """Assemble a basic selection from the cache chunks it overlaps."""
+ ranges, steps, squeeze = [], [], []
+ for axis, (k, n) in enumerate(zip(key, self.shape)):
+ if isinstance(k, (int, np.integer)):
+ k = slice(int(k), int(k) + 1)
+ squeeze.append(axis)
+ start, stop, step = k.indices(n)
+ ranges.append((start, max(start, stop)))
+ steps.append(step)
+ out = np.empty([hi - lo for lo, hi in ranges], dtype=self.dtype)
+ per_axis = [
+ range(lo // c, (hi - 1) // c + 1) if hi > lo else range(0)
+ for (lo, hi), c in zip(ranges, self._chunks)
+ ]
+ for index in itertools.product(*per_axis):
+ bounds = tuple(
+ (i * c, min((i + 1) * c, n))
+ for i, c, n in zip(index, self._chunks, self.shape)
+ )
+ chunk = self._cache._get((self._name, bounds))
+ src, dst = [], []
+ for (lo, hi), (b0, b1) in zip(ranges, bounds):
+ a, b = max(lo, b0), min(hi, b1)
+ src.append(slice(a - b0, b - b0))
+ dst.append(slice(a - lo, b - lo))
+ out[tuple(dst)] = chunk[tuple(src)]
+ out = out[tuple(slice(None, None, s) for s in steps)]
+ return out.squeeze(axis=tuple(squeeze)) if squeeze else out
+
+
+@dataclass
+class QueryStats:
+ rounds: int = 0
+ chunks_fetched: int = 0
+ seconds_fetching: float = 0.0
+ seconds_querying: float = 0.0
+ misses_per_round: list[int] = field(default_factory=list)
+
+
+class ChunkCache:
+ """Serves a remote Dataset's chunks to synchronous scans from memory.
+
+ Args:
+ source: A lazily opened Dataset (e.g. ``xr.open_zarr(...,
+ chunks=None)``). Its coordinates should already be in memory;
+ only data variables are fetched through the cache.
+ chunks: Chunk sizes per dimension. Align them with the store's
+ (inner) chunks so one cached chunk is one fetch.
+ max_bytes: Memory budget for cached chunks; least recently used
+ chunks are evicted first.
+ """
+
+ def __init__(
+ self,
+ source: xr.Dataset,
+ chunks: dict[str, int],
+ max_bytes: int = 512 * 2**20,
+ ) -> None:
+ self.source = source
+ self.chunks = chunks
+ self.max_bytes = max_bytes
+ self._chunks: OrderedDict[ChunkKey, np.ndarray] = OrderedDict()
+ self._nbytes = 0
+ self.missing: set[ChunkKey] = set()
+
+ def __len__(self) -> int:
+ """Number of chunks currently cached."""
+ return len(self._chunks)
+
+ def dataset(self) -> xr.Dataset:
+ """The source Dataset with every data variable read through the cache.
+
+ Register it with ``chunks=`` set to this cache's chunks, so each
+ scanned block is one cached chunk.
+ """
+ variables = {
+ name: xr.Variable(
+ var.dims,
+ xr.core.indexing.LazilyIndexedArray(
+ _CachedVariable(self, str(name), var.variable)
+ ),
+ attrs=var.attrs,
+ )
+ for name, var in self.source.data_vars.items()
+ }
+ return self.source.assign(variables)
+
+ def _get(self, key: ChunkKey) -> np.ndarray:
+ if key in self._chunks:
+ self._chunks.move_to_end(key)
+ return self._chunks[key]
+ self.missing.add(key)
+ dtype = self.source[key[0]].dtype
+ shape = tuple(hi - lo for lo, hi in key[1])
+ return np.full(shape, _fill_value(dtype), dtype=dtype)
+
+ async def _fetch(self, key: ChunkKey) -> None:
+ name, bounds = key
+ var = self.source[name]
+ region = {d: slice(lo, hi) for d, (lo, hi) in zip(var.dims, bounds)}
+ loaded = await var.isel(region).load_async()
+ values = np.asarray(loaded.values)
+ self._chunks[key] = values
+ self._nbytes += values.nbytes
+ while self._nbytes > self.max_bytes and len(self._chunks) > 1:
+ _, evicted = self._chunks.popitem(last=False)
+ self._nbytes -= evicted.nbytes
+
+ def _nbytes_of(self, keys: set[ChunkKey]) -> int:
+ return sum(
+ int(np.prod([hi - lo for lo, hi in bounds]))
+ * self.source[name].dtype.itemsize
+ for name, bounds in keys
+ )
+
+ async def fill(self) -> int:
+ """Fetch every chunk recorded as missing; returns how many."""
+ keys = list(self.missing)
+ self.missing.clear()
+ await asyncio.gather(*(self._fetch(k) for k in keys))
+ return len(keys)
+
+ async def query(self, run: Any) -> tuple[Any, QueryStats]:
+ """Call ``run()`` until it completes without a cache miss.
+
+ ``run`` executes the query and materializes its result, e.g.
+ ``lambda: con.sql(sql).to_arrow_table()``.
+
+ Every retry fetches chunks this query has not fetched before, so
+ the loop ends. If a run misses a chunk this query already fetched,
+ the cache evicted it to make room for the others: the query needs
+ more than the cache holds.
+ """
+ stats = QueryStats()
+ fetched: set[ChunkKey] = set()
+ self.missing.clear()
+ while True:
+ stats.rounds += 1
+ start = time.perf_counter()
+ try:
+ result = run()
+ except Exception:
+ # Fill values can fail a query that real data would not.
+ if not self.missing:
+ raise
+ stats.seconds_querying += time.perf_counter() - start
+ stats.misses_per_round.append(len(self.missing))
+ if not self.missing:
+ return result, stats
+ if (
+ self._nbytes_of(self.missing) > self.max_bytes
+ or self.missing & fetched
+ ):
+ needed = self._nbytes_of(fetched | self.missing)
+ raise MemoryError(
+ f"The query needs about {needed / 2**20:.0f} MiB of "
+ f"chunks, more than the cache's "
+ f"{self.max_bytes / 2**20:.0f} MiB. "
+ "Narrow its filters on the dimension columns."
+ )
+ fetched |= self.missing
+ start = time.perf_counter()
+ stats.chunks_fetched += await self.fill()
+ stats.seconds_fetching += time.perf_counter() - start
diff --git a/docs/demo/fetch_store.py b/docs/demo/fetch_store.py
new file mode 100644
index 0000000..d21a77c
--- /dev/null
+++ b/docs/demo/fetch_store.py
@@ -0,0 +1,148 @@
+"""A read-only Zarr store over HTTP for Pyodide, built on the browser's fetch.
+
+zarr's own HTTP path goes through fsspec and aiohttp, which open raw
+sockets that a browser does not have. This store issues ``fetch``
+requests instead (with ``Range`` headers for partial reads, which sharded
+Zarr v3 arrays rely on). zarr drives it from synchronous code through
+Pyodide's JSPI support, so ``xr.open_zarr(FetchStore(url))`` and lazy
+reads work unchanged.
+
+The server must allow cross-origin requests (``Access-Control-Allow-Origin``)
+and expose range requests.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator, Iterable
+
+from pyodide.http import pyfetch
+from zarr.abc.store import (
+ ByteRequest,
+ OffsetByteRequest,
+ RangeByteRequest,
+ Store,
+ SuffixByteRequest,
+)
+from zarr.core.buffer import Buffer, BufferPrototype
+
+# S3-style object stores answer 403 rather than 404 for a missing key
+# when listing is not public, and zarr probes for keys that may not exist.
+# A key the store refuses for any other reason also reads as missing, so
+# its chunks come back as fill values.
+_MISSING = (403, 404)
+_ATTEMPTS = 4
+
+
+def _range_header(byte_range: ByteRequest | None) -> dict[str, str]:
+ if byte_range is None:
+ return {}
+ if isinstance(byte_range, RangeByteRequest):
+ return {"Range": f"bytes={byte_range.start}-{byte_range.end - 1}"}
+ if isinstance(byte_range, OffsetByteRequest):
+ return {"Range": f"bytes={byte_range.offset}-"}
+ if isinstance(byte_range, SuffixByteRequest):
+ return {"Range": f"bytes=-{byte_range.suffix}"}
+ raise TypeError(f"Unexpected byte range: {byte_range!r}")
+
+
+def _range_slice(byte_range: ByteRequest) -> slice:
+ if isinstance(byte_range, RangeByteRequest):
+ return slice(byte_range.start, byte_range.end)
+ if isinstance(byte_range, OffsetByteRequest):
+ return slice(byte_range.offset, None)
+ if isinstance(byte_range, SuffixByteRequest):
+ if byte_range.suffix == 0:
+ return slice(0, 0) # data[-0:] would be everything.
+ return slice(-byte_range.suffix, None)
+ raise TypeError(f"Unexpected byte range: {byte_range!r}")
+
+
+class FetchStore(Store):
+ """Read-only Zarr store for an HTTP(S) URL, using ``fetch``."""
+
+ supports_writes = False
+ supports_deletes = False
+ supports_partial_writes = False
+ supports_listing = False
+
+ def __init__(self, url: str) -> None:
+ super().__init__(read_only=True)
+ self.url = url.rstrip("/")
+ self.requests = 0
+ """Number of HTTP requests issued, for observing chunk pruning."""
+ self.bytes_read = 0
+ """Bytes received, for observing chunk pruning."""
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, FetchStore) and other.url == self.url
+
+ def __repr__(self) -> str:
+ return f"FetchStore({self.url!r})"
+
+ async def get(
+ self,
+ key: str,
+ prototype: BufferPrototype,
+ byte_range: ByteRequest | None = None,
+ ) -> Buffer | None:
+ url = f"{self.url}/{key}"
+ for attempt in range(_ATTEMPTS):
+ if attempt:
+ await asyncio.sleep(0.25 * 2**attempt)
+ self.requests += 1
+ try:
+ response = await pyfetch(url, headers=_range_header(byte_range))
+ except OSError: # Network failure; retry.
+ if attempt == _ATTEMPTS - 1:
+ raise
+ continue
+ if response.status == 429 or response.status >= 500:
+ continue
+ break
+ if response.status in _MISSING:
+ return None
+ if not response.ok:
+ raise OSError(f"GET {url} failed: HTTP {response.status}")
+ data = await response.bytes()
+ self.bytes_read += len(data)
+ if byte_range is not None and response.status != 206:
+ # The server ignored the Range header and sent the whole object.
+ data = data[_range_slice(byte_range)]
+ return prototype.buffer.from_bytes(data)
+
+ async def get_partial_values(
+ self,
+ prototype: BufferPrototype,
+ key_ranges: Iterable[tuple[str, ByteRequest | None]],
+ ) -> list[Buffer | None]:
+ return list(
+ await asyncio.gather(
+ *(self.get(key, prototype, rng) for key, rng in key_ranges)
+ )
+ )
+
+ async def exists(self, key: str) -> bool:
+ response = await pyfetch(f"{self.url}/{key}", method="HEAD")
+ return bool(response.ok)
+
+ async def set(self, key: str, value: Buffer) -> None:
+ raise NotImplementedError("FetchStore is read-only")
+
+ async def set_partial_values(self, key_start_values) -> None:
+ raise NotImplementedError("FetchStore is read-only")
+
+ async def delete(self, key: str) -> None:
+ raise NotImplementedError("FetchStore is read-only")
+
+ async def list(self) -> AsyncIterator[str]:
+ raise NotImplementedError("HTTP has no listing")
+ yield # pragma: no cover - makes this an async generator
+
+ async def list_prefix(self, prefix: str) -> AsyncIterator[str]:
+ raise NotImplementedError("HTTP has no listing")
+ yield # pragma: no cover
+
+ async def list_dir(self, prefix: str) -> AsyncIterator[str]:
+ raise NotImplementedError("HTTP has no listing")
+ yield # pragma: no cover
diff --git a/docs/demo/index.html b/docs/demo/index.html
new file mode 100644
index 0000000..cb22e07
--- /dev/null
+++ b/docs/demo/index.html
@@ -0,0 +1,444 @@
+
+
+
+
+
+xarray-sql in the browser
+
+
+
+
+
xarray-sql in the browser
+
+ SQL over NOAA GFS forecasts, read straight from
+ dynamical.org's Zarr store. xarray-sql and
+ DuckDB run in Pyodide in your browser; there is
+ no server. Each query downloads only the chunks its filters on
+ init_time, latitude, and longitude reach.
+
+
+
+
+
+
+
+ Starting…
+
+
+
+
+
Download Python, DuckDB, and pyarrow
+
Install xarray-sql
+
Open the GFS forecast store
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/demo/smoke.mjs b/docs/demo/smoke.mjs
new file mode 100644
index 0000000..85200e1
--- /dev/null
+++ b/docs/demo/smoke.mjs
@@ -0,0 +1,52 @@
+// Run the demo's example queries headlessly in Node, against the live dataset.
+//
+// npm install pyodide@314.0.7
+// node smoke.mjs path/to/xarray_sql-*-pyemscripten_*.whl
+//
+// Without a wheel path, xarray-sql is installed from PyPI.
+import { loadPyodide } from "pyodide";
+import { readFileSync } from "node:fs";
+import { basename, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const wheel = process.argv[2];
+
+const py = await loadPyodide();
+await py.loadPackage("micropip");
+for (const module of ["app.py", "chunk_cache.py", "fetch_store.py"]) {
+ py.FS.writeFile(module, readFileSync(join(here, module)));
+}
+let requirement = "xarray-sql";
+if (wheel) {
+ requirement = `emfs:/tmp/${basename(wheel)}`;
+ py.FS.writeFile(`/tmp/${basename(wheel)}`, readFileSync(wheel));
+}
+const micropip = py.pyimport("micropip");
+await micropip.install([requirement, "duckdb", "zarr"]);
+
+const app = py.pyimport("app");
+let start = performance.now();
+const info = JSON.parse(await app.setup());
+console.log(
+ `setup: ${((performance.now() - start) / 1000).toFixed(1)}s,`,
+ `runs ${info.init_times.join(", ")}`,
+);
+
+let failed = false;
+for (const { title, sql } of info.examples) {
+ start = performance.now();
+ try {
+ const result = JSON.parse(await app.run(sql));
+ const seconds = ((performance.now() - start) / 1000).toFixed(1);
+ console.log(`\n${title}\n ${seconds}s, ${result.num_rows} rows`, result.stats);
+ console.log(" first row:", result.columns, result.rows[0]);
+ if (result.num_rows === 0) failed = true;
+ } catch (error) {
+ failed = true;
+ // Python tracebacks end with the exception line.
+ const lines = String(error.message ?? error).trim().split("\n");
+ console.error(`\n${title}\n FAILED: ${lines.at(-1)}\n${lines.slice(-12).join("\n")}`);
+ }
+}
+process.exit(failed ? 1 : 0);
diff --git a/docs/demo/worker.js b/docs/demo/worker.js
new file mode 100644
index 0000000..f35b42e
--- /dev/null
+++ b/docs/demo/worker.js
@@ -0,0 +1,67 @@
+// Runs Pyodide, xarray-sql, and DuckDB off the page's main thread.
+//
+// Messages in: {id, sql}
+// Messages out: {type: "status", message, step}
+// {type: "ready", info} | {type: "failed", error}
+// {type: "result", id, result} | {type: "error", id, error}
+import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.mjs";
+
+// xarray-sql comes from PyPI unless the page passes a wheel URL, e.g. a
+// locally built one: index.html?wheel=./xarray_sql-...-pyemscripten_2026_0_wasm32.whl
+const wheel = new URL(import.meta.url).searchParams.get("wheel");
+const status = (message, step) => postMessage({ type: "status", message, step });
+
+async function start() {
+ // zarr's synchronous API, which xarray uses to open the store, blocks on
+ // fetch() through JavaScript Promise Integration.
+ if (typeof WebAssembly.Suspending !== "function") {
+ throw new Error(
+ "This demo needs WebAssembly JavaScript Promise Integration (JSPI), " +
+ "available in Chrome and Edge 137 or newer.",
+ );
+ }
+ status("Loading Pyodide and packages…", 0);
+ // Everything Pyodide ships is fetched in parallel while the runtime
+ // boots; micropip then only resolves xarray-sql from PyPI.
+ const modules = Promise.all(
+ ["app.py", "chunk_cache.py", "fetch_store.py"].map(async (name) => {
+ const response = await fetch(new URL(name, import.meta.url));
+ if (!response.ok) throw new Error(`Could not load ${name}: HTTP ${response.status}`);
+ return [name, await response.text()];
+ }),
+ );
+ const py = await loadPyodide({
+ packages: ["micropip", "duckdb", "pyarrow", "xarray", "zarr"],
+ });
+ for (const [name, source] of await modules) py.FS.writeFile(name, source);
+ status("Installing xarray-sql…", 1);
+ const micropip = py.pyimport("micropip");
+ await micropip.install(wheel ?? "xarray-sql");
+ status("Opening the dataset…", 2);
+ const app = py.pyimport("app");
+ postMessage({ type: "ready", info: JSON.parse(await app.setup()) });
+ return app;
+}
+
+// Python errors carry the full traceback. Show the exception line (some
+// messages add hints after it) and keep the rest for the console.
+const describe = (error) => {
+ const detail = String(error.message ?? error).trim();
+ const lines = detail.split("\n");
+ const exception = lines.findLast((line) => /^[\w.]*(Error|Exception)\b/.test(line));
+ return { error: exception ?? lines.at(-1), detail };
+};
+
+const app = start().catch((error) => {
+ postMessage({ type: "failed", ...describe(error) });
+});
+
+onmessage = async ({ data: { id, sql } }) => {
+ const loaded = await app;
+ if (!loaded) return;
+ try {
+ postMessage({ type: "result", id, result: JSON.parse(await loaded.run(sql)) });
+ } catch (error) {
+ postMessage({ type: "error", id, ...describe(error) });
+ }
+};
diff --git a/docs/explainer/era5.json b/docs/explainer/era5.json
new file mode 100644
index 0000000..c92a1e4
--- /dev/null
+++ b/docs/explainer/era5.json
@@ -0,0 +1 @@
+{"source":"gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3","variable":"t2m","long_name":"2 metre temperature","units":"K","time":["2024-07-01","2024-07-02","2024-07-03","2024-07-04","2024-07-05","2024-07-06","2024-07-07","2024-07-08"],"latitude":[75.0,45.0,15.0,-15.0,-45.0,-75.0],"longitude":[0.0,45.0,90.0,135.0,180.0,225.0,270.0,315.0],"values":[[[277.7,275.6,273.9,274.5,273.4,273.5,273.8,269.1],[295.5,305.7,302.5,288.4,283.5,285.1,284.5,292.7],[309.2,308.2,302.2,302.0,300.4,298.8,292.9,299.7],[294.8,298.2,298.5,295.3,299.4,298.0,293.0,296.4],[283.3,279.4,283.5,283.7,285.5,285.2,279.3,278.8],[224.3,227.9,220.9,221.3,244.4,248.6,247.4,250.5]],[[278.2,275.7,275.5,274.1,274.2,273.4,274.0,263.9],[293.2,302.4,299.0,291.6,285.1,286.5,287.0,293.6],[311.3,308.5,302.0,302.2,299.8,299.0,291.8,299.8],[293.2,298.1,298.3,291.9,300.0,298.5,292.9,295.8],[280.3,279.2,279.5,284.2,282.7,283.4,281.4,280.0],[223.4,227.2,216.2,214.8,246.6,256.1,250.8,251.1]],[[278.0,275.8,279.0,273.8,273.4,273.0,276.7,265.2],[295.3,304.6,297.2,292.9,286.6,285.4,291.2,294.6],[302.7,307.4,301.7,302.3,300.5,298.2,291.6,299.3],[293.3,298.0,299.0,292.6,300.8,299.1,293.0,295.3],[282.9,281.3,281.4,284.2,281.2,282.0,279.6,277.2],[221.3,225.4,212.3,213.1,250.3,255.9,255.9,253.4]],[[277.5,276.1,289.5,274.2,273.7,273.5,274.0,266.6],[295.5,310.2,299.3,291.3,287.4,286.5,289.7,294.1],[309.4,306.8,300.3,300.9,300.6,297.3,288.6,299.4],[293.5,298.2,299.6,290.0,300.7,298.7,292.5,295.0],[280.1,281.2,278.1,283.3,282.0,284.1,281.7,278.6],[223.1,224.8,212.8,215.9,246.3,247.8,251.2,246.6]],[[278.4,276.7,289.2,274.2,273.4,273.3,277.2,267.5],[297.7,312.5,301.3,289.7,286.5,287.4,289.7,294.9],[307.1,307.5,300.4,301.2,300.2,299.0,290.2,299.8],[294.2,299.0,298.1,293.6,300.7,297.8,293.3,294.9],[278.6,282.4,279.0,283.2,282.1,283.6,282.2,280.4],[230.9,226.3,218.6,215.3,248.1,255.6,249.3,250.6]],[[277.0,280.0,291.0,275.4,273.4,273.3,280.6,266.2],[291.9,312.8,302.0,290.9,286.3,288.3,286.5,294.0],[310.8,306.7,300.1,303.0,300.6,298.6,292.2,300.3],[292.9,298.6,297.6,295.9,298.4,298.1,293.6,295.7],[276.1,281.4,280.8,281.8,281.7,282.3,280.7,280.9],[232.0,224.1,211.9,217.9,249.0,256.4,254.1,245.4]],[[276.9,279.3,288.2,274.4,273.2,273.1,280.2,265.0],[294.0,312.2,297.7,290.2,285.8,287.8,290.1,291.9],[310.5,308.1,300.7,301.4,300.5,298.4,291.1,299.8],[293.8,298.9,298.2,296.0,299.0,297.8,292.8,296.0],[277.3,281.9,279.6,283.8,281.1,284.8,281.5,277.7],[242.3,238.9,211.9,217.8,251.6,258.6,255.1,242.2]],[[276.5,279.6,289.3,272.6,273.2,272.9,284.2,261.1],[300.5,313.5,304.3,290.7,286.5,287.7,290.9,289.9],[309.0,308.2,300.9,302.0,300.2,298.4,291.8,299.9],[293.1,298.6,299.1,295.3,299.2,297.5,292.7,296.5],[280.1,281.9,277.6,283.9,282.5,285.3,281.2,278.4],[234.3,226.2,211.6,212.9,253.5,251.4,256.7,245.3]]]}
diff --git a/docs/explainer/index.html b/docs/explainer/index.html
new file mode 100644
index 0000000..f2f22c2
--- /dev/null
+++ b/docs/explainer/index.html
@@ -0,0 +1,537 @@
+
+
+
+
+
+How xarray-sql works
+
+
+
+
+
+
1A Dataset is a table
+
+ Registering a Dataset gives SQL a table with one row per grid cell: a column
+ for each dimension and one for each data variable. Nothing is copied. Each
+ chunk becomes a partition of the table, read only when a query needs it.
+ Hover over the grid or the table to match cells to rows.
+
+
import duckdb, xarray as xr, xarray_sql as xql
+
+era5 = xr.open_zarr(
+ "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3",
+ chunks=None, storage_options={"token": "anon"},
+)
+# A grid small enough to see: 12:00 UTC on 8 days, 6 latitudes, 8 longitudes.
+ds = era5[["2m_temperature"]].rename({"2m_temperature": "t2m"}).sel(
+ time=xr.date_range("2024-07-01T12", periods=8, freq="D"),
+ latitude=[75, 45, 15, -15, -45, -75],
+ longitude=range(0, 360, 45),
+)
+con = duckdb.connect()
+xql.register(con, "era5", ds, chunks={"time": 4, "latitude": 3, "longitude": 4})
+
+
+
ds.t2m, one time step
+
+
· lines mark chunks
+
+
+
+
table era5 ( rows, 8 partitions)
+
+
time
latitude
longitude
t2m
partition
+
+
+
+
+
+
+
2SQL reads only the chunks it needs
+
+ Filters on dimension columns are pushed down to the table, and xarray-sql
+ compares them with each partition's coordinate range. A partition that
+ cannot match is skipped, so its chunk is never read. Pick a query.
+
+
+ Partitions follow chunks=. ARCO-ERA5 stores each hour of the
+ whole globe as one chunk, so registered with its storage chunks, a time
+ filter would still skip chunks but a bounding box could not.
+
+ to_dataset pivots the result rows back onto a grid. The
+ dimension columns that survive the query become the dimensions, and the
+ registered Dataset, passed as the template, restores attributes such as
+ units.
+
+
out = xql.to_dataset(con.sql(query), template=ds)
+
+
+
+
+
+
+
+
+
+
+ Data: ERA5 2 m temperature from
+ ARCO-ERA5.
+ Contains modified Copernicus Climate Change Service information 2024.
+
+
+
+
+
diff --git a/docs/explainer/make_snapshot.py b/docs/explainer/make_snapshot.py
new file mode 100644
index 0000000..280ffc0
--- /dev/null
+++ b/docs/explainer/make_snapshot.py
@@ -0,0 +1,46 @@
+"""Writes the explainer's data: a tiny, real slice of ARCO-ERA5.
+
+The explainer shows every row of its table and every partition, so it
+needs a grid small enough to see: 2 m temperature at 12:00 UTC on eight
+days, at six latitudes and eight longitudes. The selection is the code the
+explainer shows. Run from the repository root:
+
+ python docs/explainer/make_snapshot.py
+"""
+
+import json
+import pathlib
+
+import numpy as np
+import xarray as xr
+
+ARCO_ERA5 = (
+ "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
+)
+
+era5 = xr.open_zarr(ARCO_ERA5, chunks=None, storage_options={"token": "anon"})
+ds = (
+ era5[["2m_temperature"]]
+ .rename({"2m_temperature": "t2m"})
+ .sel(
+ time=xr.date_range("2024-07-01T12", periods=8, freq="D"),
+ latitude=[75, 45, 15, -15, -45, -75],
+ longitude=range(0, 360, 45),
+ )
+ .load()
+)
+
+snapshot = {
+ "source": ARCO_ERA5,
+ "variable": "t2m",
+ "long_name": ds.t2m.attrs.get("long_name", "2 metre temperature"),
+ "units": ds.t2m.attrs.get("units", "K"),
+ "time": [str(t)[:10] for t in ds.time.values.astype("datetime64[D]")],
+ "latitude": ds.latitude.values.tolist(),
+ "longitude": ds.longitude.values.tolist(),
+ # Rounded to 0.1 K, in (time, latitude, longitude) order.
+ "values": np.round(ds.t2m.values.astype(float), 1).tolist(),
+}
+out = pathlib.Path(__file__).with_name("era5.json")
+out.write_text(json.dumps(snapshot, separators=(",", ":")) + "\n")
+print(f"wrote {out} ({out.stat().st_size} bytes)")
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..3af3c6b
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,41 @@
+# How it works
+
+xarray-sql lets SQL engines query Xarray Datasets as if they were tables,
+and turns query results back into Datasets. The explainer below walks
+through the three steps with a tiny slice of
+[ARCO-ERA5](https://github.com/google-research/arco-era5) 2 m temperature:
+eight days at 48 points on the globe, small enough to show every row. The
+slice is saved with the page, so it loads instantly and needs no Python.
+
+
+
+
+
+## What happens in each step
+
+1. **Registering** a Dataset with [register][xarray_sql.register] (or
+ [Context.from_dataset][xarray_sql.Context.from_dataset]) creates a table
+ whose rows are the Dataset's grid cells. The table reads from the
+ Dataset as the query runs, one chunk at a time, so registering copies
+ nothing.
+2. **Querying** runs in the engine: DuckDB, DataFusion, or any engine
+ that reads Arrow. Filters on dimension columns are pushed down to the
+ table, which skips every partition whose coordinate range cannot match,
+ so a selective query reads few chunks. See [Engines](engines.md).
+3. **Round-tripping** with [to_dataset][xarray_sql.to_dataset] pivots the
+ result rows back onto a grid and restores metadata from the registered
+ Dataset. By default the result is lazy: each access reruns the query
+ for just the part you select.
+
+To run real queries over a public Zarr store, try
+[xarray-sql in the browser](browser.md).
diff --git a/flake.nix b/flake.nix
index c393dc3..52b755a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -48,6 +48,31 @@
which
xz
zlib
+ # Shared libraries for Playwright's headless Chromium, which
+ # drives the browser demo in docs/demo.
+ alsa-lib
+ at-spi2-atk
+ at-spi2-core
+ atk
+ cairo
+ cups
+ dbus
+ expat
+ glib
+ libdrm
+ libgbm
+ libxkbcommon
+ nspr
+ nss
+ pango
+ systemdLibs
+ xorg.libX11
+ xorg.libXcomposite
+ xorg.libXdamage
+ xorg.libXext
+ xorg.libXfixes
+ xorg.libXrandr
+ xorg.libxcb
];
runScript = "bash";
profile = ''
diff --git a/zensical.toml b/zensical.toml
index a820e55..511daeb 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -9,8 +9,10 @@ edit_uri = "edit/main/docs/"
nav = [
{"Home" = "index.md"},
{"Getting started" = "examples.md"},
+ {"How it works" = "how-it-works.md"},
{"Concepts" = [
{"Engines" = "engines.md"},
+ {"Run in the browser" = "browser.md"},
]},
{"Guides" = [
{"Geospatial in SQL" = "geospatial.md"},