Add zarr.defineStoreMiddleware and zarr.extendStore middleware system#384
Open
Add zarr.defineStoreMiddleware and zarr.extendStore middleware system#384
zarr.defineStoreMiddleware and zarr.extendStore middleware system#384Conversation
🦋 Changeset detectedLatest commit: 5f1b5d0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
bfb66b7 to
37b46f5
Compare
04f0ee6 to
9c1bf45
Compare
Contributor
ba97c72 to
6827720
Compare
Owner
Author
|
Here is a bigger example: import * as zarr from "zarrita";
// Simple middleware — adds getRange fallback for stores that lack it
const withEnsureGetRange = zarr.defineStoreMiddleware((store) => ({
async getRange(key, range, opts) {
if (store.getRange) return store.getRange(key, range, opts);
let data = await store.get(key, opts);
if (!data) return undefined;
if ("suffixLength" in range) {
return data.slice(data.length - range.suffixLength);
}
return data.slice(range.offset, range.offset + range.length);
},
}));
// Generic middleware — opts are inferred from the store's options type
interface LoggingOptions<O = unknown> {
format?: (key: string, options: O | undefined) => string;
}
interface LoggingOptsFor extends zarr.GenericOptions {
readonly options: LoggingOptions<this["_O"]>;
}
const withLogging = zarr.defineStoreMiddleware.generic<LoggingOptsFor>()(
(store, opts: LoggingOptions = {}) => {
let fmt = opts.format ?? ((key) => `GET ${key}`);
return {
async get(key, options) {
console.log(fmt(key, options));
return store.get(key, options);
},
};
},
);
// Compose in a pipeline — arrow functions give full type inference
let store = await zarr.storeFrom(
new zarr.FetchStore("https://example.com/data.zarr"),
withEnsureGetRange,
(s) =>
withLogging(s, {
format: (key, opts) => `${key} [signal=${opts?.signal?.aborted}]`,
// ^? RequestInit | undefined
}),
zarr.withConsolidation,
(s) => zarr.withRangeBatching(s, { mergeOptions: (batch) => batch[0] }),
);
store.contents(); // from consolidation
store.stats; // from batching |
Stores in zarrita can now be wrapped with composable middleware using
`wrapStore`. Each middleware intercepts store methods via Proxy and can
add new methods, with automatic delegation for anything not overridden.
Middleware supports a dual API — direct `withX(store, opts)` and curried
`withX(opts)` for use with the new `createStore` pipeline.
For middleware whose options depend on the store's request options type
(e.g., `mergeOptions` in range batching), `wrapStore.generic` uses a
higher-kinded type encoding via the `GenericOptions` interface to flow
the store's `O` type through to the options at the call site.
`withConsolidated` and `tryWithConsolidated` are renamed to
`withConsolidation` and `withMaybeConsolidation` for consistency. The old
names are re-exported with `@deprecated` JSDoc. `BatchedRangeStore` class
is replaced by `withRangeBatching` built on `wrapStore.generic`.
```ts
let store = await createStore(
new FetchStore("https://..."),
withConsolidation({ format: "v3" }),
withRangeBatching(),
);
store.contents(); // from consolidation
store.stats; // from batching
```
The dual-callable pattern (direct + curried) on store middleware was
causing TypeScript to lose generic store options in pipeline
composition. For example, `mergeOptions`'s `batch` parameter was
`unknown[]` instead of `ReadonlyArray<RequestInit | undefined>` because
the curried form captured options before the store type was known.
This removes currying entirely in favor of the pattern used by
fetch-extras: pipelines use arrow functions `(s) => withX(s, opts)`
which naturally give TypeScript the store type at each step. No-config
middleware like `withConsolidation` can be passed uncalled since the
direct form has optional opts.
Renames `wrapStore` to `defineStoreMiddleware` and `createStore` to
`storeFrom`. The factory constraint now uses `AsyncReadable` (not `any`)
for the store parameter and `Partial<AsyncReadable>` for the return
type, giving autocomplete on both `store.get` and the returned
`get`/`getRange` overrides inside the factory body. Also removes
`RequireOverrides`, `HasGet`, `_dual`, and `isReadable` which were all
artifacts of the curried approach.
```ts
import * as zarr from "zarrita";
let store = await zarr.storeFrom(
new zarr.FetchStore("https://..."),
zarr.withConsolidation,
(s) => zarr.withRangeBatching(s, {
mergeOptions: (batch) => batch[0],
// ^? ReadonlyArray<RequestInit | undefined>
}),
);
```
The middleware result type was an opaque intersection chain like
`FetchStore & { contents: ... } & { stats: ... }` which was hard to read
in tooltips and error messages. This collapses the store's
`get`/`getRange` back into `AsyncReadable<O>` and shows only the
middleware extensions separately, so the result reads as
`AsyncReadable<RequestInit> & { contents: ..., stats: ... }`.
If the store or any middleware provides a concrete `getRange`, the
collapsed type uses `Required<AsyncReadable<O>>` to preserve the
non-optional guarantee. A store that only has `get` shows the default
`AsyncReadable<O>` with `getRange?` optional.
a0b6bfe to
882b8f4
Compare
Before #384 ships, rename the store middleware composer from storeFrom to extendStore. Two reasons: 1. extendStore reads more naturally at the call site ('take this store and extend it with these capabilities') than storeFrom does. 2. The chunk-layer equivalent (extendArray), landing in a follow-up, reuses the same naming convention. Settling the vocabulary before either middleware system ships means users see the same verb at both layers. Also aligns the docs in docs/store-middleware.md, which had been using stale createStore / wrapStore names left over from earlier iterations of #384.
manzt
added a commit
that referenced
this pull request
Apr 13, 2026
`AsyncReadable<Options>` existed so stores could receive arbitrary per-call state. Auth headers, presigning context, cancellation signals, even chunk-layer concerns like caching and prefetch priority (#296, vole-core's `wrapArray`). It was a catch-all for extensions that had nowhere else to live, and the cost was a pile of type magic: higher-kinded-type encoding in the store middleware system (#384), threading generic types that didn't actually provide that much type safety. (TypeScript could often bail out to `any` when inference broke.) Those extensions now have proper homes. Store middleware (#384) gives transport-layer concerns (auth, presigning, request transformation) a proper extension point, and the custom `fetch` option on `FetchStore` (#388) handles the per-store cases at the callsite. Chunk-layer concerns will move to `zarr.extendArray` in a follow-up. What's left is `signal`, which now lives properly on the (non-generic) `AsyncReadable` interface and can be passed directly in `zarr.get` and `zarr.set` from the caller: // Before interface AsyncReadable<Options = unknown> { get(key: AbsolutePath, opts?: Options): Promise<Uint8Array | undefined>; } await zarr.get(arr, null, { opts: { signal: ctl.signal } }); // After interface AsyncReadable { get(key: AbsolutePath, opts?: { signal?: AbortSignal }): Promise<Uint8Array | undefined>; } await zarr.get(arr, null, { signal: ctl.signal }); Batched caller signals in `withRangeBatching` are now merged with `AbortSignal.any` instead of a user-supplied `mergeOptions` reducer. The deprecated `opts?: { signal? }` shape still works for one major version and is folded into the new `signal` via `AbortSignal.any`.
manzt
added a commit
that referenced
this pull request
Apr 13, 2026
The data-layer counterpart to `zarr.defineStoreMiddleware` / `zarr.extendStore` (#384). Array middleware intercepts `getChunk(coords)`; everything else on the Array (shape, dtype, attrs, path, store) is delegated via `Proxy` to the inner instance. In v1 only `getChunk` is interceptable — additional fields on the factory result appear as extensions on the wrapped array, so you can bolt on caches, counters, or observability hooks without reaching into the class. The shared `createProxy` in `define.ts` had a latent bug: it forwarded property access with `receiver = proxy`, which meant getters and methods ran with `this = proxy` and choked on class private fields (`Array.#metadata`, `FetchStore.#fetch`). Fixed by running getters against the target directly and binding methods to the target. Store middleware benefits too. No built-in chunk middleware ships with this change; `withChunkCache` and similar can be layered on as follow-ups or built by users. const withChunkCache = zarr.defineArrayMiddleware( (array, opts: { cache: Map<string, zarr.Chunk<zarr.DataType>> }) => ({ async getChunk(coords, options) { let key = coords.join(","); let hit = opts.cache.get(key); if (hit) return hit; let chunk = await array.getChunk(coords, options); opts.cache.set(key, chunk); return chunk; }, }), ); let wrapped = await zarr.extendArray( await zarr.open(store, { kind: "array" }), (a) => withChunkCache(a, { cache: new Map() }), );
This was referenced Apr 13, 2026
4 tasks
zarr.defineStoreMiddleware and zarr.extendStore middleware system
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduces a composable store middleware system for wrapping stores with additional behavior (caching, batching, consolidated metadata, etc.) via Proxy delegation.
Core APIs:
zarr.defineStoreMiddleware(factory)— define a middleware that intercepts store methods. The factory receives anAsyncReadablestore and options, returns overrides and extensions. Unoverridden methods are automatically delegated via Proxy.zarr.extendStore(store, ...middleware)— compose middleware in a pipeline. Each step is an arrow function that receives the store with full type inference.Built-in middleware:
zarr.withConsolidation— loads consolidated metadata (v2/v3), interceptsgetfor cached metadata lookups, addscontents()for listingzarr.withRangeBatching— batches concurrentgetRange()calls within a microtask tick, coalesces adjacent ranges, LRU caching