Skip to content

Add composable store middleware system#384

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

Add composable store middleware system#384
manzt wants to merge 3 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.defineStoreMiddleware.generic<OptsLambda>()(factory) — variant for middleware whose options depend on the store's request options type (uses GenericOptions for higher-kinded type encoding).
  • zarr.storeFrom(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.storeFrom(
  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: a0b6bfe

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

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
```
@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

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>
  }),
);
```
@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

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.
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