Skip to content

Add zarr.defineStoreMiddleware and zarr.extendStore middleware system#384

Open
manzt wants to merge 4 commits intomainfrom
push-krknkpkmxlrn
Open

Add zarr.defineStoreMiddleware and zarr.extendStore middleware system#384
manzt wants to merge 4 commits intomainfrom
push-krknkpkmxlrn

Conversation

@manzt
Copy link
Copy Markdown
Owner

@manzt manzt commented Apr 9, 2026

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 an AsyncReadable store 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.
import * as zarr from "zarrita";

let store = await zarr.extendStore(
  new zarr.FetchStore("https://..."),
  zarr.withConsolidation,
  (s) => zarr.withRangeBatching(s, {
    mergeOptions: (batch) => batch[0],
    //             ^? ReadonlyArray<RequestInit | undefined>
  }),
);

store.contents(); // from consolidation
store.stats;      // from batching

Built-in middleware:

  • zarr.withConsolidation — loads consolidated metadata (v2/v3), intercepts get for cached metadata lookups, adds contents() for listing
  • zarr.withRangeBatching — batches concurrent getRange() calls within a microtask tick, coalesces adjacent ranges, LRU caching

@manzt manzt added the enhancement New feature or request label Apr 9, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 5f1b5d0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
zarrita Minor
@zarrita/ndarray Patch

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

@manzt manzt requested a review from keller-mark April 9, 2026 01:57
@manzt manzt force-pushed the push-krknkpkmxlrn branch 2 times, most recently from bfb66b7 to 37b46f5 Compare April 9, 2026 02:04
@manzt manzt force-pushed the push-krknkpkmxlrn branch 4 times, most recently from 04f0ee6 to 9c1bf45 Compare April 9, 2026 02:29
@manzt manzt force-pushed the push-krknkpkmxlrn branch from 9c1bf45 to 065c721 Compare April 9, 2026 11:42
@manzt manzt changed the title Add composable store middleware system Simplify middleware API to direct-only form Apr 9, 2026
@manzt manzt force-pushed the push-krknkpkmxlrn branch from a9e428d to 3fc5391 Compare April 9, 2026 21:42
@manzt manzt changed the title Simplify middleware API to direct-only form Add composable store middleware system Apr 9, 2026
@manzt manzt force-pushed the push-krknkpkmxlrn branch from 3fc5391 to ba97c72 Compare April 9, 2026 21:46
@kylebarron
Copy link
Copy Markdown
Contributor

In case it's any relevant related work, I've been working on a sort of "object store generic store" interface in obspec, but still working on how to have a generic interface on that.

I've been trying to follow the object-store model of "wrappers" like this

@manzt manzt force-pushed the push-krknkpkmxlrn branch from ba97c72 to 6827720 Compare April 10, 2026 00:24
@manzt
Copy link
Copy Markdown
Owner Author

manzt commented Apr 10, 2026

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

manzt added 3 commits April 12, 2026 10:40
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.
@manzt manzt force-pushed the push-krknkpkmxlrn branch from a0b6bfe to 882b8f4 Compare April 12, 2026 14:40
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() }),
    );
@manzt manzt changed the title Add composable store middleware system Add zarr.defineStoreMiddleware and zarr.extendStore middleware system Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants