Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/middleware-store-wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"zarrita": minor
---

Introduce composable store middleware via `defineStoreMiddleware`

**New APIs:**

- `zarr.defineStoreMiddleware(factory)` — define a store middleware with automatic Proxy delegation. The factory receives an `AsyncReadable` store and options, returning overrides and extensions. Supports sync and async factories.
- `zarr.defineStoreMiddleware.generic<OptsLambda>()(factory)` — for middleware whose options depend on the store's request options type (e.g., `mergeOptions`). Uses `GenericOptions` interface for higher-kinded type encoding.
- `zarr.extendStore(store, ...middleware)` — compose middleware in a pipeline. Each middleware is `(store) => newStore`. Returns `Promise` to support async middleware like `withConsolidation`.

**Renamed:**

- `withConsolidated` → `withConsolidation`
- `tryWithConsolidated` → `withMaybeConsolidation`
- `WithConsolidatedOptions` → `ConsolidationOptions`
- `BatchedRangeStoreOptions` → `RangeBatchingOptions`
- `Stats` → `RangeBatchingStats`

**Migration:**

The previous exports are still available but deprecated. Update your imports:

```ts
import * as zarr from "zarrita";

// Pipeline composition (use arrow functions for full type inference)
let store = await zarr.extendStore(
new zarr.FetchStore("https://..."),
zarr.withConsolidation,
(s) => zarr.withRangeBatching(s, { mergeOptions: (batch) => batch[0] }),
);
```

**Defining custom middleware:**

```ts
import * as zarr from "zarrita";

const withCaching = zarr.defineStoreMiddleware(
(store, opts: { maxSize?: number }) => {
let cache = new Map();
return {
async get(key, options) {
let hit = cache.get(key);
if (hit) return hit;
let result = await store.get(key, options);
if (result) cache.set(key, result);
return result;
},
clear() { cache.clear(); },
};
},
);
```

