ani-client 2.2.1 → 2.4.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
@@ -8,17 +8,18 @@
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
9
 
10
10
  > A fully typed, zero-dependency client for the [AniList](https://anilist.co) GraphQL API.
11
- > Supports Node.js, Bun, Deno, and modern browsers.
11
+ > Supports Node.js and modern browsers.
12
12
 
13
13
  ## Features
14
14
 
15
15
  - **Zero dependencies** — uses the native `fetch` API
16
- - **Universal** — Node.js ≥ 20, Bun, Deno, and modern browsers
16
+ - **Universal** — Node.js ≥ 20, and modern browsers
17
17
  - **Dual format** — ships ESM + CJS with full `.d.ts` declarations
18
18
  - **LRU cache** with TTL, stale-while-revalidate, and hit/miss stats
19
19
  - **Rate-limit protection** with exponential backoff, retries, and custom strategies
20
20
  - **Request deduplication** — concurrent identical queries share a single in-flight request
21
- - **Batch queries** — fetch up to 50 media, characters, or staff in a single API call
21
+ - **Automatic batching (DataLoader)** — groups rapid, individual `getMedia(id)` calls within a 50ms window into a single batch query, saving API limits.
22
+ - **Batch queries** — manually fetch up to 50 media, characters, or staff in a single API call
22
23
  - **Auto-pagination** — async iterator that yields items across all pages
23
24
  - **AbortSignal support** — cancel globally or per-request via `withSignal()`
24
25
  - **Injectable logger** — plug in `console`, pino, winston, or any compatible logger
@@ -52,180 +53,21 @@ const results = await client.searchMedia({
52
53
  genres: ["Action"],
53
54
  perPage: 10,
54
55
  });
55
-
56
- // Lookup by MyAnimeList ID
57
- const fma = await client.getMediaByMalId(5114);
58
- ```
59
-
60
- ## Usage
61
-
62
- ### Caching
63
-
64
- The client caches every response in memory by default. You can tune TTL, capacity, and enable stale-while-revalidate:
65
-
66
- ```ts
67
- const client = new AniListClient({
68
- cache: {
69
- ttl: 1000 * 60 * 5, // 5 min TTL
70
- maxSize: 200, // LRU capacity
71
- staleWhileRevalidateMs: 60_000, // serve stale for 1 min after expiry
72
- },
73
- });
74
- ```
75
-
76
- For distributed setups, swap to the built-in Redis adapter:
77
-
78
- ```ts
79
- import { AniListClient, RedisCache } from "ani-client";
80
- import { createClient } from "redis";
81
-
82
- const redis = createClient();
83
- await redis.connect();
84
-
85
- const client = new AniListClient({
86
- cacheAdapter: new RedisCache(redis),
87
- });
88
56
  ```
89
57
 
90
- ### Rate Limiting
91
-
92
- The client is pre-configured to stay within AniList's 30 req/min limit. You can override the defaults and provide a custom retry strategy:
93
-
94
- ```ts
95
- const client = new AniListClient({
96
- rateLimit: {
97
- maxRequests: 25,
98
- windowMs: 60_000,
99
- maxRetries: 3,
100
- retryOnNetworkError: true,
101
- retryStrategy: (attempt) => (attempt + 1) * 1000, // linear backoff
102
- },
103
- });
104
-
105
- // Inspect the rate limit state after any request
106
- console.log(client.rateLimitInfo);
107
- // { remaining: 22, limit: 25, reset: 1741104000 }
108
- ```
109
-
110
- ### Batch Requests
111
-
112
- Fetch multiple entries in a single API call (chunks of 50):
113
-
114
- ```ts
115
- const anime = await client.getMediaBatch([1, 5, 6, 20]);
116
- const characters = await client.getCharacterBatch([1, 2, 3]);
117
- const staff = await client.getStaffBatch([95269, 95270]);
118
- ```
119
-
120
- ### Auto-Pagination
121
-
122
- Use the built-in async iterator to walk through all pages automatically:
123
-
124
- ```ts
125
- for await (const anime of client.paginate(
126
- (page) => client.searchMedia({ query: "Gundam", page, perPage: 50 }),
127
- 5, // max 5 pages
128
- )) {
129
- console.log(anime.title.romaji);
130
- }
131
- ```
132
-
133
- ### Request Cancellation
134
-
135
- Scope any client instance to an `AbortSignal` for per-request cancellation:
136
-
137
- ```ts
138
- const controller = new AbortController();
139
- const scoped = client.withSignal(controller.signal);
140
-
141
- setTimeout(() => controller.abort(), 3_000);
142
- const anime = await scoped.getMedia(1); // cancelled after 3s
143
- ```
144
-
145
- ### Logging
146
-
147
- Pass any `console`-compatible logger to trace requests and cache events:
148
-
149
- ```ts
150
- const client = new AniListClient({ logger: console });
151
- // debug: "API request" { variables: { id: 1 } }
152
- // debug: "Request complete" { durationMs: 120 }
153
- ```
154
-
155
- ### Media Relationships
156
-
157
- Paginated access to a media's characters and staff:
158
-
159
- ```ts
160
- const characters = await client.getMediaCharacters(1, {
161
- page: 1,
162
- perPage: 25,
163
- voiceActors: true,
164
- });
165
-
166
- const staff = await client.getMediaStaff(1, { page: 1, perPage: 25 });
167
- ```
168
-
169
- ### Users, Characters, Studios & More
170
-
171
- ```ts
172
- const user = await client.getUser("AniList");
173
- const favs = await client.getUserFavorites("AniList", { perPage: 50 });
174
- const char = await client.getCharacter(1, { voiceActors: true });
175
- const studio = await client.getStudio(21, { media: { perPage: 50 } });
176
- const schedule = await client.getWeeklySchedule();
177
- const review = await client.getReview(760);
178
- ```
179
-
180
- ### Error Handling
181
-
182
- All API errors throw an `AniListError` with a `status` code and the raw GraphQL `errors` array:
183
-
184
- ```ts
185
- import { AniListError } from "ani-client";
186
-
187
- try {
188
- await client.getMedia(999999999);
189
- } catch (e) {
190
- if (e instanceof AniListError) {
191
- console.error(e.message); // "Not Found"
192
- console.error(e.status); // 404
193
- console.error(e.errors); // raw GraphQL errors array
194
- }
195
- }
196
- ```
197
-
198
- ### Lifecycle Hooks
58
+ ## Documentation
199
59
 
200
- Intercept requests, responses, cache events, and errors:
60
+ For full API reference, configuration options, caching, rate limiting, and advanced usage guides, please visit the official documentation:
201
61
 
202
- ```ts
203
- const client = new AniListClient({
204
- hooks: {
205
- onRequest: (query, variables) => console.log("→", variables),
206
- onResponse: (query, durationMs, fromCache) => console.log(`← ${durationMs}ms`),
207
- onCacheHit: (key) => console.log("cache hit", key),
208
- onRateLimit: (retryAfterMs) => console.warn(`rate limited, retrying in ${retryAfterMs}ms`),
209
- onError: (error) => console.error(error.message),
210
- },
211
- });
212
- ```
62
+ **[📚 ani-client.js.org](https://ani-client.js.org)**
213
63
 
214
64
  ## Requirements
215
65
 
216
66
  | Runtime | Version |
217
67
  |----------|--------------------|
218
68
  | Node.js | ≥ 22.13.0 |
219
- | Bun | ≥ 1.0 |
220
- | Deno | ≥ 1.28 |
221
69
  | Browsers | `fetch` + `AbortController` required |
222
70
 
223
- ## Documentation
224
-
225
- Full API reference, configuration options, and guides (caching, pagination, hooks, Redis, etc.):
226
-
227
- **[ani-client.js.org](https://ani-client.js.org)**
228
-
229
71
  ## Community
230
72
 
231
73
  - 💬 [Discord server](https://discord.gg/3P7twDurUD)
@@ -233,34 +75,10 @@ Full API reference, configuration options, and guides (caching, pagination, hook
233
75
 
234
76
  ## Contributing
235
77
 
236
- Contributions are welcome.
237
-
238
- Before opening an issue or a pull request, please read:
78
+ Contributions are welcome! Before opening an issue or a pull request, please read:
239
79
  - [CONTRIBUTING.md](CONTRIBUTING.md)
240
80
  - [SECURITY.md](SECURITY.md)
241
81
 
242
- This repository also includes GitHub issue templates and a pull request template to help keep reports and contributions consistent.
243
-
244
- ## Development
245
-
246
- Quick commands for local development and CI checks:
247
-
248
- ```bash
249
- pnpm install
250
- pnpm run build # build dist
251
- pnpm run typecheck # TypeScript strict checks
252
- pnpm run lint # lint the codebase
253
- pnpm test # run unit + integration tests
254
- pnpm run docs:dev # run documentation site locally
255
- ```
256
-
257
- Notes:
258
- - `tsconfig.json` has `strict: true` enabled to enforce stricter TypeScript checks.
259
- - Dependabot is configured to open weekly dependency PRs — you will review and merge them manually.
260
- - CI should validate `build`, `typecheck`, `lint`, and `test` before merging PRs.
261
-
262
- This repository also includes GitHub issue templates and a pull request template to help keep reports and contributions consistent.
263
-
264
82
  ## License
265
83
 
266
- [MIT](LICENSE) © [gonzyui](https://github.com/gonzyui)
84
+ [MIT](LICENSE) © [gonzyui](https://gonzyuidev.xyz)
package/dist/index.d.mts CHANGED
@@ -931,10 +931,10 @@ declare class NormalizedCache implements CacheAdapter {
931
931
  private readonly swrMs;
932
932
  private readonly queryStore;
933
933
  private readonly entityStore;
934
+ private readonly refCount;
934
935
  private _hits;
935
936
  private _misses;
936
937
  private _stales;
937
- private gcTimeout?;
938
938
  constructor(options?: CacheOptions);
939
939
  static key(query: string, variables: Record<string, unknown>): string;
940
940
  /** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
@@ -947,6 +947,7 @@ declare class NormalizedCache implements CacheAdapter {
947
947
  } | undefined;
948
948
  get<T>(key: string): T | undefined;
949
949
  set<T>(key: string, data: T): void;
950
+ private deleteQueryEntry;
950
951
  delete(key: string): boolean;
951
952
  clear(): void;
952
953
  get size(): number;
@@ -956,12 +957,6 @@ declare class NormalizedCache implements CacheAdapter {
956
957
  entitiesCount: number;
957
958
  };
958
959
  resetStats(): void;
959
- private scheduleGc;
960
- /**
961
- * Garbage-collect orphaned entities that are no longer referenced by any query.
962
- * Called automatically on LRU eviction to prevent unbounded entity store growth.
963
- */
964
- gc(): number;
965
960
  }
966
961
 
967
962
  /** Cache performance statistics. */
@@ -1074,6 +1069,9 @@ declare class AniListClient implements ClientBase {
1074
1069
  private readonly inFlight;
1075
1070
  private _rateLimitInfo?;
1076
1071
  private _lastRequestMeta?;
1072
+ private readonly mediaLoader;
1073
+ private readonly characterLoader;
1074
+ private readonly staffLoader;
1077
1075
  constructor(options?: AniListClientOptions);
1078
1076
  /**
1079
1077
  * The current rate limit information from the last API response.
package/dist/index.d.ts CHANGED
@@ -931,10 +931,10 @@ declare class NormalizedCache implements CacheAdapter {
931
931
  private readonly swrMs;
932
932
  private readonly queryStore;
933
933
  private readonly entityStore;
934
+ private readonly refCount;
934
935
  private _hits;
935
936
  private _misses;
936
937
  private _stales;
937
- private gcTimeout?;
938
938
  constructor(options?: CacheOptions);
939
939
  static key(query: string, variables: Record<string, unknown>): string;
940
940
  /** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
@@ -947,6 +947,7 @@ declare class NormalizedCache implements CacheAdapter {
947
947
  } | undefined;
948
948
  get<T>(key: string): T | undefined;
949
949
  set<T>(key: string, data: T): void;
950
+ private deleteQueryEntry;
950
951
  delete(key: string): boolean;
951
952
  clear(): void;
952
953
  get size(): number;
@@ -956,12 +957,6 @@ declare class NormalizedCache implements CacheAdapter {
956
957
  entitiesCount: number;
957
958
  };
958
959
  resetStats(): void;
959
- private scheduleGc;
960
- /**
961
- * Garbage-collect orphaned entities that are no longer referenced by any query.
962
- * Called automatically on LRU eviction to prevent unbounded entity store growth.
963
- */
964
- gc(): number;
965
960
  }
966
961
 
967
962
  /** Cache performance statistics. */
@@ -1074,6 +1069,9 @@ declare class AniListClient implements ClientBase {
1074
1069
  private readonly inFlight;
1075
1070
  private _rateLimitInfo?;
1076
1071
  private _lastRequestMeta?;
1072
+ private readonly mediaLoader;
1073
+ private readonly characterLoader;
1074
+ private readonly staffLoader;
1077
1075
  constructor(options?: AniListClientOptions);
1078
1076
  /**
1079
1077
  * The current rate limit information from the last API response.
package/dist/index.js CHANGED
@@ -1,5 +1,62 @@
1
1
  'use strict';
2
2
 
3
+ // src/utils/dataloader.ts
4
+ var BatchLoader = class {
5
+ constructor(batchFetch, maxWaitMs = 50) {
6
+ this.batchFetch = batchFetch;
7
+ this.maxWaitMs = maxWaitMs;
8
+ }
9
+ queue = /* @__PURE__ */ new Map();
10
+ timeout = null;
11
+ /**
12
+ * Queue an ID to be fetched in the next batch.
13
+ * Returns a Promise that resolves when the batch request completes.
14
+ */
15
+ async load(id) {
16
+ return new Promise((resolve, reject) => {
17
+ let callbacks = this.queue.get(id);
18
+ if (!callbacks) {
19
+ callbacks = [];
20
+ this.queue.set(id, callbacks);
21
+ }
22
+ callbacks.push({ resolve, reject });
23
+ if (this.timeout === null) {
24
+ this.timeout = setTimeout(() => this.dispatch(), this.maxWaitMs);
25
+ if (typeof this.timeout.unref === "function") {
26
+ this.timeout.unref();
27
+ }
28
+ }
29
+ });
30
+ }
31
+ async dispatch() {
32
+ this.timeout = null;
33
+ if (this.queue.size === 0) return;
34
+ const currentQueue = this.queue;
35
+ this.queue = /* @__PURE__ */ new Map();
36
+ const ids = Array.from(currentQueue.keys());
37
+ try {
38
+ const results = await this.batchFetch(ids);
39
+ const resultMap = /* @__PURE__ */ new Map();
40
+ for (const item of results) {
41
+ resultMap.set(item.id, item);
42
+ }
43
+ for (const [id, callbacks] of currentQueue.entries()) {
44
+ const result = resultMap.get(id);
45
+ if (result) {
46
+ for (const cb of callbacks) cb.resolve(result);
47
+ } else {
48
+ const err = new Error(`Item with ID ${id} not found in batch response`);
49
+ for (const cb of callbacks) cb.reject(err);
50
+ }
51
+ }
52
+ } catch (err) {
53
+ for (const callbacks of currentQueue.values()) {
54
+ for (const cb of callbacks) cb.reject(err);
55
+ }
56
+ }
57
+ }
58
+ };
59
+
3
60
  // src/utils/markdown.ts
4
61
  function isSafeUrl(url) {
5
62
  return /^https?:\/\//i.test(url);
@@ -117,8 +174,17 @@ function validateIds(ids, label = "id") {
117
174
  function sortObjectKeys(obj) {
118
175
  if (obj === null || typeof obj !== "object") return obj;
119
176
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
177
+ const keys = Object.keys(obj);
178
+ if (keys.length === 0) return obj;
179
+ if (keys.length === 1) {
180
+ const key = keys[0];
181
+ const val = obj[key];
182
+ const sortedVal = sortObjectKeys(val);
183
+ if (val === sortedVal) return obj;
184
+ return { [key]: sortedVal };
185
+ }
120
186
  const sorted = {};
121
- for (const key of Object.keys(obj).sort()) {
187
+ for (const key of keys.sort()) {
122
188
  sorted[key] = sortObjectKeys(obj[key]);
123
189
  }
124
190
  return sorted;
@@ -132,10 +198,10 @@ var NormalizedCache = class {
132
198
  swrMs;
133
199
  queryStore = /* @__PURE__ */ new Map();
134
200
  entityStore = /* @__PURE__ */ new Map();
201
+ refCount = /* @__PURE__ */ new Map();
135
202
  _hits = 0;
136
203
  _misses = 0;
137
204
  _stales = 0;
138
- gcTimeout;
139
205
  constructor(options = {}) {
140
206
  this.ttl = options.ttl ?? 24 * 60 * 60 * 1e3;
141
207
  this.maxSize = options.maxSize ?? 500;
@@ -147,11 +213,11 @@ var NormalizedCache = class {
147
213
  return `${normalized}|${JSON.stringify(sortObjectKeys(variables))}`;
148
214
  }
149
215
  /** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
150
- normalize(data, seen = /* @__PURE__ */ new WeakSet()) {
216
+ normalize(data, refsOut, seen = /* @__PURE__ */ new WeakSet()) {
151
217
  if (Array.isArray(data)) {
152
218
  if (seen.has(data)) return null;
153
219
  seen.add(data);
154
- return data.map((item) => this.normalize(item, seen));
220
+ return data.map((item) => this.normalize(item, refsOut, seen));
155
221
  }
156
222
  if (data !== null && typeof data === "object") {
157
223
  if (seen.has(data)) return null;
@@ -159,17 +225,22 @@ var NormalizedCache = class {
159
225
  const obj = data;
160
226
  if (typeof obj.__typename === "string" && (typeof obj.id === "number" || typeof obj.id === "string")) {
161
227
  const ref = `${obj.__typename}:${obj.id}`;
228
+ refsOut.add(ref);
162
229
  const normalizedObj = {};
163
- for (const [k, v] of Object.entries(obj)) {
164
- normalizedObj[k] = this.normalize(v, seen);
230
+ for (const k in obj) {
231
+ if (Object.hasOwn(obj, k)) {
232
+ normalizedObj[k] = this.normalize(obj[k], refsOut, seen);
233
+ }
165
234
  }
166
235
  const existing = this.entityStore.get(ref) || {};
167
236
  this.entityStore.set(ref, { ...existing, ...normalizedObj });
168
237
  return { __ref: ref };
169
238
  }
170
239
  const result = {};
171
- for (const [k, v] of Object.entries(obj)) {
172
- result[k] = this.normalize(v, seen);
240
+ for (const k in obj) {
241
+ if (Object.hasOwn(obj, k)) {
242
+ result[k] = this.normalize(obj[k], refsOut, seen);
243
+ }
173
244
  }
174
245
  return result;
175
246
  }
@@ -195,10 +266,12 @@ var NormalizedCache = class {
195
266
  return result2;
196
267
  }
197
268
  const result = {};
198
- for (const [k, v] of Object.entries(obj)) {
199
- const denormalized = this.denormalize(v, seen);
200
- if (denormalized === void 0) return void 0;
201
- result[k] = denormalized;
269
+ for (const k in obj) {
270
+ if (Object.hasOwn(obj, k)) {
271
+ const denormalized = this.denormalize(obj[k], seen);
272
+ if (denormalized === void 0) return void 0;
273
+ result[k] = denormalized;
274
+ }
202
275
  }
203
276
  return result;
204
277
  }
@@ -217,14 +290,14 @@ var NormalizedCache = class {
217
290
  if (this.swrMs > 0 && now <= entry.expiresAt + this.swrMs) {
218
291
  isStale = true;
219
292
  } else {
220
- this.queryStore.delete(key);
293
+ this.deleteQueryEntry(key, entry);
221
294
  this._misses++;
222
295
  return void 0;
223
296
  }
224
297
  }
225
298
  const denormalized = this.denormalize(entry.data);
226
299
  if (denormalized === void 0) {
227
- this.queryStore.delete(key);
300
+ this.deleteQueryEntry(key, entry);
228
301
  this._misses++;
229
302
  return void 0;
230
303
  }
@@ -243,27 +316,46 @@ var NormalizedCache = class {
243
316
  }
244
317
  set(key, data) {
245
318
  if (!this.enabled) return;
246
- const normalizedData = this.normalize(data);
247
- this.queryStore.delete(key);
319
+ const refs = /* @__PURE__ */ new Set();
320
+ const normalizedData = this.normalize(data, refs);
321
+ const existing = this.queryStore.get(key);
322
+ if (existing) {
323
+ this.deleteQueryEntry(key, existing);
324
+ }
248
325
  if (this.maxSize > 0 && this.queryStore.size >= this.maxSize) {
249
326
  const firstKey = this.queryStore.keys().next().value;
250
327
  if (firstKey !== void 0) {
251
- this.queryStore.delete(firstKey);
328
+ const firstEntry = this.queryStore.get(firstKey);
329
+ if (firstEntry) this.deleteQueryEntry(firstKey, firstEntry);
330
+ }
331
+ }
332
+ for (const ref of refs) {
333
+ this.refCount.set(ref, (this.refCount.get(ref) ?? 0) + 1);
334
+ }
335
+ this.queryStore.set(key, { data: normalizedData, refs, expiresAt: Date.now() + this.ttl });
336
+ }
337
+ deleteQueryEntry(key, entry) {
338
+ this.queryStore.delete(key);
339
+ for (const ref of entry.refs) {
340
+ const count = (this.refCount.get(ref) ?? 0) - 1;
341
+ if (count <= 0) {
342
+ this.refCount.delete(ref);
343
+ this.entityStore.delete(ref);
344
+ } else {
345
+ this.refCount.set(ref, count);
252
346
  }
253
- this.scheduleGc();
254
347
  }
255
- this.queryStore.set(key, { data: normalizedData, expiresAt: Date.now() + this.ttl });
256
348
  }
257
349
  delete(key) {
258
- return this.queryStore.delete(key);
350
+ const entry = this.queryStore.get(key);
351
+ if (!entry) return false;
352
+ this.deleteQueryEntry(key, entry);
353
+ return true;
259
354
  }
260
355
  clear() {
261
- if (this.gcTimeout) {
262
- clearTimeout(this.gcTimeout);
263
- this.gcTimeout = void 0;
264
- }
265
356
  this.queryStore.clear();
266
357
  this.entityStore.clear();
358
+ this.refCount.clear();
267
359
  this._hits = 0;
268
360
  this._misses = 0;
269
361
  this._stales = 0;
@@ -280,7 +372,9 @@ var NormalizedCache = class {
280
372
  for (const key of this.queryStore.keys()) {
281
373
  if (test(key)) toDelete.push(key);
282
374
  }
283
- for (const key of toDelete) this.queryStore.delete(key);
375
+ for (const key of toDelete) {
376
+ this.delete(key);
377
+ }
284
378
  return toDelete.length;
285
379
  }
286
380
  get stats() {
@@ -298,53 +392,6 @@ var NormalizedCache = class {
298
392
  this._misses = 0;
299
393
  this._stales = 0;
300
394
  }
301
- scheduleGc() {
302
- if (this.gcTimeout) return;
303
- this.gcTimeout = setTimeout(() => {
304
- this.gc();
305
- this.gcTimeout = void 0;
306
- }, 500);
307
- if (typeof this.gcTimeout.unref === "function") {
308
- this.gcTimeout.unref();
309
- }
310
- }
311
- /**
312
- * Garbage-collect orphaned entities that are no longer referenced by any query.
313
- * Called automatically on LRU eviction to prevent unbounded entity store growth.
314
- */
315
- gc() {
316
- const referencedRefs = /* @__PURE__ */ new Set();
317
- const collectRefs = (data) => {
318
- if (Array.isArray(data)) {
319
- for (const item of data) collectRefs(item);
320
- return;
321
- }
322
- if (data !== null && typeof data === "object") {
323
- const obj = data;
324
- if (typeof obj.__ref === "string") {
325
- referencedRefs.add(obj.__ref);
326
- const entity = this.entityStore.get(obj.__ref);
327
- if (entity && !referencedRefs.has(`_visited:${obj.__ref}`)) {
328
- referencedRefs.add(`_visited:${obj.__ref}`);
329
- collectRefs(entity);
330
- }
331
- return;
332
- }
333
- for (const v of Object.values(obj)) collectRefs(v);
334
- }
335
- };
336
- for (const entry of this.queryStore.values()) {
337
- collectRefs(entry.data);
338
- }
339
- let removed = 0;
340
- for (const ref of this.entityStore.keys()) {
341
- if (!referencedRefs.has(ref)) {
342
- this.entityStore.delete(ref);
343
- removed++;
344
- }
345
- }
346
- return removed;
347
- }
348
395
  };
349
396
 
350
397
  // src/cache/index.ts
@@ -2541,7 +2588,7 @@ function mapFavorites(fav) {
2541
2588
 
2542
2589
  // src/client/index.ts
2543
2590
  var DEFAULT_API_URL = "https://graphql.anilist.co";
2544
- var LIB_VERSION = "2.2.1" ;
2591
+ var LIB_VERSION = "2.4.0" ;
2545
2592
  var AniListClient = class {
2546
2593
  apiUrl;
2547
2594
  headers;
@@ -2553,6 +2600,9 @@ var AniListClient = class {
2553
2600
  inFlight = /* @__PURE__ */ new Map();
2554
2601
  _rateLimitInfo;
2555
2602
  _lastRequestMeta;
2603
+ mediaLoader;
2604
+ characterLoader;
2605
+ staffLoader;
2556
2606
  constructor(options = {}) {
2557
2607
  this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
2558
2608
  this.headers = {
@@ -2568,6 +2618,9 @@ var AniListClient = class {
2568
2618
  this.hooks = options.hooks ?? {};
2569
2619
  this.logger = options.logger;
2570
2620
  this.signal = options.signal;
2621
+ this.mediaLoader = new BatchLoader((ids) => this.getMediaBatch(ids), 50);
2622
+ this.characterLoader = new BatchLoader((ids) => this.getCharacterBatch(ids), 50);
2623
+ this.staffLoader = new BatchLoader((ids) => this.getStaffBatch(ids), 50);
2571
2624
  }
2572
2625
  /**
2573
2626
  * The current rate limit information from the last API response.
@@ -2698,6 +2751,9 @@ var AniListClient = class {
2698
2751
  * @param include - Optional related data to include
2699
2752
  */
2700
2753
  async getMedia(id, include) {
2754
+ if (!include) {
2755
+ return this.mediaLoader.load(id);
2756
+ }
2701
2757
  return getMedia(this, id, include);
2702
2758
  }
2703
2759
  async getMediaCharacters(mediaId, options = {}) {
@@ -2766,6 +2822,9 @@ var AniListClient = class {
2766
2822
  }
2767
2823
  /** Fetch a character by AniList ID. Pass `{ voiceActors: true }` to include VA data. */
2768
2824
  async getCharacter(id, include) {
2825
+ if (!include) {
2826
+ return this.characterLoader.load(id);
2827
+ }
2769
2828
  return getCharacter(this, id, include);
2770
2829
  }
2771
2830
  /** Search for characters by name. */
@@ -2774,6 +2833,9 @@ var AniListClient = class {
2774
2833
  }
2775
2834
  /** Fetch a staff member by AniList ID. Pass `{ media: true }` or `{ media: { perPage } }` for media credits. */
2776
2835
  async getStaff(id, include) {
2836
+ if (!include) {
2837
+ return this.staffLoader.load(id);
2838
+ }
2777
2839
  return getStaff(this, id, include);
2778
2840
  }
2779
2841
  /** Search for staff (voice actors, directors, etc.). */
@@ -2893,7 +2955,7 @@ var AniListClient = class {
2893
2955
  if (ids.length === 0) return [];
2894
2956
  validateIds(ids, "mediaId");
2895
2957
  const [singleMediaId] = ids;
2896
- if (ids.length === 1 && singleMediaId !== void 0) return [await this.getMedia(singleMediaId)];
2958
+ if (ids.length === 1 && singleMediaId !== void 0) return [await getMedia(this, singleMediaId)];
2897
2959
  return this.executeBatch(ids, buildBatchMediaQuery, "m");
2898
2960
  }
2899
2961
  /** Fetch multiple characters in a single API request. */
@@ -2901,7 +2963,8 @@ var AniListClient = class {
2901
2963
  if (ids.length === 0) return [];
2902
2964
  validateIds(ids, "characterId");
2903
2965
  const [singleCharId] = ids;
2904
- if (ids.length === 1 && singleCharId !== void 0) return [await this.getCharacter(singleCharId)];
2966
+ if (ids.length === 1 && singleCharId !== void 0)
2967
+ return [await getCharacter(this, singleCharId)];
2905
2968
  return this.executeBatch(ids, buildBatchCharacterQuery, "c");
2906
2969
  }
2907
2970
  /** Fetch multiple staff members in a single API request. */
@@ -2909,7 +2972,7 @@ var AniListClient = class {
2909
2972
  if (ids.length === 0) return [];
2910
2973
  validateIds(ids, "staffId");
2911
2974
  const [singleStaffId] = ids;
2912
- if (ids.length === 1 && singleStaffId !== void 0) return [await this.getStaff(singleStaffId)];
2975
+ if (ids.length === 1 && singleStaffId !== void 0) return [await getStaff(this, singleStaffId)];
2913
2976
  return this.executeBatch(ids, buildBatchStaffQuery, "s");
2914
2977
  }
2915
2978
  /** @internal */