Skip to content
39 changes: 39 additions & 0 deletions .changeset/zarr-tree-node-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'zarrextra': minor
'@spatialdata/core': minor
---

Export runtime type guards and accessors for zarr tree nodes.

`ZarrTree` admits a group or a `LazyZarrArray` at every key and shipped no way to tell
them apart, so consumers hand-rolled the check — and the obvious `typeof node === 'object'`
test is wrong, because a lazy array is an object too and its own properties (`get`) then
read as child keys. `zarrextra` now exports the discrimination itself:

- `isLazyZarrArray` / `isZarrGroup` — the guards, discriminating on `ZARRAY_KEY`.
- `getChildNode` / `getChildGroup` / `getChildArray` — "the node at this path, if it is
the kind I need", which is the shape most call sites actually want.
- `getNodeAttrs` / `getArrayMetadata` — the symbol-keyed payloads of either kind of node.
- `getArrayDtype` / `normalizeDtype` — the data type of an array node, from consolidated
metadata alone, with v2's numpy typestrings (`<f8`, `|O`) and v3's names (`float64`,
`string`) folded into one vocabulary: `zarrita`'s own `DataType`, so a check made
against tree metadata and the same check made against an opened array cannot disagree.
- `isTextDataType` — "do these values need decoding to strings", covering v3 `string`,
v2 fixed-width unicode/bytes, *and* `v2:object`. `zarrita`'s `isDataType(dtype, 'string')`
excludes the last, and testing for one spelling without the other is what makes a
reader hand back raw integer codes where labels were expected.

`LazyZarrArray`'s `ZARRAY_KEY` payload is typed as `ZarrArrayMetadata` instead of an
untyped record, so `dtype` and `data_type` can no longer be read without narrowing —
reading the v2 spelling off a v3 node and silently getting `undefined` stops type-checking.

`@spatialdata/core` re-exports all of the above and uses them throughout: `parsed` is
narrowed to a group once in `AbstractElement`, so no element subclass sees the union, and
`classifyObsColumnNode`, `getObsGroup` and `loadElements` drop their casts.
`AnnDataSource` now asks `isTextDataType` about an opened array's dtype, so the
classification a UI sees before loading a column and the decoding it gets when the column
loads come from one definition.

`readNullableArray`, `isNullableEncoding` and `NULLABLE_ENCODING_KINDS` are now public
too. Guards make "is this group a categorical or a nullable column?" *expressible*; those
make it *answerable* without every consumer re-deriving AnnData's on-disk layout.
14 changes: 10 additions & 4 deletions docs/docs/core/internals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,17 @@ These are used internally during element construction to validate and type metad

## Store Parsing

The `zarrUtils.ts` module handles parsing zarr store contents:
Parsing zarr store contents lives in `zarrextra`, not in core:

- `tryConsolidated(store)` - Attempts to load consolidated metadata
- `parseStoreContents(store)` - Builds the `ZarrTree` structure
- `serializeZarrTree(tree)` - Serializes for JSON output
- `openExtraConsolidated(source)` - Resolves consolidated metadata and builds the
`ZarrTree`, returning a `Result`
- `serializeZarrTree(tree)` - Serializes for JSON output, converting the symbol-keyed
attributes and array metadata to string keys

Element construction walks that tree with the guards and accessors described in
[tree nodes](../zarrextra/tree-nodes) — `AbstractElement` narrows an element's own
node to a group once, so no element subclass has to discriminate group from array
itself.

## Result Type Implementation

Expand Down
8 changes: 8 additions & 0 deletions docs/docs/zarrextra/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"label": "zarrextra",
"position": 3,
"link": {
"type": "generated-index",
"description": "Lower-level zarr helpers used by @spatialdata/core: consolidated-metadata trees, node type guards, data types, codecs and worker-backed chunk decode."
}
}
142 changes: 142 additions & 0 deletions docs/docs/zarrextra/overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
sidebar_position: 1
---

# zarrextra Overview

