From 49f80087f481d4c4e09e1ca319f66e88893459a8 Mon Sep 17 00:00:00 2001
From: Seij <55897006+SeijDeLeon@users.noreply.github.com>
Date: Fri, 2 Oct 2026 13:19:36 -0700
Subject: [PATCH 1/4] Enhance/tiled-client-queries (#70)
* add tiled api client internally, add remaining hooks for mutations
* fix potential cache conflicts in tables, fix credential overrides and clearing on logout
* add tiled query playground component for live testing of query hooks
* enhance token storage and clearing
* add tiled query playground
* fix storybook docs for new tiled hooks
---
src/api/qServer/README.md | 3 +-
src/api/tiled/README.md | 582 +-
src/api/tiled/SKILLS.md | 223 +-
src/api/tiled/client/TiledApiClient.ts | 2100 +++++
src/api/tiled/client/arraySlicing.ts | 195 +
src/api/tiled/client/defaultClient.ts | 201 +
src/api/tiled/client/facade.ts | 643 ++
src/api/tiled/client/formats.ts | 138 +
src/api/tiled/client/interceptorRegistry.ts | 94 +
src/api/tiled/client/searchParams.ts | 225 +
src/api/tiled/client/tokenStorage.ts | 100 +
src/api/tiled/client/urlUtils.ts | 105 +
src/api/tiled/endpointRegistry.ts | 130 +
src/api/tiled/endpoints/readEndpoints.ts | 402 +
src/api/tiled/endpoints/writeEndpoints.ts | 590 ++
src/api/tiled/generated/README.md | 56 +
src/api/tiled/generated/schema.d.ts | 3543 ++++++++
src/api/tiled/hooks/arrayHooks.ts | 175 +-
src/api/tiled/hooks/authHooks.ts | 174 +-
src/api/tiled/hooks/distinctHooks.ts | 68 +
src/api/tiled/hooks/index.ts | 119 +-
src/api/tiled/hooks/infoHooks.ts | 101 +-
.../hooks/internal/encodeSearchConfig.ts | 73 -
src/api/tiled/hooks/internal/keyParts.ts | 35 +-
src/api/tiled/hooks/invalidation.ts | 94 +-
src/api/tiled/hooks/managementHooks.ts | 382 +
src/api/tiled/hooks/metadataHooks.ts | 225 +-
src/api/tiled/hooks/nodeHooks.ts | 368 +
src/api/tiled/hooks/queryKeys.ts | 123 +-
src/api/tiled/hooks/searchHooks.ts | 18 +-
src/api/tiled/hooks/tableHooks.ts | 213 +-
src/api/tiled/hooks/typeTests.ts | 30 +-
src/api/tiled/hooks/useTiledClient.ts | 18 +-
src/api/tiled/index.ts | 201 +-
src/api/tiled/openapi.json | 7870 +++++++++++++++++
src/api/tiled/runtime/clientLike.ts | 144 +-
src/api/tiled/types/common.ts | 190 +-
src/api/tiled/types/dataOptions.ts | 237 +
src/api/tiled/types/errors.ts | 147 +
src/api/tiled/types/generatedAliases.ts | 207 +
src/api/tiled/types/info.ts | 109 +
src/api/tiled/types/nodes.ts | 131 +
src/api/tiled/types/packageAliases.ts | 176 -
src/api/tiled/types/paths.ts | 167 +
src/api/tiled/types/registry.ts | 125 +
src/api/tiled/types/requestOptions.ts | 80 +
src/api/tiled/types/searchFilters.ts | 158 +-
src/api/tiled/types/structures.ts | 205 +
src/app/App.tsx | 46 +-
src/app/pages/TestPage.tsx | 17 -
.../Experiment/ExperimentHistory.tsx | 2 +-
.../Experiment/ExperimentXASAlignment.tsx | 2 +-
.../OphydSocketViewer/useOphydSocketViewer.ts | 2 +-
src/components/QServerTest/EndpointRow.tsx | 2 +-
src/components/QServerTest/SocketPane.tsx | 2 +-
src/components/Tiled/types/tempTypes.ts | 9 +-
.../MutationResultPanel.tsx | 182 +
.../QueryCacheInspector.tsx | 105 +
.../TiledQueryPlayground/QueryCatalogList.tsx | 143 +
.../TiledQueryPlayground/QueryDetailPanel.tsx | 220 +
.../TiledQueryPlayground/QueryFieldEditor.tsx | 274 +
.../TiledQueryPlayground/QueryResultPanel.tsx | 136 +
src/components/TiledQueryPlayground/README.md | 180 +
.../TiledQueryPlayground/ResultRenderer.tsx | 162 +
.../TiledQueryPlayground.tsx | 253 +
.../catalog/arrayQueries.tsx | 261 +
.../catalog/dataMutations.tsx | 516 ++
.../TiledQueryPlayground/catalog/index.ts | 69 +
.../catalog/infoQueries.tsx | 120 +
.../catalog/managementMutations.tsx | 436 +
.../catalog/managementQueries.tsx | 195 +
.../catalog/metadataMutations.tsx | 237 +
.../catalog/nodeQueries.tsx | 236 +
.../catalog/searchQueries.tsx | 363 +
.../catalog/tableQueries.tsx | 292 +
.../TiledQueryPlayground/mutationArming.ts | 15 +
src/components/TiledQueryPlayground/types.ts | 222 +
.../useDebouncedValues.ts | 39 +
src/components/TiledTest/TestTiled.tsx | 171 +
src/components/TiledTest/TiledEndpointRow.tsx | 230 +
.../TiledTest/useTiledEndpointRunner.ts | 113 +
.../JsonResultViewer.tsx | 14 +-
.../devtools/TiledConnectionBar.tsx | 240 +
src/components/devtools/TiledNodeBrowser.tsx | 84 +
.../devtools/tiledConnectionStorage.ts | 135 +
src/index.ts | 85 +-
.../ophyd-sim/scenarios/beamstopBeamline.ts | 2 +-
.../ophyd-sim/scenarios/defaultBeamline.ts | 2 +-
src/stories/TiledHooks.md | 659 +-
src/stories/TiledHooks.mdx | 16 +-
src/testing/tests/TiledClient.test.ts | 588 ++
.../tests/TiledConnectionStorage.test.ts | 119 +
src/testing/tests/TiledHooks.test.tsx | 408 +-
src/testing/tests/TiledHooksDoc.test.ts | 80 +
src/testing/tests/TiledQueryCatalog.test.ts | 206 +
src/testing/tests/TiledRegistry.test.ts | 244 +
96 files changed, 28619 insertions(+), 1208 deletions(-)
create mode 100644 src/api/tiled/client/TiledApiClient.ts
create mode 100644 src/api/tiled/client/arraySlicing.ts
create mode 100644 src/api/tiled/client/defaultClient.ts
create mode 100644 src/api/tiled/client/facade.ts
create mode 100644 src/api/tiled/client/formats.ts
create mode 100644 src/api/tiled/client/interceptorRegistry.ts
create mode 100644 src/api/tiled/client/searchParams.ts
create mode 100644 src/api/tiled/client/tokenStorage.ts
create mode 100644 src/api/tiled/client/urlUtils.ts
create mode 100644 src/api/tiled/endpointRegistry.ts
create mode 100644 src/api/tiled/endpoints/readEndpoints.ts
create mode 100644 src/api/tiled/endpoints/writeEndpoints.ts
create mode 100644 src/api/tiled/generated/README.md
create mode 100644 src/api/tiled/generated/schema.d.ts
create mode 100644 src/api/tiled/hooks/distinctHooks.ts
delete mode 100644 src/api/tiled/hooks/internal/encodeSearchConfig.ts
create mode 100644 src/api/tiled/hooks/managementHooks.ts
create mode 100644 src/api/tiled/hooks/nodeHooks.ts
create mode 100644 src/api/tiled/openapi.json
create mode 100644 src/api/tiled/types/dataOptions.ts
create mode 100644 src/api/tiled/types/errors.ts
create mode 100644 src/api/tiled/types/generatedAliases.ts
create mode 100644 src/api/tiled/types/info.ts
create mode 100644 src/api/tiled/types/nodes.ts
delete mode 100644 src/api/tiled/types/packageAliases.ts
create mode 100644 src/api/tiled/types/paths.ts
create mode 100644 src/api/tiled/types/registry.ts
create mode 100644 src/api/tiled/types/requestOptions.ts
create mode 100644 src/api/tiled/types/structures.ts
delete mode 100644 src/app/pages/TestPage.tsx
create mode 100644 src/components/TiledQueryPlayground/MutationResultPanel.tsx
create mode 100644 src/components/TiledQueryPlayground/QueryCacheInspector.tsx
create mode 100644 src/components/TiledQueryPlayground/QueryCatalogList.tsx
create mode 100644 src/components/TiledQueryPlayground/QueryDetailPanel.tsx
create mode 100644 src/components/TiledQueryPlayground/QueryFieldEditor.tsx
create mode 100644 src/components/TiledQueryPlayground/QueryResultPanel.tsx
create mode 100644 src/components/TiledQueryPlayground/README.md
create mode 100644 src/components/TiledQueryPlayground/ResultRenderer.tsx
create mode 100644 src/components/TiledQueryPlayground/TiledQueryPlayground.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/arrayQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/dataMutations.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/index.ts
create mode 100644 src/components/TiledQueryPlayground/catalog/infoQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/managementMutations.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/managementQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/metadataMutations.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/nodeQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/searchQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/catalog/tableQueries.tsx
create mode 100644 src/components/TiledQueryPlayground/mutationArming.ts
create mode 100644 src/components/TiledQueryPlayground/types.ts
create mode 100644 src/components/TiledQueryPlayground/useDebouncedValues.ts
create mode 100644 src/components/TiledTest/TestTiled.tsx
create mode 100644 src/components/TiledTest/TiledEndpointRow.tsx
create mode 100644 src/components/TiledTest/useTiledEndpointRunner.ts
rename src/components/{QServerTest => devtools}/JsonResultViewer.tsx (77%)
create mode 100644 src/components/devtools/TiledConnectionBar.tsx
create mode 100644 src/components/devtools/TiledNodeBrowser.tsx
create mode 100644 src/components/devtools/tiledConnectionStorage.ts
create mode 100644 src/testing/tests/TiledClient.test.ts
create mode 100644 src/testing/tests/TiledConnectionStorage.test.ts
create mode 100644 src/testing/tests/TiledHooksDoc.test.ts
create mode 100644 src/testing/tests/TiledQueryCatalog.test.ts
create mode 100644 src/testing/tests/TiledRegistry.test.ts
diff --git a/src/api/qServer/README.md b/src/api/qServer/README.md
index 8bc1787a..9d0f5ae8 100644
--- a/src/api/qServer/README.md
+++ b/src/api/qServer/README.md
@@ -395,7 +395,8 @@ RE Manager v0.0.19.
`src/components/QServerTest/TestQserver.tsx` renders every registry entry with an editable
payload, runs the three sockets in both auth modes, and exercises the interceptor utilities.
-It is wired into `src/app/pages/TestPage.tsx`.
+It is **not currently mounted** anywhere: the Test route's tabs (`src/app/App.tsx`) are
+Experiment, Tiled API and Tiled Queries. Adding it back is one more entry in that `tabs` array.
## Differences from `src/api/qServer`
diff --git a/src/api/tiled/README.md b/src/api/tiled/README.md
index 83f4e670..f890cb91 100644
--- a/src/api/tiled/README.md
+++ b/src/api/tiled/README.md
@@ -1,27 +1,35 @@
-# Tiled query layer (`tiled`)
+# Tiled API client (`tiled`)
-TanStack Query hooks over [`@blueskyproject/tiled`](https://www.npmjs.com/package/@blueskyproject/tiled)
-— **18 queries, one mutation and one URL helper** — plus a provider for injecting a client and
-re-exports of the package's own client and configuration functions.
+A complete client for [Tiled](https://github.com/bluesky/tiled): **all 48 API operations** from
+`openapi.json`, the seven auth routes the spec omits, zarr URL builders, and a TanStack Query hook
+for every one of them.
-> This replaced the hand-rolled hooks now parked in `src/api/tiled_archive/hooks.ts`. That file is kept
-> for reference only: nothing imports it, and `tsconfig.json` excludes it because it cannot compile
-> against `@blueskyproject/tiled` 0.0.33. See the bottom of `SKILLS.md` for the old-to-new hook map.
+Built on the client in
+[tiled-viewer-react](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api)
+— the read paths are ports, not rewrites — and shaped like
+[`src/api/qServer`](../qServer/README.md), so the two backends are learnable as one thing: a
+`TiledApiClient` class, a module-level default instance, a flat free-function facade, and hooks with
+the same argument order, key shape and invalidation model.
+
+> **This replaced a hook layer over `@blueskyproject/tiled`.** Nothing under `src/api/tiled` imports
+> that package any more. Every name it used to re-export is exported from here with the same
+> signature, so no call site changed — but the implementation is Finch's, the types come from Tiled's
+> own OpenAPI schema, and the write half of the API exists for the first time.
## Quickstart
-```tsx
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { FinchConfigProvider } from '@/app/FinchConfigProvider';
-import { useTiledSearchBySpecsQuery, useTiledMetadataQuery } from '@/api/tiled';
+```ts
+import { getTiledSearch, setDefaultTiledUrl, setGlobalTiledApiKey } from '@/api/tiled';
+
+setDefaultTiledUrl('http://localhost:8000/api/v1'); // include the version segment
+setGlobalTiledApiKey('…');
-const queryClient = new QueryClient();
+const runs = await getTiledSearch('', { searchFilters: { specs: { include: ['BlueskyRun'], exclude: [] } } });
+console.log(runs.meta.count);
+```
-
-
-
-
-;
+```tsx
+import { useTiledSearchBySpecsQuery, useTiledMetadataQuery } from '@/api/tiled';
function RunBrowser() {
const runs = useTiledSearchBySpecsQuery('', { include: ['BlueskyRun'], exclude: [] });
@@ -30,224 +38,428 @@ function RunBrowser() {
if (runs.isPending) return
loading…
;
if (runs.isError) return {runs.error.message}
;
- return (
-
- {runs.data.data.map((run) => (
- - {run.id}
- ))}
-
- );
+ return {runs.data.data.map((run) => - {run.id}
)}
;
}
```
-The base URL must include the API version segment — `http://host:8000/api/v1` — because that is what
-the package expects. Nothing here appends it: guessing would silently point a misconfigured app at a
-URL it never asked for.
+### The base URL includes the API version
+
+`http://host:8000/api/v1`. Nothing appends it: guessing would silently point a misconfigured app at
+a URL it never asked for. This is the opposite of the queue-server rule, where the base URL is the
+bare origin because its spec paths already start with `/api/` — each is forced by its own server.
+
+The handful of routes outside the version segment — `/healthz`, `/tiled-ui-settings`, every zarr
+route — are issued against the origin derived from the base URL. `tiledOriginFromBaseUrl` drops
+everything from the **last** `/api/v1`, so a server mounted under a sub-path works too.
+
+## Three levels of control
+
+```ts
+// 1. the app-wide client
+import { getTiledSearch, setGlobalTiledApiKey } from '@/api/tiled';
+setGlobalTiledApiKey('another-key'); // affects the very next request; no rebuild
+await getTiledSearch('');
+
+// 2. your own instance
+import { createTiledApiClient, setDefaultTiledApiClient } from '@/api/tiled';
+const client = createTiledApiClient({
+ baseUrl: 'http://localhost:8000/api/v1',
+ apiKey: '…',
+ client: myAxiosInstance, // optional: adopt an existing axios instance
+ maxArrayBytes: 2_000_000,
+});
+setDefaultTiledApiClient(client); // optional: make it the app-wide one
+
+// 3. one call at a time
+await getTiledSearch('', undefined, {
+ baseUrl: 'http://other-host:8000/api/v1',
+ apiKey: 'one-off',
+ signal: controller.signal,
+});
+```
+
+Per-request options never touch client state. `resetDefaultTiledApiClient()` discards the singleton —
+call it in `beforeEach` so tests do not leak configuration into one another.
+
+### Authentication
+
+| What | How |
+| --- | --- |
+| API key in a header | default; `Authorization: ApiKey ` (the casing the package sends) |
+| Spec casing | `setGlobalTiledApiKeyScheme('Apikey')` |
+| API key in the query | `setGlobalTiledApiKeyLocation('query')` → `?api_key=` |
+| Bearer token | `setDefaultBearerToken(jwt)`; takes precedence over the API key |
+| No credentials, one call | `options.apiKey: null` — distinct from omitting it, which inherits |
+| Refresh on 401 | automatic, single-flight, then one retry — but only when the call did not choose its own credentials and went to the configured server (see below). `/auth/session/refresh`, falling back to `/auth/refresh` on a 404 |
+
+The key is read **at request time** by a built-in interceptor, which is why `setApiKey` takes effect
+immediately.
+
+#### The 401 refresh will not override you
+
+Two conditions beyond "it was a 401" decide whether a refresh is attempted, and both exist so the
+client cannot quietly undo a decision the caller made:
+
+- **The caller did not choose this call's credentials.** A request sent with `apiKey: null`, a
+ one-off key, or an explicit `Authorization` header already said what identity to use. Refreshing
+ and retrying with the stored session's bearer token would substitute credentials the caller
+ deliberately withheld — turning an intentionally anonymous probe into an authenticated one,
+ invisibly. Such a call surfaces the 401 instead.
+- **The request went to this client's own server.** The stored refresh token was issued by the
+ configured server; a per-call `baseUrl` points somewhere else. The refresh always targets the
+ configured server and is skipped entirely for a redirected call, so the token is never offered to
+ a host that did not issue it.
+
+`logout()` is the mirror image: it clears local credentials in a `finally` that covers endpoint
+discovery as well as the request, so an unreachable server — or one with authentication disabled —
+still logs you out of this tab. It rejects afterwards so the failure is reportable.
+
+`'query'` mode exists for the two places a header cannot go: an `
` built by
+`getArrayAsImagePath`, and — once they land — websocket handshakes, which browsers will not let you
+set headers on. Note it puts the key in the DOM and in any referrer logging.
+
+**Login tokens are shared with the `` viewer component.** Both write `tiledAccessToken` and
+`tiledRefreshToken` to `localStorage` under the same keys, so a login through either is a login for
+both. Storage is injectable (`tokenStorage`), defaulting to `localStorage` in a browser and memory
+elsewhere — which is what makes this work under SSR and keeps auth tests from leaking into one
+another.
+
+### The auth routes are not in the spec
+
+Tiled generates `openapi.json` **without its auth router**, so there is no spec entry for `whoami`,
+`apikey`, `refresh_session`, `revoke_session` or `logout`. What the spec does carry is
+`AboutAuthenticationLinks`, so the client resolves every auth URL from `GET /api/v1/`'s
+`authentication.links` rather than hard-coding paths.
+
+A server with authentication disabled reports `{ required: false, providers: [], links: null }`
+(verified against Tiled 0.2.15b1). Every auth method then fails with a `TiledApiError` saying so,
+rather than requesting a URL built from `null`. Check
+`useTiledServerInfoQuery().data?.authentication?.required` before offering a login UI at all.
+
+### Interceptors
+
+```ts
+import { addRequestInterceptor, clearInterceptors, ejectInterceptor } from '@/api/tiled';
+
+const handle = addRequestInterceptor((config) => {
+ console.log(config.method, config.url);
+ return config;
+});
+
+ejectInterceptor(handle);
+clearInterceptors(); // removes only YOUR interceptors
+```
+
+The built-in auth and refresh handlers are tracked separately and survive `clearInterceptors()`.
+`setAxiosClient(next)` re-installs everything — built-ins first, then yours in registration order.
+Axios runs request interceptors last-registered-first, so yours sees the config *before*
+`Authorization` is attached.
+
+## Four wire-format corrections
+
+These were found by probing a live Tiled 0.2.15b1, not by reading the spec — the spec says a
+parameter's *type*, not how a list is serialised, and Tiled uses two different conventions. Each one
+failed quietly under `@blueskyproject/tiled`.
+
+**1. Three filter names were wrong.** FastAPI drops an unknown query parameter rather than rejecting
+it, so these filters simply never applied and the search came back unfiltered — which reads as "the
+filter matched everything", not as a bug.
+
+| Filter | Package sent | Server expects |
+| --- | --- | --- |
+| `keysFilter` | `filter[keys][condition][keys][]` | `filter[keys_filter][condition][keys]` |
+| `keyPresent` | `filter[key_present][condition][…]` | `filter[keypresent][condition][…]` |
+| `accessBlob` | `filter[access_blob][condition][…]` | `filter[access_blob_filter][condition][…]` |
+
+**2. Axios brackets array parameters by default.** `column[]=a&column[]=b` where FastAPI reads
+`column=a&column=b`. So `column` and `field` selections silently returned *everything*, and `fields`
+never narrowed a response. Fixed with `paramsSerializer: { indexes: null }` on every request.
+
+**3. Four filters want one JSON array, not repeated keys.** `keys_filter.keys`, `in.value`,
+`notin.value` and `specs.include`/`exclude` travel as
+`filter[in][condition][value]=["count"]`. Sent as repeated keys, `in` matches nothing and
+`keys_filter` answers **500**. `specs` additionally needs *both* `include` and `exclude` present —
+sending one alone is a 500, which is why `TiledSpecsFilter` requires both.
+
+**4. Filter values are JSON-encoded for you.** Six filters — `eq`, `noteq`, `comparison`,
+`contains`, `in`, `notin` — have a `value` the server reads with `json.loads`, so matching the string
+`xas_scan` requires sending `"xas_scan"`, quotes included. Pass the value itself:
+
+```ts
+useTiledSearchQuery('', { searchFilters: { contains: { key: 'start.plan_name', value: 'xas_scan' } } });
+// on the wire: filter[contains][condition][value]="xas_scan"
+```
+
+`string | number | boolean | null` are all accepted. Encoding is unconditional — there is no attempt
+to detect an already-encoded value, because it is not decidable (given `"5"`, is that the number 5
+encoded, or the two-character string `5`?). Pre-quoting is a bug the widened types now catch.
-## Positional arguments
+## The hooks
-Every hook takes its arguments positionally, always in the same order:
+One per operation, in [`hooks/`](./hooks). Arguments are positional, always in the same order:
```ts
useTiledSomethingQuery(...endpointArgs, queryOptions?, requestOptions?);
useTiledSomethingMutation(mutationOptions?, requestOptions?);
```
-| position | what it is |
-| ---------------------------- | --------------------------------------------------------------------------------- |
+| position | what it is |
+| --- | --- |
| the endpoint's own arguments | a path, a filter, a format — named and typed, so hover tells you what is required |
-| TanStack options | `FinchQueryOptions` for queries, `FinchMutationOptions` for mutations |
-| `requestOptions` | `baseUrl`, `apiKey`, `initialPath`, `pathMode`, `signal`, `client` |
+| TanStack options | `FinchQueryOptions` for queries, `FinchMutationOptions` for mutations |
+| `requestOptions` | `baseUrl`, `apiKey`, `initialPath`, `pathMode`, `signal`, `client`, `headers`, `query`, `axiosConfig` |
**`requestOptions` is always last**, and means exactly the same thing on every Finch backend — where
this one call goes and who it is. The queue-server hooks use the identical order; the convention is
-stated in full, once, in [`src/api/shared/queryOptions.ts`](../shared/queryOptions.ts). Transport goes
-last because it is the rarest thing to pass, so the common call needs no placeholder:
+stated in full, once, in [`src/api/shared/queryOptions.ts`](../shared/queryOptions.ts). Transport
+goes last because it is the rarest thing to pass, so the common call needs no placeholder.
+
+Mutations take their arguments through `mutate`, so one hook instance performs many writes — the
+path is part of the variables, not of the hook:
```ts
-useTiledMetadataQuery('scan/detector', { staleTime: 60_000 });
-useTiledSearchQuery('', undefined, undefined, { baseUrl: 'http://other:8000/api/v1' });
+const patch = useTiledPatchMetadataMutation();
+patch.mutate({ path: 'scan/1', metadata: { comment: 'calibration run' } });
```
-**The array and table hooks take one extra slot** for the endpoint's own parameters, because the
-package merges them with transport — its `TiledArrayRequestOptions` extends `TiledRequestOptions`, so
-`stack` and `baseUrl` arrive in one object. The hooks split them apart (`TiledArrayJSONEndpointOptions`
-is `Omit`) and recombine before calling through, so
-that `requestOptions` does not mean two different things depending on which hook you are looking at:
+**The array, table and node hooks take one extra slot** for the endpoint's own parameters, because
+the client's option types merge them with transport (`TiledArrayRequestOptions extends
+TiledRequestOptions`, so `stack` and `baseUrl` arrive in one object). The hooks split them apart and
+recombine before calling through, so `requestOptions` does not mean two different things depending
+on which hook you are looking at. `hooks/typeTests.ts` asserts that no transport key survives in the
+endpoint types.
+
+### What is covered
+
+| Group | Queries | Mutations |
+| --- | --- | --- |
+| Search | `useTiledSearchQuery` + 6 filter conveniences, `useTiledDistinctQuery` | — |
+| Metadata | `useTiledMetadataQuery` | create, update, patch, delete |
+| Arrays | 4 formats, `useTiledArrayBlockQuery`, `useTiledArrayImagePath` | put full, put block, patch full |
+| Ragged | `useTiledRaggedFullQuery` | put full, put block, patch full |
+| Tables | 5 JSON reads, `useTiledTableFullAsQuery` (CSV/parquet/arrow/…), 2 POST reads | put partition, patch partition, put full |
+| Containers / nodes | full reads + POST variants | put node full |
+| Awkward | full, buffers, POST buffers | put full |
+| Management | revisions, assets | register, put data source, delete revision, close stream |
+| Webhooks | list, history | register, delete |
+| Server info | about, health, UI settings, metrics | — |
+| Auth | whoami | login, logout, API key create/revoke, session refresh/revoke |
+
+### `useTiledDistinctQuery` is the one to know about
+
+It did not exist before. It answers "which plan names are in this catalogue, and how many runs does
+each have" in **one request**, over the same filters a search takes — so a facet count can be scoped
+to the current query. The alternative was paginating the whole container client-side and counting.
```ts
-useTiledArrayAsJSONQuery('scan/detector', { stack: [4], maxBytesAllowed: 2_000_000 });
-useTiledTablePartitionAsJSONQuery('scan/primary', { partition: 0 }, { refetchInterval: 1000 });
-useTiledTablePartitionAsJSONQuery('scan/primary', { partition: 0 }, undefined, { baseUrl });
+const facets = useTiledDistinctQuery('', {
+ metadata: ['start.plan_name'],
+ counts: true,
+ searchFilters: { comparison: { operator: 'gt', key: 'start.time', value: lastWeek } },
+});
```
-`hooks/typeTests.ts` asserts that no transport key survives in the endpoint types, so a future package
-version that moves `stack` into `TiledRequestOptions` breaks the build rather than the split.
-
-`queryKey`, `queryFn` and `mutationFn` are omitted from the TanStack option types — the hook owns them,
-and overriding the key would detach the entry from the invalidation map. `hooks/typeTests.ts` pins this
-at compile time.
+### Two return values that surprise people
-## The hooks
+- **`useTiledServerInfoQuery().data` can be `null`** — an unreachable server resolves `null` instead
+ of throwing, so `isError` stays `false`. Inherited deliberately: the login screen probes servers it
+ knows nothing about and needs an answer, not an exception. Use `useTiledAboutQuery` when you want
+ the error.
+- **`useTiledLoginMutation()` resolves `null` on a wrong password** rather than rejecting. A wrong
+ password is an expected outcome of a login form. Check the resolved value, not `isError`.
-### Search — `searchHooks.ts`
+### Guarded queries
-All seven call `GET /api/v1/search/{path}`; the six conveniences just build the filter for you. Shape:
-`(searchPath, filter, searchOptions?, queryOptions?, requestOptions?)`.
+`enabled` is applied **after** the caller's options, with `??` — so an options object carrying
+`enabled: undefined` (trivially produced by spreading props) falls through to the guard instead of
+clobbering it.
-| hook | filter type |
-| ----------------------------------------- | ----------------------------------------- |
-| `useTiledSearchQuery` | a whole `TiledSearchConfig` (second slot) |
-| `useTiledSearchBySpecsQuery` | `{ include, exclude }` |
-| `useTiledSearchByFullTextQuery` | `{ text }` |
-| `useTiledSearchByMetadataEqualsQuery` | `{ key, value }` |
-| `useTiledSearchByStructureFamilyQuery` | `{ value: 'array' \| … }` |
-| `useTiledSearchByRegexQuery` | `{ key, pattern, caseSensitive? }` |
-| `useTiledSearchByMetadataComparisonQuery` | `{ operator, key, value }` |
+Every path-addressed query idles while its path is empty, so `useTiledMetadataQuery(selected ?? '')`
+makes no request until something is selected. Queries whose argument cannot be defaulted —
+`useTiledArrayBlockQuery`, the asset hooks, `useTiledWebhookHistoryQuery` — idle while it is
+`undefined`, and raise `FinchMissingArgumentError` if `enabled: true` forces them past the guard.
-**Filter values are passed as themselves, not as hand-written JSON.** Six filters — `eq`, `noteq`,
-`comparison`, `contains`, `in`, `notin` — have a `value` that Tiled reads with `json.loads`, so the raw
-API needs the quotes baked into the string. The hooks encode for you:
+## Writing data
```ts
-// what you write
-useTiledSearchQuery('', {
- searchFilters: { contains: { key: 'start.plan_name', value: 'xas_scan' } },
+const create = useTiledCreateNodeMutation();
+const write = useTiledPutArrayFullMutation();
+
+await create.mutateAsync({
+ parentPath: '', // the CONTAINER to create in, not the new node's path
+ body: {
+ id: 'processed', // the new node's key; omit for a server-assigned uuid
+ structure_family: 'array',
+ metadata: {},
+ specs: [],
+ access_blob: {},
+ data_sources: [{ /* … structure describing what you are about to write … */ }],
+ },
});
-// what the server receives
-// filter[contains][condition][value]="xas_scan"
+await write.mutateAsync({ path: 'processed', data: new Float64Array([1, 2, 3, 4, 5, 6]) });
```
-`string | number | boolean | null` are all accepted, and `in` / `notin` encode each element. The other
-filters are untouched: `fulltext.text`, `regex.pattern`, `like.pattern`, `lookup.key` and
-`structureFamily.value` are plain strings server-side, and the package already encodes `specs`.
-
-Encoding is unconditional — there is no attempt to detect an already-quoted value, because it is not
-decidable (given `"5"`, is that the number 5 encoded, or the string `5`?). If you have a value that
-looks pre-quoted, it is a bug; the widened types now make it a compile error rather than a 422. See
-`hooks/internal/encodeSearchConfig.ts`.
-
-`searchPath: ''` is the **root container**, and legal — so unlike the data hooks these have no path
-guard. The first four mirror functions the package ships; regex and comparison do not exist in the
-package but did in the legacy Finch hooks, and `TiledSearchFilters` supports them. For the other nine
-filters (`lookup`, `keysFilter`, `noteq`, `contains`, `in`, `notin`, `keyPresent`, `like`, `accessBlob`)
-use `useTiledSearchQuery` directly.
-
-### Metadata, data and info
-
-| hook | reads |
-| ------------------------------------------------- | ----------------------------------------------- |
-| `useTiledMetadataQuery(path)` | one item's metadata, specs, links and structure |
-| `useTiledArrayAsQuery(path, type)` | an array in any of the four formats |
-| `useTiledArrayAsJSONQuery(path)` | `number[][]` by default; override the shape |
-| `useTiledArrayAsPngQuery(path)` | a PNG `Blob` |
-| `useTiledArrayAsBufferQuery(path)` | a raw `ArrayBuffer` |
-| `useTiledArrayImagePath(path)` | **not a query** — a URL for `
` |
-| `useTiledTableAsQuery(path, type, endpoint)` | a table in either format, either endpoint |
-| `useTiledTablePartitionAsJSONQuery(path)` | one partition, column-oriented |
-| `useTiledTablePartitionAsJSONSequenceQuery(path)` | one partition, row-oriented |
-| `useTiledTableFullAsJSONQuery(path)` | every partition, column-oriented |
-| `useTiledTableFullAsJSONSequenceQuery(path)` | every partition, row-oriented |
-| `useTiledServerInfoQuery()` | the `/api/v1/` root document |
-| `useTiledLoginMutation()` | username/password login (the only mutation) |
-
-Narrow a metadata structure by passing it: `useTiledMetadataQuery(path)` types
-`data.attributes.structure.shape`. Or use the re-exported `isArrayStructure` / `isTableStructure` /
-`isContainerStructure` guards.
-
-Two return values that surprise people, both the package's behaviour rather than ours:
-
-- **`useTiledServerInfoQuery().data` can be `null`** — an unreachable server resolves `null` instead of
- throwing, so `isError` stays `false`.
-- **`useTiledLoginMutation()` resolves `null` on a wrong password** rather than rejecting. Check the
- resolved value, not `isError`.
+Three things to get right, all verified against a live server:
+
+- **`POST /metadata/{path}` addresses the parent.** The new node's key goes in `body.id`. Posting to
+ the path you want the node to have answers 404 `No such entry`.
+- **An array or table node needs its structure up front**, in `data_sources[0].structure`. The server
+ allocates from it and does not infer it later.
+- **`DELETE` defaults to `external_only: true`**, which refuses with a 409 when any of the tree is
+ internally managed — deleting those records would delete the underlying data files. Pass
+ `external_only: false` to mean it.
+
+Binary writes take an `ArrayBuffer`, a typed array or a `Blob`, sent as `application/octet-stream` in
+the array's own dtype and C order — the server trusts the declared structure and does not convert. A
+nested `number[][]` is sent as JSON instead. **Nothing converts one into the other**: a dtype
+mismatch writes plausible-looking garbage rather than failing, so that encoding is left to the
+caller, who knows the dtype.
+
+Table writes additionally require an explicit `mimetype` (`'application/x-parquet'`, `'text/csv'`,
+`'application/vnd.apache.arrow.file'`) because the server dispatches its reader on exactly that
+header.
+
+## Keys and invalidation
+
+Keys are `['tiled', , , { baseUrl, initialPath }]`, with fourteen resources.
+The scope is last so prefixes like `['tiled','search']` still match.
+
+**The scope carries `initialPath`, not just `baseUrl`.** Tiled prepends the client's initial path to
+relative request paths, so the same relative path under two prefixes is two different pieces of data.
+`pathMode: 'absolute'` resolves the prefix to `''`, because such a request ignores it entirely.
+
+**Args are projections, never raw option objects** — see
+[`hooks/internal/keyParts.ts`](./hooks/internal/keyParts.ts). An options object carries a `signal`
+(a fresh identity most renders), possibly a `client`, and possibly a whole `arrayItem`. Keying on it
+directly would rewrite the key every render and refetch forever, so only the fields that change the
+response take part. `structure` / `arrayItem` are excluded on purpose — they only let the client skip
+a metadata round-trip on the way to identical bytes.
+
+The rule when adding an option is **include it unless it provably cannot change the bytes the server
+sends**. `column` is the cautionary case: it narrows a table response, so leaving it out of the key
+made a read of `['energy']` and a read of `['intensity']` one cache entry, and let each be served the
+other's columns.
+
+Mutations invalidate named bundles automatically, awaited before `mutateAsync` resolves. Three rules
+decide the map:
+
+- a **metadata write** refreshes that node and the searches that could have matched on what changed;
+- a **data write** refreshes the data *and* the metadata, because a write can change a structure —
+ `patchArrayFull` with `extend: true` grows the shape, and a cached structure saying otherwise is
+ what the downsampling maths reads;
+- a **create, delete or register** uses the `structure` bundle, which also covers the parent
+ container's contents.
+
+The API key is deliberately absent from every key: it would put a secret in the Devtools cache
+inspector, and a credential change invalidates everything rather than one entry. That is why every
+auth mutation invalidates `all`.
+
+```ts
+const invalidate = useTiledInvalidate();
+await invalidate.roots('search'); // one resource
+await invalidate.bundles('data'); // every structure family's payloads
+await invalidate.all();
+```
+
+## Errors
+
+Every failure is a `TiledApiError` carrying `status`, `method`, `path`, `responseBody`, and — for
+FastAPI's 422 — `isValidationError` plus parsed `validationErrors`. The message is assembled from
+whichever envelope Tiled used, so `GET /metadata/x failed with 404: No such entry` is what you get
+rather than `Request failed with status code 404`.
+
+```ts
+import { isTiledApiError } from '@/api/tiled';
+
+try {
+ await createTiledNode('', body);
+} catch (error) {
+ if (isTiledApiError(error) && error.isValidationError) console.warn(error.validationErrors);
+}
+```
+
+A cancelled request passes through untouched, so TanStack can still recognise the `AbortError`.
+`TiledEndpointUnavailableError` is raised instead when a hook's method is missing from a client
+injected through `TiledApiProvider`.
## Where the client comes from
1. the client injected via `TiledApiProvider`, if there is one — the seam that lets a stub drive
hook-based components in tests and Storybook;
-2. otherwise the package's module-level singleton, `getDefaultTiledApiClient()`.
+2. otherwise the module-level default client.
Setting `tiledApiUrl` / `tiledApiKey` on `FinchConfigProvider` is enough: those values are applied to
-the default client _and_ carried on every request, so even the first fetch of the first render uses the
-configured server. Absent Finch config the client's own configuration stands, so
-`setDefaultTiledApiClient` and `setDefaultTiledUrl` keep working. An **injected client is never
-redirected** — it is the caller's explicit choice, so Finch config is ignored for that subtree.
+the default client *and* carried on every request, so even the first fetch of the first render uses
+the configured server. Absent Finch config the client's own configuration stands. An **injected
+client is never redirected** — it is the caller's explicit choice, so Finch config is ignored for
+that subtree.
A partial injected client (a hand-written stub missing some methods) makes the affected hooks reject
-with `TiledEndpointUnavailableError` rather than silently falling through to the network.
+with `TiledEndpointUnavailableError` rather than silently falling through to the network. The surface
+is now the whole API rather than seventeen methods, which is a real cost for stub authors; a stub
+only needs the methods its component actually calls.
-## Keys and invalidation
+## Types
-Keys are `['tiled', , , { baseUrl, initialPath }]`, with five resources:
-`search`, `metadata`, `array`, `table`, `serverInfo`. The scope is last so prefixes like
-`['tiled','search']` still match — including the ones existing code already invalidates with.
+`openapi-typescript` output lives in [`generated/schema.d.ts`](./generated/) and is **authoritative
+for nearly everything** — request bodies, structures, enums, envelopes — which is the opposite of the
+queue server, whose spec declares every body as an untyped object. `types/generatedAliases.ts` gives
+them readable names.
-Three things worth knowing:
+Hand-written types are confined to three places: `types/auth.ts`-shaped content in `types/info.ts`
+(the auth routes are not in the spec), `types/searchFilters.ts` (the deliberate widening above), and
+`types/structures.ts` / `types/nodes.ts`.
-**All seven search hooks share the `search` root.** They call one endpoint, and the built
-`TiledSearchConfig` fully determines the response, so two hooks that produce identical filters
-correctly share one cache entry and `['tiled','search']` refreshes every search however it was written.
-The legacy hooks had eight separate search roots; of their keys only `['tiled','search']` and
-`['tiled','serverInfo']` still match here.
+**Those last two are ports, deliberately.** `ArrayStructure`, `TiledSearchItem` and friends keep the
+exact shapes `@blueskyproject/tiled` used, because every Tiled component in this repo already
+destructures them and a shape change would be a silent breakage dressed up as a type improvement. The
+spec's own versions are exported alongside under a `Schema` prefix, and they do differ — the spec's
+`ArrayStructure.data_type` is `BuiltinDtype | StructDtype` where Finch's is the builtin form alone.
+**Reading** a response: use the unprefixed ones. **Building** a request body: use `Schema…`, which is
+what the validator on the other end checks against.
-**The scope carries `initialPath`, not just `baseUrl`.** Tiled prepends the client's initial path to
-relative request paths, so the same relative path under two prefixes is two different pieces of data.
-A per-call `requestOptions.baseUrl` or `initialPath` overrides the scope; `pathMode: 'absolute'`
-resolves the prefix to `''`, because such a request ignores it entirely.
+One correction was made: `attributes.data_sources` is `unknown[] | null`, not `string | null`. The
+spec and the server both contradict the package, and nothing in this repo read the field.
-**Args are projections, never raw option objects** — see [`hooks/internal/keyParts.ts`](./hooks/internal/keyParts.ts).
-An array/table options object carries a `signal` (a fresh identity most renders), possibly a `client`,
-and possibly a whole `arrayItem`. Keying on it directly would rewrite the key every render and refetch
-forever, so only the fields that change the response take part: `stack`, `downSampleRatio`,
-`maxBytesAllowed`, `format`, `isRGB`, `channelFirst` for arrays; `partition` and `format` for tables.
-`structure` / `arrayItem` are excluded on purpose — they only let the client skip a metadata round-trip
-on the way to identical bytes.
+## `@blueskyproject/tiled` is still a dependency
-The API key is deliberately absent from every key: it would put a secret in the Devtools cache
-inspector, and a credential change invalidates everything rather than one entry. `useTiledLoginMutation`
-therefore invalidates all five roots, and `invalidateAllTiledQueries(queryClient)` is the hook-free way
-to do the same after rotating a key.
+For the `` **viewer component** and its CSS only — `src/components/Tiled/Tiled.tsx`,
+`src/features/TiledHeatmapSelector.tsx`, `src/app/App.tsx`.
-```ts
-const invalidate = useTiledInvalidate();
-await invalidate.roots('search'); // one resource
-await invalidate.bundles('data'); // array + table
-await invalidate.all(); // everything
-```
+That component keeps its own internal client and its own singleton, so `setGlobalTiledApiKey` here
+does **not** configure it. `Tiled.tsx` passes the URL and key as props from Finch config, which is the
+arrangement to keep. Login tokens *are* shared, through the `localStorage` keys above.
-## Guarded queries
+## Completeness is checked, not claimed
-`enabled` is applied **after** the caller's options, with `??` — so an options object carrying
-`enabled: undefined` (trivially produced by spreading props) falls through to the guard instead of
-clobbering it. That is a real bug in the legacy hooks.
-
-| hook | idle until |
-| ----------------------------------------------- | -------------------------- |
-| `useTiledMetadataQuery` | `path` is non-empty |
-| the four array hooks + `useTiledArrayImagePath` | `arrayPath` is non-empty |
-| the five table hooks | `tablePath` is non-empty |
-| `useTiledSearchByFullTextQuery` | `filter.text` is non-empty |
-
-So `useTiledMetadataQuery(selected ?? '')` makes no request until something is selected. Pass
-`enabled` in the last parameter to override.
-
-## Notes
-
-- **Cancellation composes.** TanStack's signal and any `requestOptions.signal` are merged, so
- unmounting or `cancelQueries` aborts the in-flight request whether or not you passed one.
-- **The hooks call client methods, not the package's free functions.** `getTiledSearch(…)` and friends
- are hard-wired to the package's own singleton, so there would be no way to inject a client. The
- methods are what those functions call anyway.
-- **`useTiledArrayImagePath` is not a query.** `getArrayAsImagePath` is synchronous — it composes a URL
- and makes no request — so caching it would only cache string concatenation. Being synchronous it
- cannot fetch the array structure either: pass `structure` or `arrayItem` if you want downsampling.
-- **Some package types are derived, not imported.** `TiledInfoResponse`, the array/table format unions
- and the option maps are not exported from the package index, and its `exports` map forbids deep
- imports — so [`types/packageAliases.ts`](./types/packageAliases.ts) derives them from the public
- method signatures. Watch out for `TiledTableJSONResponse` in particular: the package exports that
- name from two modules with two different definitions, and only the one beside the table methods
- matches what they resolve to. Use `TiledTableJSONData` from here instead.
-- **No write hooks.** This version of the package has no POST endpoints. The invalidation bundles are
- already shaped for them.
+[`src/testing/tests/TiledRegistry.test.ts`](../../testing/tests/TiledRegistry.test.ts) diffs
+`endpointRegistry.ts` against the committed `openapi.json` and fails on any operation with no client
+method. Regenerate the spec when the Tiled version changes (see `generated/README.md`); a new route
+then fails that suite, which is the signal to add a path, a method, a hook and a descriptor.
+
+Ten spec operations are deliberately unimplemented and listed in that test: `/ui/{path}` and `/`
+serve the server's own web UI, and the eight per-file zarr routes (`.zattrs`, `.zgroup`, `.zarray`,
+`zarr.json`, chunk paths) are derived by a zarr reader from the two base URLs `getZarrV2Url` /
+`getZarrV3Url` return. Shipping typed fetchers for those would invite a consumer that should have
+used a zarr library.
+
+## Not here yet
+
+**Websockets and GraphQL.** Three seams are reserved so they land additively:
+
+- `apiKeyLocation: 'query'` is implemented already, because a browser websocket handshake cannot set
+ headers and query-string auth is the only mode that will work. The queue server learned this the
+ hard way; [its README](../qServer/README.md) documents the whole matrix.
+- `closeStream` and the spec's `EventType` enum (`container-child-created`,
+ `container-child-metadata-updated`, `stream-closed`) are the streaming vocabulary. Webhooks already
+ model delivery; a socket transport will reuse those types rather than inventing parallel ones.
+- Query roots are keyed by resource, not transport, so a socket that pushes an update invalidates
+ through the same `useTiledInvalidate()` surface.
+
+## Known duplication
+
+`client/interceptorRegistry.ts` is a copy of the queue server's. Both want to live in `@/api/shared`,
+but moving it would mean editing `src/api/qServer`, which this work deliberately did not touch. The
+duplication is on purpose and the deduplication is a clean separate change. If you fix a bug in one,
+fix it in the other.
diff --git a/src/api/tiled/SKILLS.md b/src/api/tiled/SKILLS.md
index 1c709ad4..2f30c7f7 100644
--- a/src/api/tiled/SKILLS.md
+++ b/src/api/tiled/SKILLS.md
@@ -5,37 +5,68 @@ Terse map for anyone (human or agent) changing this folder. Prose docs are in `R
## File map
```
-index.ts public barrel — hooks + runtime + types + package re-exports
+index.ts public barrel — client + hooks + runtime + types + free functions
+openapi.json the spec, fetched from a live server; the coverage test's input
+endpointRegistry.ts TILED_ENDPOINTS + groups + labels + getReadOnlyEndpoints/getWriteEndpoints
README.md / SKILLS.md docs
+generated/
+ schema.d.ts openapi-typescript output — DO NOT hand-edit
+ README.md the regeneration command
+
+client/
+ TiledApiClient.ts the class: config, auth, 401 refresh, funnels, every endpoint method
+ defaultClient.ts module singleton + global setters (setGlobalTiledApiKey, …)
+ facade.ts one flat free function per operation, bound to the singleton
+ interceptorRegistry.ts COPIED from qServer — keep the two in sync
+ urlUtils.ts base URL / origin derivation, path normalisation + per-segment encoding
+ formats.ts format name <-> media type <-> axios responseType, JSON-seq parser
+ searchParams.ts filter + option encoding (the four wire-format corrections live here)
+ arraySlicing.ts downsampling maths, ported from upstream TiledArrayApi
+ tokenStorage.ts injectable login-token store; localStorage keys shared with
+
+endpoints/
+ readEndpoints.ts registry descriptors: info, search, metadata GET, data reads, assets
+ writeEndpoints.ts registry descriptors: writes, auth, zarr
+
types/
- packageAliases.ts types the package USES but does not EXPORT, derived from method signatures
- searchFilters.ts the six JSON-valued filters, widened to take real values
- common.ts re-exports of the package types the hooks expose
+ paths.ts TILED_PATHS (spec paths) + toClientPath + buildPath + completeness assert
+ generatedAliases.ts readable names over generated/schema.d.ts; structures are `Schema`-prefixed
+ structures.ts Finch's structure types (ports) + family guards
+ nodes.ts TiledSearchItem, TiledSearchResult, metadata, table rows
+ info.ts TiledInfoResponse + auth links + isValidTiledInfoResponse
+ requestOptions.ts TiledRequestOptions — the transport contract
+ dataOptions.ts array/table/node option + return maps, and the endpoint/transport split
+ searchFilters.ts all 15 filters; six values widened to take real values
+ errors.ts TiledApiError + toTiledApiError
+ registry.ts descriptor types + payloadAs/numberParam/tupleParam
+ common.ts the re-export hub + interceptor types
index.ts barrel
runtime/
- clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS
+ clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS
TiledApiProvider.tsx client context + useTiledApiClient / useTiledApiClientOptional
index.ts barrel
hooks/
types.ts TiledHookError + re-export of the shared Finch option types
errors.ts TiledEndpointUnavailableError
- queryKeys.ts 5 roots + key builders; scope LAST, and carries initialPath
+ queryKeys.ts 14 roots + key builders; scope LAST, and carries initialPath
invalidation.ts bundles + mutation->bundle map + useTiledInvalidate
useTiledClient.ts resolver: provider -> Finch-configured singleton; useTiledQueryScope()
internal/
- encodeSearchConfig.ts JSON-encodes the six filter values the server parses as JSON
keyParts.ts projects cache-relevant fields out of the endpoint options
useTiledQuery.ts the one query engine
useTiledMutation.ts the one mutation engine
searchHooks.ts 7 queries, all on the `search` root
- metadataHooks.ts 1 query
- arrayHooks.ts 4 queries + useTiledArrayImagePath (NOT a query)
- tableHooks.ts 5 queries
- infoHooks.ts 1 query
- authHooks.ts 1 mutation (login)
+ distinctHooks.ts 1 query (faceting)
+ metadataHooks.ts 1 query + 4 mutations
+ arrayHooks.ts 5 queries + useTiledArrayImagePath (NOT a query) + 3 mutations
+ tableHooks.ts 8 queries + 3 mutations
+ nodeHooks.ts container / node / awkward / ragged — 7 queries + 5 mutations
+ managementHooks.ts revisions, assets, webhooks, register, data source, stream — 5q + 6m
+ infoHooks.ts 5 queries
+ authHooks.ts 1 query + 6 mutations
typeTests.ts compile-time assertions (tsconfig excludes src/testing, so they live here)
index.ts barrel
```
@@ -50,6 +81,21 @@ Shared with the queue server, under `src/api/shared/` — change any of it and y
| `errors.ts` | `FinchHookError`, `FinchMissingArgumentError`, `requireArg` |
| `invalidation.ts` | `resolveInvalidationRoots` + `invalidateRoots` (the pure halves) |
+## Adding an endpoint
+
+The spec is the source of truth and the coverage test enforces it. Five steps, in this order:
+
+1. **`types/paths.ts`** — add the spec path. `_AssertNoMissingPaths` already failed the build if you
+ regenerated the schema without doing this.
+2. **`client/TiledApiClient.ts`** — add the method. Use the `get`/`post`/`put`/`patch`/`del` funnels,
+ or `sendBinary` for a binary body. Node paths go through `this.dataPath(...)`, never `buildPath`.
+3. **`client/facade.ts`** — add the free function.
+4. **`runtime/clientLike.ts`** — add the method name to *both* the `Pick` and
+ `TILED_CLIENT_LIKE_METHODS` (the `satisfies` makes one without the other a compile error).
+5. **`endpoints/*.ts`** — add the descriptor, with the spec's `operationId`.
+
+Then the hook: see below.
+
## Adding a hook
Hooks are written out explicitly, never generated by a factory — a factory would erase hover types,
@@ -58,24 +104,37 @@ JSDoc and the endpoint's own argument names. Each body is ~10 lines over the sha
Call shape: positional — `useTiledXQuery(...endpointArgs, queryOptions?, requestOptions?)` and
`useTiledXMutation(mutationOptions?, requestOptions?)`. **`requestOptions` is always last and always
-means transport**, on this backend and the queue server alike. Array and table hooks carry an extra
-endpoint slot (`arrayOptions` / `tableOptions`) because the package's option types merge endpoint
-parameters with transport and the hooks do not — they `Omit` the transport keys and recombine in
-`fetch`. Mutation variables go through `mutate(variables)`, never the hook.
+means transport**, on this backend and the queue server alike. Array, table and node hooks carry an
+extra endpoint slot because the client's option types merge endpoint parameters with transport and
+the hooks do not — they `Omit` the transport keys and recombine in `fetch`. Mutation variables go
+through `mutate(variables)`, never the hook.
-1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing five.
+1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing fourteen.
2. Write the hook in the matching `hooks/Hooks.ts`, with `@param` docs on every parameter.
3. If the hook takes an options object that mixes endpoint and transport fields, key it through a
`keyParts` projection — never the object itself.
4. For a mutation, add its bundle list to `TILED_MUTATION_INVALIDATIONS`.
5. Export it from `hooks/index.ts`.
-`TiledNewHooks.test.tsx` counts the exported hooks and maps every `TILED_CLIENT_LIKE_METHODS` entry to
-one, so a hook that never reaches the barrel fails CI.
+`TiledHooks.test.tsx` maps every `TILED_CLIENT_LIKE_METHODS` entry to a hook, so a client method
+without one fails CI. The two zarr URL builders are the only entries mapped to `NOT_A_HOOK`.
## Invariants the tests enforce
-- 18 queries + 1 mutation + `useTiledArrayImagePath`, and every non-config client method has a hook.
+`TiledRegistry.test.ts`:
+
+- every operation in `openapi.json` has a descriptor, except the ten listed as intentionally
+ unimplemented (the web UI's two, and the eight per-file zarr routes).
+- no descriptor claims an `operationId` the spec does not declare, and each one's path and method
+ match the spec's.
+- only the `auth` group may carry `operationId: null` — otherwise the coverage check could be silenced.
+- every descriptor names a real client method; ids are unique; every group is non-empty and labelled.
+- every write is `destructive`, except `metadata.create` / `webhooks.register`; only the four
+ body-carrying reads may set `readOnly`.
+- `TILED_PATHS` and the spec's path list agree in both directions.
+
+`TiledHooks.test.tsx`:
+
- key shape `['tiled', resource, args, { baseUrl, initialPath }]`, prefix `['tiled','search']` matching.
- all search variants land on the `search` root; identical filters share one entry.
- the scope forks on `baseUrl` and on `initialPath`; `pathMode: 'absolute'` blanks the prefix.
@@ -86,65 +145,85 @@ one, so a hook that never reaches the barrel fails CI.
- unmount aborts the signal the client received; a caller signal also aborts it.
- a partial injected client throws `TiledEndpointUnavailableError` and never falls back to the network.
- `getServerInfo` resolving `null` is `data === null`, not `isError`.
-- login invalidates all five roots.
+- the three corrected filter parameter names, and the JSON-array encodings.
+
+`TiledClient.test.ts`: path encoding, origin derivation, downsampling maths (including the
+non-array-structure guard), format resolution, JSON-seq parsing, search option mapping, error
+normalisation.
## Gotchas
-- **The package exports `TiledTableJSONResponse` twice, with two different definitions.**
- `Record` beside the table methods, `{ [column: string]: number[] }` in the shared
- types module — and the index re-exports the _narrow_ one, which does not match what the methods
- resolve to. Use `TiledTableJSONData` from `types/packageAliases.ts`.
-- **Deep imports into the package are impossible.** Its `exports` map allows only `.` and
- `./style.css`, so anything the index does not export must be derived via
- `Parameters<>` / `ReturnType<>` in `types/packageAliases.ts`.
-- **Six filter values are JSON, not text.** `eq`, `noteq`, `comparison`, `contains`, `in`, `notin`
- have a `value` the server reads with `json.loads`, so `xas_scan` is malformed and `"xas_scan"` is
- what it wants. `internal/encodeSearchConfig.ts` encodes them; callers pass real values. Numbers and
- booleans are valid JSON bare, which is why forgetting to encode used to look intermittent. Do not
- encode `fulltext`/`regex`/`like`/`lookup`/`structureFamily` — those are plain strings — and do not
- encode `specs`, which the package already handles.
+- **Lists travel two different ways.** Repeated keys for `fields`/`sort`/`column`/`field`/`form_key`/
+ `metadata`; one JSON array for `keys_filter.keys`, `in.value`, `notin.value`, `specs.*`. Getting it
+ wrong is a silent no-op or a 500. Both forms are in `client/searchParams.ts` with the evidence.
+- **`paramsSerializer: { indexes: null }` is load-bearing.** Axios otherwise writes `column[]=`,
+ which FastAPI drops — so a column selection silently returns every column. Set on the instance
+ *and* per request, so an adopted axios client behaves too.
+- **Six filter values are JSON, not text.** `eq`, `noteq`, `comparison`, `contains`, `in`, `notin`.
+ Callers pass real values; `searchParams.ts` encodes. Do not encode `fulltext`/`regex`/`like`/
+ `lookup`/`structureFamily` — those are plain strings.
+- **`POST /metadata/{path}` addresses the PARENT.** `body.id` names the child. Posting to the new
+ node's intended path is a 404 `No such entry`.
+- **`DELETE` defaults to `external_only: true`** server-side — a 409 on internally-managed data until
+ you pass `false`.
- **Never put a raw options object in a query key.** A `signal` in a key refetches forever. Project
through `keyParts.ts`.
+- **An option left out of `keyParts.ts` is a cache collision, not just a stale entry.** Include any
+ option that can change the response; `column` was omitted once and made two column selections
+ share an entry, each serving the other's data.
+- **The 401 refresh refuses to override the caller.** It is skipped when the request chose its own
+ credentials (`__tiledCallerCredentials`: `apiKey` given, or an `Authorization` header) and when it
+ went to a server other than the configured one. `doTokenRefresh` always targets the configured
+ server — never the host of the request that failed, which would leak the refresh token.
+- **`logout()` clears local state in a `finally` that wraps endpoint discovery too.** Moving the
+ `requireAuthLink` call out of the `try` reintroduces a logout that leaves credentials behind
+ whenever the server is unreachable.
+- **`useTiledServerInfoQuery` and `useTiledAboutQuery` key separately** (`null` vs the `'about'`
+ variant). Same endpoint, different contracts — one resolves `null`, the other rejects — so sharing
+ a key let the strict hook return a cached `null` its type forbids.
- **`enabled` goes after the caller's spread**, with `??`. Before it, a caller spreading an options
object containing `enabled: undefined` clobbers the guard and fetches with an empty path.
-- **`initialPath` belongs in the cache scope.** The package prepends it to relative paths, so keying on
+- **`initialPath` belongs in the cache scope.** The client prepends it to relative paths, so keying on
`baseUrl` alone serves one prefix's data for another's.
-- **Base URL includes `/api/v1`.** The package expects it, and nothing here appends it.
-- **The hooks call client methods, never the package's `getTiled*` free functions** — those are bound to
- the package's own singleton, which would defeat `TiledApiProvider`.
+- **Base URL includes `/api/v1`; origin-scoped routes derive the origin from it.** `/healthz`,
+ `/tiled-ui-settings` and zarr are not under the version segment — pass `origin: true` in the
+ request extras.
+- **Node paths are encoded per segment.** `encodeTiledPath`, never `encodeURIComponent` on the whole
+ path, or the separators become `%2F`. `buildPath` is for scalars only (`{webhook_id}`).
+- **A structure may not be an array structure.** `resolveArrayStructure` validates `shape` before the
+ downsampling maths touches it; a caller can point an array read at a table, and the server's 404 is
+ a far better error than a `TypeError` from inside our arithmetic.
- **`getArrayAsImagePath` is synchronous.** It is a `useMemo` helper, not a query, and it cannot fetch
structure for downsampling — the caller must pass `structure` or `arrayItem`.
-- **`src/api/tiled_archive` is excluded from `tsconfig.json`.** It is the retired hook layer, kept for
- reference; it cannot compile against 0.0.33. Nothing imports it — do not add an import.
-
-## The retired layer (`src/api/tiled_archive`)
-
-The old hooks were replaced one-for-one; this is the map, for anyone reading a git blame or an app
-still on an older Finch.
-
-| retired hook | replacement |
-| ---------------------------------------- | ---------------------------------------------------------------------------- |
-| `useTiledSearchResultsQuery(config)` | `useTiledSearchQuery(path, { searchFilters, searchOptions })` |
-| `useTiledSearchByIdQuery({ path })` | `useTiledSearchQuery(path, undefined, { retry: false })` |
-| `useTiledSearchBySpecsQuery(include, …)` | `useTiledSearchBySpecsQuery(path, { include, exclude })` |
-| `useTiledSearchByFulltextQuery(text, …)` | `useTiledSearchByFullTextQuery(path, { text })` |
-| `useTiledItemMetadataQuery(path)` | `useTiledMetadataQuery(path)` |
-| `useTiledBlueskyPlanMetadataQuery(path)` | `useTiledMetadataQuery(path)` — resolves the item, not a `{ data }` envelope |
-| `useTiledTableDataAsJsonQuery(path, n)` | `useTiledTablePartitionAsJSONQuery(path, { partition: n })` |
-| `useTiledTableDataAsSequenceQuery(p, n)` | `useTiledTablePartitionAsJSONSequenceQuery(p, { partition: n })` |
-| `useTiledXArrayDataQuery(path, stack)` | `useTiledArrayAsJSONQuery(path, { stack })` |
-| `useTiledStructuredArrayDataQuery` | `useTiledArrayAsJSONQuery` — the package has no structured-array read |
-| `useTiledServerInfoQuery()` | `useTiledServerInfoQuery()` — same name, now takes request options |
-
-Two behaviour changes worth knowing when reading old call sites:
-
-- **The retired `searchById` swallowed a 404 into `data: null`.** The replacement lets it surface as
- `isError`, so a call site testing `!!data` should test the status instead. That is what
- `useTiledWriterScatterPlot.ts` now does — see `runQuery.isSuccess` / `directQuery.isError` there.
- Do not reintroduce the swallow; TanStack already models the failure.
-- **`useTiledWriterMultiScatterPlot.ts` used to hand-build `['tiled','searchById',baseUrl,{path}]`**, a
- key nothing else matched. It now builds keys with `tiledQueryKeys.search`, so its `useQueries`
- entries live in the same namespace as the hooks' and are refreshed by the same invalidation. The two
- `useQueries` call sites (that one and `TiledMultiScatterPlot.tsx`) are the only places that legitimately
- bypass the hooks — one query per item cannot be a hook call. Both use the key factory and the
- package's request functions, so they stay consistent with the cache.
+- **`getArrayAsPng` is browser-only.** `responseType: 'blob'` is an XHR concept; under Node the
+ result is not a `Blob`. Use `getArrayAsBuffer` outside a browser.
+- **`getServerInfo` resolves `null` instead of throwing.** Deliberate and inherited — the login screen
+ probes unknown servers. Use `getAbout` when you want the error.
+- **`loginWithUsernamePassword` resolves `null` on a bad password.** Also deliberate.
+- **The auth routes are not in `openapi.json`.** URLs come from `About.authentication.links`, which is
+ `null` on a server with auth disabled. Descriptors carry `operationId: null`.
+- **`interceptorRegistry.ts` is duplicated from qServer on purpose.** Fix bugs in both.
+- **`@blueskyproject/tiled` is still installed, for the `` viewer only.** Nothing in this
+ folder may import it. The viewer has its own client and singleton; only `localStorage` tokens are
+ shared.
+- **`src/components/Tiled/types/tempTypes.ts` is a stale duplicate** of the node/structure types, kept
+ for the viewer's prop types. It still types `data_sources` as `string | null`, which is wrong. Do
+ not add call sites; import from `@/api/tiled`.
+
+## Verifying against a live server
+
+The client was built and checked against Tiled 0.2.15b1. To re-verify after a change:
+
+```ts
+// run with: npx vite-node .ts (NOT vitest — the jsdom setup has no `window` in node env,
+// and jsdom's XHR hits CORS. vite-node uses the axios http adapter and resolves `@/`.)
+import { TiledApiClient } from '@/api/tiled';
+import { getReadOnlyEndpoints } from '@/api/tiled/endpointRegistry';
+```
+
+Sweep `getReadOnlyEndpoints()` and call each descriptor's `call`. Endpoints for structure families
+the server has no data for answer a clean 404 naming the mismatch — that is a pass, not a failure.
+
+**CORS:** a Tiled dev server typically allows one origin. The client sets `withCredentials: true`
+(inherited; Tiled's cookie session flows need it), which means the server must echo the origin rather
+than answer `Access-Control-Allow-Origin: *`. Browser-side testing has to run on the allowed port.
diff --git a/src/api/tiled/client/TiledApiClient.ts b/src/api/tiled/client/TiledApiClient.ts
new file mode 100644
index 00000000..22cc43d2
--- /dev/null
+++ b/src/api/tiled/client/TiledApiClient.ts
@@ -0,0 +1,2100 @@
+import axios, {
+ type AxiosInstance,
+ type AxiosRequestConfig,
+ type AxiosResponse,
+ type InternalAxiosRequestConfig,
+ type ResponseType,
+} from 'axios';
+import type {
+ InterceptorHandle,
+ TiledBinaryBody,
+ TiledBody,
+ TiledErrorInterceptor,
+ TiledRequestInterceptor,
+ TiledResponseInterceptor,
+} from '../types/common';
+import { TiledApiError, toTiledApiError, type TiledHttpMethod } from '../types/errors';
+import type {
+ GetDistinctResponse,
+ DeliveryResponse,
+ PatchMetadataRequest,
+ PatchMetadataResponse,
+ PostMetadataRequest,
+ PostMetadataResponse,
+ PutDataSourceRequest,
+ PutMetadataRequest,
+ PutMetadataResponse,
+ WebhookRegistrationRequest,
+ WebhookResponse,
+} from '../types/generatedAliases';
+import {
+ isValidTiledInfoResponse,
+ type TiledAuthProvider,
+ type TiledInfoResponse,
+} from '../types/info';
+import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes';
+import { TILED_PATHS, buildPath } from '../types/paths';
+import type {
+ TiledApiKeyLocation,
+ TiledApiKeyScheme,
+ TiledAuthErrorCallback,
+ TiledLoginTokens,
+ TiledPathMode,
+ TiledRequestOptions,
+} from '../types/requestOptions';
+import type {
+ TiledArrayEndpointParams,
+ TiledArrayBufferOptions,
+ TiledArrayImagePathOptions,
+ TiledArrayJSONOptions,
+ TiledArrayOptionsMap,
+ TiledArrayPngOptions,
+ TiledArrayReturnMap,
+ TiledArrayReturnType,
+ TiledAwkwardRequestOptions,
+ TiledNodeRequestOptions,
+ TiledRaggedRequestOptions,
+ TiledTableJSONOptions,
+ TiledTableJSONSequenceOptions,
+ TiledTableOptionsMap,
+ TiledTableRequestOptions,
+ TiledTableReturnMap,
+ TiledTableReturnType,
+ TiledTableEndpoint,
+} from '../types/dataOptions';
+import type { ArrayStructure, TiledStructures } from '../types/structures';
+import type { TiledDistinctConfig, TiledSearchConfig } from '../types/searchFilters';
+import { buildArraySliceAsync, buildArraySlice, type StructureFetcher } from './arraySlicing';
+import { parseJsonSequence, resolveFormat, type TiledFormatName } from './formats';
+import { InterceptorRegistry } from './interceptorRegistry';
+import { buildDistinctParams, buildSearchParams, type TiledQueryParams } from './searchParams';
+import {
+ createDefaultTokenStorage,
+ type TiledTokenStorage,
+ type TiledStoredTokens,
+} from './tokenStorage';
+import {
+ defaultTiledBaseUrl,
+ formatIndexTuple,
+ normalizeTiledBaseUrl,
+ normalizeTiledPath,
+ resolveEncodedTiledPath,
+ tiledOriginFromBaseUrl,
+} from './urlUtils';
+
+export interface TiledApiClientConfig {
+ /** Adopt a pre-built axios instance. Built-in interceptors are installed onto it. */
+ client?: AxiosInstance;
+ /** Server base URL **including** the version segment, e.g. `http://localhost:8000/api/v1`. */
+ baseUrl?: string;
+ /** Path prefix prepended to every relative request path. */
+ initialPath?: string;
+ apiKey?: string | null;
+ /** Sent as `Authorization: Bearer …`; takes precedence over `apiKey`. */
+ bearerToken?: string | null;
+ signal?: AbortSignal;
+ timeout?: number;
+ /** Default byte budget for array reads, overridable per call with `maxBytesAllowed`. */
+ maxArrayBytes?: number;
+ apiKeyScheme?: TiledApiKeyScheme;
+ apiKeyLocation?: TiledApiKeyLocation;
+ onAuthError?: TiledAuthErrorCallback;
+ /** Where login tokens are persisted. Defaults to `localStorage`, or memory outside a browser. */
+ tokenStorage?: TiledTokenStorage;
+}
+
+/**
+ * How array-valued query parameters are serialised.
+ *
+ * **`indexes: null` is load-bearing.** Axios's default appends brackets — `column[]=a&column[]=b` —
+ * and FastAPI reads a repeated bare key, `column=a&column=b`. The mismatch does not fail loudly: an
+ * unrecognised parameter is dropped, so `column` selection silently returned every column and
+ * `fields` silently returned every field. The `in` / `notin` filters are worse — their bracketed
+ * form makes the server answer **500**.
+ *
+ * Verified against Tiled 0.2.15b1. This affects `column`, `field`, `form_key`, `fields`, `sort`,
+ * `metadata`, and the `keys_filter`, `in`, `notin` and `access_blob_filter` conditions — which is
+ * most of the interesting query surface.
+ */
+const TILED_PARAMS_SERIALIZER = { indexes: null } as const;
+
+/**
+ * Flags the auth interceptor and the 401 handler read off a request.
+ *
+ * `__tiledNoAuth` means the caller opted this one call out of credentials entirely
+ * (`options.apiKey === null`). `__tiledCallerCredentials` is wider: the caller chose *which*
+ * credentials this call carries, whether that is a one-off key, an explicit `Authorization` header,
+ * or none at all. The 401 handler refuses to substitute the stored session for either.
+ */
+type AuthAwareConfig = InternalAxiosRequestConfig & {
+ __tiledNoAuth?: boolean;
+ __tiledCallerCredentials?: boolean;
+};
+/** Marks a request that has already been retried once after a 401. */
+type RetryableConfig = InternalAxiosRequestConfig & { __tiledRetried?: boolean };
+
+/** Options for the low-level request funnel, beyond the public transport contract. */
+interface RequestExtras {
+ params?: TiledQueryParams;
+ headers?: Record;
+ responseType?: ResponseType;
+ /** Issue against the server origin rather than the `/api/v1` base — `/healthz`, zarr, … */
+ origin?: boolean;
+}
+
+/**
+ * The Tiled API client.
+ *
+ * Covers every operation in `../openapi.json` plus the auth routes the spec omits. Built on the
+ * client in
+ * [`tiled-viewer-react`](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api)
+ * — the read paths are ports, not rewrites — and shaped like
+ * [`QServerApiClient`](../../qServer/client/QServerApiClient.ts) so the two backends are learnable
+ * as one thing.
+ *
+ * Three levels of control, same as the queue server:
+ *
+ * 1. mutate the app-wide client — `setGlobalTiledApiKey`, `setDefaultTiledUrl`, …
+ * 2. replace it wholesale — `setDefaultTiledApiClient(new TiledApiClient({...}))`
+ * 3. override a single call — every method takes `options.baseUrl` / `options.apiKey` /
+ * `options.client`
+ *
+ * ## Two things that are Tiled's rather than ours
+ *
+ * **The base URL carries `/api/v1`.** Nothing here appends or strips it — guessing would silently
+ * point a misconfigured app at a URL it never asked for. The handful of routes outside the version
+ * segment (`/healthz`, `/tiled-ui-settings`, zarr) are issued against the derived origin instead;
+ * see `urlUtils.tiledOriginFromBaseUrl`.
+ *
+ * **`getServerInfo` resolves `null` instead of throwing** when the server is unreachable. Every
+ * other method rejects with a {@link TiledApiError}. The asymmetry is upstream's and is preserved,
+ * because the login screen depends on it: it asks an unknown server what it supports and needs an
+ * answer, not an exception.
+ */
+export class TiledApiClient {
+ private client: AxiosInstance;
+ private baseUrl: string;
+ private initialPath: string;
+ private apiKey: string | null;
+ private bearerToken: string | null;
+ private signal: AbortSignal | undefined;
+ private timeout: number | undefined;
+ private maxArrayBytes: number | undefined;
+ private apiKeyScheme: TiledApiKeyScheme;
+ private apiKeyLocation: TiledApiKeyLocation;
+ private authErrorCallback: TiledAuthErrorCallback | undefined;
+ private tokenStorage: TiledTokenStorage;
+ private interceptors = new InterceptorRegistry();
+ private refreshPromise: Promise | null = null;
+
+ constructor(config: TiledApiClientConfig = {}) {
+ this.baseUrl = normalizeTiledBaseUrl(config.baseUrl ?? defaultTiledBaseUrl());
+ this.initialPath = normalizeTiledPath(config.initialPath ?? '');
+ this.apiKey = config.apiKey ?? null;
+ this.bearerToken = config.bearerToken ?? null;
+ this.signal = config.signal;
+ this.timeout = config.timeout;
+ this.maxArrayBytes = config.maxArrayBytes;
+ this.apiKeyScheme = config.apiKeyScheme ?? 'ApiKey';
+ this.apiKeyLocation = config.apiKeyLocation ?? 'header';
+ this.authErrorCallback = config.onAuthError;
+ this.tokenStorage = config.tokenStorage ?? createDefaultTokenStorage();
+
+ this.client =
+ config.client ??
+ axios.create({
+ baseURL: this.baseUrl,
+ // Upstream sets this, and Tiled's cookie-based session flows need it. Note it is
+ // also why a cross-origin dev server must allow credentials explicitly rather than
+ // answering `Access-Control-Allow-Origin: *`.
+ withCredentials: true,
+ paramsSerializer: TILED_PARAMS_SERIALIZER,
+ ...(this.timeout !== undefined ? { timeout: this.timeout } : {}),
+ });
+
+ this.installBuiltinInterceptors();
+ }
+
+ // #region configuration
+
+ /** The base URL, **including** the `/api/v1` segment. */
+ getBaseUrl(): string {
+ return this.baseUrl;
+ }
+
+ setBaseUrl(baseUrl: string): void {
+ this.baseUrl = normalizeTiledBaseUrl(baseUrl);
+ this.client.defaults.baseURL = this.baseUrl;
+ }
+
+ getInitialPath(): string {
+ return this.initialPath;
+ }
+
+ setInitialPath(initialPath: string): void {
+ this.initialPath = normalizeTiledPath(initialPath);
+ }
+
+ getApiKey(): string | null {
+ return this.apiKey;
+ }
+
+ /** Takes effect on the next request — the auth interceptor reads the key at request time. */
+ setApiKey(apiKey: string | null): void {
+ this.apiKey = apiKey;
+ }
+
+ getApiKeyScheme(): TiledApiKeyScheme {
+ return this.apiKeyScheme;
+ }
+
+ setApiKeyScheme(scheme: TiledApiKeyScheme): void {
+ this.apiKeyScheme = scheme;
+ }
+
+ getApiKeyLocation(): TiledApiKeyLocation {
+ return this.apiKeyLocation;
+ }
+
+ setApiKeyLocation(location: TiledApiKeyLocation): void {
+ this.apiKeyLocation = location;
+ }
+
+ getBearerToken(): string | null {
+ return this.bearerToken;
+ }
+
+ setBearerToken(token: string | null): void {
+ this.bearerToken = token;
+ }
+
+ /** Drop every credential this client holds, including anything in token storage. */
+ clearAuth(): void {
+ this.apiKey = null;
+ this.bearerToken = null;
+ this.tokenStorage.clear();
+ }
+
+ getStoredTokens(): TiledStoredTokens | null {
+ return this.tokenStorage.read();
+ }
+
+ /**
+ * Seed or clear the session: the bearer token *and* the stored refresh token together.
+ *
+ * The counterpart to {@link getStoredTokens}, which could read a session the client had no way
+ * to write. `setBearerToken` sets only the in-memory half, and the refresh token lived in a
+ * `TiledTokenStorage` a caller could replace wholesale but not update — so handing the client a
+ * session you already held (from another tab, a test fixture, or a UI that asks for one) meant
+ * constructing a storage object yourself.
+ *
+ * `null` clears both, equivalent to {@link clearAuth} minus the API key.
+ *
+ * **Mind which storage is installed.** The default browser storage shares its keys with the
+ * `` viewer component, so writing an experimental token here changes the session for
+ * anything else reading those keys. Install a memory storage first when that is not what you
+ * want — see `createMemoryTokenStorage`.
+ */
+ setSession(tokens: TiledStoredTokens | null): void {
+ if (!tokens) {
+ this.tokenStorage.clear();
+ this.setBearerToken(null);
+ return;
+ }
+ this.tokenStorage.write(tokens);
+ this.setBearerToken(tokens.accessToken);
+ }
+
+ setTokenStorage(storage: TiledTokenStorage): void {
+ this.tokenStorage = storage;
+ }
+
+ getAxiosClient(): AxiosInstance {
+ return this.client;
+ }
+
+ /** Adopt a different axios instance, re-installing every interceptor onto it. */
+ setAxiosClient(client: AxiosInstance): void {
+ this.client = client;
+ this.client.defaults.baseURL = this.baseUrl;
+ this.interceptors.reinstallOn(client);
+ }
+
+ getSignal(): AbortSignal | undefined {
+ return this.signal;
+ }
+
+ setSignal(signal: AbortSignal | undefined): void {
+ this.signal = signal;
+ }
+
+ getMaxArrayBytes(): number | undefined {
+ return this.maxArrayBytes;
+ }
+
+ setMaxArrayBytes(maxArrayBytes: number | undefined): void {
+ this.maxArrayBytes = maxArrayBytes;
+ }
+
+ setTimeout(timeout: number): void {
+ this.timeout = timeout;
+ this.client.defaults.timeout = timeout;
+ }
+
+ setAuthErrorCallback(callback: TiledAuthErrorCallback | undefined): void {
+ this.authErrorCallback = callback;
+ }
+
+ /** Everything this client is currently configured with, for diagnostics and test harnesses. */
+ getConfigSnapshot(): {
+ baseUrl: string;
+ origin: string;
+ initialPath: string;
+ hasApiKey: boolean;
+ hasBearerToken: boolean;
+ apiKeyScheme: TiledApiKeyScheme;
+ apiKeyLocation: TiledApiKeyLocation;
+ maxArrayBytes: number | undefined;
+ timeout: number | undefined;
+ } {
+ return {
+ baseUrl: this.baseUrl,
+ origin: tiledOriginFromBaseUrl(this.baseUrl),
+ initialPath: this.initialPath,
+ // Never the key itself: a snapshot ends up in logs and test output.
+ hasApiKey: this.apiKey !== null,
+ hasBearerToken: this.bearerToken !== null,
+ apiKeyScheme: this.apiKeyScheme,
+ apiKeyLocation: this.apiKeyLocation,
+ maxArrayBytes: this.maxArrayBytes,
+ timeout: this.timeout,
+ };
+ }
+
+ // #endregion
+
+ // #region interceptors
+
+ addRequestInterceptor(
+ onFulfilled: TiledRequestInterceptor,
+ onRejected?: TiledErrorInterceptor,
+ ): InterceptorHandle {
+ return this.interceptors.registerUser(this.client, (client) => ({
+ kind: 'request',
+ id: client.interceptors.request.use(onFulfilled, onRejected),
+ }));
+ }
+
+ addResponseInterceptor(
+ onFulfilled: TiledResponseInterceptor,
+ onRejected?: TiledErrorInterceptor,
+ ): InterceptorHandle {
+ return this.interceptors.registerUser(this.client, (client) => ({
+ kind: 'response',
+ id: client.interceptors.response.use(onFulfilled, onRejected),
+ }));
+ }
+
+ ejectInterceptor(handle: InterceptorHandle): boolean {
+ return this.interceptors.ejectUser(this.client, handle);
+ }
+
+ /** Remove caller-registered interceptors; the built-in auth handlers are untouched. */
+ clearInterceptors(kind?: 'request' | 'response'): void {
+ this.interceptors.clearUsers(this.client, kind);
+ }
+
+ listInterceptors(): readonly InterceptorHandle[] {
+ return this.interceptors.listUsers();
+ }
+
+ private installBuiltinInterceptors(): void {
+ this.interceptors.registerBuiltin(this.client, (client) => ({
+ kind: 'request',
+ id: client.interceptors.request.use((config) => this.applyAuth(config)),
+ }));
+
+ this.interceptors.registerBuiltin(this.client, (client) => ({
+ kind: 'response',
+ id: client.interceptors.response.use(
+ (response) => response,
+ (error: unknown) => this.handleResponseError(error),
+ ),
+ }));
+ }
+
+ /**
+ * Attach credentials, unless the call already carries its own.
+ *
+ * Reading the key here rather than baking it into a header at construction is what makes
+ * `setApiKey` effective immediately, with nothing to rebuild.
+ */
+ private applyAuth(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
+ // `options.apiKey: null` opted this call out of auth entirely.
+ if ((config as AuthAwareConfig).__tiledNoAuth) return config;
+
+ const params = (config.params ?? {}) as Record;
+ const alreadyAuthorized = !!config.headers?.Authorization || params.api_key !== undefined;
+ if (alreadyAuthorized) return config;
+
+ if (this.bearerToken) {
+ config.headers.Authorization = `Bearer ${this.bearerToken}`;
+ return config;
+ }
+ if (!this.apiKey) return config;
+
+ if (this.apiKeyLocation === 'query') {
+ config.params = { ...params, api_key: this.apiKey };
+ } else {
+ config.headers.Authorization = `${this.apiKeyScheme} ${this.apiKey}`;
+ }
+ return config;
+ }
+
+ /**
+ * Single-flight 401 refresh, then one retry of the original request.
+ *
+ * Two conditions beyond "it was a 401" decide whether a refresh is even attempted, and both
+ * exist to stop the client overriding a decision the caller made:
+ *
+ * - **The caller did not choose this call's credentials.** A request sent with
+ * `apiKey: null`, a one-off key, or an explicit `Authorization` header said what identity to
+ * use. Refreshing and retrying with the stored session's bearer token would substitute
+ * credentials the caller deliberately did not send — turning an intentionally anonymous probe
+ * into an authenticated one, and silently.
+ * - **The request went to this client's own server.** The stored refresh token was issued by
+ * the configured server. A per-call `baseUrl` points somewhere else, and sending that token
+ * there would hand a credential to a third party that never had it. Related:
+ * {@link doTokenRefresh} now always talks to the configured server rather than to whichever
+ * host the failed request used.
+ */
+ private async handleResponseError(error: unknown): Promise {
+ const originalRequest = axios.isAxiosError(error)
+ ? (error.config as (RetryableConfig & AuthAwareConfig) | undefined)
+ : undefined;
+ const status = axios.isAxiosError(error) ? error.response?.status : undefined;
+
+ const canRetry =
+ status === 401 &&
+ !!originalRequest &&
+ !originalRequest.__tiledRetried &&
+ !originalRequest.__tiledCallerCredentials &&
+ this.isOwnServer(originalRequest.baseURL) &&
+ !!this.tokenStorage.read();
+
+ if (!canRetry) throw error;
+
+ originalRequest.__tiledRetried = true;
+ if (!this.refreshPromise) {
+ this.refreshPromise = this.doTokenRefresh().finally(() => {
+ this.refreshPromise = null;
+ });
+ }
+
+ let accessToken: string;
+ try {
+ // Concurrent 401s all await the first refresh rather than starting their own.
+ accessToken = await this.refreshPromise;
+ } catch {
+ throw error;
+ }
+
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ return this.client(originalRequest);
+ }
+
+ /**
+ * Whether a request went to this client's own server.
+ *
+ * `baseURL` is absent only on a request built outside `buildConfig`, which means it inherited
+ * the axios instance's default — this client's own. A per-call override is compared after
+ * normalisation, and the derived origin counts as the same server because the origin-scoped
+ * routes (`/healthz`, `/tiled-ui-settings`, zarr) are legitimately issued against it.
+ */
+ private isOwnServer(baseURL?: string): boolean {
+ if (!baseURL) return true;
+ const normalized = normalizeTiledBaseUrl(baseURL);
+ return normalized === this.baseUrl || normalized === tiledOriginFromBaseUrl(this.baseUrl);
+ }
+
+ /**
+ * Exchange the refresh token for a new access token.
+ *
+ * Issued through a bare `axios`, not `this.client`, so a failure cannot re-enter this handler
+ * and recurse. Two endpoints are tried because Tiled moved the route: `/auth/session/refresh`
+ * is current, `/auth/refresh` is what older servers answer, and a 404 on the first is the
+ * documented signal to try the second. Upstream does the same.
+ *
+ * **The target is always this client's configured server**, never the host of the request that
+ * 401'd. Deriving it from the failed request meant a per-call `baseUrl` override could send the
+ * stored refresh token to an unrelated server. `QServerApiClient.doTokenRefresh` has always
+ * worked this way; this now matches it.
+ */
+ private async doTokenRefresh(): Promise {
+ const origin = tiledOriginFromBaseUrl(this.baseUrl);
+ const stored = this.tokenStorage.read();
+ if (!stored) {
+ this.authErrorCallback?.(null);
+ throw new Error('No stored auth tokens');
+ }
+
+ try {
+ const body = { refresh_token: stored.refreshToken };
+ let accessToken: string | undefined;
+
+ const response = await axios.post<{ access_token?: string; refresh_token?: string }>(
+ `${origin}/api/v1/auth/session/refresh`,
+ body,
+ { timeout: this.timeout, validateStatus: (code) => code < 500 },
+ );
+
+ if (response.status === 404) {
+ const legacy = await axios.post<{ access_token?: string }>(
+ `${origin}/api/v1/auth/refresh`,
+ body,
+ { timeout: this.timeout },
+ );
+ accessToken = legacy.data?.access_token;
+ } else if (response.status >= 400) {
+ throw new Error(`Token refresh failed with ${response.status}`);
+ } else {
+ accessToken = response.data?.access_token;
+ }
+
+ if (!accessToken) throw new Error('No access token returned from refresh endpoint');
+
+ this.tokenStorage.write({ accessToken, refreshToken: stored.refreshToken });
+ this.setBearerToken(accessToken);
+ return accessToken;
+ } catch (refreshError) {
+ this.clearAuth();
+ this.authErrorCallback?.(refreshError);
+ throw refreshError;
+ }
+ }
+
+ // #endregion
+
+ // #region request funnels
+
+ private resolveClient(options?: TiledRequestOptions): AxiosInstance {
+ return options?.client ?? this.client;
+ }
+
+ /** The base URL this call should go to: the API base, or the bare origin for `origin` routes. */
+ private resolveBaseUrl(options?: TiledRequestOptions, origin = false): string {
+ const base =
+ options?.baseUrl !== undefined ? normalizeTiledBaseUrl(options.baseUrl) : this.baseUrl;
+ return origin ? tiledOriginFromBaseUrl(base) : base;
+ }
+
+ /** Resolve a Tiled node path against the initial path, honouring `pathMode`. */
+ private encodePath(path: string, options?: TiledRequestOptions): string {
+ return resolveEncodedTiledPath(
+ path,
+ options?.initialPath ?? this.initialPath,
+ options?.pathMode ?? ('relative' as TiledPathMode),
+ );
+ }
+
+ private buildConfig(
+ path: string,
+ options: TiledRequestOptions | undefined,
+ extras: RequestExtras | undefined,
+ ): AxiosRequestConfig {
+ const headers: Record = { ...options?.headers, ...extras?.headers };
+ const params: Record = {};
+
+ // The endpoint's own parameters first, then the caller's `query`, so a caller can override.
+ for (const [key, value] of Object.entries(extras?.params ?? {})) {
+ if (value !== undefined) params[key] = value;
+ }
+ for (const [key, value] of Object.entries(options?.query ?? {})) {
+ if (value !== undefined) params[key] = value;
+ }
+
+ // A per-request key must be applied here; the built-in interceptor defers to it.
+ if (options?.apiKey) {
+ if (this.apiKeyLocation === 'query') params.api_key = options.apiKey;
+ else headers.Authorization = `${this.apiKeyScheme} ${options.apiKey}`;
+ }
+
+ // Whether the caller decided this call's identity: a one-off key, an explicit header, or
+ // `null` for none. The 401 handler checks this before substituting the stored session.
+ const callerCredentials =
+ options?.apiKey !== undefined || headers.Authorization !== undefined;
+
+ return {
+ ...options?.axiosConfig,
+ baseURL: this.resolveBaseUrl(options, extras?.origin),
+ url: path,
+ signal: options?.signal ?? this.signal,
+ headers,
+ params,
+ // Also set per request, not just on the instance, so an adopted axios client
+ // (`config.client`, or `options.client`) serialises arrays correctly too.
+ paramsSerializer: options?.axiosConfig?.paramsSerializer ?? TILED_PARAMS_SERIALIZER,
+ ...(extras?.responseType ? { responseType: extras.responseType } : {}),
+ // An explicit `null` means "no credentials for this call" — distinct from `undefined`,
+ // which inherits the client's. Flagged here so the auth interceptor can tell them apart.
+ ...(options?.apiKey === null ? { __tiledNoAuth: true } : {}),
+ ...(callerCredentials ? { __tiledCallerCredentials: true } : {}),
+ } as AxiosRequestConfig;
+ }
+
+ /** Issue a request and unwrap `response.data`, normalizing failures. */
+ protected async request(
+ method: TiledHttpMethod,
+ path: string,
+ data: unknown,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ const client = this.resolveClient(options);
+ const config: AxiosRequestConfig = {
+ ...this.buildConfig(path, options, extras),
+ method,
+ ...(data !== undefined ? { data } : {}),
+ };
+
+ try {
+ const response = await client.request(config);
+ return response.data;
+ } catch (error) {
+ throw toTiledApiError(error, method, path);
+ }
+ }
+
+ /** Untyped escape hatch for callers who want to declare a shape themselves. */
+ requestRaw(
+ method: TiledHttpMethod,
+ path: string,
+ data?: unknown,
+ options?: TiledRequestOptions,
+ extras?: {
+ params?: TiledQueryParams;
+ headers?: Record;
+ responseType?: ResponseType;
+ },
+ ): Promise {
+ return this.request(method, path, data, options, extras);
+ }
+
+ private get(
+ path: string,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request('GET', path, undefined, options, extras);
+ }
+
+ private post(
+ path: string,
+ body: TiledBody | undefined,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request('POST', path, body ?? {}, options, extras);
+ }
+
+ private put(
+ path: string,
+ body: TiledBody | undefined,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request('PUT', path, body ?? {}, options, extras);
+ }
+
+ private patch(
+ path: string,
+ body: TiledBody | undefined,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request('PATCH', path, body ?? {}, options, extras);
+ }
+
+ private del(
+ path: string,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request('DELETE', path, undefined, options, extras);
+ }
+
+ /**
+ * Send a binary payload.
+ *
+ * The content type is explicit rather than inferred. axios would otherwise guess from the body
+ * — `application/json` for a plain array, nothing at all for an `ArrayBuffer` — and Tiled
+ * dispatches its writers on exactly this header, so a wrong guess is a 415 or, worse, a
+ * successful write of the wrong thing.
+ */
+ private sendBinary(
+ method: 'PUT' | 'PATCH' | 'POST',
+ path: string,
+ body: TiledBinaryBody | unknown[],
+ mimetype: string,
+ options?: TiledRequestOptions,
+ extras?: RequestExtras,
+ ): Promise {
+ return this.request(method, path, body, options, {
+ ...extras,
+ headers: { 'Content-Type': mimetype, ...extras?.headers },
+ });
+ }
+
+ /**
+ * A structure fetcher bound to these request options, for the downsampling helpers.
+ *
+ * The node may not be an array — a caller can point an array read at a table by mistake — so
+ * what comes back is whatever structure the node has. `resolveArrayStructure` validates it and
+ * treats a non-array structure as absent, so the request still goes out and the *server*
+ * reports the mismatch, rather than the downsampling maths throwing first.
+ */
+ private makeStructureFetcher(options: TiledRequestOptions): StructureFetcher {
+ return (path: string) =>
+ this.getMetadata(path, options)
+ .then((item) => item.attributes.structure)
+ .catch(() => undefined);
+ }
+
+ /** Apply the client's array byte budget unless the call set its own. */
+ private resolveArrayOptions(options: T): T {
+ if (this.maxArrayBytes === undefined || options.maxBytesAllowed !== undefined) {
+ return options;
+ }
+ return { ...options, maxBytesAllowed: this.maxArrayBytes };
+ }
+
+ // #endregion
+
+ // #region endpoint paths
+
+ private searchPath(path: string, options?: TiledRequestOptions): string {
+ const encoded = this.encodePath(path, options);
+ // The root container is a legal search target, and the server wants the trailing slash.
+ return encoded ? `/search/${encoded}` : '/search/';
+ }
+
+ private distinctPath(path: string, options?: TiledRequestOptions): string {
+ const encoded = this.encodePath(path, options);
+ return encoded ? `/distinct/${encoded}` : '/distinct/';
+ }
+
+ private metadataPath(path: string, options?: TiledRequestOptions): string {
+ return `/metadata/${this.encodePath(path, options)}`;
+ }
+
+ private dataPath(
+ kind:
+ | 'array/full'
+ | 'array/block'
+ | 'ragged/full'
+ | 'ragged/block'
+ | 'table/full'
+ | 'table/partition'
+ | 'container/full'
+ | 'node/full'
+ | 'awkward/full'
+ | 'awkward/buffers'
+ | 'register'
+ | 'data_source'
+ | 'revisions'
+ | 'stream/close'
+ | 'asset/bytes'
+ | 'asset/manifest'
+ | 'webhooks/target',
+ path: string,
+ options?: TiledRequestOptions,
+ ): string {
+ return `/${kind}/${this.encodePath(path, options)}`;
+ }
+
+ // #endregion
+
+ // #region info
+
+ /**
+ * The `/api/v1/` root document: versions, supported formats, auth providers.
+ *
+ * **Resolves `null` rather than rejecting** when the server is unreachable or answers something
+ * that is not an About document. Upstream's behaviour, preserved deliberately — the login flow
+ * probes servers it knows nothing about and needs a value back. Every other read on this client
+ * rejects with a {@link TiledApiError}.
+ */
+ async getServerInfo(options: TiledRequestOptions = {}): Promise {
+ try {
+ const data = await this.get('/', options, {
+ headers: { Accept: 'application/json' },
+ });
+ return isValidTiledInfoResponse(data) ? data : null;
+ } catch {
+ return null;
+ }
+ }
+
+ /** The same document, but rejecting on failure. For callers that want the error. */
+ getAbout(options: TiledRequestOptions = {}): Promise {
+ return this.get('/', options, {
+ headers: { Accept: 'application/json' },
+ });
+ }
+
+ /** `GET /healthz` — liveness. Outside `/api/v1`, so issued against the origin. */
+ getHealth(options: TiledRequestOptions = {}): Promise {
+ return this.get(TILED_PATHS.healthz, options, { origin: true });
+ }
+
+ /** `GET /tiled-ui-settings` — the server's hints for its own web UI. */
+ getUiSettings(options: TiledRequestOptions = {}): Promise {
+ return this.get(TILED_PATHS.uiSettings, options, { origin: true });
+ }
+
+ /** `GET /api/v1/metrics` — Prometheus-style server metrics. */
+ getMetrics(options: TiledRequestOptions = {}): Promise {
+ return this.get('/metrics', options);
+ }
+
+ // #endregion
+
+ // #region search
+
+ /**
+ * `GET /search/{path}` — list and filter the children of a container.
+ *
+ * `searchPath: ''` is the root container, and legal.
+ */
+ getSearch(
+ searchPath: string,
+ config: TiledSearchConfig = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(this.searchPath(searchPath, options), options, {
+ params: buildSearchParams(config),
+ headers: { Accept: 'application/json' },
+ });
+ }
+
+ /**
+ * `GET /distinct/{path}` — the distinct values of metadata keys, specs and structure families
+ * across a container, optionally with counts.
+ *
+ * New: the package never exposed this, so building a facet UI meant paginating the whole
+ * container client-side.
+ */
+ getDistinct(
+ searchPath: string,
+ config: TiledDistinctConfig = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(this.distinctPath(searchPath, options), options, {
+ params: buildDistinctParams(config),
+ headers: { Accept: 'application/json' },
+ });
+ }
+
+ // #endregion
+
+ // #region metadata
+
+ /** `GET /metadata/{path}` — one node's metadata, specs, structure and links. */
+ async getMetadata(
+ path: string,
+ options: TiledRequestOptions = {},
+ ): Promise> {
+ const response = await this.get<{ data: TiledSearchItem }>(
+ this.metadataPath(path, options),
+ options,
+ { headers: { Accept: 'application/json' } },
+ );
+ return response.data;
+ }
+
+ /**
+ * `POST /metadata/{path}` — create a node **inside** the container at `path`.
+ *
+ * **`parentPath` is the container to create in, not the node being created.** The new node's own
+ * key goes in `body.id`; omit it and the server assigns a uuid. Posting to the path you want the
+ * node to have answers 404 `No such entry`, which is confusing enough to be worth the parameter
+ * name. Verified against Tiled 0.2.15b1.
+ *
+ * ```ts
+ * await client.createNode('', { id: 'processed', structure_family: 'container', … });
+ * // creates /processed
+ * await client.createNode('processed', { id: 'run1', structure_family: 'array', … });
+ * // creates /processed/run1
+ * ```
+ *
+ * For an array or table, `body.data_sources[0].structure` must describe the data you are about
+ * to write — the server allocates from it and does not infer it later.
+ */
+ createNode(
+ parentPath: string,
+ body: PostMetadataRequest,
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.post(
+ this.metadataPath(parentPath, options),
+ body,
+ options,
+ );
+ }
+
+ /** `PUT /metadata/{path}` — replace a node's metadata, specs and access blob wholesale. */
+ updateMetadata(
+ path: string,
+ body: PutMetadataRequest,
+ params: { drop_revision?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.put(this.metadataPath(path, options), body, options, {
+ params,
+ });
+ }
+
+ /**
+ * `PATCH /metadata/{path}` — partial update, in either of two modes.
+ *
+ * Tiled dispatches on the body's own `content-type` field, not the HTTP header:
+ *
+ * - `'application/merge-patch+json'` — `metadata` is an object merged into the existing one
+ * - `'application/json-patch+json'` — `metadata` is an RFC 6902 operation list
+ *
+ * One method rather than two, because it is one endpoint and the mode is a property of the
+ * body. `patchMetadataMerge` and `patchMetadataJsonPatch` below are the ergonomic wrappers.
+ */
+ patchMetadata(
+ path: string,
+ body: PatchMetadataRequest,
+ params: { drop_revision?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.patch(this.metadataPath(path, options), body, options, {
+ params,
+ });
+ }
+
+ /** {@link patchMetadata} in merge mode: the given fields are merged into what is there. */
+ patchMetadataMerge(
+ path: string,
+ body: {
+ metadata?: Record;
+ specs?: PatchMetadataRequest['specs'];
+ access_blob?: Record;
+ },
+ params: { drop_revision?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.patchMetadata(
+ path,
+ {
+ 'content-type': 'application/merge-patch+json',
+ specs: body.specs ?? null,
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
+ ...(body.access_blob !== undefined ? { access_blob: body.access_blob } : {}),
+ },
+ params,
+ options,
+ );
+ }
+
+ /** {@link patchMetadata} in JSON Patch mode: `metadata` is a list of RFC 6902 operations. */
+ patchMetadataJsonPatch(
+ path: string,
+ body: {
+ metadata?: PatchMetadataRequest['metadata'];
+ specs?: PatchMetadataRequest['specs'];
+ access_blob?: PatchMetadataRequest['access_blob'];
+ },
+ params: { drop_revision?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.patchMetadata(
+ path,
+ {
+ 'content-type': 'application/json-patch+json',
+ specs: body.specs ?? null,
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
+ ...(body.access_blob !== undefined ? { access_blob: body.access_blob } : {}),
+ },
+ params,
+ options,
+ );
+ }
+
+ /**
+ * `DELETE /metadata/{path}` — remove a node.
+ *
+ * `recursive` deletes children too. `external_only` defaults to **true** server-side, which
+ * refuses the delete with a 409 when any of the tree is internally managed — deleting those
+ * records would delete the underlying data files. Pass `external_only: false` to mean it.
+ * Verified against Tiled 0.2.15b1.
+ *
+ * Destructive and not undoable; flagged as such in the endpoint registry.
+ */
+ deleteNode(
+ path: string,
+ params: { recursive?: boolean; external_only?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.del(this.metadataPath(path, options), options, { params });
+ }
+
+ // #endregion
+
+ // #region arrays
+
+ /** Generic array dispatcher. Prefer the typed helpers below. */
+ async getArrayAs(
+ arrayPath: string,
+ type: T,
+ options: TiledArrayOptionsMap[T] = {} as TiledArrayOptionsMap[T],
+ ): Promise {
+ switch (type) {
+ // The switch discriminates on `type`, but TypeScript cannot tie that back to the
+ // options parameter's own union, so each branch asserts the pairing the map declares.
+ case 'JSON':
+ return (await this.getArrayAsJSON(
+ arrayPath,
+ options as TiledArrayJSONOptions,
+ )) as TiledArrayReturnMap[T];
+ case 'PNG':
+ return (await this.getArrayAsPng(
+ arrayPath,
+ options as TiledArrayPngOptions,
+ )) as TiledArrayReturnMap[T];
+ case 'BUFFER':
+ return (await this.getArrayAsBuffer(
+ arrayPath,
+ options as TiledArrayBufferOptions,
+ )) as TiledArrayReturnMap[T];
+ case 'IMAGE_PATH':
+ return this.getArrayAsImagePath(
+ arrayPath,
+ options as TiledArrayImagePathOptions,
+ ) as TiledArrayReturnMap[T];
+ default:
+ throw new Error(`Unsupported array return type: ${String(type)}`);
+ }
+ }
+
+ /** `GET /array/full/{path}` as JSON. Defaults to `number[][]`; override the shape if you know it. */
+ async getArrayAsJSON(
+ arrayPath: string,
+ options: TiledArrayJSONOptions = {},
+ ): Promise {
+ const resolved = this.resolveArrayOptions(options);
+ const format = resolved.format ?? 'application/json';
+ const slice = await buildArraySliceAsync(
+ arrayPath,
+ resolved,
+ this.makeStructureFetcher(resolved),
+ );
+ return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, {
+ params: { format, slice },
+ headers: { Accept: format },
+ });
+ }
+
+ /**
+ * `GET /array/full/{path}` as a PNG `Blob`.
+ *
+ * **Browser only.** `responseType: 'blob'` is an XHR/fetch concept; under Node the axios HTTP
+ * adapter ignores it and the result is not a `Blob` (`size` and `type` read `undefined`). Use
+ * {@link getArrayAsBuffer} outside a browser. Inherited behaviour, not introduced here.
+ */
+ async getArrayAsPng(arrayPath: string, options: TiledArrayPngOptions = {}): Promise {
+ const resolved = this.resolveArrayOptions(options);
+ const format = resolved.format ?? 'image/png';
+ const slice = await buildArraySliceAsync(
+ arrayPath,
+ resolved,
+ this.makeStructureFetcher(resolved),
+ );
+ return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, {
+ params: { format, slice },
+ headers: { Accept: format },
+ responseType: 'blob',
+ });
+ }
+
+ /** `GET /array/full/{path}` as raw bytes. */
+ async getArrayAsBuffer(
+ arrayPath: string,
+ options: TiledArrayBufferOptions = {},
+ ): Promise {
+ const resolved = this.resolveArrayOptions(options);
+ const format = resolved.format ?? 'application/octet-stream';
+ const slice = await buildArraySliceAsync(
+ arrayPath,
+ resolved,
+ this.makeStructureFetcher(resolved),
+ );
+ return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, {
+ params: { format, slice },
+ headers: { Accept: format },
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /**
+ * A URL for `
`. **Synchronous**, and therefore makes no request.
+ *
+ * Being synchronous it cannot fetch the array's structure either, so downsampling only happens
+ * when `structure` or `arrayItem` is passed. With `apiKeyLocation: 'query'` the key is appended
+ * to the URL, which is the only way an `
` can authenticate — note that this puts the key in
+ * the DOM and in any referrer logging.
+ */
+ getArrayAsImagePath(arrayPath: string, options: TiledArrayImagePathOptions = {}): string {
+ const resolved = this.resolveArrayOptions(options);
+ const base = this.resolveBaseUrl(resolved);
+ const url = new URL(`${base}${this.dataPath('array/full', arrayPath, resolved)}`);
+ url.searchParams.set('format', resolved.format ?? 'image/png');
+
+ const slice = buildArraySlice(resolved);
+ if (slice) url.searchParams.set('slice', slice);
+
+ const key = resolved.apiKey !== undefined ? resolved.apiKey : this.apiKey;
+ if (key && this.apiKeyLocation === 'query') url.searchParams.set('api_key', key);
+
+ return url.toString();
+ }
+
+ /** `GET /array/block/{path}` — one chunk, addressed by its block index. */
+ getArrayBlock(
+ arrayPath: string,
+ params: { block: number[]; slice?: string; expected_shape?: string; format?: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const format = params.format ?? 'application/octet-stream';
+ return this.get(this.dataPath('array/block', arrayPath, options), options, {
+ params: {
+ block: formatIndexTuple(params.block),
+ format,
+ ...(params.slice !== undefined ? { slice: params.slice } : {}),
+ ...(params.expected_shape !== undefined
+ ? { expected_shape: params.expected_shape }
+ : {}),
+ },
+ headers: { Accept: format },
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /**
+ * `PUT /array/full/{path}` — write a whole array.
+ *
+ * Bytes (`ArrayBuffer` / typed array / `Blob`) are sent as `application/octet-stream` in the
+ * array's own dtype and C order — the server trusts the declared structure and does not
+ * convert. A nested `number[][]` is sent as JSON instead, which is slower but needs no dtype
+ * knowledge. No encoding of one into the other happens here; that was a deliberate scope call,
+ * because getting it wrong writes plausible-looking garbage.
+ */
+ putArrayFull(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params: { persist?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const path = this.dataPath('array/full', arrayPath, options);
+ if (Array.isArray(data)) return this.put(path, data, options, { params });
+ return this.sendBinary('PUT', path, data, 'application/octet-stream', options, {
+ params,
+ });
+ }
+
+ /** `PUT /array/block/{path}` — write one chunk. */
+ putArrayBlock(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params: { block: number[]; persist?: boolean },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const path = this.dataPath('array/block', arrayPath, options);
+ const query = { block: formatIndexTuple(params.block), persist: params.persist };
+ if (Array.isArray(data)) return this.put(path, data, options, { params: query });
+ return this.sendBinary('PUT', path, data, 'application/octet-stream', options, {
+ params: query,
+ });
+ }
+
+ /**
+ * `PATCH /array/full/{path}` — write a sub-region, optionally growing the array.
+ *
+ * `offset` and `shape` locate the region and are required. `extend: true` lets the write grow a
+ * resizable array past its current bounds, which is how a running scan appends.
+ */
+ patchArrayFull(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const path = this.dataPath('array/full', arrayPath, options);
+ const query = {
+ offset: formatIndexTuple(params.offset),
+ shape: formatIndexTuple(params.shape),
+ extend: params.extend,
+ persist: params.persist,
+ };
+ if (Array.isArray(data)) return this.patch(path, data, options, { params: query });
+ return this.sendBinary('PATCH', path, data, 'application/octet-stream', options, {
+ params: query,
+ });
+ }
+
+ // #endregion
+
+ // #region ragged arrays
+
+ /** `GET /ragged/full/{path}`. */
+ getRaggedFull(path: string, options: TiledRaggedRequestOptions = {}): Promise {
+ const spec = resolveFormat(options.format ?? 'JSON');
+ return this.get(this.dataPath('ragged/full', path, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.slice !== undefined ? { slice: options.slice } : {}),
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /** `PUT /ragged/full/{path}`. */
+ putRaggedFull(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params: { persist?: boolean } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const target = this.dataPath('ragged/full', path, options);
+ if (Array.isArray(data)) return this.put(target, data, options, { params });
+ return this.sendBinary('PUT', target, data, 'application/octet-stream', options, {
+ params,
+ });
+ }
+
+ /** `PUT /ragged/block/{path}`. */
+ putRaggedBlock(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params: { block: number[]; persist?: boolean },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const target = this.dataPath('ragged/block', path, options);
+ const query = { block: formatIndexTuple(params.block), persist: params.persist };
+ if (Array.isArray(data)) return this.put(target, data, options, { params: query });
+ return this.sendBinary('PUT', target, data, 'application/octet-stream', options, {
+ params: query,
+ });
+ }
+
+ /** `PATCH /ragged/full/{path}` — write a sub-region of a ragged array. */
+ patchRaggedFull(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const target = this.dataPath('ragged/full', path, options);
+ const query = {
+ offset: formatIndexTuple(params.offset),
+ shape: formatIndexTuple(params.shape),
+ extend: params.extend,
+ persist: params.persist,
+ };
+ if (Array.isArray(data))
+ return this.patch(target, data, options, { params: query });
+ return this.sendBinary(
+ 'PATCH',
+ target,
+ data,
+ 'application/octet-stream',
+ options,
+ {
+ params: query,
+ },
+ );
+ }
+
+ // #endregion
+
+ // #region tables
+
+ /** Generic table dispatcher. Prefer the typed helpers below. */
+ async getTableAs(
+ tablePath: string,
+ type: T = 'JSON' as T,
+ endpoint: TiledTableEndpoint = 'partition',
+ options: TiledTableOptionsMap[T] = {} as TiledTableOptionsMap[T],
+ ): Promise {
+ // As in `getArrayAs`: the switch narrows `type` but not the options union alongside it.
+ if (endpoint === 'full') {
+ switch (type) {
+ case 'JSON':
+ return (await this.getTableFullAsJSON(
+ tablePath,
+ options as TiledTableJSONOptions,
+ )) as TiledTableReturnMap[T];
+ case 'JSON_SEQ':
+ return (await this.getTableFullAsJSONSequence(
+ tablePath,
+ options as TiledTableJSONSequenceOptions,
+ )) as TiledTableReturnMap[T];
+ default:
+ throw new Error(`Unsupported table return type: ${String(type)}`);
+ }
+ }
+ switch (type) {
+ case 'JSON':
+ return (await this.getTablePartitionAsJSON(
+ tablePath,
+ options as TiledTableJSONOptions,
+ )) as TiledTableReturnMap[T];
+ case 'JSON_SEQ':
+ return (await this.getTablePartitionAsJSONSequence(
+ tablePath,
+ options as TiledTableJSONSequenceOptions,
+ )) as TiledTableReturnMap[T];
+ default:
+ throw new Error(`Unsupported table return type: ${String(type)}`);
+ }
+ }
+
+ /** `GET /table/partition/{path}` — one partition, column-oriented. */
+ getTablePartitionAsJSON(
+ tablePath: string,
+ options: TiledTableJSONOptions = {},
+ ): Promise {
+ const format = options.format ?? 'application/json';
+ return this.get(
+ this.dataPath('table/partition', tablePath, options),
+ options,
+ {
+ params: {
+ partition: options.partition ?? 0,
+ format,
+ ...(options.column?.length ? { column: options.column } : {}),
+ },
+ headers: { Accept: format },
+ },
+ );
+ }
+
+ /** `GET /table/partition/{path}` — one partition, row-oriented. */
+ async getTablePartitionAsJSONSequence(
+ tablePath: string,
+ options: TiledTableJSONSequenceOptions = {},
+ ): Promise {
+ const format = options.format ?? 'application/json-seq';
+ const body = await this.get(
+ this.dataPath('table/partition', tablePath, options),
+ options,
+ {
+ params: {
+ partition: options.partition ?? 0,
+ format,
+ ...(options.column?.length ? { column: options.column } : {}),
+ },
+ headers: { Accept: format },
+ responseType: 'text',
+ },
+ );
+ return parseJsonSequence(body) as TiledTableRow[];
+ }
+
+ /** `GET /table/full/{path}` — every partition, column-oriented. */
+ getTableFullAsJSON(
+ tablePath: string,
+ options: TiledTableJSONOptions = {},
+ ): Promise {
+ const format = options.format ?? 'application/json';
+ return this.get(
+ this.dataPath('table/full', tablePath, options),
+ options,
+ {
+ params: {
+ format,
+ ...(options.column?.length ? { column: options.column } : {}),
+ },
+ headers: { Accept: format },
+ },
+ );
+ }
+
+ /** `GET /table/full/{path}` — every partition, row-oriented. */
+ async getTableFullAsJSONSequence(
+ tablePath: string,
+ options: TiledTableJSONSequenceOptions = {},
+ ): Promise {
+ const format = options.format ?? 'application/json-seq';
+ const body = await this.get(
+ this.dataPath('table/full', tablePath, options),
+ options,
+ {
+ params: {
+ format,
+ ...(options.column?.length ? { column: options.column } : {}),
+ },
+ headers: { Accept: format },
+ responseType: 'text',
+ },
+ );
+ return parseJsonSequence(body) as TiledTableRow[];
+ }
+
+ /**
+ * `GET /table/full/{path}` in any representation — CSV, parquet, arrow, Excel, HDF5.
+ *
+ * The format decides the return type; see `client/formats.ts`. This is how a download button is
+ * built without hand-rolling an `Accept` header and an axios `responseType`.
+ */
+ getTableFullAs(
+ tablePath: string,
+ format: TiledFormatName | string,
+ options: TiledTableRequestOptions = {},
+ ): Promise {
+ const spec = resolveFormat(format);
+ return this.get(this.dataPath('table/full', tablePath, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.column?.length ? { column: options.column } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /**
+ * `POST /table/full/{path}` — the same read, with the column list in the body.
+ *
+ * A read, despite the verb: the body selects columns rather than changing anything. It exists
+ * for selections too long for a query string. The hook for this is a **query**, for the same
+ * reason `useQueueGetRunsQuery` is.
+ */
+ postTableFull(
+ tablePath: string,
+ columns: string[] | null,
+ params: { format?: string; filename?: string } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const spec = resolveFormat(params.format ?? 'JSON');
+ return this.request(
+ 'POST',
+ this.dataPath('table/full', tablePath, options),
+ columns,
+ options,
+ {
+ params: { format: spec.accept, filename: params.filename },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ },
+ );
+ }
+
+ /** `POST /table/partition/{path}` — one partition, column list in the body. A read. */
+ postTablePartition(
+ tablePath: string,
+ columns: string[] | null,
+ params: { partition: number; format?: string; filename?: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const spec = resolveFormat(params.format ?? 'JSON');
+ return this.request(
+ 'POST',
+ this.dataPath('table/partition', tablePath, options),
+ columns,
+ options,
+ {
+ params: {
+ partition: params.partition,
+ format: spec.accept,
+ filename: params.filename,
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ },
+ );
+ }
+
+ /**
+ * `PUT /table/partition/{path}` — write one partition.
+ *
+ * The payload is encoded table bytes and the `mimetype` says which encoding — parquet, CSV or
+ * arrow. Required rather than defaulted: the server dispatches its reader on this header, and a
+ * wrong guess either fails or silently writes nonsense.
+ */
+ putTablePartition(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { partition: number; mimetype: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.sendBinary(
+ 'PUT',
+ this.dataPath('table/partition', tablePath, options),
+ data,
+ params.mimetype,
+ options,
+ { params: { partition: params.partition } },
+ );
+ }
+
+ /** `PATCH /table/partition/{path}` — append to one partition. */
+ patchTablePartition(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { partition: number; mimetype: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.sendBinary(
+ 'PATCH',
+ this.dataPath('table/partition', tablePath, options),
+ data,
+ params.mimetype,
+ options,
+ { params: { partition: params.partition } },
+ );
+ }
+
+ /** `PUT /table/full/{path}` — write a whole table. Same operation as {@link putNodeFull}. */
+ putTableFull(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { mimetype: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.sendBinary(
+ 'PUT',
+ this.dataPath('table/full', tablePath, options),
+ data,
+ params.mimetype,
+ options,
+ );
+ }
+
+ // #endregion
+
+ // #region containers and nodes
+
+ /** `GET /container/full/{path}` — a container's metadata and data together. */
+ getContainerFull(path: string, options: TiledNodeRequestOptions = {}): Promise {
+ const spec = resolveFormat(options.format ?? 'JSON');
+ return this.get(this.dataPath('container/full', path, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.field?.length ? { field: options.field } : {}),
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /** `POST /container/full/{path}` — the same read, with the field list in the body. */
+ postContainerFull(
+ path: string,
+ fields: string[] | null,
+ params: { format?: string; filename?: string } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const spec = resolveFormat(params.format ?? 'JSON');
+ return this.request(
+ 'POST',
+ this.dataPath('container/full', path, options),
+ fields,
+ options,
+ {
+ params: { format: spec.accept, filename: params.filename },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ },
+ );
+ }
+
+ /** `GET /node/full/{path}` — whichever of container or table the node turns out to be. */
+ getNodeFull(path: string, options: TiledNodeRequestOptions = {}): Promise {
+ const spec = resolveFormat(options.format ?? 'JSON');
+ return this.get(this.dataPath('node/full', path, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.field?.length ? { field: options.field } : {}),
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /** `PUT /node/full/{path}` — write a whole node. */
+ putNodeFull(
+ path: string,
+ data: TiledBinaryBody,
+ params: { mimetype: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.sendBinary(
+ 'PUT',
+ this.dataPath('node/full', path, options),
+ data,
+ params.mimetype,
+ options,
+ );
+ }
+
+ // #endregion
+
+ // #region awkward arrays
+
+ /** `GET /awkward/full/{path}` — the whole awkward array. */
+ getAwkwardFull(path: string, options: TiledAwkwardRequestOptions = {}): Promise {
+ const spec = resolveFormat(options.format ?? 'JSON');
+ return this.get(this.dataPath('awkward/full', path, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /** `GET /awkward/buffers/{path}` — selected buffers, by form key. */
+ getAwkwardBuffers(path: string, options: TiledAwkwardRequestOptions = {}): Promise {
+ const spec = resolveFormat(options.format ?? 'JSON');
+ return this.get(this.dataPath('awkward/buffers', path, options), options, {
+ params: {
+ format: spec.accept,
+ ...(options.form_key?.length ? { form_key: options.form_key } : {}),
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
+ },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ });
+ }
+
+ /** `POST /awkward/buffers/{path}` — the same read with the form-key list in the body. */
+ postAwkwardBuffers(
+ path: string,
+ formKeys: string[],
+ params: { format?: string; filename?: string } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const spec = resolveFormat(params.format ?? 'JSON');
+ return this.request(
+ 'POST',
+ this.dataPath('awkward/buffers', path, options),
+ formKeys,
+ options,
+ {
+ params: { format: spec.accept, filename: params.filename },
+ headers: { Accept: spec.accept },
+ responseType: spec.responseType,
+ },
+ );
+ }
+
+ /** `PUT /awkward/full/{path}` — write an awkward array as its form plus buffer map. */
+ putAwkwardFull(
+ path: string,
+ body: { form: unknown; length: number; container: Record },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.put(
+ this.dataPath('awkward/full', path, options),
+ body as unknown as TiledBody,
+ options,
+ );
+ }
+
+ // #endregion
+
+ // #region registration, data sources, revisions, streams
+
+ /**
+ * `POST /register/{path}` — register data that already exists on disk.
+ *
+ * Takes the same body as {@link createNode}, and addresses the **parent** container the same
+ * way, but the data sources point at files the server can already see rather than at bytes about
+ * to be uploaded.
+ */
+ postRegister(
+ parentPath: string,
+ body: PostMetadataRequest,
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.post(
+ this.dataPath('register', parentPath, options),
+ body,
+ options,
+ );
+ }
+
+ /**
+ * `PUT /data_source/{path}` — replace a node's data source.
+ *
+ * `patch_shape` / `patch_offset` scope the change to a region when the source is being grown.
+ */
+ putDataSource(
+ path: string,
+ body: PutDataSourceRequest,
+ params: { patch_shape?: string; patch_offset?: string } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.put(
+ this.dataPath('data_source', path, options),
+ body as unknown as TiledBody,
+ options,
+ { params },
+ );
+ }
+
+ /** `GET /revisions/{path}` — the metadata revision history, paginated. */
+ getRevisions(
+ path: string,
+ params: { pageOffset?: number; pageCursor?: number; pageLimit?: number } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(this.dataPath('revisions', path, options), options, {
+ params: {
+ 'page[offset]': params.pageOffset,
+ 'page[cursor]': params.pageCursor,
+ 'page[limit]': params.pageLimit,
+ },
+ });
+ }
+
+ /** `DELETE /revisions/{path}` — drop one revision by number. Destructive. */
+ deleteRevision(
+ path: string,
+ params: { number: number },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.del(this.dataPath('revisions', path, options), options, {
+ params: { number: params.number },
+ });
+ }
+
+ /**
+ * `DELETE /stream/close/{path}` — mark an append-only node complete.
+ *
+ * The one write that is really a streaming operation: it is what emits the `stream-closed`
+ * event webhooks subscribe to. Useful on its own today, and the seam the websocket work will
+ * build against.
+ */
+ closeStream(path: string, options: TiledRequestOptions = {}): Promise {
+ return this.del(this.dataPath('stream/close', path, options), options);
+ }
+
+ // #endregion
+
+ // #region assets
+
+ /** `GET /asset/bytes/{path}` — the raw bytes of one asset backing a node. */
+ getAssetBytes(
+ path: string,
+ params: { id: number; relative_path?: string },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(this.dataPath('asset/bytes', path, options), options, {
+ params: { id: params.id, relative_path: params.relative_path },
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** `GET /asset/manifest/{path}` — the file list of a directory-shaped asset. */
+ getAssetManifest(
+ path: string,
+ params: { id: number },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(this.dataPath('asset/manifest', path, options), options, {
+ params: { id: params.id },
+ });
+ }
+
+ // #endregion
+
+ // #region webhooks
+
+ /** `GET /webhooks/target/{path}` — the webhooks registered on a node. */
+ listWebhooks(path: string, options: TiledRequestOptions = {}): Promise {
+ return this.get(
+ this.dataPath('webhooks/target', path, options),
+ options,
+ );
+ }
+
+ /** `POST /webhooks/target/{path}` — register a webhook for a node's events. */
+ registerWebhook(
+ path: string,
+ body: WebhookRegistrationRequest,
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.post(
+ this.dataPath('webhooks/target', path, options),
+ body as unknown as TiledBody,
+ options,
+ );
+ }
+
+ /** `DELETE /webhooks/{webhook_id}` — deactivate and remove a webhook. */
+ deleteWebhook(webhookId: number, options: TiledRequestOptions = {}): Promise {
+ return this.del(
+ buildPath('/webhooks/{webhook_id}', { webhook_id: webhookId }),
+ options,
+ );
+ }
+
+ /** `GET /webhooks/history/{webhook_id}` — recent delivery attempts and their outcomes. */
+ getWebhookHistory(
+ webhookId: number,
+ params: { limit?: number } = {},
+ options: TiledRequestOptions = {},
+ ): Promise {
+ return this.get(
+ buildPath('/webhooks/history/{webhook_id}', { webhook_id: webhookId }),
+ options,
+ { params: { limit: params.limit } },
+ );
+ }
+
+ // #endregion
+
+ // #region auth
+ //
+ // None of these routes are in `openapi.json` — the spec is generated without the auth router.
+ // What the spec *does* carry is `AboutAuthenticationLinks`, so every URL below is resolved from
+ // `GET /api/v1/`'s `authentication.links` rather than hard-coded. A server with authentication
+ // disabled reports `links: null`, and these methods say so plainly instead of requesting a URL
+ // built from `null`.
+
+ /** The auth URLs this server advertises, or `null` when authentication is disabled. */
+ private async resolveAuthLinks(
+ options: TiledRequestOptions,
+ ): Promise | null> {
+ const info = await this.getServerInfo(options);
+ const links = info?.authentication?.links;
+ return links ? (links as unknown as Record) : null;
+ }
+
+ private async requireAuthLink(
+ name: 'whoami' | 'apikey' | 'refresh_session' | 'revoke_session' | 'logout',
+ options: TiledRequestOptions,
+ ): Promise {
+ const links = await this.resolveAuthLinks(options);
+ const url = links?.[name];
+ if (!url) {
+ throw new TiledApiError({
+ message:
+ `This Tiled server does not advertise an auth endpoint for '${name}'. ` +
+ 'Its `authentication.links` is null, which means authentication is disabled — ' +
+ 'use an API key instead.',
+ method: 'GET',
+ path: `auth:${name}`,
+ });
+ }
+ return url;
+ }
+
+ /**
+ * Log in with a username and password.
+ *
+ * Resolves the auth endpoint from the given provider, or from the first password/internal
+ * provider the server advertises. On success the tokens are persisted and set as this client's
+ * bearer token.
+ *
+ * **Resolves `null` on a failed login rather than rejecting.** That is upstream's behaviour and
+ * callers depend on it — a wrong password is an expected outcome of a login form, not an
+ * exception. Check the resolved value, not `isError`.
+ *
+ * @param url Server override. Kept as a positional parameter for compatibility; equivalent to
+ * `options.baseUrl`, and wins over it when both are given.
+ * @param options Transport, like every other method: a substitute client and an abort signal
+ * apply to both the server-info probe and the login request itself.
+ */
+ async loginWithUsernamePassword(
+ username: string,
+ password: string,
+ url?: string,
+ provider?: TiledAuthProvider,
+ options: TiledRequestOptions = {},
+ ): Promise {
+ // `url` is the long-standing explicit server override and still wins; `options.baseUrl` is
+ // the ordinary transport route into the same thing, so the two are reconciled once here
+ // rather than at each use below.
+ const transport: TiledRequestOptions = url ? { ...options, baseUrl: url } : options;
+
+ try {
+ let authEndpoint: string;
+
+ if (provider && (provider.mode === 'password' || provider.mode === 'internal')) {
+ if (!provider.links?.auth_endpoint) {
+ console.error('Provided authentication provider is missing auth_endpoint');
+ return null;
+ }
+ authEndpoint = provider.links.auth_endpoint;
+ } else {
+ const info = await this.getServerInfo(transport);
+ const providers = info?.authentication?.providers;
+ if (!providers?.length) {
+ console.error('No authentication providers found in server info');
+ return null;
+ }
+ const match = providers.find(
+ (candidate) => candidate.mode === 'password' || candidate.mode === 'internal',
+ );
+ if (!match?.links?.auth_endpoint) {
+ console.error('No password authentication provider found');
+ return null;
+ }
+ authEndpoint = match.links.auth_endpoint;
+ }
+
+ const form = new FormData();
+ form.append('username', username);
+ form.append('password', password);
+
+ // `authEndpoint` is an absolute URL from the provider's links, so no `baseURL` applies.
+ // The substitute client and the abort signal still do, which is why this resolves them
+ // rather than reaching for `this.client` directly as it used to.
+ const response = await this.resolveClient(transport).post>(
+ authEndpoint,
+ form,
+ {
+ ...transport.axiosConfig,
+ headers: {
+ ...transport.headers,
+ 'Content-Type': 'multipart/form-data',
+ },
+ signal: transport.signal ?? this.signal,
+ },
+ );
+
+ const { access_token, refresh_token } = response.data;
+ if (!access_token || !refresh_token) {
+ console.error('Login response missing required tokens');
+ return null;
+ }
+
+ this.tokenStorage.write({ accessToken: access_token, refreshToken: refresh_token });
+ this.setBearerToken(access_token);
+ return { access_token, refresh_token };
+ } catch (error) {
+ console.error('Login failed:', error);
+ return null;
+ }
+ }
+
+ /** Who the current credentials identify. */
+ async whoami(options: TiledRequestOptions = {}): Promise {
+ const url = await this.requireAuthLink('whoami', options);
+ return this.get(url, { ...options, baseUrl: '' });
+ }
+
+ /** Mint a new API key for the current principal. */
+ async createApiKey(
+ body: { expires_in?: number | null; scopes?: string[] | null; note?: string | null },
+ options: TiledRequestOptions = {},
+ ): Promise {
+ const url = await this.requireAuthLink('apikey', options);
+ return this.post(url, body, { ...options, baseUrl: '' });
+ }
+
+ /** Revoke an API key by its first eight characters. */
+ async revokeApiKey(firstEight: string, options: TiledRequestOptions = {}): Promise {
+ const url = await this.requireAuthLink('apikey', options);
+ return this.del(url, {
+ ...options,
+ baseUrl: '',
+ query: { first_eight: firstEight },
+ });
+ }
+
+ /** Exchange the stored refresh token for a new access token, out of band of the 401 handler. */
+ async refreshSession(options: TiledRequestOptions = {}): Promise {
+ const url = await this.requireAuthLink('refresh_session', options);
+ const stored = this.tokenStorage.read();
+ if (!stored)
+ throw new TiledApiError({
+ message: 'No stored refresh token',
+ method: 'POST',
+ path: url,
+ });
+
+ const tokens = await this.post<{ access_token?: string; refresh_token?: string }>(
+ url,
+ { refresh_token: stored.refreshToken },
+ { ...options, baseUrl: '' },
+ );
+ if (tokens.access_token) {
+ this.tokenStorage.write({
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token ?? stored.refreshToken,
+ });
+ this.setBearerToken(tokens.access_token);
+ }
+ return tokens;
+ }
+
+ /** Revoke one session by id. */
+ async revokeSession(sessionId: string, options: TiledRequestOptions = {}): Promise {
+ const url = await this.requireAuthLink('revoke_session', options);
+ return this.del(`${url}/${encodeURIComponent(sessionId)}`, {
+ ...options,
+ baseUrl: '',
+ });
+ }
+
+ /**
+ * Log out, then drop every local credential.
+ *
+ * **Local state is always cleared**, including when the server cannot be reached or does not
+ * advertise a logout endpoint at all. Someone who pressed log out is logged out of this tab
+ * regardless of whether the server agreed, which is the only behaviour that is safe on a shared
+ * machine.
+ *
+ * The `finally` therefore wraps the endpoint *discovery* as well as the request. It did not
+ * originally, which meant the common failure — an unreachable server, or one with
+ * authentication disabled, where `requireAuthLink` throws — left the bearer token and the
+ * stored session exactly where they were while appearing to fail at logging out.
+ *
+ * The call still rejects on failure; it clears first. A caller who wants "log out locally and
+ * do not tell me if the server was unreachable" should catch, and can rely on the credentials
+ * being gone.
+ */
+ async logout(options: TiledRequestOptions = {}): Promise {
+ try {
+ const url = await this.requireAuthLink('logout', options);
+ return await this.post(url, {}, { ...options, baseUrl: '' });
+ } finally {
+ this.clearAuth();
+ }
+ }
+
+ // #endregion
+
+ // #region zarr
+ //
+ // URL builders only, deliberately. These routes exist to be consumed by zarr.js or xarray,
+ // which want a base URL and do their own chunk fetching; a typed fetcher here would invite a
+ // consumer that should have used a zarr library. The registry lists them so the surface is
+ // discoverable and the coverage test stays satisfied.
+
+ /** The base URL to hand a zarr v2 reader for a node. */
+ getZarrV2Url(path: string, options: TiledRequestOptions = {}): string {
+ const origin = tiledOriginFromBaseUrl(this.resolveBaseUrl(options));
+ return `${origin}/zarr/v2/${this.encodePath(path, options)}`;
+ }
+
+ /** The base URL to hand a zarr v3 reader for a node. */
+ getZarrV3Url(path: string, options: TiledRequestOptions = {}): string {
+ const origin = tiledOriginFromBaseUrl(this.resolveBaseUrl(options));
+ return `${origin}/zarr/v3/${this.encodePath(path, options)}`;
+ }
+
+ // #endregion
+}
diff --git a/src/api/tiled/client/arraySlicing.ts b/src/api/tiled/client/arraySlicing.ts
new file mode 100644
index 00000000..bd99bcac
--- /dev/null
+++ b/src/api/tiled/client/arraySlicing.ts
@@ -0,0 +1,195 @@
+import type { TiledArrayEndpointParams } from '../types/dataOptions';
+import type { ArrayStructure } from '../types/structures';
+
+/**
+ * Downsampling and slice-string construction for array reads.
+ *
+ * A faithful port of upstream's `TiledArrayApi`, constants included. The arithmetic decides how much
+ * data crosses the network for every image Finch displays, so it is reproduced rather than
+ * reinterpreted; where a number looks arbitrary it is upstream's and the comment says so.
+ */
+
+/** Fallback byte budget when neither `maxBytesAllowed` nor a client limit is set: 1 MB. */
+const DEFAULT_MAX_BYTES = 1_000_000;
+
+/**
+ * Bytes per element by NumPy dtype kind, used when the dtype omits `itemsize`.
+ *
+ * Upstream's table, and deliberately pessimistic — `i` and `u` assume 4 bytes, `f` assumes 8 — so a
+ * missing itemsize over-estimates the payload and downsamples a little harder rather than blowing
+ * the budget.
+ */
+const ITEMSIZE_BY_KIND: Record = {
+ b: 1,
+ i: 4,
+ u: 4,
+ f: 8,
+ c: 16,
+ m: 8,
+ M: 8,
+};
+
+/**
+ * The structure a caller supplied, from either of the two fields that can carry it.
+ *
+ * **Validated, not just read.** The structure can arrive from a metadata fetch for a path the
+ * caller believed was an array, and a table's structure has no `shape` at all — reading
+ * `shape.length` off one threw a `TypeError` from inside the downsampling maths, three layers from
+ * the mistake. Anything that is not shaped like an array structure is treated as "no structure",
+ * which means no downsampling and lets the server answer with the real error.
+ */
+export function resolveArrayStructure(
+ options: TiledArrayEndpointParams = {},
+): ArrayStructure | undefined {
+ const candidate = options.structure ?? options.arrayItem?.attributes.structure;
+ return isArrayShaped(candidate) ? candidate : undefined;
+}
+
+/** Whether a structure has the one field the downsampling maths needs. */
+function isArrayShaped(structure: unknown): structure is ArrayStructure {
+ if (!structure || typeof structure !== 'object') return false;
+ const shape = (structure as { shape?: unknown }).shape;
+ return Array.isArray(shape) && shape.every((axis) => typeof axis === 'number');
+}
+
+/** Whether enough structure is already in hand to decide downsampling without a round-trip. */
+export function hasArrayStructure(options: TiledArrayEndpointParams = {}): boolean {
+ return resolveArrayStructure(options) !== undefined;
+}
+
+/**
+ * The display dimensions of an array.
+ *
+ * For an RGB array the last three axes are `[height, width, channels]`, or the first three when
+ * `channelFirst`. Otherwise the last two are `[height, width]` and there is one channel. A 1-D array
+ * is treated as a single row.
+ */
+export function getDisplayShape(
+ structure: ArrayStructure,
+ options: TiledArrayEndpointParams = {},
+): { height: number; width: number; channels: number } {
+ // Defensive: callers reach this through `resolveArrayStructure`, which already rejects a
+ // non-array structure, but this is exported and a caller may hand it one directly.
+ const shape = Array.isArray(structure?.shape) ? structure.shape : [];
+
+ if (shape.length < 2) {
+ return { height: 1, width: shape[0] ?? 1, channels: 1 };
+ }
+ if (options.isRGB && options.channelFirst && shape.length >= 3 && shape[0] === 3) {
+ return { height: shape[1], width: shape[2], channels: 3 };
+ }
+ if (options.isRGB && shape.length >= 3 && shape[shape.length - 1] === 3) {
+ return { height: shape[shape.length - 3], width: shape[shape.length - 2], channels: 3 };
+ }
+ return { height: shape[shape.length - 2], width: shape[shape.length - 1], channels: 1 };
+}
+
+/** Bytes per element, from the dtype's own `itemsize` or, failing that, its kind. */
+export function getItemSize(structure: ArrayStructure): number {
+ const declared = structure.data_type?.itemsize;
+ if (typeof declared === 'number' && declared > 0) return declared;
+
+ const kind = structure.data_type?.kind?.[0];
+ return (kind && ITEMSIZE_BY_KIND[kind]) || 1;
+}
+
+export interface TiledDownsampleSteps {
+ stepX: number;
+ stepY: number;
+}
+
+/**
+ * How far to stride in each axis.
+ *
+ * An explicit `downSampleRatio` wins outright. Otherwise the payload is estimated from the display
+ * shape and item size, and if it exceeds the budget both axes are strided by
+ * `ceil(sqrt(overBudget))` — square-rooted because striding both axes reduces the payload
+ * quadratically.
+ *
+ * With no structure available there is nothing to estimate from, so nothing is downsampled. That is
+ * upstream's behaviour and the right default: guessing would mean silently returning a different
+ * array than asked for.
+ */
+export function computeDownsampleSteps(
+ structure: ArrayStructure | undefined,
+ options: TiledArrayEndpointParams,
+): TiledDownsampleSteps {
+ if (options.downSampleRatio && options.downSampleRatio > 1) {
+ const step = Math.ceil(options.downSampleRatio);
+ return { stepX: step, stepY: step };
+ }
+
+ if (!isArrayShaped(structure)) return { stepX: 1, stepY: 1 };
+
+ const { width, height, channels } = getDisplayShape(structure, options);
+ const itemSize = getItemSize(structure);
+ const budget = options.maxBytesAllowed ?? DEFAULT_MAX_BYTES;
+ const estimated = width * height * channels * itemSize;
+
+ if (estimated <= budget) return { stepX: 1, stepY: 1 };
+
+ const step = Math.ceil(Math.sqrt(estimated / budget));
+ return { stepX: step, stepY: step };
+}
+
+/**
+ * Format the `slice` query parameter.
+ *
+ * `stack` indices are fixed leading axes; the last two axes get the stride. The RGB variants keep
+ * the channel axis whole — `:` leading when the channels come first, trailing when they come last —
+ * because striding it would mix colours together.
+ */
+export function formatArraySlice(
+ options: TiledArrayEndpointParams,
+ steps: TiledDownsampleSteps,
+): string {
+ const { stepX, stepY } = steps;
+ const stack = options.stack ?? [];
+ const leading = stack.length > 0 ? `${stack.join(',')},` : '';
+
+ if (options.isRGB && options.channelFirst) return `:,${leading}::${stepY},::${stepX}`;
+ if (options.isRGB) return `${leading}::${stepY},::${stepX},:`;
+ return `${leading}::${stepY},::${stepX}`;
+}
+
+/**
+ * Build the `slice` parameter from structure already in hand.
+ *
+ * Synchronous, and therefore the version `getArrayAsImagePath` uses — it composes a URL and cannot
+ * await a metadata fetch. Without a `structure` or `arrayItem` in the options, no downsampling is
+ * applied.
+ */
+export function buildArraySlice(options: TiledArrayEndpointParams = {}): string {
+ return formatArraySlice(
+ options,
+ computeDownsampleSteps(resolveArrayStructure(options), options),
+ );
+}
+
+/** Fetches an array's structure for a path. Supplied by the client so this module stays acyclic. */
+export type StructureFetcher = (path: string) => Promise;
+
+/**
+ * Build the `slice` parameter, fetching the array's structure when it is not already in hand.
+ *
+ * A failed fetch is swallowed and treated as "no structure", which means no downsampling rather than
+ * a failed read. Upstream's behaviour, and the better trade: the caller asked for an array, and
+ * returning all of it beats returning nothing because a metadata request was slow.
+ */
+export async function buildArraySliceAsync(
+ arrayPath: string,
+ options: TiledArrayEndpointParams = {},
+ fetchStructure?: StructureFetcher,
+): Promise {
+ let structure = resolveArrayStructure(options);
+
+ if (!structure && fetchStructure) {
+ try {
+ structure = await fetchStructure(arrayPath);
+ } catch {
+ // Intentionally ignored — see the doc comment.
+ }
+ }
+
+ return formatArraySlice(options, computeDownsampleSteps(structure, options));
+}
diff --git a/src/api/tiled/client/defaultClient.ts b/src/api/tiled/client/defaultClient.ts
new file mode 100644
index 00000000..ac9a125c
--- /dev/null
+++ b/src/api/tiled/client/defaultClient.ts
@@ -0,0 +1,201 @@
+import type { AxiosInstance } from 'axios';
+import type {
+ InterceptorHandle,
+ TiledErrorInterceptor,
+ TiledRequestInterceptor,
+ TiledResponseInterceptor,
+} from '../types/common';
+import type {
+ TiledApiKeyLocation,
+ TiledApiKeyScheme,
+ TiledAuthErrorCallback,
+} from '../types/requestOptions';
+import { TiledApiClient, type TiledApiClientConfig } from './TiledApiClient';
+import type { TiledStoredTokens, TiledTokenStorage } from './tokenStorage';
+import { defaultTiledBaseUrl } from './urlUtils';
+
+/**
+ * The app-wide Tiled client.
+ *
+ * A module-level instance plus flat utility functions, so callers can reconfigure every request
+ * from anywhere without prop-drilling a client. Same three levels of control as
+ * [`qServer/client/defaultClient.ts`](../../qServer/client/defaultClient.ts):
+ *
+ * 1. mutate the active client — `setGlobalTiledApiKey`, `setDefaultTiledUrl`, …
+ * 2. replace it wholesale — `setDefaultTiledApiClient(new TiledApiClient({...}))`
+ * 3. override a single call — every endpoint method takes `options.client` / `options.apiKey`
+ *
+ * The hooks use this whenever no `TiledApiProvider` is mounted, and `FinchConfigProvider` applies
+ * `tiledApiUrl` / `tiledApiKey` to it — so most apps need none of these functions directly.
+ */
+
+let activeClient: TiledApiClient | null = null;
+
+/** Construct a client without touching the singleton. */
+export function createTiledApiClient(config: TiledApiClientConfig = {}): TiledApiClient {
+ return new TiledApiClient(config);
+}
+
+/** The active client, constructed from `window.location` defaults on first use. */
+export function getDefaultTiledApiClient(): TiledApiClient {
+ if (!activeClient) {
+ activeClient = new TiledApiClient({ baseUrl: defaultTiledBaseUrl() });
+ }
+ return activeClient;
+}
+
+/** Install a caller-built client as the app-wide instance. */
+export function setDefaultTiledApiClient(client: TiledApiClient): void {
+ activeClient = client;
+}
+
+/** Discard the active client. The next call builds a fresh one; useful in `beforeEach`. */
+export function resetDefaultTiledApiClient(): void {
+ activeClient = null;
+}
+
+/**
+ * Apply a partial configuration to the active client, creating it if needed.
+ * Only the provided keys are touched.
+ */
+export function configureTiledClient(config: TiledApiClientConfig): TiledApiClient {
+ const client = getDefaultTiledApiClient();
+ if (config.client) client.setAxiosClient(config.client);
+ if (config.baseUrl !== undefined) client.setBaseUrl(config.baseUrl);
+ if (config.initialPath !== undefined) client.setInitialPath(config.initialPath);
+ if (config.apiKey !== undefined) client.setApiKey(config.apiKey);
+ if (config.bearerToken !== undefined) client.setBearerToken(config.bearerToken);
+ if (config.apiKeyScheme !== undefined) client.setApiKeyScheme(config.apiKeyScheme);
+ if (config.apiKeyLocation !== undefined) client.setApiKeyLocation(config.apiKeyLocation);
+ if (config.signal !== undefined) client.setSignal(config.signal);
+ if (config.timeout !== undefined) client.setTimeout(config.timeout);
+ if (config.maxArrayBytes !== undefined) client.setMaxArrayBytes(config.maxArrayBytes);
+ if (config.onAuthError !== undefined) client.setAuthErrorCallback(config.onAuthError);
+ if (config.tokenStorage !== undefined) client.setTokenStorage(config.tokenStorage);
+ return client;
+}
+
+// #region transport utilities
+
+/**
+ * The base URL every subsequent request uses. **Must include the `/api/v1` segment.**
+ *
+ * Nothing appends it for you: a base URL is the one piece of configuration that cannot be guessed
+ * at without risking a misconfigured app silently talking to a URL it never asked for.
+ */
+export function setDefaultTiledUrl(baseUrl: string): void {
+ getDefaultTiledApiClient().setBaseUrl(baseUrl);
+}
+
+export function getDefaultTiledUrl(): string {
+ return getDefaultTiledApiClient().getBaseUrl();
+}
+
+/** The path prefix prepended to every relative request path. */
+export function setDefaultInitialPath(initialPath: string): void {
+ getDefaultTiledApiClient().setInitialPath(initialPath);
+}
+
+export function getDefaultTiledInitialPath(): string {
+ return getDefaultTiledApiClient().getInitialPath();
+}
+
+/** Adopt a caller-built axios instance, carrying interceptors over to it. */
+export function setGlobalTiledAxiosClient(client: AxiosInstance): void {
+ getDefaultTiledApiClient().setAxiosClient(client);
+}
+
+export function getGlobalTiledAxiosClient(): AxiosInstance {
+ return getDefaultTiledApiClient().getAxiosClient();
+}
+
+/** Default byte budget for array reads, so large images downsample instead of saturating the link. */
+export function setGlobalMaxArrayBytes(maxArrayBytes: number | undefined): void {
+ getDefaultTiledApiClient().setMaxArrayBytes(maxArrayBytes);
+}
+
+export function getGlobalMaxArrayBytes(): number | undefined {
+ return getDefaultTiledApiClient().getMaxArrayBytes();
+}
+
+// #endregion
+
+// #region auth utilities
+
+/** Swap the API key used by every subsequent request. Effective immediately; nothing to rebuild. */
+export function setGlobalApiKey(apiKey: string | null): void {
+ getDefaultTiledApiClient().setApiKey(apiKey);
+}
+
+export function getGlobalApiKey(): string | null {
+ return getDefaultTiledApiClient().getApiKey();
+}
+
+export function setDefaultBearerToken(token: string | null): void {
+ getDefaultTiledApiClient().setBearerToken(token);
+}
+
+/** Seed or clear the app-wide client's session — bearer token and stored refresh token together. */
+export function setGlobalTiledSession(tokens: TiledStoredTokens | null): void {
+ getDefaultTiledApiClient().setSession(tokens);
+}
+
+export function getGlobalTiledSession(): TiledStoredTokens | null {
+ return getDefaultTiledApiClient().getStoredTokens();
+}
+
+export function clearGlobalTiledAuth(): void {
+ getDefaultTiledApiClient().clearAuth();
+}
+
+/** `'header'` (default) or `'query'` (`?api_key=`), for `
` and future websockets. */
+export function setGlobalTiledApiKeyLocation(location: TiledApiKeyLocation): void {
+ getDefaultTiledApiClient().setApiKeyLocation(location);
+}
+
+/** `'ApiKey'` (default, the package's casing) or `'Apikey'` (Tiled's documented casing). */
+export function setGlobalTiledApiKeyScheme(scheme: TiledApiKeyScheme): void {
+ getDefaultTiledApiClient().setApiKeyScheme(scheme);
+}
+
+export function setDefaultAuthErrorCallback(callback: TiledAuthErrorCallback | undefined): void {
+ getDefaultTiledApiClient().setAuthErrorCallback(callback);
+}
+
+/** Replace where login tokens are persisted — memory in tests, `localStorage` in a browser. */
+export function setGlobalTiledTokenStorage(storage: TiledTokenStorage): void {
+ getDefaultTiledApiClient().setTokenStorage(storage);
+}
+
+// #endregion
+
+// #region interceptor utilities
+
+export function addRequestInterceptor(
+ onFulfilled: TiledRequestInterceptor,
+ onRejected?: TiledErrorInterceptor,
+): InterceptorHandle {
+ return getDefaultTiledApiClient().addRequestInterceptor(onFulfilled, onRejected);
+}
+
+export function addResponseInterceptor(
+ onFulfilled: TiledResponseInterceptor,
+ onRejected?: TiledErrorInterceptor,
+): InterceptorHandle {
+ return getDefaultTiledApiClient().addResponseInterceptor(onFulfilled, onRejected);
+}
+
+export function ejectInterceptor(handle: InterceptorHandle): boolean {
+ return getDefaultTiledApiClient().ejectInterceptor(handle);
+}
+
+/** Remove caller-registered interceptors; the built-in auth handlers are untouched. */
+export function clearInterceptors(kind?: 'request' | 'response'): void {
+ getDefaultTiledApiClient().clearInterceptors(kind);
+}
+
+export function listInterceptors(): readonly InterceptorHandle[] {
+ return getDefaultTiledApiClient().listInterceptors();
+}
+
+// #endregion
diff --git a/src/api/tiled/client/facade.ts b/src/api/tiled/client/facade.ts
new file mode 100644
index 00000000..f477670d
--- /dev/null
+++ b/src/api/tiled/client/facade.ts
@@ -0,0 +1,643 @@
+import { getDefaultTiledApiClient } from './defaultClient';
+import type { TiledFormatName } from './formats';
+import type { TiledBinaryBody } from '../types/common';
+import type {
+ DeliveryResponse,
+ GetDistinctResponse,
+ PatchMetadataRequest,
+ PatchMetadataResponse,
+ PostMetadataRequest,
+ PostMetadataResponse,
+ PutDataSourceRequest,
+ PutMetadataRequest,
+ PutMetadataResponse,
+ StructureFamily,
+ WebhookRegistrationRequest,
+ WebhookResponse,
+} from '../types/generatedAliases';
+import type { TiledAuthProvider, TiledInfoResponse } from '../types/info';
+import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes';
+import type { TiledLoginTokens, TiledRequestOptions } from '../types/requestOptions';
+import type { TiledDistinctConfig, TiledSearchConfig } from '../types/searchFilters';
+import type {
+ TiledArrayBufferOptions,
+ TiledArrayImagePathOptions,
+ TiledArrayJSONOptions,
+ TiledArrayOptionsMap,
+ TiledArrayPngOptions,
+ TiledArrayReturnMap,
+ TiledArrayReturnType,
+ TiledAwkwardRequestOptions,
+ TiledNodeRequestOptions,
+ TiledRaggedRequestOptions,
+ TiledTableEndpoint,
+ TiledTableJSONOptions,
+ TiledTableJSONSequenceOptions,
+ TiledTableOptionsMap,
+ TiledTableRequestOptions,
+ TiledTableReturnMap,
+ TiledTableReturnType,
+} from '../types/dataOptions';
+import type { TiledStructures } from '../types/structures';
+
+/**
+ * One free function per operation, issued against the app-wide client.
+ *
+ * These are the right tool outside a React component — a `useEffect`, an event handler, a plain
+ * module function. **Inside a component prefer the hooks**: these are hard-wired to the default
+ * client, so they ignore `TiledApiProvider` and take no part in the query cache.
+ *
+ * The seventeen names `@blueskyproject/tiled` exported are all here with their original signatures,
+ * so code moving over from the package keeps working unchanged.
+ */
+
+// #region info
+
+export function getTiledServerInfo(
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getServerInfo(options);
+}
+
+/** Like {@link getTiledServerInfo}, but rejects instead of resolving `null`. */
+export function getTiledAbout(options?: TiledRequestOptions): Promise {
+ return getDefaultTiledApiClient().getAbout(options);
+}
+
+export function getTiledHealth(options?: TiledRequestOptions): Promise {
+ return getDefaultTiledApiClient().getHealth(options);
+}
+
+export function getTiledUiSettings(options?: TiledRequestOptions): Promise {
+ return getDefaultTiledApiClient().getUiSettings(options);
+}
+
+export function getTiledMetrics(options?: TiledRequestOptions): Promise {
+ return getDefaultTiledApiClient().getMetrics(options);
+}
+
+// #endregion
+
+// #region search
+
+export function getTiledSearch(
+ searchPath: string,
+ config?: TiledSearchConfig,
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getSearch(searchPath, config, options);
+}
+
+export function getTiledDistinct(
+ searchPath: string,
+ config?: TiledDistinctConfig,
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getDistinct(searchPath, config, options);
+}
+
+/**
+ * The four filter conveniences the package shipped, rebuilt on {@link getTiledSearch}.
+ *
+ * Each just assembles the filter — there is one search endpoint, and these exist so the common
+ * cases read as what they are at the call site.
+ */
+export function getTiledSearchBySpecs(
+ searchPath: string,
+ filter: { include: string[]; exclude: string[] },
+ searchOptions?: TiledSearchConfig['searchOptions'],
+ options?: TiledRequestOptions,
+): Promise {
+ return getTiledSearch(searchPath, { searchFilters: { specs: filter }, searchOptions }, options);
+}
+
+export function getTiledSearchByFullText(
+ searchPath: string,
+ filter: { text: string },
+ searchOptions?: TiledSearchConfig['searchOptions'],
+ options?: TiledRequestOptions,
+): Promise {
+ return getTiledSearch(
+ searchPath,
+ { searchFilters: { fulltext: filter }, searchOptions },
+ options,
+ );
+}
+
+export function getTiledSearchByMetadataEquals(
+ searchPath: string,
+ filter: { key: string; value: string | number | boolean | null },
+ searchOptions?: TiledSearchConfig['searchOptions'],
+ options?: TiledRequestOptions,
+): Promise {
+ return getTiledSearch(searchPath, { searchFilters: { eq: filter }, searchOptions }, options);
+}
+
+export function getTiledSearchByStructureFamily(
+ searchPath: string,
+ filter: { value: StructureFamily },
+ searchOptions?: TiledSearchConfig['searchOptions'],
+ options?: TiledRequestOptions,
+): Promise {
+ return getTiledSearch(
+ searchPath,
+ { searchFilters: { structureFamily: filter }, searchOptions },
+ options,
+ );
+}
+
+// #endregion
+
+// #region metadata
+
+export function getTiledMetadata(
+ path: string,
+ options?: TiledRequestOptions,
+): Promise> {
+ return getDefaultTiledApiClient().getMetadata(path, options);
+}
+
+export function createTiledNode(
+ path: string,
+ body: PostMetadataRequest,
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().createNode(path, body, options);
+}
+
+export function updateTiledMetadata(
+ path: string,
+ body: PutMetadataRequest,
+ params?: { drop_revision?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().updateMetadata(path, body, params, options);
+}
+
+export function patchTiledMetadata(
+ path: string,
+ body: PatchMetadataRequest,
+ params?: { drop_revision?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchMetadata(path, body, params, options);
+}
+
+export function patchTiledMetadataMerge(
+ path: string,
+ body: Parameters['patchMetadataMerge']>[1],
+ params?: { drop_revision?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchMetadataMerge(path, body, params, options);
+}
+
+export function patchTiledMetadataJsonPatch(
+ path: string,
+ body: Parameters['patchMetadataJsonPatch']>[1],
+ params?: { drop_revision?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchMetadataJsonPatch(path, body, params, options);
+}
+
+export function deleteTiledNode(
+ path: string,
+ params?: { recursive?: boolean; external_only?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().deleteNode(path, params, options);
+}
+
+// #endregion
+
+// #region arrays
+
+export function getTiledArrayAs(
+ arrayPath: string,
+ type: T,
+ options?: TiledArrayOptionsMap[T],
+): Promise {
+ return getDefaultTiledApiClient().getArrayAs(arrayPath, type, options);
+}
+
+export function getTiledArrayAsJSON(
+ arrayPath: string,
+ options?: TiledArrayJSONOptions,
+): Promise {
+ return getDefaultTiledApiClient().getArrayAsJSON(arrayPath, options);
+}
+
+export function getTiledArrayAsPng(
+ arrayPath: string,
+ options?: TiledArrayPngOptions,
+): Promise {
+ return getDefaultTiledApiClient().getArrayAsPng(arrayPath, options);
+}
+
+export function getTiledArrayAsBuffer(
+ arrayPath: string,
+ options?: TiledArrayBufferOptions,
+): Promise {
+ return getDefaultTiledApiClient().getArrayAsBuffer(arrayPath, options);
+}
+
+export function getTiledArrayAsImagePath(
+ arrayPath: string,
+ options?: TiledArrayImagePathOptions,
+): string {
+ return getDefaultTiledApiClient().getArrayAsImagePath(arrayPath, options);
+}
+
+export function getTiledArrayBlock(
+ arrayPath: string,
+ params: { block: number[]; slice?: string; expected_shape?: string; format?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getArrayBlock(arrayPath, params, options);
+}
+
+export function putTiledArrayFull(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params?: { persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putArrayFull(arrayPath, data, params, options);
+}
+
+export function putTiledArrayBlock(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params: { block: number[]; persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putArrayBlock(arrayPath, data, params, options);
+}
+
+export function patchTiledArrayFull(
+ arrayPath: string,
+ data: TiledBinaryBody | number[][],
+ params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchArrayFull(arrayPath, data, params, options);
+}
+
+// #endregion
+
+// #region ragged
+
+export function getTiledRaggedFull(
+ path: string,
+ options?: TiledRaggedRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getRaggedFull(path, options);
+}
+
+export function putTiledRaggedFull(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params?: { persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putRaggedFull(path, data, params, options);
+}
+
+export function putTiledRaggedBlock(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params: { block: number[]; persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putRaggedBlock(path, data, params, options);
+}
+
+export function patchTiledRaggedFull(
+ path: string,
+ data: TiledBinaryBody | unknown[],
+ params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchRaggedFull(path, data, params, options);
+}
+
+// #endregion
+
+// #region tables
+
+export function getTiledTableAs(
+ tablePath: string,
+ type?: T,
+ endpoint?: TiledTableEndpoint,
+ options?: TiledTableOptionsMap[T],
+): Promise {
+ return getDefaultTiledApiClient().getTableAs(tablePath, type, endpoint, options);
+}
+
+export function getTiledTablePartitionAsJSON(
+ tablePath: string,
+ options?: TiledTableJSONOptions,
+): Promise {
+ return getDefaultTiledApiClient().getTablePartitionAsJSON(tablePath, options);
+}
+
+export function getTiledTablePartitionAsJSONSequence(
+ tablePath: string,
+ options?: TiledTableJSONSequenceOptions,
+): Promise {
+ return getDefaultTiledApiClient().getTablePartitionAsJSONSequence(tablePath, options);
+}
+
+export function getTiledTableFullAsJSON(
+ tablePath: string,
+ options?: TiledTableJSONOptions,
+): Promise {
+ return getDefaultTiledApiClient().getTableFullAsJSON(tablePath, options);
+}
+
+export function getTiledTableFullAsJSONSequence(
+ tablePath: string,
+ options?: TiledTableJSONSequenceOptions,
+): Promise {
+ return getDefaultTiledApiClient().getTableFullAsJSONSequence(tablePath, options);
+}
+
+export function getTiledTableFullAs(
+ tablePath: string,
+ format: TiledFormatName | string,
+ options?: TiledTableRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getTableFullAs(tablePath, format, options);
+}
+
+export function postTiledTableFull(
+ tablePath: string,
+ columns: string[] | null,
+ params?: { format?: string; filename?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().postTableFull(tablePath, columns, params, options);
+}
+
+export function postTiledTablePartition(
+ tablePath: string,
+ columns: string[] | null,
+ params: { partition: number; format?: string; filename?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().postTablePartition(tablePath, columns, params, options);
+}
+
+export function putTiledTablePartition(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { partition: number; mimetype: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putTablePartition(tablePath, data, params, options);
+}
+
+export function patchTiledTablePartition(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { partition: number; mimetype: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().patchTablePartition(tablePath, data, params, options);
+}
+
+export function putTiledTableFull(
+ tablePath: string,
+ data: TiledBinaryBody,
+ params: { mimetype: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putTableFull(tablePath, data, params, options);
+}
+
+// #endregion
+
+// #region containers, nodes, awkward
+
+export function getTiledContainerFull(
+ path: string,
+ options?: TiledNodeRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getContainerFull(path, options);
+}
+
+export function postTiledContainerFull(
+ path: string,
+ fields: string[] | null,
+ params?: { format?: string; filename?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().postContainerFull(path, fields, params, options);
+}
+
+export function getTiledNodeFull(
+ path: string,
+ options?: TiledNodeRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getNodeFull(path, options);
+}
+
+export function putTiledNodeFull(
+ path: string,
+ data: TiledBinaryBody,
+ params: { mimetype: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putNodeFull(path, data, params, options);
+}
+
+export function getTiledAwkwardFull(
+ path: string,
+ options?: TiledAwkwardRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getAwkwardFull(path, options);
+}
+
+export function getTiledAwkwardBuffers(
+ path: string,
+ options?: TiledAwkwardRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getAwkwardBuffers(path, options);
+}
+
+export function postTiledAwkwardBuffers(
+ path: string,
+ formKeys: string[],
+ params?: { format?: string; filename?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().postAwkwardBuffers(path, formKeys, params, options);
+}
+
+export function putTiledAwkwardFull(
+ path: string,
+ body: { form: unknown; length: number; container: Record },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putAwkwardFull(path, body, options);
+}
+
+// #endregion
+
+// #region registration, data sources, revisions, streams, assets
+
+export function postTiledRegister(
+ path: string,
+ body: PostMetadataRequest,
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().postRegister(path, body, options);
+}
+
+export function putTiledDataSource(
+ path: string,
+ body: PutDataSourceRequest,
+ params?: { patch_shape?: string; patch_offset?: string },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().putDataSource(path, body, params, options);
+}
+
+export function getTiledRevisions(
+ path: string,
+ params?: { pageOffset?: number; pageCursor?: number; pageLimit?: number },
+ options?: TiledRequestOptions,
+): Promise {
+ return getDefaultTiledApiClient().getRevisions(path, params, options);
+}
+
+export function deleteTiledRevision(
+ path: string,
+ params: { number: number },
+ options?: TiledRequestOptions,
+): Promise