@ayepi/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,7 +82,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
82
82
  - [`ayepi-core-types.md`](./ayepi-core-types.md)
83
83
  - [`ayepi-core.md`](./ayepi-core.md)
84
84
 
85
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/core) and are **not** shipped in the npm tarball.
85
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/core).
86
86
 
87
87
  ## License
88
88
 
@@ -0,0 +1,441 @@
1
+ <!--
2
+ ayepi-core-client.md — reference for `@ayepi/core`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/core` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/core` — client
11
+
12
+ The typed client exposes `call` / `url` / `on` whose argument and return types are derived
13
+ per endpoint from the spec **type** (used type-only). It speaks both HTTP and the ws frame
14
+ protocol, splitting the single `data` payload back into kinds via the manifest key tables.
15
+
16
+ ## The zod-free entry — `@ayepi/core/client`
17
+
18
+ Import the client from **`@ayepi/core/client`** in browser/frontend code. That entry
19
+ contains **zero zod runtime code** (verified in CI). The client module imports zod
20
+ **type-only** — it never references `z` as a value — so nothing in the request/response path
21
+ pulls zod into your bundle.
22
+
23
+ ```ts
24
+ import { client, wsTransport } from '@ayepi/core/client' // zod-free
25
+ import type { api } from './api' // type-only — erased at build
26
+ ```
27
+
28
+ **Why the type-only spec import matters:** `client<typeof api>(...)` uses the spec purely for
29
+ inference. Because `import type` is erased at build, your bundle gets the exact typed surface
30
+ (`sdk.call('getUser', { id })` is fully checked) **without** shipping the schemas. The
31
+ runtime routing comes from the `Manifest`, which is plain data.
32
+
33
+ ## `client()`
34
+
35
+ ```ts
36
+ function client<S extends AnySpec>(opts: ClientOptions): ApiClient<S>
37
+
38
+ interface ClientOptions {
39
+ readonly baseUrl: string // HTTP base (trailing slash optional)
40
+ readonly manifest: Manifest | AnySpec // routing table — Manifest OR the spec
41
+ readonly headers?: Record<string,string> | (() => Record<string,string>) // static or computed per request
42
+ readonly fetchImpl?: (req: Request) => Promise<Response> // override fetch (tests / in-memory)
43
+ readonly ws?: ClientWs // ws transport — required for ws calls + events
44
+ readonly prefer?: 'http' | 'ws' // preferred transport for dual endpoints (default 'http')
45
+ readonly validate?: AnySpec // opt-in: parse responses/items with their schemas
46
+ readonly cache?: ClientCacheOptions // defaults for sdk.caller(...) caches (max/ttl/store) — see Callers
47
+ }
48
+ ```
49
+
50
+ ### `manifest`: a `Manifest` OR the spec
51
+
52
+ `client()` accepts **either**:
53
+
54
+ - a zod-free **`Manifest`** — keeps the frontend bundle schema-free. This is the recommended
55
+ path. The slim path stays zod-free purely by tree-shaking: a manifest value carries no
56
+ derivation code.
57
+ - the **spec itself** — convenient when bundle size isn't a concern; the client derives the
58
+ manifest from it. Because the spec holds zod, **this pulls zod into the bundle**. (The
59
+ spec is read for its stamped manifest-builder under `Symbol.for('ayepi.manifest')`, not by
60
+ importing the deriver.)
61
+
62
+ ### Acquiring the manifest
63
+
64
+ Get the manifest one of three ways:
65
+
66
+ ```ts
67
+ // 1. from the running server
68
+ const manifest = app.manifest()
69
+
70
+ // 2. from the spec (importing manifestFromSpec pulls zod into the bundle)
71
+ import { manifestFromSpec } from '@ayepi/core'
72
+ const manifest = manifestFromSpec(api)
73
+
74
+ // 3. hand the spec straight to the client (ships zod)
75
+ const sdk = client<typeof api>({ baseUrl, manifest: api })
76
+ ```
77
+
78
+ The recommended frontend pattern: build the manifest once (it's plain JSON), commit it (or
79
+ write it to a file the frontend imports), and pass that value:
80
+
81
+ ```ts
82
+ import manifest from './manifest.gen' // prebuilt zod-free manifest (plain data)
83
+ const sdk = client<typeof api>({ baseUrl: 'https://api.example.dev', manifest })
84
+ ```
85
+
86
+ ### Headers (static / computed)
87
+
88
+ `headers` may be a static record or a **function** called per request (e.g. to inject a
89
+ fresh auth token). Per-call `opts.headers` merge on top and also deliver typed request
90
+ headers/cookies.
91
+
92
+ ```ts
93
+ const sdk = client<typeof api>({ baseUrl, manifest, headers: () => ({ authorization: `Bearer ${getToken()}` }) })
94
+ ```
95
+
96
+ ## `sdk.call` / `sdk.url` / `sdk.on`
97
+
98
+ ```ts
99
+ interface ApiClient<S extends AnySpec> {
100
+ call<K>(name: K, ...args: CallArgs<S['endpoints'][K]>): CallReturn<S['endpoints'][K]>
101
+ url<K extends GetUrlKeys<S>>(name: K, ...args: …): string // GET endpoints only
102
+ on<K>(name: K, ...args: …): () => void // subscribe; returns unsubscribe
103
+ }
104
+ ```
105
+
106
+ ### `call(name, data?, opts?)`
107
+
108
+ Arguments are computed per endpoint (see `ayepi-core-types.md` for `CallArgs`):
109
+
110
+ ```ts
111
+ await sdk.call('health') // no data → opts-only
112
+ await sdk.call('getUser', { id: 'u1' }) // merged data
113
+ await sdk.call('searchDocs', { q: 'x', filters: ['a'] }) // query + body in one data
114
+ await sdk.call('echoText', 'hello') // non-object body IS the data (required positional)
115
+ await sdk.call('createThing', { name }) // → { status, data } discriminated union
116
+ await sdk.call('ingestData', { tag: 't' }, { stream }) // streamIn → opts.stream required
117
+ ```
118
+
119
+ Per-call `opts` (`CallOpts`):
120
+
121
+ ```ts
122
+ interface CallOptsBase {
123
+ readonly signal?: AbortSignal // cancels the in-flight request (and, over ws, the call)
124
+ readonly headers?: Record<string,string> // extra headers; also delivers typed headers/cookies
125
+ readonly onUploadProgress?: (p: { loaded: number; total: number }) => void // request-upload progress
126
+ }
127
+ // plus, per endpoint:
128
+ // transport?: 'http' | 'ws' (narrowed to 'http' for httpOnly endpoints)
129
+ // stream: required for streamIn endpoints (StreamBody for raw, AsyncIterable for typed items)
130
+ ```
131
+
132
+ **Upload progress.** Pass `onUploadProgress` to watch bytes sent for a file/multipart or body
133
+ upload — the natural fit for a progress bar:
134
+
135
+ ```ts
136
+ await sdk.call('upload', { doc: file, title }, {
137
+ onUploadProgress: ({ loaded, total }) => setPct(Math.round((loaded / total) * 100)),
138
+ })
139
+ ```
140
+
141
+ `fetch` exposes no upload-progress events, so when this is set the request goes via
142
+ `XMLHttpRequest` instead (the only transport that reports them). Consequences: it **bypasses a
143
+ custom `fetchImpl`**, applies only to **non-streaming** requests/responses (ignored for
144
+ `streamIn`/`streamOut` endpoints), and is a **browser** feature — where `XMLHttpRequest` is absent
145
+ (e.g. server-side) the call still completes via `fetch`, just without progress. The response and
146
+ errors are identical to the `fetch` path (`ApiError`, `AbortError` on `signal`).
147
+
148
+ ### `url(name, data?)` — GET-only
149
+
150
+ Builds a plain GET URL (typed against the endpoint's data). Hand it to the browser
151
+ (`location`, `<a href>`, `window.open`, `EventSource`) for native streamed downloads / SSE.
152
+ Only `GET` endpoints are accepted (`GetUrlKeys<S>`); calling it on a non-GET endpoint is a
153
+ type error and throws at runtime.
154
+
155
+ ```ts
156
+ const zipUrl = sdk.url('downloadZip', { name: 'report' }) // 'https://…/downloadZip?name=report'
157
+ window.open(zipUrl) // browser streams the download (Content-Disposition)
158
+
159
+ const es = new EventSource(sdk.url('ticker', { n: 100 })) // SSE endpoint
160
+ es.onmessage = (e) => console.log(JSON.parse(e.data))
161
+ ```
162
+
163
+ ### `on(name, params?, cb)` — event subscriptions
164
+
165
+ Subscribe to a server-pushed event; returns an unsubscribe function. Parameterized channels
166
+ **require** the params object (and it keys delivery); broadcast channels omit it.
167
+
168
+ ```ts
169
+ const off = sdk.on('jobProgress', { jobId: 'job-7' }, (d) => console.log(d.pct)) // typed, param-keyed
170
+ sdk.on('systemNotice', (d) => console.log(d.msg)) // broadcast, no params
171
+ off() // unsubscribe (sends an unsub frame when the last listener for a key leaves)
172
+ ```
173
+
174
+ `on()` requires a configured `ws` transport (throws otherwise).
175
+
176
+ ## Callers — client-side call policy
177
+
178
+ `sdk.caller(name, options)` returns a **typed `Caller`** whose `call(...args)` is the same as
179
+ `sdk.call(name, ...args)` but wrapped with stateful policy. Each feature is its own wrapper
180
+ function layered around the base call (so their state never tangles), composed in a fixed order:
181
+
182
+ ```
183
+ hooks → cache(read/write + SWR) → dedupe → lastOnly → debounce → rateLimit → retry → call
184
+ ```
185
+
186
+ ```ts
187
+ interface Caller<E> {
188
+ call(...args): Promise<…> // same arguments/return as sdk.call
189
+ cancel(): void // abort in-flight calls + drop pending debounced calls
190
+ invalidate(): void // clear this caller's own cached entries
191
+ readonly pending: number // calls currently in flight
192
+ }
193
+ ```
194
+
195
+ A read with caching + dedupe, and a search box that debounces and only keeps the latest:
196
+
197
+ ```ts
198
+ const getUser = sdk.caller('getUser', { cache: { ttl: 30_000, tags: ['user'] }, dedupe: true })
199
+ await getUser.call({ id: 'u1' }) // network; cached 30s, shared with concurrent identical calls
200
+
201
+ const search = sdk.caller('search', { debounce: 250, lastOnly: true })
202
+ search.call({ q }) // coalesces keystrokes; supersedes older requests (they reject AbortError)
203
+ ```
204
+
205
+ A mutation that **invalidates** other callers' caches by tag (shared across the client's callers):
206
+
207
+ ```ts
208
+ const createUser = sdk.caller('createUser', { invalidates: ['user'], retry: { attempts: 3 } })
209
+ await createUser.call({ name }) // on success, clears every cached entry tagged 'user' (e.g. getUser above)
210
+ ```
211
+
212
+ `CallerOptions` (each is independent; omit to skip that layer):
213
+
214
+ | Option | Shape | Effect |
215
+ |---|---|---|
216
+ | `cache` | `true` \| `{ ttl?, staleWhileRevalidate?, key?, tags?, store? }` | Cache by the call's data. `key` defaults to stable JSON; `store` is `'memory'` (default) / `'session'` / `'local'` / a custom `KVStore`. `staleWhileRevalidate` serves a stale value while refetching. |
217
+ | `debounce` | `number` \| `{ wait, maxWait?, leading?, accumulate?, spread? }` | Trailing debounce. `accumulate(dataList)` merges queued calls into one; `spread(result, dataList)` fans the result back. |
218
+ | `rateLimit` | `{ limit, window, onLimit? }` | Token bucket. `onLimit`: `'wait'` (default) / `'drop'` / `'throw'` (rejects `CallerRateLimited`). |
219
+ | `retry` | `{ attempts?, base?, factor?, max?, jitter? }` | Exponential-backoff retry on failure. |
220
+ | `lastOnly` | `boolean` | Only the most recent call resolves; older in-flight calls are aborted (reject `AbortError`). |
221
+ | `dedupe` | `boolean` | Concurrent identical calls share one request. |
222
+ | `invalidates` | `string[]` \| `(data, result) => string[]` | Tags to clear (in **all** the client's caches) when this caller runs. `invalidateOn`: `'success'` (default) / `'start'` / `'both'`. |
223
+ | `onStart`/`onSuccess`/`onError`/`onSettled` | callbacks | Lifecycle hooks; pair with `caller.pending`. |
224
+
225
+ Caches are **shared across a client's callers** (one cache per distinct `store`), so a mutating
226
+ caller's tag invalidation reaches the cached reads of other callers. Set `client({ cache: { max,
227
+ ttl, store } })` for cache defaults. **Streaming** endpoints (item/raw streams) bypass every layer
228
+ — policies apply to unary request/response calls. The cache primitive is also exported standalone
229
+ as `createClientCache(...)`.
230
+
231
+ ## HTTP vs ws transport selection
232
+
233
+ - Default transport is `'http'`, unless `prefer: 'ws'` is set (and the endpoint is
234
+ ws-eligible and a `ws` transport is configured).
235
+ - Per call, `opts.transport: 'http' | 'ws'` overrides. For **httpOnly** endpoints
236
+ (files / raw streams), `transport` is narrowed to `'http'` at the type level; passing
237
+ `'ws'` is a compile error and rejects/throws at runtime.
238
+ - Typed item streams travel over **either** transport (NDJSON/SSE over HTTP, chunk frames
239
+ over ws). Raw byte streams and file uploads are HTTP-only.
240
+
241
+ ```ts
242
+ await sdk.call('getUser', { id: 'u1' }, { transport: 'ws' }) // dual endpoint over ws
243
+ for await (const r of sdk.call('streamRows', { n: 3 }, { transport: 'ws' })) … // item stream over ws
244
+ ```
245
+
246
+ ## Typed item streams
247
+
248
+ `call()` on a `streamOut`-schema endpoint returns an `AsyncIterable` — `for await` it:
249
+
250
+ ```ts
251
+ for await (const row of sdk.call('streamRows', { n: 4 })) console.log(row.i) // typed items
252
+ ```
253
+
254
+ - **Over HTTP**, items decode lazily from NDJSON (`application/x-ndjson`) or SSE
255
+ (`text/event-stream`); the request fires on first pull.
256
+ - **Over ws**, items arrive as chunk frames until an `end` frame.
257
+ - **Streaming IN** (`streamIn` schema): pass `opts.stream` as an `AsyncIterable` (or a
258
+ generator function). Over HTTP it's sent as an NDJSON request body (`duplex: 'half'`); over
259
+ ws it's pumped as chunk frames. Duplex endpoints stream items both directions.
260
+
261
+ ```ts
262
+ for await (const r of sdk.call('enrich', { factor: 10 }, {
263
+ stream: async function* () { yield { id: 1, v: 1 }; yield { id: 2, v: 2 } },
264
+ })) console.log(r.scaled)
265
+ ```
266
+
267
+ Raw `streamOut` (string content-type) resolves a `Promise<ReadableStream<Uint8Array>>`
268
+ instead.
269
+
270
+ ## Opt-in response validation (`validate`)
271
+
272
+ By default the client does **not** validate responses (types assert shapes statically; no zod
273
+ at runtime). Pass `validate: spec` to parse responses/items with their zod schemas as they
274
+ arrive — this only pulls zod in because *you* supplied the schema-bearing spec:
275
+
276
+ ```ts
277
+ const sdk = client<typeof api>({ baseUrl, manifest, validate: api })
278
+ const u = await sdk.call('getUser', { id: 'u1' }) // response .parse()'d
279
+ for await (const r of sdk.call('streamRows', { n: 2 })) … // each item .parse()'d
280
+ ```
281
+
282
+ ## Errors — `ApiError`
283
+
284
+ Failed calls reject with an `ApiError`, reconstructed identically from an HTTP error
285
+ envelope or a ws error frame:
286
+
287
+ ```ts
288
+ class ApiError extends Error {
289
+ readonly status: number // HTTP (or ws-mapped) status
290
+ readonly code: string // stable machine code, e.g. 'UNAUTHORIZED'
291
+ readonly data?: unknown // declared-error body, or the raw envelope
292
+ }
293
+ ```
294
+
295
+ ```ts
296
+ try {
297
+ await sdk.call('login', { user: 'blocked' })
298
+ } catch (err) {
299
+ if (err instanceof ApiError && err.status === 403) {
300
+ const { reason } = err.data as { reason: string } // declared typed error body
301
+ }
302
+ }
303
+ ```
304
+
305
+ `reject(status, code, message?)` constructs an `ApiError` for throwing from
306
+ handlers/middleware (see `ayepi-core-middleware.md`).
307
+
308
+ ### The ws wire protocol (errors + status)
309
+
310
+ Every ws **call response** carries a reserved `$status` (the `$`-prefix avoids colliding
311
+ with your payload, which lives under `data`). The client throws whenever `$status` is **not
312
+ 2xx**, mirroring HTTP:
313
+
314
+ ```jsonc
315
+ // success: { "id": "c1", "$status": 200, "data": <result> } // multi-status: data = { status, data }
316
+ // error: { "id": "c1", "$status": 404, "$error": "Not Found", "$code": "NOT_FOUND", "data": <typed body?> }
317
+ ```
318
+
319
+ - `$status` — the status code (always present on a call response).
320
+ - `$error` — a human-readable message. If omitted, the client derives one from `$status`
321
+ (a known status text, else `Request failed with status <n>`).
322
+ - `$code` — the machine code → `ApiError.code` (defaults to `'ERROR'` when omitted, e.g. for
323
+ declared errors, matching HTTP).
324
+ - `data` — the typed error body for **declared** errors (`errors: { 404: … }`); read it via
325
+ `err.data` exactly as over HTTP.
326
+
327
+ A non-2xx `$status` becomes `new ApiError($status, $code, $error, data)`. The transport also
328
+ synthesizes `$status: 0` `DISCONNECTED` frames so awaited ws calls reject instead of hanging
329
+ when the socket drops. The generated **AsyncAPI** document models both the success and error
330
+ reply frames per endpoint (see `app.asyncapi()`).
331
+
332
+ ## Cancellation
333
+
334
+ Every `call()` accepts `opts.signal`. Over HTTP it aborts the `fetch`; **over ws it sends an
335
+ `{ id, abort: true }` frame** so the server aborts the per-call signal and stops streaming,
336
+ and the client rejects the pending / fails the item stream.
337
+
338
+ ```ts
339
+ const ac = new AbortController()
340
+ const rows = sdk.call('streamRows', { n: 1_000_000 }, { transport: 'ws', signal: ac.signal })
341
+ setTimeout(() => ac.abort(), 100) // stops the server mid-stream
342
+ ```
343
+
344
+ ## `wsTransport()` — resilient browser WebSocket
345
+
346
+ `client`'s `ws` option accepts any `ClientWs` (`{ send, onMessage }`). `wsTransport` is a
347
+ production-ready one with lazy connect, reconnect (exponential backoff + jitter, capped),
348
+ **resubscribe** of live channels after reconnect, in-flight call failure on drop, and an
349
+ optional heartbeat. It speaks only `WebSocket` + JSON, so it stays zod-free and ships in the
350
+ `@ayepi/core/client` entry.
351
+
352
+ ```ts
353
+ function wsTransport(url: string | (() => string), opts?: WsTransportOptions): WsTransport
354
+ ```
355
+
356
+ > **Option names have no `Ms` suffix.** Durations are plain numbers in **milliseconds**
357
+ > (`interval`, `timeout`, `initial`, `max`) — there is no `intervalMs` etc.
358
+
359
+ ### Authenticating the connection
360
+
361
+ Browsers **can't set headers** on a WebSocket handshake, so a bearer token can't ride an
362
+ `Authorization` header the way HTTP requests do (where `client({ headers })` handles it).
363
+ Instead, pass `url` (or `protocols`) as a **function** — it's re-resolved at each (re)connect,
364
+ so you can carry a token that isn't known until after login (as a query param or subprotocol):
365
+
366
+ ```ts
367
+ const token = ref('') // set after a REST login call
368
+ const sdk = client<typeof api>({
369
+ baseUrl: location.origin,
370
+ manifest,
371
+ headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}), // HTTP auth
372
+ ws: wsTransport(() => `wss://api.example.dev/ws?access_token=${token.value}`), // ws auth
373
+ })
374
+ ```
375
+
376
+ The transport connects **lazily** (on the first `sdk.on(...)` / ws call), so if you only
377
+ subscribe after login the token is always present. On the server, `@ayepi/auth`'s `bearerAuth`
378
+ reads that `?access_token=` query param over ws by default (it's on the upgrade request) — see
379
+ `ayepi-auth.md`. Never sign tokens on the client; mint them server-side at login and pass the
380
+ result.
381
+
382
+ ```ts
383
+ interface WsTransportOptions {
384
+ readonly protocols?: string | string[] | (() => string | string[] | undefined) // value, or resolved per (re)connect
385
+ readonly WebSocket?: WebSocketCtor // ctor override (defaults to global; pass `ws` in Node)
386
+ readonly whileDisconnected?: 'queue' | 'fail' // non-sub frames while down (default 'fail' = reject immediately)
387
+ readonly backoff?: BackoffOptions // reconnect backoff tuning
388
+ readonly heartbeat?: HeartbeatOptions | false // heartbeat tuning, or false to disable (default enabled)
389
+ readonly maxRetries?: number // give up after N consecutive failed reconnects (default Infinity)
390
+ readonly onStateChange?: (state: WsState) => void // 'closed' | 'connecting' | 'open'
391
+ readonly onError?: (error: unknown) => void
392
+ }
393
+
394
+ interface BackoffOptions {
395
+ readonly initial?: number // first retry delay, ms (default 500)
396
+ readonly max?: number // max retry delay, ms (default 30_000)
397
+ readonly factor?: number // growth factor per attempt (default 2)
398
+ readonly jitter?: boolean // random jitter in [delay/2, delay] (default true)
399
+ }
400
+
401
+ interface HeartbeatOptions {
402
+ readonly interval?: number // ms between { ping: true } (default 30_000)
403
+ readonly timeout?: number // ms to await { pong: true } before force-reconnect (default 10_000)
404
+ }
405
+ ```
406
+
407
+ `WsTransport` extends `ClientWs` with explicit lifecycle control:
408
+
409
+ ```ts
410
+ interface WsTransport extends ClientWs {
411
+ connect(): void // open now (otherwise lazy on first send)
412
+ close(): void // close permanently, stop reconnecting
413
+ readonly state: WsState // 'closed' | 'connecting' | 'open'
414
+ }
415
+ ```
416
+
417
+ ```ts
418
+ const sdk = client<typeof api>({
419
+ baseUrl: 'https://api.example.dev',
420
+ manifest,
421
+ ws: wsTransport('wss://api.example.dev/ws', {
422
+ heartbeat: { interval: 30_000, timeout: 10_000 },
423
+ backoff: { initial: 500, max: 30_000 },
424
+ whileDisconnected: 'queue',
425
+ }),
426
+ })
427
+ ```
428
+
429
+ ## Exported client symbols
430
+
431
+ `client`, `wsTransport`, and the types `ApiClient`, `ClientOptions`, `ClientWs`,
432
+ `GetUrlKeys`, `WsTransport`, `WsTransportOptions`, `WsState`, `BackoffOptions`,
433
+ `HeartbeatOptions`, `WebSocketLike`, `WebSocketCtor`, `WsMessageEvent`. Plus `ApiError` /
434
+ `reject` from errors, and the payload types in `ayepi-core-types.md` (incl. `UploadProgress`,
435
+ the `onUploadProgress` shape).
436
+
437
+ **Callers** (this doc's [Callers](#callers--client-side-call-policy) section): `createClientCache`,
438
+ `createCallerContext`, `stableStringify`, `CallerRateLimited`, and the types `Caller`,
439
+ `CallerOptions`, `CallerCacheConfig`, `CallerDebounceConfig`, `CallerRateLimitConfig`,
440
+ `CallerRetryConfig`, `Tagger`, `ClientCache`, `ClientCacheOptions`, `CallerContext`,
441
+ `CacheStoreSpec`, `CacheHit`, `KVStore`.