`BatchedRangeStore` class has been removed in favor of `withRangeBatching` built on `zarr.defineStoreMiddleware`.
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default defineConfig({
{ text: "Slicing and Indexing", link: "/slicing" },
{ text: "Supported Types", link: "/supported-types" },
{ text: "Cookbook", link: "/cookbook" },
{ text: "Store Middleware", link: "/store-middleware" },
],
},
{
Expand Down
177 changes: 177 additions & 0 deletions docs/store-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Store Middleware

Many of the stores in [`@zarrita/storage`](/packages/storage) (like
[`FetchStore`](/packages/storage#fetchstore) and
[`FileSystemStore`](/packages/storage#filesystemstore)) handle the raw
byte-level connection with a data source.

A common pattern is to _wrap_ a store in another store that adds behavior
(e.g., caching responses, batching range requests, or serving pre-loaded
metadata) while delegating everything else to the inner store.

`zarr.defineStoreMiddleware` lets you define this kind of "middleware" that you can
_compose_ with base stores using `zarr.extendStore`.

## Built-in middleware

**zarrita** ships with middleware for consolidated metadata and range batching:

```ts
import * as zarr from "zarrita";

let store = await zarr.extendStore(
new zarr.FetchStore("https://example.com/data.zarr"),
zarr.withConsolidation({ format: "v3" }),
zarr.withRangeBatching(),
);

store.contents(); // from zarr.withConsolidation
store.stats; // from zarr.withRangeBatching
```

Each middleware in the pipeline wraps the previous result. `zarr.extendStore`
handles async middleware (like `zarr.withConsolidation`, which loads metadata)
automatically. It always returns a `Promise`.

You can also use middleware directly without `zarr.extendStore`:

```ts
let consolidated = await zarr.withConsolidation(
new zarr.FetchStore("https://example.com/data.zarr"),
{ format: "v3" },
);
```

## Defining your own middleware

Use `zarr.defineStoreMiddleware` to define custom middleware. The factory function receives
the inner store and custom options, and returns an object with method overrides
and/or new methods. Anything not returned is automatically delegated to the
inner store via `Proxy`.

```ts
import * as zarr from "zarrita";

const withCaching = zarr.defineStoreMiddleware(
(store: zarr.AsyncReadable, opts: { maxSize?: number } = {}) => {
let cache = new Map<string, Uint8Array>();
return {
async get(key: zarr.AbsolutePath, options?: unknown) {
let hit = cache.get(key);
if (hit) return hit;
let result = await store.get(key, options);
if (result) cache.set(key, result);
return result;
},
clear() {
cache.clear();
},
};
},
);

let store = withCaching(new zarr.FetchStore("https://..."), { maxSize: 256 });
store.clear(); // new method from the middleware
```

The returned middleware supports a **dual API**: call it directly with `(store,
opts)` or curry it with `(opts)` for use in `zarr.extendStore` pipelines:

```ts
// Direct
let store = withCaching(new zarr.FetchStore("https://..."), { maxSize: 256 });

// Curried (for zarr.extendStore)
let store = await zarr.extendStore(
new zarr.FetchStore("https://..."),
withCaching({ maxSize: 256 }),
);
```

Middleware can be **sync or async**. If the factory returns a `Promise`, the
wrapper returns a `Promise` too:

```ts
const withMetadata = zarr.defineStoreMiddleware(
async (store: zarr.AsyncReadable, opts: { key: string }) => {
let meta = JSON.parse(new TextDecoder().decode(await store.get(opts.key)));
return {
metadata() { return meta; },
};
},
);

let store = await withMetadata(rawStore, { key: "/meta.json" });
store.metadata(); // loaded during initialization
```

## Store options and generics

Stores are generic over their request options type. For example,
[`zarr.FetchStore`](/packages/storage#fetchstore) uses `RequestInit` so you can
pass headers or an `AbortSignal` to individual requests. Most middleware
doesn't need to know about this type, and `zarr.defineStoreMiddleware` preserves it
automatically through the chain.

Sometimes, though, middleware options _depend_ on the store's request options.
For example, `zarr.withRangeBatching` has a `mergeOptions` callback that
combines request options from concurrent callers, and its parameter type
should match the store's options.

This is an advanced typing feature purely for caller convenience. It ensures
users get proper type inference and autocomplete on options that reference
the store's request type. Use `zarr.defineStoreMiddleware.generic` with a
`zarr.GenericOptions` interface that maps the store's options type into your
middleware options:

```ts
import * as zarr from "zarrita";

// 1. Define your options as a normal generic interface
interface LoggingOptions<O> {
label?: string;
formatOptions?: (opts: O) => string;
}

// 2. Create the type lambda (one line that wires up the generic)
interface LoggingOptsFor extends zarr.GenericOptions {
readonly options: LoggingOptions<this["_O"]>;
}

// 3. Define the middleware
const withLogging = zarr.defineStoreMiddleware.generic<LoggingOptsFor>()(
(store, opts: LoggingOptions<unknown> = {}) => {
let label = opts.label ?? "store";
let format = opts.formatOptions ?? String;
return {
async get(key: zarr.AbsolutePath, options?: unknown) {
console.log(`[${label}] get ${key} ${format(options)}`);
return store.get(key, options);
},
};
},
);
```

At the call site, `formatOptions` receives the store's options type:

```ts
let store = withLogging(new zarr.FetchStore("https://..."), {
label: "my-store",
formatOptions: (opts) => {
// ^^^^ typed as RequestInit
return opts.method ?? "GET";
},
});
```

This is an advanced pattern. Most middleware won't need it. It exists so
that _users_ of your middleware get proper type inference and autocomplete
when their options reference the store's request type. If your options don't
depend on the store type, use the simpler `zarr.defineStoreMiddleware` and skip the
`zarr.GenericOptions` boilerplate entirely.

Under the hood, `zarr.GenericOptions` uses TypeScript's `this` types to encode
a [higher-kinded type](https://www.typescriptlang.org/docs/handbook/2/classes.html#this-types):
`this["_O"]` refers to the store's request options, which gets substituted with
the concrete type (e.g., `RequestInit`) at the call site.
2 changes: 1 addition & 1 deletion packages/zarrita/__tests__/batched-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AbsolutePath, RangeQuery } from "@zarrita/storage";
import { describe, expect, it, vi } from "vitest";
import { withRangeBatching } from "../src/batched-fetch.js";
import { withRangeBatching } from "../src/middleware/range-batching.js";

/**
* Create a fake store with controllable getRange.
Expand Down
Loading