`zarrextra` is the layer between [`zarrita`](https://github.com/manzt/zarrita.js) and
`@spatialdata/core`. It holds the things a SpatialData reader needs that are not
specific to SpatialData: opening a store and reading its whole hierarchy up front,
navigating that hierarchy, decoding image codecs `zarrita` does not ship, and moving
chunk decode off the main thread.

It is published outside the `@spatialdata/*` namespace because nothing in it knows
what an image or a table is. If you are reading SpatialData stores, you want
`@spatialdata/core`, which re-exports the parts of this package you are likely to
need. If you are reading some other zarr hierarchy and want the same tree and codec
machinery, you can depend on `zarrextra` alone.

:::info alpha prerelease

Versioned alongside the `@spatialdata/*` packages for now, but able to version
independently in future releases. The API described here is not yet stable.

:::

```bash
npm install zarrextra
# or
pnpm add zarrextra
```

## Opening a store

`openExtraConsolidated` takes a URL or any `zarrita` `Readable`, resolves consolidated
metadata, and reads the entire hierarchy into a tree — every group, every array, and
every node's attributes — before returning.

```ts
import { openExtraConsolidated, isErr } from 'zarrextra';

const result = await openExtraConsolidated('https://example.com/store.zarr');
if (isErr(result)) {
console.error(result.error);
} else {
const { zarritaStore, tree } = result.value;
}
```

It returns a [`Result`](#result), not a thrown error: failing to open a store is an
ordinary outcome for a URL a user typed, and the message is worth handling rather
than catching.

The `ConsolidatedStore` it resolves to has two halves, and which one you want depends
on whether you need data or only structure:

| Field | What it is | Use it for |
|---|---|---|
| `zarritaStore` | `zarr.Listable<Readable>` — the store, with metadata served from memory | Opening arrays, reading chunks |
| `tree` | `ZarrTree` — the hierarchy as a plain object | Enumerating, navigating, reading metadata without I/O |

The tree is the reason this package exists. Because every node's attributes and array
metadata are already in memory, a question like "what columns does this table have,
and what type is each one?" is answerable synchronously, before deciding whether any
of it is worth loading. [Tree nodes](./tree-nodes) covers the shape of a node, how to
tell a group from an array, and how to read a data type out of one.

## Store extras

Two smaller helpers that come up when a store is not being consumed whole:

- `createPrefixedStore(store, prefix)` — a `Readable` view rooted at a subpath. Used
to hand a table's own subtree to `anndata.js` without it knowing about SpatialData's
layout above.
- `loadOmeZarrMultiscalesFromStore(store, path)` — reads an OME-Zarr multiscales group
into Viv-compatible pixel sources, reusing an already-open store rather than making
the viewer open its own.

## Result

`Result<T, E>` is a small Rust-style success-or-error union, used wherever a failure
is expected rather than exceptional:

```ts
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```

with `Ok`, `Err`, `isOk`, `isErr`, `unwrap` and `unwrapOr`. It is re-exported from
`@spatialdata/core`, so importing it from either package gives the same type. See
[error handling](../core/error-handling) for how core uses it.

## Codecs and workers

SpatialData stores in the wild use image codecs `zarrita` does not register by
default, and decoding them on the main thread will stall a render. Those two concerns
are documented where they are used rather than repeated here:

| Topic | Where |
|---|---|
| Registering JP2K and experimental HTJ2K decode, encoding HTJ2K | [package README](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/zarrextra/README.md) |
| Which setup each context needs (Node, browser with vis, browser without) | [codec fixtures](../vis/codec-fixtures) |
| Shipping a worker entry point consumers can use without private URLs | [worker bundling pattern](../worker-bundling) |

The short version: in Node, call `registerJpeg2kCodec()` / `registerExperimentalHtj2kCodec()`
on the main thread. In a browser using `@spatialdata/vis`, do nothing — the renderer
path enables the bundled codec worker for you. In a browser without vis, call
`enableWorkerChunkDecode()` from `zarrextra/workers` before loading codec-backed data.

## Weight

`zarrita` is deliberately minimal. `zarrextra` is not, and it is worth being explicit
about where that lands before depending on it.

Measured from the current build:

| Entry | Raw | Gzipped |
|---|---|---|
| `zarrextra` | 15.4 kB | 5.2 kB |
| `zarrextra/codec-worker` | 3.2 MB | 899 kB |

The main entry is small, and the WASM codec packages are `optionalDependencies` whose
decoders are injected by the caller — so importing `zarrextra` does not drag
OpenJPEG or OpenJPH in. What is heavy is the codec worker, and it is one bundle
carrying every codec: enabling worker decode for JP2K also ships OpenJPH, and vice
versa. An application reading uncompressed or blosc-compressed stores — which is most
of them — gets no benefit from either.

The install footprint is also larger than the import graph suggests. `zod` is a hard
dependency reached by a single internal schema module that nothing currently calls,
so every consumer installs it whether or not anything uses it. Which way that should
resolve is open: array metadata is read from a store with a bare `JSON.parse` today
and typed as [an unvalidated record](./tree-nodes#array-metadata-types), so there is
at least as good a case for validating more at that boundary — and earning the
dependency — as for dropping it.

:::caution subject to redesign

None of this is settled. Splitting the worker so applications can opt into lighter
bundles when they do not need JP2K or HTJ2K is the obvious first move, and is already
noted as future work in the package README. Treat the packaging — not the APIs on
this page — as the part most likely to change.

:::
Loading
Loading