anilink-api-wrapper 3.0.0 → 3.1.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/dist/AniLink.mjs CHANGED
@@ -1,79 +1,128 @@
1
- import { randomInt, createHash, randomUUID } from 'node:crypto';
2
1
  import http from 'node:http';
3
2
  import https from 'node:https';
4
3
  import axios from 'axios';
4
+ import { randomInt, createHash, randomUUID } from 'node:crypto';
5
5
 
6
- const TRANSPORT_OPTION_KEYS = [
7
- "timeout",
8
- "signal",
9
- "exposeRawAxiosError",
10
- "retry",
11
- "paceWithRateLimit",
12
- "rateLimitFloor",
13
- "circuitBreaker",
14
- "retryBudget",
15
- "maxSockets",
16
- "maxFreeSockets",
17
- "onError",
18
- "onRetry",
19
- "onRequestStart",
20
- "onResponse",
21
- "onPace",
22
- "onHookError",
23
- "diagnostics",
24
- "onCircuitOpen",
25
- "onCircuitClose",
26
- "ignorePaceDeadline",
27
- "allowPartialData",
28
- "responseCache"
29
- ];
6
+ const DEFAULT_REQUEST_TIMEOUT = 3e4;
7
+ const MAX_FREE_SOCKETS = 5;
8
+ const MAX_SOCKETS = 20;
9
+ const resolveDiagnosticsMode = (diagnostics) => {
10
+ const resolved = diagnostics ?? "warn";
11
+ if (resolved !== "warn" && resolved !== "hook" && resolved !== "silent") {
12
+ throw new TypeError(
13
+ `Invalid diagnostics ${JSON.stringify(resolved)}: it must be one of "warn", "hook", or "silent".`
14
+ );
15
+ }
16
+ return resolved;
17
+ };
30
18
 
31
- const isNonBlank = (value) => typeof value === "string" && value.trim() !== "";
32
- const resolveTransportOptions = (credentials, providerFields) => {
33
- const allowedKeys = /* @__PURE__ */ new Set([...TRANSPORT_OPTION_KEYS, ...providerFields]);
34
- const options = {};
35
- for (const [key, value] of Object.entries(credentials)) {
36
- if (providerFields.includes(key)) continue;
37
- if (!allowedKeys.has(key)) {
19
+ const DEFAULT_AGENT_OPTIONS = {
20
+ keepAlive: true,
21
+ maxSockets: MAX_SOCKETS,
22
+ maxFreeSockets: MAX_FREE_SOCKETS,
23
+ scheduling: "lifo"
24
+ };
25
+ let defaultHttpAgent = new http.Agent(DEFAULT_AGENT_OPTIONS);
26
+ let defaultHttpsAgent = new https.Agent(DEFAULT_AGENT_OPTIONS);
27
+ let axiosClient = axios.create({
28
+ timeout: DEFAULT_REQUEST_TIMEOUT,
29
+ httpAgent: defaultHttpAgent,
30
+ httpsAgent: defaultHttpsAgent
31
+ });
32
+ let defaultAgentsTornDown = false;
33
+ const ensureDefaultAgents = () => {
34
+ if (!defaultAgentsTornDown) {
35
+ return;
36
+ }
37
+ defaultHttpAgent = new http.Agent(DEFAULT_AGENT_OPTIONS);
38
+ defaultHttpsAgent = new https.Agent(DEFAULT_AGENT_OPTIONS);
39
+ axiosClient = axios.create({
40
+ timeout: DEFAULT_REQUEST_TIMEOUT,
41
+ httpAgent: defaultHttpAgent,
42
+ httpsAgent: defaultHttpsAgent
43
+ });
44
+ defaultAgentsTornDown = false;
45
+ };
46
+ const MAX_CACHED_AGENT_PAIRS = 8;
47
+ const MAX_PARKED_EVICTED_PAIRS = 16;
48
+ const cachedAgentPairs = /* @__PURE__ */ new Map();
49
+ const parkedEvictedPairs = [];
50
+ const buildAgentCacheKey = (maxSockets, maxFreeSockets) => `${maxSockets}:${maxFreeSockets}`;
51
+ const evictLruAgentPair = () => {
52
+ const oldestKey = cachedAgentPairs.keys().next().value;
53
+ if (oldestKey !== void 0) {
54
+ const evicted = cachedAgentPairs.get(oldestKey);
55
+ cachedAgentPairs.delete(oldestKey);
56
+ if (evicted !== void 0) {
57
+ if (parkedEvictedPairs.length >= MAX_PARKED_EVICTED_PAIRS) {
58
+ const oldestParked = parkedEvictedPairs.shift();
59
+ if (oldestParked !== void 0) {
60
+ oldestParked.httpAgent.destroy();
61
+ oldestParked.httpsAgent.destroy();
62
+ }
63
+ }
64
+ parkedEvictedPairs.push(evicted);
65
+ }
66
+ }
67
+ };
68
+ const destroyCachedAgents = () => {
69
+ for (const pair of cachedAgentPairs.values()) {
70
+ pair.httpAgent.destroy();
71
+ pair.httpsAgent.destroy();
72
+ }
73
+ cachedAgentPairs.clear();
74
+ for (const pair of parkedEvictedPairs) {
75
+ pair.httpAgent.destroy();
76
+ pair.httpsAgent.destroy();
77
+ }
78
+ parkedEvictedPairs.length = 0;
79
+ if (!defaultAgentsTornDown) {
80
+ defaultHttpAgent.destroy();
81
+ defaultHttpsAgent.destroy();
82
+ defaultAgentsTornDown = true;
83
+ }
84
+ };
85
+ const resolveAgents = (maxSockets, maxFreeSockets) => {
86
+ ensureDefaultAgents();
87
+ if (maxSockets === void 0 && maxFreeSockets === void 0) {
88
+ return { httpAgent: defaultHttpAgent, httpsAgent: defaultHttpsAgent };
89
+ }
90
+ const normalizeSockets = (value, fallback, min) => {
91
+ if (value === void 0) {
92
+ return fallback;
93
+ }
94
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
38
95
  throw new TypeError(
39
- `Unknown credential key "${key}". Valid transport options are: ${TRANSPORT_OPTION_KEYS.join(
40
- ", "
41
- )}. Provider auth fields are: ${providerFields.join(", ")}.`
96
+ `Invalid socket bound ${value}: maxSockets/maxFreeSockets must be finite, non-negative integers.`
42
97
  );
43
98
  }
44
- options[key] = value;
99
+ return Math.max(min, value);
100
+ };
101
+ const sockets = normalizeSockets(maxSockets, MAX_SOCKETS, 1);
102
+ const freeSockets = normalizeSockets(maxFreeSockets, MAX_FREE_SOCKETS, 0);
103
+ const key = buildAgentCacheKey(sockets, freeSockets);
104
+ const cached = cachedAgentPairs.get(key);
105
+ if (cached !== void 0) {
106
+ cachedAgentPairs.delete(key);
107
+ cachedAgentPairs.set(key, cached);
108
+ return { httpAgent: cached.httpAgent, httpsAgent: cached.httpsAgent };
45
109
  }
46
- return Object.keys(options).length === 0 ? void 0 : options;
47
- };
48
- function resolveAniListCredentials(credentials) {
49
- if (credentials === void 0) return {};
50
- return {
51
- auth: credentials.authToken,
52
- options: resolveTransportOptions(credentials, [
53
- "authToken",
54
- "refreshToken",
55
- "clientId",
56
- "clientSecret",
57
- "onTokenRefresh",
58
- "onTokenRefreshError"
59
- ])
110
+ if (cachedAgentPairs.size >= MAX_CACHED_AGENT_PAIRS) {
111
+ evictLruAgentPair();
112
+ }
113
+ const agentOptions = {
114
+ keepAlive: true,
115
+ maxSockets: sockets,
116
+ maxFreeSockets: freeSockets,
117
+ scheduling: "lifo"
60
118
  };
61
- }
62
- function resolveMalCredentials(credentials) {
63
- if (credentials === void 0) return {};
64
- const headers = credentials.clientId !== void 0 && credentials.accessToken === void 0 ? { "X-MAL-CLIENT-ID": credentials.clientId } : void 0;
65
- return {
66
- auth: credentials.accessToken === void 0 && headers === void 0 ? void 0 : { token: credentials.accessToken, headers },
67
- options: resolveTransportOptions(credentials, [
68
- "accessToken",
69
- "refreshToken",
70
- "clientId",
71
- "clientSecret",
72
- "onTokenRefresh",
73
- "onTokenRefreshError"
74
- ])
119
+ const pair = {
120
+ httpAgent: new http.Agent(agentOptions),
121
+ httpsAgent: new https.Agent(agentOptions)
75
122
  };
76
- }
123
+ cachedAgentPairs.set(key, pair);
124
+ return { httpAgent: pair.httpAgent, httpsAgent: pair.httpsAgent };
125
+ };
77
126
 
78
127
  const AniLinkErrorCodes = {
79
128
  API: "API_ERROR",
@@ -245,111 +294,6 @@ class AniLinkNetworkError extends AniLinkError {
245
294
  }
246
295
  }
247
296
 
248
- const DEFAULT_REQUEST_TIMEOUT = 3e4;
249
- const MAX_FREE_SOCKETS = 5;
250
- const MAX_SOCKETS = 20;
251
- const resolveDiagnosticsMode = (diagnostics) => {
252
- const resolved = diagnostics ?? "warn";
253
- if (resolved !== "warn" && resolved !== "hook" && resolved !== "silent") {
254
- throw new TypeError(
255
- `Invalid diagnostics ${JSON.stringify(resolved)}: it must be one of "warn", "hook", or "silent".`
256
- );
257
- }
258
- return resolved;
259
- };
260
-
261
- const defaultHttpAgent = new http.Agent({
262
- keepAlive: true,
263
- maxSockets: MAX_SOCKETS,
264
- maxFreeSockets: MAX_FREE_SOCKETS,
265
- scheduling: "lifo"
266
- });
267
- const defaultHttpsAgent = new https.Agent({
268
- keepAlive: true,
269
- maxSockets: MAX_SOCKETS,
270
- maxFreeSockets: MAX_FREE_SOCKETS,
271
- scheduling: "lifo"
272
- });
273
- const axiosClient = axios.create({
274
- timeout: DEFAULT_REQUEST_TIMEOUT,
275
- httpAgent: defaultHttpAgent,
276
- httpsAgent: defaultHttpsAgent
277
- });
278
- const MAX_CACHED_AGENT_PAIRS = 8;
279
- const MAX_PARKED_EVICTED_PAIRS = 16;
280
- const cachedAgentPairs = /* @__PURE__ */ new Map();
281
- const parkedEvictedPairs = [];
282
- const buildAgentCacheKey = (maxSockets, maxFreeSockets) => `${maxSockets}:${maxFreeSockets}`;
283
- const evictLruAgentPair = () => {
284
- const oldestKey = cachedAgentPairs.keys().next().value;
285
- if (oldestKey !== void 0) {
286
- const evicted = cachedAgentPairs.get(oldestKey);
287
- cachedAgentPairs.delete(oldestKey);
288
- if (evicted !== void 0) {
289
- if (parkedEvictedPairs.length >= MAX_PARKED_EVICTED_PAIRS) {
290
- const oldestParked = parkedEvictedPairs.shift();
291
- if (oldestParked !== void 0) {
292
- oldestParked.httpAgent.destroy();
293
- oldestParked.httpsAgent.destroy();
294
- }
295
- }
296
- parkedEvictedPairs.push(evicted);
297
- }
298
- }
299
- };
300
- const destroyCachedAgents = () => {
301
- for (const pair of cachedAgentPairs.values()) {
302
- pair.httpAgent.destroy();
303
- pair.httpsAgent.destroy();
304
- }
305
- cachedAgentPairs.clear();
306
- for (const pair of parkedEvictedPairs) {
307
- pair.httpAgent.destroy();
308
- pair.httpsAgent.destroy();
309
- }
310
- parkedEvictedPairs.length = 0;
311
- };
312
- const resolveAgents = (maxSockets, maxFreeSockets) => {
313
- if (maxSockets === void 0 && maxFreeSockets === void 0) {
314
- return { httpAgent: defaultHttpAgent, httpsAgent: defaultHttpsAgent };
315
- }
316
- const normalizeSockets = (value, fallback, min) => {
317
- if (value === void 0) {
318
- return fallback;
319
- }
320
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
321
- throw new TypeError(
322
- `Invalid socket bound ${value}: maxSockets/maxFreeSockets must be finite, non-negative integers.`
323
- );
324
- }
325
- return Math.max(min, value);
326
- };
327
- const sockets = normalizeSockets(maxSockets, MAX_SOCKETS, 1);
328
- const freeSockets = normalizeSockets(maxFreeSockets, MAX_FREE_SOCKETS, 0);
329
- const key = buildAgentCacheKey(sockets, freeSockets);
330
- const cached = cachedAgentPairs.get(key);
331
- if (cached !== void 0) {
332
- cachedAgentPairs.delete(key);
333
- cachedAgentPairs.set(key, cached);
334
- return { httpAgent: cached.httpAgent, httpsAgent: cached.httpsAgent };
335
- }
336
- if (cachedAgentPairs.size >= MAX_CACHED_AGENT_PAIRS) {
337
- evictLruAgentPair();
338
- }
339
- const agentOptions = {
340
- keepAlive: true,
341
- maxSockets: sockets,
342
- maxFreeSockets: freeSockets,
343
- scheduling: "lifo"
344
- };
345
- const pair = {
346
- httpAgent: new http.Agent(agentOptions),
347
- httpsAgent: new https.Agent(agentOptions)
348
- };
349
- cachedAgentPairs.set(key, pair);
350
- return { httpAgent: pair.httpAgent, httpsAgent: pair.httpsAgent };
351
- };
352
-
353
297
  const MAX_RETRY_AFTER_MS = 6e4;
354
298
  const DEFAULT_RETRY_POLICY = {
355
299
  maxRetries: 3,
@@ -476,6 +420,19 @@ const computeNextRetryDelay = (input) => {
476
420
  }
477
421
  return getRetryDelay(normalized, rawError, attempt, policy);
478
422
  };
423
+ const isBudgetGatedFailure = (input) => {
424
+ const { normalized, rawError, attempt, policy, budgetState, budget, wasProbe } = input;
425
+ if (wasProbe || policy === null) {
426
+ return false;
427
+ }
428
+ if (budgetState === void 0 || budget === void 0) {
429
+ return false;
430
+ }
431
+ if (budgetState.retriesUsed < budget.maxRetriesPerWindow) {
432
+ return false;
433
+ }
434
+ return getRetryDelay(normalized, rawError, attempt, policy) !== null;
435
+ };
479
436
  const retryBudgetStates = /* @__PURE__ */ new WeakMap();
480
437
  const getRetryBudgetState = (owner, budget) => {
481
438
  if (owner === void 0 || budget === void 0) {
@@ -529,8 +486,184 @@ const resolveRequestOptions = (options = {}) => {
529
486
  onCircuitClose: options.onCircuitClose,
530
487
  ignorePaceDeadline: options.ignorePaceDeadline ?? false,
531
488
  allowPartialData: options.allowPartialData ?? false,
532
- responseCache: options.responseCache
489
+ responseCache: options.responseCache,
490
+ bypassResponseCache: options.bypassResponseCache ?? false
491
+ };
492
+ };
493
+ const DEEP_MERGED_OPTION_KEYS = ["retry", "circuitBreaker", "retryBudget"];
494
+ const assignDeepMergedOption = (merged, key, value) => {
495
+ merged[key] = value;
496
+ };
497
+ const mergeOptions = (base, overrides) => {
498
+ if (overrides === void 0) return base;
499
+ if (base === void 0) return overrides;
500
+ const merged = { ...base, ...overrides };
501
+ for (const key of DEEP_MERGED_OPTION_KEYS) {
502
+ const baseValue = base[key];
503
+ const overrideValue = overrides[key];
504
+ if (typeof baseValue === "object" && baseValue !== null && typeof overrideValue === "object" && overrideValue !== null) {
505
+ assignDeepMergedOption(merged, key, { ...baseValue, ...overrideValue });
506
+ }
507
+ }
508
+ return merged;
509
+ };
510
+ const TRANSPORT_OPTION_KEYS = [
511
+ "timeout",
512
+ "signal",
513
+ "exposeRawAxiosError",
514
+ "retry",
515
+ "paceWithRateLimit",
516
+ "rateLimitFloor",
517
+ "circuitBreaker",
518
+ "retryBudget",
519
+ "maxSockets",
520
+ "maxFreeSockets",
521
+ "onError",
522
+ "onRetry",
523
+ "onRequestStart",
524
+ "onResponse",
525
+ "onPace",
526
+ "onHookError",
527
+ "diagnostics",
528
+ "onCircuitOpen",
529
+ "onCircuitClose",
530
+ "ignorePaceDeadline",
531
+ "allowPartialData",
532
+ "responseCache",
533
+ "bypassResponseCache"
534
+ ];
535
+
536
+ const isNonBlank = (value) => typeof value === "string" && value.trim() !== "";
537
+ const resolveTransportOptions = (credentials, providerFields) => {
538
+ const allowedKeys = /* @__PURE__ */ new Set([...TRANSPORT_OPTION_KEYS, ...providerFields]);
539
+ const options = {};
540
+ for (const [key, value] of Object.entries(credentials)) {
541
+ if (providerFields.includes(key)) continue;
542
+ if (!allowedKeys.has(key)) {
543
+ throw new TypeError(
544
+ `Unknown credential key "${key}". Valid transport options are: ${TRANSPORT_OPTION_KEYS.join(
545
+ ", "
546
+ )}. Provider auth fields are: ${providerFields.join(", ")}.`
547
+ );
548
+ }
549
+ options[key] = value;
550
+ }
551
+ return Object.keys(options).length === 0 ? void 0 : options;
552
+ };
553
+ function resolveAniListCredentials(credentials) {
554
+ if (credentials === void 0) return {};
555
+ return {
556
+ auth: credentials.authToken,
557
+ options: resolveTransportOptions(credentials, [
558
+ "authToken",
559
+ "refreshToken",
560
+ "clientId",
561
+ "clientSecret",
562
+ "onTokenRefresh",
563
+ "onTokenRefreshError"
564
+ ])
533
565
  };
566
+ }
567
+ function resolveMalCredentials(credentials) {
568
+ if (credentials === void 0) return {};
569
+ const headers = credentials.clientId !== void 0 && credentials.accessToken === void 0 ? { "X-MAL-CLIENT-ID": credentials.clientId } : void 0;
570
+ return {
571
+ auth: credentials.accessToken === void 0 && headers === void 0 ? void 0 : { token: credentials.accessToken, headers },
572
+ options: resolveTransportOptions(credentials, [
573
+ "accessToken",
574
+ "refreshToken",
575
+ "clientId",
576
+ "clientSecret",
577
+ "onTokenRefresh",
578
+ "onTokenRefreshError"
579
+ ])
580
+ };
581
+ }
582
+
583
+ const buildDiagnostic = (kind, hookName, message, requestId) => ({
584
+ source: "anilink",
585
+ kind,
586
+ hookName,
587
+ ...requestId === void 0 ? {} : { requestId },
588
+ message
589
+ });
590
+ const reportDiagnostic = (options) => {
591
+ const { kind, hookName, message, requestId, onHookError, diagnostics, rawError, rethrown } = options;
592
+ if (onHookError !== void 0 && (rawError !== void 0 || diagnostics !== "silent")) {
593
+ const record = buildDiagnostic(kind, hookName, message, requestId);
594
+ try {
595
+ onHookError(
596
+ hookName,
597
+ new Error(message, {
598
+ cause: rawError !== void 0 ? rawError : record
599
+ })
600
+ );
601
+ } catch {
602
+ }
603
+ return true;
604
+ }
605
+ if (rethrown === true || diagnostics !== "warn") {
606
+ return false;
607
+ }
608
+ console.warn(JSON.stringify(buildDiagnostic(kind, hookName, message, requestId)));
609
+ return true;
610
+ };
611
+ const safeInvoke = (hook, name, onHookError, diagnostics, ...args) => {
612
+ if (hook === void 0) {
613
+ return;
614
+ }
615
+ try {
616
+ hook(...args);
617
+ } catch (hookError) {
618
+ const firstArg = args[0];
619
+ const requestId = firstArg !== null && typeof firstArg === "object" && "requestId" in firstArg && typeof firstArg.requestId === "string" ? firstArg.requestId : void 0;
620
+ const detail = hookError instanceof Error ? hookError.message : String(hookError);
621
+ reportDiagnostic({
622
+ kind: "hook-failure",
623
+ hookName: name,
624
+ message: `The ${name} hook threw and was ignored: ${detail}`,
625
+ requestId,
626
+ onHookError,
627
+ diagnostics,
628
+ rawError: hookError
629
+ });
630
+ }
631
+ };
632
+ const buildErrorContext = (requestId, url, method, attempt, normalized, facts = {}) => ({
633
+ requestId,
634
+ url,
635
+ method,
636
+ attempt,
637
+ code: normalized.code,
638
+ ...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
639
+ ...normalized instanceof AniLinkApiError && normalized.rateLimit !== void 0 ? { rateLimit: normalized.rateLimit } : {},
640
+ ...facts.nextDelayMs === void 0 ? {} : { nextDelayMs: facts.nextDelayMs },
641
+ ...facts.retryWaitMs === void 0 || facts.retryWaitMs <= 0 ? {} : { retryWaitMs: facts.retryWaitMs },
642
+ ...facts.budgetExhausted ? { budgetExhausted: true } : {},
643
+ ...facts.host === void 0 ? {} : { host: facts.host },
644
+ ...facts.retryAfterMs === void 0 ? {} : { retryAfterMs: facts.retryAfterMs }
645
+ });
646
+ const reportFailure = (requestId, url, method, attempt, normalized, resolved, facts = {}) => {
647
+ const context = buildErrorContext(requestId, url, method, attempt, normalized, facts);
648
+ if (facts.nextDelayMs !== void 0) {
649
+ safeInvoke(
650
+ resolved.onRetry ?? resolved.onError,
651
+ resolved.onRetry === void 0 ? "onError" : "onRetry",
652
+ resolved.onHookError,
653
+ resolved.diagnostics,
654
+ normalized,
655
+ context
656
+ );
657
+ return;
658
+ }
659
+ safeInvoke(
660
+ resolved.onError,
661
+ "onError",
662
+ resolved.onHookError,
663
+ resolved.diagnostics,
664
+ normalized,
665
+ context
666
+ );
534
667
  };
535
668
 
536
669
  const GRAPHQL_QUERY_PATTERN = /^\s*(?:\{|query\b[\s\S]*\{)/;
@@ -592,6 +725,176 @@ const resolveCacheAuthKey = (cache, method, data, isRestCall, auth) => {
592
725
  }
593
726
  return buildCacheAuthKey(auth.hasBearerToken, auth.hasCredentialHeaders, auth.auth?.token);
594
727
  };
728
+ const extractRootField = (document) => {
729
+ const keyword = /^\s*(query|mutation|subscription|fragment)\b/.exec(document);
730
+ let rest = keyword === null ? document : document.slice(keyword[0].length);
731
+ const name = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
732
+ if (name !== null && name[0].trim() !== "") {
733
+ rest = rest.slice(name[0].length);
734
+ }
735
+ if (/^\s*\(/.test(rest)) {
736
+ let depth = 0;
737
+ let end = -1;
738
+ for (let i = 0; i < rest.length; i += 1) {
739
+ const ch = rest[i];
740
+ if (ch === "(") {
741
+ depth += 1;
742
+ } else if (ch === ")") {
743
+ depth -= 1;
744
+ if (depth === 0) {
745
+ end = i + 1;
746
+ break;
747
+ }
748
+ }
749
+ }
750
+ if (end === -1) {
751
+ return void 0;
752
+ }
753
+ rest = rest.slice(end);
754
+ }
755
+ const openBrace = rest.indexOf("{");
756
+ if (openBrace === -1) {
757
+ return void 0;
758
+ }
759
+ const inside = rest.slice(openBrace + 1);
760
+ if (/^\s*\.\.\./.test(inside)) {
761
+ return void 0;
762
+ }
763
+ let cursor = inside;
764
+ let selection = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(cursor);
765
+ if (selection === null) {
766
+ return void 0;
767
+ }
768
+ let fieldName = selection[0].trim();
769
+ cursor = cursor.slice(selection[0].length);
770
+ const colon = /^\s*:/.exec(cursor);
771
+ if (colon !== null) {
772
+ selection = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(cursor.slice(colon[0].length));
773
+ if (selection === null) {
774
+ return void 0;
775
+ }
776
+ fieldName = selection[0].trim();
777
+ cursor = cursor.slice(colon[0].length + selection[0].length);
778
+ }
779
+ if (/^\s*\(/.test(cursor)) {
780
+ const openParen = cursor.indexOf("(");
781
+ let depth = 0;
782
+ let end = -1;
783
+ for (let i = openParen; i < cursor.length; i += 1) {
784
+ const ch = cursor[i];
785
+ if (ch === "(") {
786
+ depth += 1;
787
+ } else if (ch === ")") {
788
+ depth -= 1;
789
+ if (depth === 0) {
790
+ end = i + 1;
791
+ break;
792
+ }
793
+ }
794
+ }
795
+ if (end === -1) {
796
+ return void 0;
797
+ }
798
+ cursor = cursor.slice(end);
799
+ }
800
+ if (/^\s*\{/.test(cursor)) {
801
+ const openBraceIdx = cursor.indexOf("{");
802
+ let depth = 0;
803
+ let end = -1;
804
+ for (let i = openBraceIdx; i < cursor.length; i += 1) {
805
+ const ch = cursor[i];
806
+ if (ch === "{") {
807
+ depth += 1;
808
+ } else if (ch === "}") {
809
+ depth -= 1;
810
+ if (depth === 0) {
811
+ end = i + 1;
812
+ break;
813
+ }
814
+ }
815
+ }
816
+ if (end === -1) {
817
+ return void 0;
818
+ }
819
+ cursor = cursor.slice(end);
820
+ }
821
+ const trailing = cursor.trimStart();
822
+ if (trailing === "" || trailing[0] !== "}") {
823
+ return void 0;
824
+ }
825
+ return fieldName;
826
+ };
827
+ const extractQueryRootField = (data) => {
828
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
829
+ return void 0;
830
+ }
831
+ const { query } = data;
832
+ if (typeof query !== "string") {
833
+ return void 0;
834
+ }
835
+ return extractRootField(stripLeadingComments$1(query));
836
+ };
837
+ const MUTATION_ROOT_FIELD_INVALIDATION = /* @__PURE__ */ new Map([
838
+ // Media list entry writes change what MediaList, MediaListCollection,
839
+ // and Page (media lists) queries report, plus the Media documents
840
+ // that embed the requesting user's list entry for that media
841
+ // (`Media.mediaListEntry`), the User/Viewer documents that embed a
842
+ // `mediaListCollection`, and the Activity queries that embed the
843
+ // ListActivity AniList generates from the write. The shipped schemas
844
+ // do not select those embeds, but `custom()` documents can, and the
845
+ // map must not under-invalidate them.
846
+ [
847
+ "SaveMediaListEntry",
848
+ ["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
849
+ ],
850
+ [
851
+ "UpdateMediaListEntries",
852
+ ["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
853
+ ],
854
+ [
855
+ "DeleteMediaListEntry",
856
+ ["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
857
+ ],
858
+ // Favourite toggles change the user's Favourites, the User/Viewer
859
+ // documents that embed them, the isFavourite/favourites embeds on the
860
+ // object types the shipped Media/Character/Staff/Studio schemas
861
+ // select, and the Page queries that reach those embeds
862
+ // (Page→users→favourites, Page→media→isFavourite).
863
+ [
864
+ "ToggleFavourite",
865
+ ["Favourites", "User", "Viewer", "Media", "Character", "Staff", "Studio", "Page"]
866
+ ],
867
+ [
868
+ "UpdateFavouriteOrder",
869
+ ["Favourites", "User", "Viewer", "Media", "Character", "Staff", "Studio", "Page"]
870
+ ],
871
+ // Review writes change Review queries, Page (reviews), the Media
872
+ // documents that embed the reviews connection (`Media.reviews`), and
873
+ // the User/Viewer documents that embed one (`User.reviews`).
874
+ ["SaveReview", ["Review", "Page", "Media", "User", "Viewer"]],
875
+ ["DeleteReview", ["Review", "Page", "Media", "User", "Viewer"]],
876
+ ["RateReview", ["Review", "Page", "Media", "User", "Viewer"]],
877
+ // Profile writes change User and Viewer documents, the Follower and
878
+ // Following collections (the user's name/avatar/about change what they
879
+ // report), and the Page queries that embed user profiles. The
880
+ // animeListOptions/mangaListOptions variables also change list options
881
+ // (custom lists, scoring display), so the MediaList/MediaListCollection
882
+ // queries and the Media documents embedding the user's list entry
883
+ // (`Media.mediaListEntry`) are affected too.
884
+ [
885
+ "UpdateUser",
886
+ [
887
+ "User",
888
+ "Viewer",
889
+ "Follower",
890
+ "Following",
891
+ "Page",
892
+ "MediaList",
893
+ "MediaListCollection",
894
+ "Media"
895
+ ]
896
+ ]
897
+ ]);
595
898
  const MUTATION_ACTION_SEGMENTS = /* @__PURE__ */ new Set(["my_list_status"]);
596
899
  const deriveMutationCachePrefix = (url) => {
597
900
  const fragmentIndex = url.indexOf("#");
@@ -618,7 +921,13 @@ const invalidateAfterMutation = (cache, method, url, data, isRestCall) => {
618
921
  return;
619
922
  }
620
923
  if (!isRestCall && isGraphQLDocumentRequest(method, data) && !isCacheableRequest(method, data)) {
621
- cache.deleteAllForUrl(url);
924
+ const rootField = extractQueryRootField(data);
925
+ const affected = rootField === void 0 ? void 0 : MUTATION_ROOT_FIELD_INVALIDATION.get(rootField);
926
+ if (affected !== void 0) {
927
+ cache.deleteRootFieldsForUrl(url, affected);
928
+ } else {
929
+ cache.deleteAllForUrl(url);
930
+ }
622
931
  return;
623
932
  }
624
933
  const prefix = deriveMutationCachePrefix(url);
@@ -626,6 +935,29 @@ const invalidateAfterMutation = (cache, method, url, data, isRestCall) => {
626
935
  cache.deleteMatching(prefix);
627
936
  }
628
937
  };
938
+ const tryCacheRead = (resolved, method, url, data, cacheAuthKey) => {
939
+ const cached = resolved.responseCache.get(method, url, data, cacheAuthKey);
940
+ if (cached === void 0) {
941
+ return void 0;
942
+ }
943
+ if (resolved.onRequestStart !== void 0 || resolved.onResponse !== void 0) {
944
+ const requestId = randomUUID();
945
+ const hookContext = { requestId, url, method, attempt: 1 };
946
+ safeInvoke(
947
+ resolved.onRequestStart,
948
+ "onRequestStart",
949
+ resolved.onHookError,
950
+ resolved.diagnostics,
951
+ hookContext
952
+ );
953
+ safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, resolved.diagnostics, {
954
+ ...hookContext,
955
+ durationMs: 0,
956
+ cacheHit: true
957
+ });
958
+ }
959
+ return cached;
960
+ };
629
961
  const MAX_INVALIDATION_EVENTS = 64;
630
962
  const isPlainObject$1 = (value) => {
631
963
  const proto = Object.getPrototypeOf(value);
@@ -699,12 +1031,12 @@ class ResponseCache {
699
1031
  }
700
1032
  /**
701
1033
  * Canonicalizes a URL for keying: the query string's parameters are
702
- * sorted so `?a=1&b=2` and `?b=2&a=1` — the same resource — share one
1034
+ * sorted so `?a=1&b=2` and `?b=2&a=1`, the same resource, share one
703
1035
  * cache entry instead of missing each other.
704
1036
  *
705
1037
  * The query string is anchored at the first `?` that appears before any
706
1038
  * `#`, so a `?` inside a fragment (`path#frag?x`) is never mistaken for
707
- * the query delimiter — harmless for keying, but the method must stay
1039
+ * the query delimiter. That is harmless for keying, but the method must stay
708
1040
  * correct if it is ever reused for matching or allowlists.
709
1041
  *
710
1042
  * @param url - The request URL, possibly carrying a query string.
@@ -731,7 +1063,7 @@ class ResponseCache {
731
1063
  *
732
1064
  * The serialized body is SHA-256 hashed (truncated to 16 hex chars)
733
1065
  * before it enters the key, so a credential-bearing GET body is never
734
- * duplicated into the key string in plaintext — the key map retains
1066
+ * duplicated into the key string in plaintext. The key map retains
735
1067
  * entries for up to the TTL, outliving the error paths the rest of the
736
1068
  * library scrubs. The hash is deterministic, so equal bodies still share
737
1069
  * one entry and different bodies still get different entries. The URL's
@@ -815,15 +1147,15 @@ class ResponseCache {
815
1147
  }
816
1148
  /**
817
1149
  * Stores a response in the cache, evicting the LRU entry when the cap is
818
- * is reached. Only cacheable reads are stored — `GET` requests and
1150
+ * is reached. Only cacheable reads are stored, `GET` requests and
819
1151
  * GraphQL query documents dispatched as `POST`; mutations and other
820
1152
  * methods are no-ops. The value is deep-copied on write; the cache never
821
1153
  * aliases the caller's object.
822
1154
  *
823
1155
  * Note for direct callers: a `POST` body shaped like `{ query: "..." }`
824
1156
  * is treated as a GraphQL document and cached when the document declares
825
- * a read. Do not use `set` for REST `POST` writes whose body merely
826
- * carries a `query` field — the transport excludes those via its protocol
1157
+ * a read. Do not use `set` for REST `POST` writes whose body only
1158
+ * carries a `query` field: the transport excludes those via its protocol
827
1159
  * flag, but `set` itself cannot distinguish them.
828
1160
  *
829
1161
  * @param method - The HTTP method.
@@ -834,14 +1166,35 @@ class ResponseCache {
834
1166
  * @param response - The response body to cache.
835
1167
  */
836
1168
  set(method, url, data, authKey, response) {
837
- if (!isCacheableRequest(method, data)) return;
838
- if (this.ttlMs === 0) return;
1169
+ this.setEntry(method, url, data, authKey, response, void 0);
1170
+ }
1171
+ /**
1172
+ * The single write path behind {@link set} and {@link setIfFresh}: all
1173
+ * the write-side guards (cacheability, `ttlMs: 0`, the clone, the
1174
+ * opportunistic purge, LRU eviction) with the entry's root field
1175
+ * recorded for GraphQL query documents so scoped invalidation can
1176
+ * attribute the entry to the root field its document selects.
1177
+ *
1178
+ * @param method - The HTTP method.
1179
+ * @param url - The request URL.
1180
+ * @param data - The request body, when present.
1181
+ * @param authKey - An authentication-safe credential identity.
1182
+ * @param response - The response body to cache.
1183
+ * @param rootField - The GraphQL document's single selected root field,
1184
+ * when the caller knows it (the transport extracts it for query reads).
1185
+ * @returns Whether the response was stored: `false` when a
1186
+ * write-side guard skipped it (not cacheable, `ttlMs: 0`, or an
1187
+ * uncloneable payload).
1188
+ */
1189
+ setEntry(method, url, data, authKey, response, rootField) {
1190
+ if (!isCacheableRequest(method, data)) return false;
1191
+ if (this.ttlMs === 0) return false;
839
1192
  const key = ResponseCache.buildKey(method, url, data, authKey);
840
1193
  let snapshot;
841
1194
  try {
842
1195
  snapshot = structuredClone(response);
843
1196
  } catch {
844
- return;
1197
+ return false;
845
1198
  }
846
1199
  this.purgeExpired();
847
1200
  if (this.entries.has(key)) {
@@ -851,8 +1204,10 @@ class ResponseCache {
851
1204
  }
852
1205
  this.entries.set(key, {
853
1206
  data: snapshot,
854
- expiresAt: Date.now() + this.ttlMs
1207
+ expiresAt: Date.now() + this.ttlMs,
1208
+ ...rootField !== void 0 ? { rootField } : {}
855
1209
  });
1210
+ return true;
856
1211
  }
857
1212
  /**
858
1213
  * Returns the current invalidation generation, for callers that need to
@@ -868,7 +1223,7 @@ class ResponseCache {
868
1223
  }
869
1224
  /**
870
1225
  * Stores a response only when no invalidation affecting the request has
871
- * landed since the caller captured the generation — the write-back half
1226
+ * landed since the caller captured the generation, the write-back half
872
1227
  * of the in-flight-read guard.
873
1228
  *
874
1229
  * The transport captures the generation right after a cache miss (before
@@ -888,16 +1243,26 @@ class ResponseCache {
888
1243
  * @param generationAtRead - The generation the caller captured before
889
1244
  * the read went to the network.
890
1245
  * @param response - The response body to cache.
1246
+ * @param rootField - The GraphQL document's single selected root field,
1247
+ * when the caller knows it, so scoped invalidations that landed while
1248
+ * the read was in flight are matched against the read's own root field;
1249
+ * an unattributed document (`undefined`) is treated as affected by
1250
+ * every scoped invalidation, fail-closed.
1251
+ * @returns Whether the response was stored: `false` when an
1252
+ * invalidation affecting the read landed while it was in flight (the
1253
+ * stale response is dropped) or a write-side guard skipped it. That is
1254
+ * the signal behind the `cacheWrite` flag on the transport's `onResponse`
1255
+ * emission, so consumers can measure cache fill rate.
891
1256
  */
892
- setIfFresh(method, url, data, authKey, generationAtRead, response) {
893
- if (this.wasInvalidatedSince(generationAtRead, method, url, data, authKey)) {
894
- return;
1257
+ setIfFresh(method, url, data, authKey, generationAtRead, response, rootField) {
1258
+ if (this.wasInvalidatedSince(generationAtRead, method, url, data, authKey, rootField)) {
1259
+ return false;
895
1260
  }
896
- this.set(method, url, data, authKey, response);
1261
+ return this.setEntry(method, url, data, authKey, response, rootField);
897
1262
  }
898
1263
  /**
899
1264
  * Whether an invalidation affecting the given request's key landed
900
- * after the caller captured the generation — the decision half of the
1265
+ * after the caller captured the generation, the decision half of the
901
1266
  * in-flight-read guard.
902
1267
  *
903
1268
  * The check is scoped, not global: an invalidation of one resource does
@@ -914,9 +1279,13 @@ class ResponseCache {
914
1279
  * @param url - The URL of the read.
915
1280
  * @param data - The request body of the read, when present.
916
1281
  * @param authKey - The auth-scoping cache key fragment of the read.
1282
+ * @param rootField - The read's GraphQL root field, when known, so a
1283
+ * scoped invalidation is matched against the read's own root field; an
1284
+ * unattributed document is treated as affected by every scoped
1285
+ * invalidation, fail-closed.
917
1286
  * @returns `true` when an affecting invalidation landed mid-flight.
918
1287
  */
919
- wasInvalidatedSince(generationAtRead, method, url, data, authKey) {
1288
+ wasInvalidatedSince(generationAtRead, method, url, data, authKey, rootField) {
920
1289
  if (generationAtRead === this.generation) {
921
1290
  return false;
922
1291
  }
@@ -929,7 +1298,7 @@ class ResponseCache {
929
1298
  if (event.at <= generationAtRead) {
930
1299
  break;
931
1300
  }
932
- if (event.affects(key)) {
1301
+ if (event.affects(key, rootField)) {
933
1302
  return true;
934
1303
  }
935
1304
  }
@@ -940,8 +1309,8 @@ class ResponseCache {
940
1309
  * so an in-flight read can later tell whether the invalidation affected
941
1310
  * its key (see {@link setIfFresh}).
942
1311
  *
943
- * @param affects - Whether a cache key falls inside the invalidation's
944
- * scope.
1312
+ * @param affects - Whether a cache key (with its entry's recorded root
1313
+ * field, when one exists) falls inside the invalidation's scope.
945
1314
  */
946
1315
  recordInvalidation(affects) {
947
1316
  this.generation += 1;
@@ -955,8 +1324,8 @@ class ResponseCache {
955
1324
  * Removes the cached entry for the given request, if present. Use this
956
1325
  * for targeted invalidation after a mutation that changes the resource
957
1326
  * (for example a `POST` that updates the entity a cached `GET` returned).
958
- * Only cacheable reads are tracked — `GET` requests and GraphQL query
959
- * documents dispatched as `POST` — so other methods are a no-op and
1327
+ * Only cacheable reads are tracked, `GET` requests and GraphQL query
1328
+ * documents dispatched as `POST`, so other methods are a no-op and
960
1329
  * return `false`.
961
1330
  *
962
1331
  * @param method - The HTTP method.
@@ -981,14 +1350,14 @@ class ResponseCache {
981
1350
  * string and fragment stripped, so a cached read of
982
1351
  * `https://host/anime/21?fields=...` is invalidated by the prefix
983
1352
  * `https://host/anime/21`. The match is boundary-aware: the prefix
984
- * `https://host/anime/21` does **not** match `https://host/anime/212` —
985
- * the cached URL must be either exactly the prefix, continue with `/`
1353
+ * `https://host/anime/21` does **not** match `https://host/anime/212`.
1354
+ * The cached URL must be either exactly the prefix, continue with `/`
986
1355
  * (a child path), `?` (a query string), or `#` (a fragment). Matching
987
1356
  * spans every auth namespace, because a mutation performed by one
988
1357
  * identity changes the underlying resource for every identity that can
989
1358
  * read it.
990
1359
  *
991
- * This is the invalidation primitive behind mutation-triggered cache
1360
+ * This is the invalidation path used by mutation-triggered cache
992
1361
  * invalidation for REST writes: after a successful write, the transport
993
1362
  * derives the mutated resource's base URL and drops every cached read
994
1363
  * of that resource. It is also usable directly for manual bulk
@@ -1018,18 +1387,18 @@ class ResponseCache {
1018
1387
  return removed;
1019
1388
  }
1020
1389
  /**
1021
- * Removes every cached read keyed at the given URL — `GET` entries and
1390
+ * Removes every cached read keyed at the given URL, `GET` entries and
1022
1391
  * GraphQL query `POST` entries alike, across query strings, documents,
1023
- * variables, and auth namespaces — and returns how many entries were
1392
+ * variables, and auth namespaces, and returns how many entries were
1024
1393
  * removed.
1025
1394
  *
1026
- * This is the invalidation primitive for GraphQL writes: every GraphQL
1395
+ * This is the invalidation path for GraphQL writes: every GraphQL
1027
1396
  * operation of one provider is keyed at the same endpoint URL, and a
1028
1397
  * `mutation` document can change what many different query documents
1029
1398
  * return (a `SaveMediaListEntry` changes what both a `MediaList` and a
1030
1399
  * `MediaListCollection` query report), so no finer-grained prefix than
1031
1400
  * the endpoint can be derived. Dropping the endpoint's cached queries is
1032
- * deliberately conservative: the next reads refetch fresh data instead
1401
+ * conservative: the next reads refetch fresh data instead
1033
1402
  * of serving pre-mutation entries for the rest of the TTL.
1034
1403
  *
1035
1404
  * @param url - The request URL whose cached reads should be dropped;
@@ -1060,6 +1429,50 @@ class ResponseCache {
1060
1429
  }
1061
1430
  return removed;
1062
1431
  }
1432
+ /**
1433
+ * Removes every cached GraphQL query `POST` entry at the given URL whose
1434
+ * document selects one of the given root fields, and returns how many
1435
+ * entries were removed.
1436
+ *
1437
+ * This is the scoped invalidation path for mapped GraphQL mutations
1438
+ * (see {@link invalidateAfterMutation}): a mutation whose root field is
1439
+ * known to affect only certain query root fields drops exactly those
1440
+ * cached queries, leaving unrelated root fields' entries warm, instead
1441
+ * of the whole-endpoint sweep {@link deleteAllForUrl} performs. The
1442
+ * match is against the document's single selected root field, recorded
1443
+ * on the entry at write time (see {@link CacheEntry.rootField}), so a
1444
+ * query document is attributed to the root field it selects,
1445
+ * not to every field it mentions. Entries whose document could not be
1446
+ * attributed to a single root field (fragment-spread roots,
1447
+ * multi-field selections) are dropped too, fail-closed like the
1448
+ * unmapped-mutation fallback, because such a document may select data
1449
+ * the affected-field match cannot see.
1450
+ *
1451
+ * @param url - The request URL whose cached reads should be considered;
1452
+ * its query string and fragment are ignored.
1453
+ * @param rootFields - The query root fields whose cached entries should
1454
+ * be dropped.
1455
+ * @returns The number of cached entries removed.
1456
+ */
1457
+ deleteRootFieldsForUrl(url, rootFields) {
1458
+ const fragmentIndex = url.indexOf("#");
1459
+ const withoutFragment = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
1460
+ const queryIndex = withoutFragment.indexOf("?");
1461
+ const base = queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex);
1462
+ const prefix = base.endsWith("/") && base.length > 1 ? base.slice(0, -1) : base;
1463
+ const fieldSet = new Set(rootFields);
1464
+ this.recordInvalidation(
1465
+ (key, rootField) => ResponseCache.keyAtPrefix(key, `POST:${prefix}`, ":?#") && (rootField === void 0 || fieldSet.has(rootField))
1466
+ );
1467
+ let removed = 0;
1468
+ for (const [key, entry] of this.entries) {
1469
+ if (ResponseCache.keyAtPrefix(key, `POST:${prefix}`, ":?#") && (entry.rootField === void 0 || fieldSet.has(entry.rootField))) {
1470
+ this.entries.delete(key);
1471
+ removed += 1;
1472
+ }
1473
+ }
1474
+ return removed;
1475
+ }
1063
1476
  /**
1064
1477
  * Evicts the least-recently-used entry.
1065
1478
  */
@@ -1105,13 +1518,14 @@ class ResponseCache {
1105
1518
  * counters, so cache tuning (`ttlMs`/`maxEntries`) can be data-driven:
1106
1519
  * entry counts and eviction/expiration counters distinguish a too-small
1107
1520
  * cache (rising `evictions`) from a too-short TTL (rising
1108
- * `expirations`), and hit-rate dashboards can read `hits`/`misses`
1109
- * directly instead of inferring misses by subtracting hits from total
1110
- * response counts.
1521
+ * `expirations`), and hit-rate dashboards compute
1522
+ * `hits / (hits + misses + expirations)`: an expired-on-read entry
1523
+ * counts as an expiration, not a miss, so the three counters partition
1524
+ * every `get()` call.
1111
1525
  *
1112
- * The counters are cumulative for the cache instance's lifetime —
1526
+ * The counters are cumulative for the cache instance's lifetime:
1113
1527
  * `delete()`, `deleteMatching()`, `deleteAllForUrl()`, and `clear()`
1114
- * drop entries but never reset or increment the counters — and
1528
+ * drop entries but never reset or increment the counters, and
1115
1529
  * `entries` reflects the current live entry count. The returned object
1116
1530
  * is frozen, so a caller cannot mutate the cache's internal state
1117
1531
  * through it.
@@ -1313,88 +1727,6 @@ const unwrapGraphQLResponse = (response, headers, options) => {
1313
1727
  return unwrapped === void 0 ? response : unwrapped;
1314
1728
  };
1315
1729
 
1316
- const buildDiagnostic = (kind, hookName, message, requestId) => ({
1317
- source: "anilink",
1318
- kind,
1319
- hookName,
1320
- ...requestId === void 0 ? {} : { requestId },
1321
- message
1322
- });
1323
- const reportDiagnostic = (options) => {
1324
- const { kind, hookName, message, requestId, onHookError, diagnostics, rawError, rethrown } = options;
1325
- if (onHookError !== void 0 && (rawError !== void 0 || diagnostics !== "silent")) {
1326
- const record = buildDiagnostic(kind, hookName, message, requestId);
1327
- try {
1328
- onHookError(
1329
- hookName,
1330
- new Error(message, {
1331
- cause: rawError !== void 0 ? rawError : record
1332
- })
1333
- );
1334
- } catch {
1335
- }
1336
- return true;
1337
- }
1338
- if (rethrown === true || diagnostics !== "warn") {
1339
- return false;
1340
- }
1341
- console.warn(JSON.stringify(buildDiagnostic(kind, hookName, message, requestId)));
1342
- return true;
1343
- };
1344
- const safeInvoke = (hook, name, onHookError, diagnostics, ...args) => {
1345
- if (hook === void 0) {
1346
- return;
1347
- }
1348
- try {
1349
- hook(...args);
1350
- } catch (hookError) {
1351
- const firstArg = args[0];
1352
- const requestId = firstArg !== null && typeof firstArg === "object" && "requestId" in firstArg && typeof firstArg.requestId === "string" ? firstArg.requestId : void 0;
1353
- const detail = hookError instanceof Error ? hookError.message : String(hookError);
1354
- reportDiagnostic({
1355
- kind: "hook-failure",
1356
- hookName: name,
1357
- message: `The ${name} hook threw and was ignored: ${detail}`,
1358
- requestId,
1359
- onHookError,
1360
- diagnostics,
1361
- rawError: hookError
1362
- });
1363
- }
1364
- };
1365
- const buildErrorContext = (requestId, url, method, attempt, normalized, nextDelayMs) => ({
1366
- requestId,
1367
- url,
1368
- method,
1369
- attempt,
1370
- code: normalized.code,
1371
- ...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
1372
- ...normalized instanceof AniLinkApiError && normalized.rateLimit !== void 0 ? { rateLimit: normalized.rateLimit } : {},
1373
- ...nextDelayMs === void 0 ? {} : { nextDelayMs }
1374
- });
1375
- const reportFailure = (requestId, url, method, attempt, normalized, resolved, nextDelayMs) => {
1376
- const context = buildErrorContext(requestId, url, method, attempt, normalized, nextDelayMs);
1377
- if (nextDelayMs !== void 0) {
1378
- safeInvoke(
1379
- resolved.onRetry ?? resolved.onError,
1380
- resolved.onRetry === void 0 ? "onError" : "onRetry",
1381
- resolved.onHookError,
1382
- resolved.diagnostics,
1383
- normalized,
1384
- context
1385
- );
1386
- return;
1387
- }
1388
- safeInvoke(
1389
- resolved.onError,
1390
- "onError",
1391
- resolved.onHookError,
1392
- resolved.diagnostics,
1393
- normalized,
1394
- context
1395
- );
1396
- };
1397
-
1398
1730
  const circuitStates = /* @__PURE__ */ new WeakMap();
1399
1731
  const MAX_PROBE_BACKOFF_EXPONENT = 3;
1400
1732
  const scaledCooldownMs = (breaker, failedProbes) => {
@@ -1410,6 +1742,7 @@ const isAvailabilityFailure = (error) => {
1410
1742
  }
1411
1743
  return false;
1412
1744
  };
1745
+ const isStreakNeutralFailure = (error) => error instanceof AniLinkGraphQLError && error.status === 200;
1413
1746
  const MAX_CIRCUIT_SCOPES_PER_OWNER = 64;
1414
1747
  const circuitScopeOf = (url, requestId) => {
1415
1748
  try {
@@ -1447,6 +1780,16 @@ const getCircuitState = (owner, scope) => {
1447
1780
  scopes.set(scope, state);
1448
1781
  return state;
1449
1782
  };
1783
+ const circuitCooldownRemainingMs = (circuit, breaker) => {
1784
+ if (circuit === void 0 || breaker === void 0) {
1785
+ return void 0;
1786
+ }
1787
+ if (circuit.probeInFlight || circuit.openedAt === null) {
1788
+ return void 0;
1789
+ }
1790
+ const remaining = circuit.openedAt + scaledCooldownMs(breaker, circuit.failedProbes) - Date.now();
1791
+ return remaining > 0 ? remaining : void 0;
1792
+ };
1450
1793
  const peekCircuitStates = (owner) => circuitStates.get(owner);
1451
1794
  const checkCircuitOpen = (circuit, breaker) => {
1452
1795
  if (circuit === void 0 || breaker === void 0) {
@@ -1498,6 +1841,9 @@ const recordCircuitFailure = (circuit, breaker, normalized, resolved, hookContex
1498
1841
  return;
1499
1842
  }
1500
1843
  if (!isAvailabilityFailure(normalized)) {
1844
+ if (isStreakNeutralFailure(normalized) && !circuit.probeInFlight) {
1845
+ return;
1846
+ }
1501
1847
  recordCircuitSuccess(circuit, resolved, hookContext, host);
1502
1848
  return;
1503
1849
  }
@@ -1550,6 +1896,8 @@ const sleep = (ms, signal, requestId) => new Promise((resolve, reject) => {
1550
1896
 
1551
1897
  const paceDeadlines = /* @__PURE__ */ new WeakMap();
1552
1898
  const MAX_PACE_WAIT_MS = 5 * 60 * 1e3;
1899
+ const MAX_PACE_SCOPES_PER_OWNER = 64;
1900
+ const MAX_PACE_JITTER_MS = 500;
1553
1901
  const sleepForPacing = async (delayMs, resolved, hookContext) => {
1554
1902
  try {
1555
1903
  await sleep(delayMs, resolved.signal, hookContext.requestId);
@@ -1573,79 +1921,121 @@ const recordPaceDeadline = (owner, host, deadlineMs) => {
1573
1921
  scopes = /* @__PURE__ */ new Map();
1574
1922
  paceDeadlines.set(owner, scopes);
1575
1923
  }
1576
- if (deadlineMs <= Date.now()) {
1577
- scopes.delete(host);
1924
+ const now = Date.now();
1925
+ const existing = scopes.get(host);
1926
+ if (deadlineMs <= now) {
1927
+ if (existing === void 0 || existing <= now) {
1928
+ scopes.delete(host);
1929
+ }
1578
1930
  return;
1579
1931
  }
1580
- const existing = scopes.get(host);
1581
- if (existing !== void 0 && deadlineMs <= existing) {
1932
+ if (existing !== void 0) {
1933
+ scopes.delete(host);
1934
+ scopes.set(host, Math.max(existing, deadlineMs));
1582
1935
  return;
1583
1936
  }
1937
+ if (scopes.size >= MAX_PACE_SCOPES_PER_OWNER) {
1938
+ const oldestHost = scopes.keys().next().value;
1939
+ if (oldestHost !== void 0) {
1940
+ scopes.delete(oldestHost);
1941
+ }
1942
+ }
1584
1943
  scopes.set(host, deadlineMs);
1585
1944
  };
1586
1945
  const awaitPaceDeadline = async (owner, host, resolved, hookContext) => {
1587
1946
  if (owner === void 0 || !resolved.paceWithRateLimit || resolved.ignorePaceDeadline) {
1588
- return;
1947
+ return 0;
1948
+ }
1949
+ const scopes = paceDeadlines.get(owner);
1950
+ if (scopes === void 0) {
1951
+ return 0;
1589
1952
  }
1590
- const deadlineMs = paceDeadlines.get(owner)?.get(host);
1953
+ const deadlineMs = scopes.get(host);
1591
1954
  if (deadlineMs === void 0) {
1592
- return;
1955
+ return 0;
1593
1956
  }
1594
1957
  const delayMs = deadlineMs - Date.now();
1595
1958
  if (delayMs <= 0) {
1596
- paceDeadlines.get(owner)?.delete(host);
1597
- return;
1959
+ scopes.delete(host);
1960
+ return 0;
1961
+ }
1962
+ scopes.delete(host);
1963
+ scopes.set(host, deadlineMs);
1964
+ const jitterMs = randomInt(Math.min(MAX_PACE_JITTER_MS, Math.floor(delayMs / 10)) + 1);
1965
+ const waitStartedAt = Date.now();
1966
+ try {
1967
+ await sleepForPacing(delayMs + jitterMs, resolved, hookContext);
1968
+ } catch (error) {
1969
+ if (error instanceof AniLinkNetworkError && error.code === AniLinkErrorCodes.ABORTED && error.abortedDuringPacing === true) {
1970
+ const elapsedMs = Math.min(Date.now() - waitStartedAt, delayMs);
1971
+ safeInvoke(resolved.onPace, "onPace", resolved.onHookError, resolved.diagnostics, {
1972
+ ...hookContext,
1973
+ delayMs: elapsedMs,
1974
+ aborted: true
1975
+ });
1976
+ throw error;
1977
+ }
1978
+ throw error;
1598
1979
  }
1599
- await sleepForPacing(delayMs, resolved, hookContext);
1600
1980
  safeInvoke(resolved.onPace, "onPace", resolved.onHookError, resolved.diagnostics, {
1601
1981
  ...hookContext,
1602
1982
  delayMs
1603
1983
  });
1984
+ return delayMs;
1604
1985
  };
1605
1986
  const paceAfterSuccess = (response, resolved, rateLimit, owner, host) => {
1606
1987
  if (!resolved.paceWithRateLimit) {
1607
1988
  return;
1608
1989
  }
1609
- const info = rateLimit ?? getRateLimitInfo(response.headers);
1610
- if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
1611
- const rawDeadlineMs = info.reset * 1e3;
1612
- const deadlineMs = Math.min(rawDeadlineMs, Date.now() + MAX_PACE_WAIT_MS);
1613
- if (owner !== void 0 && host !== void 0) {
1614
- recordPaceDeadline(owner, host, deadlineMs);
1615
- }
1990
+ const info = rateLimit ?? getRateLimitInfo(response.headers);
1991
+ if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
1992
+ const rawDeadlineMs = info.reset * 1e3;
1993
+ const deadlineMs = Math.min(rawDeadlineMs, Date.now() + MAX_PACE_WAIT_MS);
1994
+ if (owner !== void 0 && host !== void 0) {
1995
+ recordPaceDeadline(owner, host, deadlineMs);
1996
+ }
1997
+ }
1998
+ };
1999
+ const paceAfterTerminalRateLimit = (normalized, resolved, owner, host) => {
2000
+ if (!resolved.paceWithRateLimit) {
2001
+ return;
2002
+ }
2003
+ if (!(normalized instanceof AniLinkApiError) || normalized.status !== 429) {
2004
+ return;
2005
+ }
2006
+ const reset = normalized.rateLimit?.reset;
2007
+ if (reset === void 0 || !Number.isFinite(reset)) {
2008
+ return;
2009
+ }
2010
+ const deadlineMs = Math.min(reset * 1e3, Date.now() + MAX_PACE_WAIT_MS);
2011
+ if (owner !== void 0) {
2012
+ recordPaceDeadline(owner, host, deadlineMs);
1616
2013
  }
1617
2014
  };
1618
2015
  const peekPaceDeadlines = (owner) => paceDeadlines.get(owner);
1619
2016
 
1620
- const resolveEnvelopeOutcome = (response, resolved, rawPassthrough, requestId, url, method, attempt) => {
2017
+ const resolveEnvelopeOutcome = (response, resolved, rawPassthrough) => {
1621
2018
  let resolvedPartial = false;
1622
- let partialAvailabilityFailure;
2019
+ let partialError;
2020
+ let partialBreakerError;
1623
2021
  const result = rawPassthrough ? response.data : unwrapGraphQLResponse(
1624
2022
  response.data,
1625
2023
  response.headers,
1626
2024
  resolved.allowPartialData ? {
1627
2025
  allowPartialData: true,
1628
- onPartialData: (partialError) => {
2026
+ onPartialData: (error) => {
1629
2027
  resolvedPartial = true;
1630
- if (isAvailabilityFailure(partialError)) {
1631
- partialAvailabilityFailure = partialError;
2028
+ partialError = error;
2029
+ if (isAvailabilityFailure(error) || isStreakNeutralFailure(error)) {
2030
+ partialBreakerError = error;
1632
2031
  }
1633
- stampRequestId(partialError, requestId);
1634
- safeInvoke(
1635
- resolved.onError,
1636
- "onError",
1637
- resolved.onHookError,
1638
- resolved.diagnostics,
1639
- partialError,
1640
- buildErrorContext(requestId, url, method, attempt + 1, partialError)
1641
- );
1642
2032
  }
1643
2033
  } : void 0
1644
2034
  );
1645
- return { result, resolvedPartial, partialAvailabilityFailure };
2035
+ return { result, resolvedPartial, partialError, partialBreakerError };
1646
2036
  };
1647
2037
  const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) => {
1648
- const { rawPassthrough = false, cacheMiss = false } = modifiers;
2038
+ const { rawPassthrough = false, cacheMiss = false, writeBack, authGuard } = modifiers;
1649
2039
  const { url, method, data, headers } = options;
1650
2040
  const policy = resolved.retry;
1651
2041
  const requestId = randomUUID();
@@ -1654,9 +2044,30 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
1654
2044
  const budgetState = getRetryBudgetState(stateOwner, resolved.retryBudget);
1655
2045
  let attempt = 0;
1656
2046
  let pacedWaitMs = 0;
2047
+ let retryWaitMs = 0;
1657
2048
  for (; ; ) {
1658
2049
  const startedAt = Date.now();
1659
2050
  const hookContext = { requestId, url, method, attempt: attempt + 1 };
2051
+ const authError = authGuard?.();
2052
+ if (authError !== void 0) {
2053
+ safeInvoke(
2054
+ resolved.onRequestStart,
2055
+ "onRequestStart",
2056
+ resolved.onHookError,
2057
+ resolved.diagnostics,
2058
+ hookContext
2059
+ );
2060
+ stampRequestId(authError, requestId);
2061
+ safeInvoke(
2062
+ resolved.onError,
2063
+ "onError",
2064
+ resolved.onHookError,
2065
+ resolved.diagnostics,
2066
+ authError,
2067
+ buildErrorContext(requestId, url, method, attempt + 1, authError)
2068
+ );
2069
+ throw authError;
2070
+ }
1660
2071
  const circuitError = checkCircuitOpen(circuit, resolved.circuitBreaker);
1661
2072
  if (circuitError !== void 0) {
1662
2073
  safeInvoke(
@@ -1667,20 +2078,25 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
1667
2078
  hookContext
1668
2079
  );
1669
2080
  stampRequestId(circuitError, requestId);
2081
+ const cooldownRemainingMs = circuitCooldownRemainingMs(
2082
+ circuit,
2083
+ resolved.circuitBreaker
2084
+ );
1670
2085
  safeInvoke(
1671
2086
  resolved.onError,
1672
2087
  "onError",
1673
2088
  resolved.onHookError,
1674
2089
  resolved.diagnostics,
1675
2090
  circuitError,
1676
- buildErrorContext(requestId, url, method, attempt + 1, circuitError)
2091
+ buildErrorContext(requestId, url, method, attempt + 1, circuitError, {
2092
+ host,
2093
+ ...cooldownRemainingMs !== void 0 ? { retryAfterMs: cooldownRemainingMs } : {}
2094
+ })
1677
2095
  );
1678
2096
  throw circuitError;
1679
2097
  }
1680
- const paceStartedAt = Date.now();
1681
2098
  try {
1682
- await awaitPaceDeadline(stateOwner, host, resolved, hookContext);
1683
- pacedWaitMs += Date.now() - paceStartedAt;
2099
+ pacedWaitMs += await awaitPaceDeadline(stateOwner, host, resolved, hookContext);
1684
2100
  } catch (paceError) {
1685
2101
  if (circuit !== void 0 && circuit.probeInFlight) {
1686
2102
  recordCircuitSuccess(circuit, resolved, hookContext, host);
@@ -1707,34 +2123,54 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
1707
2123
  httpsAgent: resolved.httpsAgent
1708
2124
  });
1709
2125
  const rateLimit = getRateLimitInfo(response.headers);
2126
+ const attemptDurationMs = Date.now() - startedAt;
2127
+ const responseFacts = {
2128
+ ...hookContext,
2129
+ durationMs: attemptDurationMs,
2130
+ ...rateLimit !== void 0 ? { rateLimit } : {},
2131
+ ...cacheMiss ? { cacheHit: false } : {},
2132
+ ...pacedWaitMs > 0 ? { pacedMs: pacedWaitMs } : {}
2133
+ };
2134
+ let outcome;
2135
+ try {
2136
+ outcome = resolveEnvelopeOutcome(response, resolved, rawPassthrough);
2137
+ } catch (envelopeError) {
2138
+ safeInvoke(
2139
+ resolved.onResponse,
2140
+ "onResponse",
2141
+ resolved.onHookError,
2142
+ resolved.diagnostics,
2143
+ responseFacts
2144
+ );
2145
+ responseReported = true;
2146
+ throw envelopeError;
2147
+ }
2148
+ const { result, resolvedPartial, partialError, partialBreakerError } = outcome;
2149
+ const cacheWritten = writeBack?.(result, resolvedPartial) ?? false;
1710
2150
  safeInvoke(
1711
2151
  resolved.onResponse,
1712
2152
  "onResponse",
1713
2153
  resolved.onHookError,
1714
2154
  resolved.diagnostics,
1715
- {
1716
- ...hookContext,
1717
- durationMs: Date.now() - startedAt,
1718
- ...rateLimit !== void 0 ? { rateLimit } : {},
1719
- ...cacheMiss ? { cacheHit: false } : {},
1720
- ...pacedWaitMs > 0 ? { pacedMs: pacedWaitMs } : {}
1721
- }
2155
+ { ...responseFacts, ...cacheWritten ? { cacheWrite: true } : {} }
1722
2156
  );
1723
2157
  responseReported = true;
1724
- const { result, resolvedPartial, partialAvailabilityFailure } = resolveEnvelopeOutcome(
1725
- response,
1726
- resolved,
1727
- rawPassthrough,
1728
- requestId,
1729
- url,
1730
- method,
1731
- attempt
1732
- );
1733
- if (partialAvailabilityFailure !== void 0) {
2158
+ if (partialError !== void 0) {
2159
+ stampRequestId(partialError, requestId);
2160
+ safeInvoke(
2161
+ resolved.onError,
2162
+ "onError",
2163
+ resolved.onHookError,
2164
+ resolved.diagnostics,
2165
+ partialError,
2166
+ buildErrorContext(requestId, url, method, attempt + 1, partialError)
2167
+ );
2168
+ }
2169
+ if (partialBreakerError !== void 0) {
1734
2170
  recordCircuitFailure(
1735
2171
  circuit,
1736
2172
  resolved.circuitBreaker,
1737
- partialAvailabilityFailure,
2173
+ partialBreakerError,
1738
2174
  resolved,
1739
2175
  hookContext,
1740
2176
  host
@@ -1768,7 +2204,7 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
1768
2204
  hookContext,
1769
2205
  host
1770
2206
  );
1771
- const delay = computeNextRetryDelay({
2207
+ const delayInput = {
1772
2208
  normalized,
1773
2209
  rawError: error,
1774
2210
  attempt,
@@ -1776,27 +2212,42 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
1776
2212
  budgetState,
1777
2213
  budget: resolved.retryBudget,
1778
2214
  wasProbe
1779
- });
2215
+ };
2216
+ const delay = computeNextRetryDelay(delayInput);
1780
2217
  if (delay !== null && budgetState !== void 0) {
1781
2218
  budgetState.retriesUsed += 1;
1782
2219
  }
1783
- reportFailure(
1784
- requestId,
1785
- url,
1786
- method,
1787
- attempt + 1,
1788
- normalized,
1789
- resolved,
1790
- delay ?? void 0
1791
- );
2220
+ if (delay === null) {
2221
+ paceAfterTerminalRateLimit(normalized, resolved, stateOwner, host);
2222
+ }
2223
+ reportFailure(requestId, url, method, attempt + 1, normalized, resolved, {
2224
+ nextDelayMs: delay ?? void 0,
2225
+ // The cumulative retry wait is present only when a wait
2226
+ // occurred, following the same optional-presence convention
2227
+ // as `pacedMs`. Terminal-failure metrics can report the
2228
+ // total retry investment directly from the onError
2229
+ // payload.
2230
+ ...retryWaitMs > 0 ? { retryWaitMs } : {},
2231
+ // The budget-exhaustion signal: this failure was
2232
+ // retryable, but the window's retry spend was already
2233
+ // spent. The hook can distinguish this from a failure that
2234
+ // was never retryable, without polling
2235
+ // transport-state snapshots. Gated on actual retryability
2236
+ // (see isBudgetGatedFailure) so a never-retryable failure
2237
+ // landing in a spent window is not miscounted as chronic
2238
+ // budget exhaustion.
2239
+ ...delay === null && isBudgetGatedFailure(delayInput) ? { budgetExhausted: true } : {}
2240
+ });
1792
2241
  if (delay === null) {
1793
2242
  throw normalized;
1794
2243
  }
2244
+ retryWaitMs += delay;
1795
2245
  attempt += 1;
1796
2246
  await sleep(delay, resolved.signal, requestId);
1797
2247
  }
1798
2248
  }
1799
2249
  };
2250
+
1800
2251
  let warnedOptionsKeyedState = false;
1801
2252
  const warnOptionsKeyedState = (onHookError, diagnostics) => {
1802
2253
  if (warnedOptionsKeyedState) {
@@ -1846,29 +2297,6 @@ const buildHeaders = (resolvedAuth, contentType) => {
1846
2297
  }
1847
2298
  return headers;
1848
2299
  };
1849
- const tryCacheRead = (resolved, method, url, data, cacheAuthKey) => {
1850
- const cached = resolved.responseCache.get(method, url, data, cacheAuthKey);
1851
- if (cached === void 0) {
1852
- return void 0;
1853
- }
1854
- if (resolved.onRequestStart !== void 0 || resolved.onResponse !== void 0) {
1855
- const requestId = randomUUID();
1856
- const hookContext = { requestId, url, method, attempt: 1 };
1857
- safeInvoke(
1858
- resolved.onRequestStart,
1859
- "onRequestStart",
1860
- resolved.onHookError,
1861
- resolved.diagnostics,
1862
- hookContext
1863
- );
1864
- safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, resolved.diagnostics, {
1865
- ...hookContext,
1866
- durationMs: 0,
1867
- cacheHit: true
1868
- });
1869
- }
1870
- return cached;
1871
- };
1872
2300
  const sendRequest = async (url, method, data, auth, sendOptions) => {
1873
2301
  const {
1874
2302
  requiresAuth = false,
@@ -1880,21 +2308,13 @@ const sendRequest = async (url, method, data, auth, sendOptions) => {
1880
2308
  } = sendOptions ?? {};
1881
2309
  const isRestCall = protocol === "rest" || protocol === void 0 && contentType !== void 0;
1882
2310
  const resolvedAuth = resolveAuthMaterial(auth);
1883
- if (requiresAuth && !resolvedAuth.hasAuthMaterial) {
1884
- throw new AniLinkAuthError(operation);
1885
- }
2311
+ const authGuard = requiresAuth && !resolvedAuth.hasAuthMaterial ? () => new AniLinkAuthError(operation) : void 0;
1886
2312
  const headers = buildHeaders(resolvedAuth, contentType);
1887
2313
  const resolved = resolveRequestOptions(options);
1888
2314
  if (stateOwner === void 0 && options !== void 0 && (resolved.circuitBreaker !== void 0 || resolved.retryBudget !== void 0 || resolved.paceWithRateLimit)) {
1889
2315
  warnOptionsKeyedState(resolved.onHookError, resolved.diagnostics);
1890
2316
  }
1891
- const cacheAuthKey = resolveCacheAuthKey(
1892
- resolved.responseCache,
1893
- method,
1894
- data,
1895
- isRestCall,
1896
- resolvedAuth
1897
- );
2317
+ const cacheAuthKey = authGuard !== void 0 || resolved.bypassResponseCache ? void 0 : resolveCacheAuthKey(resolved.responseCache, method, data, isRestCall, resolvedAuth);
1898
2318
  let cacheGenerationAtRead;
1899
2319
  if (cacheAuthKey !== void 0) {
1900
2320
  const cached = tryCacheRead(resolved, method, url, data, cacheAuthKey);
@@ -1903,53 +2323,32 @@ const sendRequest = async (url, method, data, auth, sendOptions) => {
1903
2323
  }
1904
2324
  cacheGenerationAtRead = resolved.responseCache.getGeneration();
1905
2325
  }
1906
- const { result, resolvedPartial } = await executeWithRetry(
2326
+ const writeBack = cacheAuthKey !== void 0 ? (result2, resolvedPartial) => resolvedPartial ? false : resolved.responseCache.setIfFresh(
2327
+ method,
2328
+ url,
2329
+ data,
2330
+ cacheAuthKey,
2331
+ cacheGenerationAtRead,
2332
+ result2,
2333
+ isRestCall ? void 0 : extractQueryRootField(data)
2334
+ ) : void 0;
2335
+ const { result } = await executeWithRetry(
1907
2336
  { url, method, data, headers },
1908
2337
  resolved,
1909
2338
  stateOwner ?? options,
1910
2339
  {
1911
2340
  rawPassthrough: isRestCall,
1912
- // A cacheable read that missed the cache: its network response
1913
- // carries cacheHit: false so consumers can count misses without
1914
- // subtracting hits from total response counts. Fail-closed reads
1915
- // (no cache key) and mutations stay unmarked.
1916
- cacheMiss: cacheAuthKey !== void 0
2341
+ cacheMiss: cacheAuthKey !== void 0,
2342
+ authGuard,
2343
+ writeBack
1917
2344
  }
1918
2345
  );
1919
- if (cacheAuthKey !== void 0) {
1920
- if (!resolvedPartial) {
1921
- resolved.responseCache.setIfFresh(
1922
- method,
1923
- url,
1924
- data,
1925
- cacheAuthKey,
1926
- cacheGenerationAtRead,
1927
- result
1928
- );
1929
- }
1930
- } else {
2346
+ if (cacheAuthKey === void 0) {
1931
2347
  invalidateAfterMutation(resolved.responseCache, method, url, data, isRestCall);
1932
2348
  }
1933
2349
  return result;
1934
2350
  };
1935
2351
 
1936
- const DEEP_MERGED_OPTION_KEYS = ["retry", "circuitBreaker", "retryBudget"];
1937
- const assignDeepMergedOption = (merged, key, value) => {
1938
- merged[key] = value;
1939
- };
1940
- const mergeOptions = (base, overrides) => {
1941
- if (overrides === void 0) return base;
1942
- if (base === void 0) return overrides;
1943
- const merged = { ...base, ...overrides };
1944
- for (const key of DEEP_MERGED_OPTION_KEYS) {
1945
- const baseValue = base[key];
1946
- const overrideValue = overrides[key];
1947
- if (typeof baseValue === "object" && baseValue !== null && typeof overrideValue === "object" && overrideValue !== null) {
1948
- assignDeepMergedOption(merged, key, { ...baseValue, ...overrideValue });
1949
- }
1950
- }
1951
- return merged;
1952
- };
1953
2352
  const resolveOperationLabel = (operation) => {
1954
2353
  const name = operation.constructor?.name;
1955
2354
  return typeof name === "string" && name.length > 0 ? name : void 0;
@@ -1970,8 +2369,8 @@ class BaseOperation {
1970
2369
  /**
1971
2370
  * The authentication token shared by all operations of an instance.
1972
2371
  *
1973
- * Mutable only through {@link BaseOperation.updateAuth} so a provider
1974
- * wiring seam can swap in refreshed auth material (for example the MAL
2372
+ * Mutable only through {@link BaseOperation.updateAuth} so provider
2373
+ * wiring can swap in refreshed auth material (for example the MAL
1975
2374
  * automatic token-refresh lifecycle) without rebuilding operations.
1976
2375
  */
1977
2376
  requestAuth;
@@ -2018,10 +2417,10 @@ class BaseOperation {
2018
2417
  /**
2019
2418
  * Swaps the instance authentication material in place.
2020
2419
  *
2021
- * @internal This mutator exists for provider wiring seams that refresh
2420
+ * @internal This mutator exists for provider wiring that refreshes
2022
2421
  * credentials mid-flight (the MAL automatic token-refresh lifecycle swaps
2023
2422
  * the stored auth on the operation instances before replaying a 401'd
2024
- * request). It is not part of the public API surface.
2423
+ * request). It is not part of the public API.
2025
2424
  *
2026
2425
  * @param auth - The replacement authentication material, or `undefined` to clear it.
2027
2426
  */
@@ -2031,12 +2430,11 @@ class BaseOperation {
2031
2430
  /**
2032
2431
  * Reads the current instance authentication material.
2033
2432
  *
2034
- * @internal Companion to {@link BaseOperation.updateAuth} for wiring
2035
- * seams that rebuild auth from the operation's live state at swap time
2433
+ * @internal Companion to {@link BaseOperation.updateAuth} for provider
2434
+ * wiring that rebuilds auth from the operation's current state at swap time
2036
2435
  * (the MAL refresh lifecycle reads each operation's current auth before
2037
2436
  * applying a fresh access token, so a replay never rebuilds headers from
2038
- * a stale construction-time snapshot). It is not part of the public API
2039
- * surface.
2437
+ * a stale construction-time snapshot). It is not part of the public API.
2040
2438
  *
2041
2439
  * @returns The current {@link RequestAuthInput}, or `undefined`.
2042
2440
  */
@@ -2070,7 +2468,7 @@ class BaseOperation {
2070
2468
  // The stable owner keys cross-request transport state (circuit
2071
2469
  // breaker, retry budget, pacing deadlines) so failure streaks
2072
2470
  // accumulate across requests even when each call carries fresh
2073
- // per-request options — across the whole client when the wiring
2471
+ // per-request options, across the whole client when the wiring
2074
2472
  // shared one owner, or across this instance's requests otherwise.
2075
2473
  stateOwner: this.stateOwner
2076
2474
  });
@@ -2264,8 +2662,8 @@ class AniListOperation extends BaseOperation {
2264
2662
  * Runs the shared validate-then-dispatch pipeline for an operation.
2265
2663
  *
2266
2664
  * Operations declare their contract as a {@link AniListExecuteOptions}
2267
- * object — variable-presence requirements, an optional type map, and the
2268
- * auth requirement — and this method applies them in a fixed order before
2665
+ * object: variable-presence requirements, an optional type map, and the
2666
+ * auth requirement. This method applies them in a fixed order before
2269
2667
  * delegating to {@link AniListOperation.request}.
2270
2668
  *
2271
2669
  * @param query - The GraphQL document to execute.
@@ -2278,144 +2676,17 @@ class AniListOperation extends BaseOperation {
2278
2676
  */
2279
2677
  async execute(query, variables, options) {
2280
2678
  const { requirements, mappings, requiresAuth, transportOptions } = options;
2281
- const variablesForRequirements = variables ?? {};
2282
- if (requirements) {
2283
- for (const requirement of requirements) {
2284
- requireVariables(variablesForRequirements, requirement, requirement.message);
2285
- }
2286
- }
2287
- if (mappings && variables !== void 0) {
2288
- validateVariables(variables, mappings);
2289
- }
2290
- return await this.request(query, variables, { requiresAuth, transportOptions });
2291
- }
2292
- }
2293
-
2294
- const GRAPHQL_OPERATION_PATTERN = /^\s*(?:\{|(?:query|mutation)\b[\s\S]*\{)/;
2295
- const stripLeadingComments = (query) => {
2296
- let rest = query;
2297
- for (; ; ) {
2298
- const line = /^[^\n]*\n/.exec(rest);
2299
- if (line === null) {
2300
- return rest;
2301
- }
2302
- if (!/^\s*(#|$)/.test(line[0])) {
2303
- return rest;
2304
- }
2305
- rest = rest.slice(line[0].length);
2306
- }
2307
- };
2308
- class CustomRequest extends AniListOperation {
2309
- /**
2310
- * `custom` sends a caller-authored GraphQL query or mutation document.
2311
- *
2312
- * The response follows the same unwrapping rule as every other operation:
2313
- * a document with a single root field resolves to the bare field value,
2314
- * while a document with multiple root fields resolves to the full
2315
- * `{ data }` envelope. Annotate `T` with the shape you expect — the bare
2316
- * value for single-root-field documents, or the envelope type itself when
2317
- * the document selects several root fields:
2318
- *
2319
- * ```typescript
2320
- * const viewer = await aniLink.anilist.custom<{ id: number }>("query { Viewer { id } }");
2321
- *
2322
- * // Multi-root-field document: T is the full envelope.
2323
- * const both = await aniLink.anilist.custom<{ data: { Media: { id: number }; User: { id: number } } }>(
2324
- * "query { Media (id: 1) { id } User (id: 1) { id } }"
2325
- * );
2326
- * ```
2327
- *
2328
- * @param query - The GraphQL document to execute. It must declare an executable operation: an anonymous shorthand selection (`{ Viewer { id } }`) or a `query`/`mutation` document. AniList's HTTP endpoint does not serve subscriptions, so `subscription` documents are rejected here rather than failing remotely.
2329
- * @param variables - The variables for the document. This parameter is optional.
2330
- * @param options - Optional per-request transport settings merged over the instance-level ones for this call only.
2331
- * @returns A promise that resolves to the unwrapped response data for single-root-field documents, or the full `{ data }` envelope otherwise.
2332
- * @throws An {@link AniLinkValidationError} when the query is empty or is not an executable document (for example a fragment-only definition).
2333
- * @throws An `AniLinkError` when the request fails. When AniList returns partial success (some fields resolve while others fail inside an HTTP 200 envelope), the thrown `AniLinkGraphQLError` exposes the resolved portion via its `partialData` field, so the fields that did resolve remain recoverable from the error.
2334
- * @see https://docs.anilist.co/reference/query
2335
- * @see https://docs.anilist.co/reference/mutation
2336
- */
2337
- async custom(query, variables = {}, options) {
2338
- if (typeof query !== "string" || !GRAPHQL_OPERATION_PATTERN.test(stripLeadingComments(query))) {
2339
- throw new AniLinkValidationError([
2340
- "custom() requires an executable GraphQL document (an anonymous selection or a query/mutation operation)"
2341
- ]);
2342
- }
2343
- return await this.request(query, variables, { transportOptions: options });
2344
- }
2345
- }
2346
-
2347
- function fuzzyDate(options) {
2348
- const input = {
2349
- year: options?.year ?? 0,
2350
- month: options?.month ?? 0,
2351
- day: options?.day ?? 0
2352
- };
2353
- return input;
2354
- }
2355
-
2356
- function fuzzyDateInt(options) {
2357
- const year = options?.year ?? 0;
2358
- const month = options?.month ?? 0;
2359
- const day = options?.day ?? 0;
2360
- return year * 1e4 + month * 100 + day;
2361
- }
2362
-
2363
- function flattenMediaListCollection(response) {
2364
- if (!response || !Array.isArray(response.lists)) {
2365
- return [];
2366
- }
2367
- const byId = /* @__PURE__ */ new Map();
2368
- for (const group of response.lists) {
2369
- if (!Array.isArray(group.entries)) {
2370
- continue;
2371
- }
2372
- for (const entry of group.entries) {
2373
- mergeEntry(byId, entry, group.name, group.isCustomList, group.isSplitCompletedList);
2374
- }
2375
- }
2376
- return Array.from(byId.values());
2377
- }
2378
- function mergeEntry(byId, entry, listName, isCustomList, isSplitCompletedList) {
2379
- if (typeof entry !== "object" || entry === null || typeof entry.id !== "number" || !Number.isFinite(entry.id)) {
2380
- throw new AniLinkValidationError([
2381
- `flattenMediaListCollection: a list entry in "${listName}" is malformed \u2014 every entry must be an object with a finite numeric id.`
2382
- ]);
2383
- }
2384
- const existing = byId.get(entry.id);
2385
- if (existing) {
2386
- if (!existing.listNames.includes(listName)) {
2387
- existing.listNames.push(listName);
2388
- }
2389
- if (isCustomList) existing.inCustomList = true;
2390
- if (isSplitCompletedList) existing.inSplitCompletedList = true;
2391
- return;
2392
- }
2393
- byId.set(entry.id, {
2394
- id: entry.id,
2395
- userId: entry.userId,
2396
- mediaId: entry.mediaId,
2397
- status: entry.status,
2398
- score: entry.score,
2399
- progress: entry.progress,
2400
- listNames: [listName],
2401
- inCustomList: isCustomList,
2402
- inSplitCompletedList: isSplitCompletedList
2403
- });
2404
- }
2405
-
2406
- function crossLink(media) {
2407
- const anilistToMal = /* @__PURE__ */ new Map();
2408
- const malToAnilist = /* @__PURE__ */ new Map();
2409
- const unmapped = [];
2410
- for (const entry of media) {
2411
- if (typeof entry.idMal !== "number") {
2412
- unmapped.push(entry);
2413
- continue;
2679
+ const variablesForRequirements = variables ?? {};
2680
+ if (requirements) {
2681
+ for (const requirement of requirements) {
2682
+ requireVariables(variablesForRequirements, requirement, requirement.message);
2683
+ }
2414
2684
  }
2415
- anilistToMal.set(entry.id, entry.idMal);
2416
- malToAnilist.set(entry.idMal, entry.id);
2685
+ if (mappings && variables !== void 0) {
2686
+ validateVariables(variables, mappings);
2687
+ }
2688
+ return await this.request(query, variables, { requiresAuth, transportOptions });
2417
2689
  }
2418
- return { anilistToMal, malToAnilist, unmapped };
2419
2690
  }
2420
2691
 
2421
2692
  const MAX_CONCURRENCY = 8;
@@ -2429,6 +2700,32 @@ function resolvePositiveInt(value, fallback, name = "option") {
2429
2700
  function resolveCappedInt(value, max, fallback, name = "option") {
2430
2701
  return Math.min(resolvePositiveInt(value, fallback, name), max);
2431
2702
  }
2703
+ function resolvePaginationOptions(options, defaults) {
2704
+ const perEntryName = defaults.naming === "page" ? "perPage" : "perChunk";
2705
+ const startName = defaults.naming === "page" ? "startPage" : "startChunk";
2706
+ const maxEntriesName = defaults.naming === "page" ? "maxPages" : "maxChunks";
2707
+ return {
2708
+ perEntry: resolveCappedInt(
2709
+ options?.[perEntryName],
2710
+ defaults.maxPerEntry,
2711
+ defaults.defaultPerEntry,
2712
+ perEntryName
2713
+ ),
2714
+ startEntry: resolvePositiveInt(options?.[startName], 1, startName),
2715
+ maxEntries: resolvePositiveInt(
2716
+ options?.[maxEntriesName],
2717
+ defaults.defaultMaxEntries,
2718
+ maxEntriesName
2719
+ ),
2720
+ concurrency: resolveCappedInt(
2721
+ options?.concurrency,
2722
+ MAX_CONCURRENCY,
2723
+ defaults.defaultConcurrency,
2724
+ "concurrency"
2725
+ ),
2726
+ diagnostics: resolveDiagnosticsMode(options?.diagnostics)
2727
+ };
2728
+ }
2432
2729
  function bridgeAbortSignal(external) {
2433
2730
  const controller = new AbortController();
2434
2731
  if (external === void 0) {
@@ -2449,30 +2746,7 @@ function bridgeAbortSignal(external) {
2449
2746
  }
2450
2747
  };
2451
2748
  }
2452
- async function fetchWithLookAhead(...args) {
2453
- if (typeof args[2] === "function") {
2454
- const [fetch2, extractHasMore2, extractNextKey, firstKey, maxEntries2, , signal2] = args;
2455
- return fetchCursorChain(
2456
- fetch2,
2457
- extractHasMore2,
2458
- extractNextKey,
2459
- firstKey,
2460
- maxEntries2,
2461
- signal2
2462
- );
2463
- }
2464
- if (args[2] === void 0) {
2465
- const [fetch2, extractHasMore2, , startNumber2, maxEntries2, concurrency2, signal2] = args;
2466
- return fetchNumericWithLookAhead(
2467
- fetch2,
2468
- extractHasMore2,
2469
- startNumber2,
2470
- maxEntries2,
2471
- concurrency2,
2472
- signal2
2473
- );
2474
- }
2475
- const [fetch, extractHasMore, startNumber, maxEntries, concurrency, signal, extractBound] = args;
2749
+ async function fetchWithLookAhead(fetch, extractHasMore, startNumber, maxEntries, concurrency, signal, extractBound) {
2476
2750
  return fetchNumericWithLookAhead(
2477
2751
  fetch,
2478
2752
  extractHasMore,
@@ -2501,13 +2775,14 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
2501
2775
  let truncated = false;
2502
2776
  const readBound = extractBound ?? (() => void 0);
2503
2777
  let lastPageBound = Number.POSITIVE_INFINITY;
2778
+ let windowSize = Math.min(1, concurrency);
2504
2779
  while (count < maxEntries) {
2505
2780
  if (signal?.aborted) {
2506
2781
  await Promise.allSettled(pending.slice(count));
2507
2782
  responses.length = count;
2508
2783
  return { responses, count, truncated: false };
2509
2784
  }
2510
- while (launched < maxEntries && launched - count < concurrency && startNumber + launched <= lastPageBound) {
2785
+ while (launched < maxEntries && launched - count < windowSize && startNumber + launched <= lastPageBound) {
2511
2786
  const slot = launched;
2512
2787
  launched += 1;
2513
2788
  const request = fetch(startNumber + slot).then((response) => {
@@ -2543,6 +2818,7 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
2543
2818
  responses.length = count;
2544
2819
  return { responses, count, truncated: false };
2545
2820
  }
2821
+ windowSize = concurrency;
2546
2822
  if (count >= maxEntries) {
2547
2823
  await Promise.allSettled(pending.slice(count));
2548
2824
  truncated = true;
@@ -2551,38 +2827,6 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
2551
2827
  }
2552
2828
  return { responses, count, truncated };
2553
2829
  }
2554
- async function fetchCursorChain(fetch, extractHasMore, extractNextKey, firstKey, maxEntries, signal) {
2555
- const responses = [];
2556
- let key = firstKey;
2557
- while (responses.length < maxEntries && key !== void 0) {
2558
- if (signal?.aborted) {
2559
- return { responses, count: responses.length, truncated: false };
2560
- }
2561
- let entry;
2562
- try {
2563
- entry = await fetch(key);
2564
- } catch (err) {
2565
- if (signal?.aborted) {
2566
- return { responses, count: responses.length, truncated: false };
2567
- }
2568
- throw err;
2569
- }
2570
- responses.push(entry);
2571
- if (!extractHasMore(entry)) {
2572
- return { responses, count: responses.length, truncated: false };
2573
- }
2574
- key = extractNextKey(entry);
2575
- }
2576
- return {
2577
- responses,
2578
- count: responses.length,
2579
- // A degenerate guard (maxEntries <= 0) fetched nothing and cut
2580
- // nothing short: `truncated` reports whether the guard ended a run
2581
- // that still had data, matching the numeric driver's `while (count <
2582
- // maxEntries)` early exit.
2583
- truncated: responses.length >= maxEntries && maxEntries > 0
2584
- };
2585
- }
2586
2830
  async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBound) {
2587
2831
  const { perPage, startPage, maxPages, concurrency } = options;
2588
2832
  const { signal, dispose } = bridgeAbortSignal(options.signal);
@@ -2596,8 +2840,9 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
2596
2840
  let terminal = false;
2597
2841
  let launchBound = Number.POSITIVE_INFINITY;
2598
2842
  const readBound = extractBound ?? (() => void 0);
2599
- const launchWindow = () => {
2600
- while (!terminal && nextToLaunch - startPage < maxPages && pending.size < concurrency && nextToLaunch <= launchBound) {
2843
+ let windowSize = Math.min(1, concurrency);
2844
+ const launchWindow = (limit) => {
2845
+ while (!terminal && nextToLaunch - startPage < maxPages && pending.size < limit && nextToLaunch <= launchBound) {
2601
2846
  const page = nextToLaunch;
2602
2847
  nextToLaunch += 1;
2603
2848
  const request = fetchPage(page, perPage, signal);
@@ -2608,7 +2853,7 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
2608
2853
  };
2609
2854
  try {
2610
2855
  while (nextToYield - startPage < maxPages) {
2611
- launchWindow();
2856
+ launchWindow(windowSize);
2612
2857
  const page = nextToYield;
2613
2858
  const request = pending.get(page);
2614
2859
  if (request === void 0) {
@@ -2629,8 +2874,13 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
2629
2874
  if (observedBound !== void 0 && observedBound < launchBound) {
2630
2875
  launchBound = observedBound;
2631
2876
  }
2877
+ const pageIsTerminal = isTerminalPage(response);
2878
+ if (!pageIsTerminal) {
2879
+ windowSize = concurrency;
2880
+ launchWindow(concurrency - 1);
2881
+ }
2632
2882
  yield response;
2633
- if (isTerminalPage(response)) {
2883
+ if (pageIsTerminal) {
2634
2884
  terminal = true;
2635
2885
  await Promise.allSettled([...pending.values()]);
2636
2886
  break;
@@ -2642,8 +2892,8 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
2642
2892
  }
2643
2893
  }
2644
2894
 
2645
- const DEFAULT_PER_PAGE$1 = 50;
2646
- const MAX_PER_PAGE$1 = DEFAULT_PER_PAGE$1;
2895
+ const DEFAULT_PER_PAGE$2 = 50;
2896
+ const MAX_PER_PAGE$2 = DEFAULT_PER_PAGE$2;
2647
2897
  const DEFAULT_MAX_PAGES$1 = 100;
2648
2898
  const DEFAULT_PER_CHUNK = 500;
2649
2899
  const MAX_PER_CHUNK = DEFAULT_PER_CHUNK;
@@ -2652,6 +2902,20 @@ const safeCallback$1 = (callback, name, onHookError, diagnostics, payload) => {
2652
2902
  safeInvoke(callback, name, onHookError, diagnostics, payload);
2653
2903
  };
2654
2904
  const DEFAULT_CONCURRENCY$1 = 3;
2905
+ const PAGE_DEFAULTS = {
2906
+ naming: "page",
2907
+ maxPerEntry: MAX_PER_PAGE$2,
2908
+ defaultPerEntry: DEFAULT_PER_PAGE$2,
2909
+ defaultMaxEntries: DEFAULT_MAX_PAGES$1,
2910
+ defaultConcurrency: DEFAULT_CONCURRENCY$1
2911
+ };
2912
+ const CHUNK_DEFAULTS = {
2913
+ naming: "chunk",
2914
+ maxPerEntry: MAX_PER_CHUNK,
2915
+ defaultPerEntry: DEFAULT_PER_CHUNK,
2916
+ defaultMaxEntries: DEFAULT_MAX_CHUNKS,
2917
+ defaultConcurrency: DEFAULT_CONCURRENCY$1
2918
+ };
2655
2919
  function extractHasMore$1(response) {
2656
2920
  if (typeof response !== "object" || response === null) return false;
2657
2921
  if ("pageInfo" in response) {
@@ -2664,16 +2928,13 @@ function extractHasMore$1(response) {
2664
2928
  return response.hasNextChunk === true;
2665
2929
  }
2666
2930
  async function paginate(fetchPage, itemsKey, options) {
2667
- const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE$1, DEFAULT_PER_PAGE$1, "perPage");
2668
- const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
2669
- const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES$1, "maxPages");
2670
- const concurrency = resolveCappedInt(
2671
- options?.concurrency,
2672
- MAX_CONCURRENCY,
2673
- DEFAULT_CONCURRENCY$1,
2674
- "concurrency"
2675
- );
2676
- const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
2931
+ const {
2932
+ perEntry: perPage,
2933
+ startEntry: startPage,
2934
+ maxEntries: maxPages,
2935
+ concurrency,
2936
+ diagnostics
2937
+ } = resolvePaginationOptions(options, PAGE_DEFAULTS);
2677
2938
  const { signal, dispose } = bridgeAbortSignal(options?.signal);
2678
2939
  try {
2679
2940
  const { responses, count, truncated } = await fetchWithLookAhead(
@@ -2685,92 +2946,500 @@ async function paginate(fetchPage, itemsKey, options) {
2685
2946
  signal,
2686
2947
  extractLastPageBound
2687
2948
  );
2688
- const items = [];
2689
- const pages = [];
2690
- for (const response of responses) {
2691
- const raw = response[itemsKey];
2692
- if (raw === void 0) {
2693
- throw new AniLinkValidationError([
2694
- `paginate: the page response has no "${itemsKey}" key. Check the itemsKey argument.`
2695
- ]);
2949
+ const items = [];
2950
+ const pages = [];
2951
+ for (const response of responses) {
2952
+ const raw = response[itemsKey];
2953
+ if (raw === void 0) {
2954
+ throw new AniLinkValidationError([
2955
+ `paginate: the page response has no "${itemsKey}" key. Check the itemsKey argument.`
2956
+ ]);
2957
+ }
2958
+ const pageItems = Array.isArray(raw) ? raw : [];
2959
+ pages.push({ pageInfo: response.pageInfo, items: pageItems });
2960
+ items.push(...pageItems);
2961
+ safeCallback$1(options?.onPage, "onPage", options?.onHookError, diagnostics, {
2962
+ pageInfo: response.pageInfo,
2963
+ items: pageItems
2964
+ });
2965
+ }
2966
+ return { items, pages, pageCount: count, truncated };
2967
+ } finally {
2968
+ dispose();
2969
+ }
2970
+ }
2971
+ async function* paginatePages(fetchPage, options) {
2972
+ const {
2973
+ perEntry: perPage,
2974
+ startEntry: startPage,
2975
+ maxEntries: maxPages,
2976
+ concurrency
2977
+ } = resolvePaginationOptions(options, PAGE_DEFAULTS);
2978
+ yield* streamNumericPages(
2979
+ fetchPage,
2980
+ (response) => !response.pageInfo.hasNextPage,
2981
+ { perPage, startPage, maxPages, concurrency, signal: options?.signal },
2982
+ extractLastPageBound
2983
+ );
2984
+ }
2985
+ async function paginateChunks(fetchChunk, itemsKey, options) {
2986
+ const {
2987
+ perEntry: perChunk,
2988
+ startEntry: startChunk,
2989
+ maxEntries: maxChunks,
2990
+ concurrency,
2991
+ diagnostics
2992
+ } = resolvePaginationOptions(options, CHUNK_DEFAULTS);
2993
+ const { signal, dispose } = bridgeAbortSignal(options?.signal);
2994
+ try {
2995
+ const { responses, count, truncated } = await fetchWithLookAhead(
2996
+ (number) => fetchChunk(number, perChunk, signal),
2997
+ extractHasMore$1,
2998
+ startChunk,
2999
+ maxChunks,
3000
+ concurrency,
3001
+ signal
3002
+ );
3003
+ const items = [];
3004
+ const chunks = [];
3005
+ for (const response of responses) {
3006
+ const raw = response[itemsKey];
3007
+ if (raw === void 0) {
3008
+ throw new AniLinkValidationError([
3009
+ `paginateChunks: the chunk response has no "${itemsKey}" key. Check the itemsKey argument.`
3010
+ ]);
3011
+ }
3012
+ const chunkItems = Array.isArray(raw) ? raw : [];
3013
+ chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
3014
+ items.push(...chunkItems);
3015
+ safeCallback$1(options?.onChunk, "onChunk", options?.onHookError, diagnostics, {
3016
+ hasNextChunk: response.hasNextChunk,
3017
+ items: chunkItems
3018
+ });
3019
+ }
3020
+ return { items, chunks, chunkCount: count, truncated };
3021
+ } finally {
3022
+ dispose();
3023
+ }
3024
+ }
3025
+
3026
+ const GRAPHQL_OPERATION_PATTERN = /^\s*(?:\{|(?:query|mutation)\b[\s\S]*\{)/;
3027
+ const GRAPHQL_PAGE_QUERY_PATTERN = /^\s*query\b[\s\S]*\bPage\s*\(/;
3028
+ const CUSTOM_PAGE_VALIDATION_MESSAGE = "customPage() requires a query document whose single root field is Page, selecting it with $page and $perPage Int variables";
3029
+ const stripLeadingComments = (query) => {
3030
+ let rest = query;
3031
+ for (; ; ) {
3032
+ const line = /^[^\n]*\n/.exec(rest);
3033
+ if (line === null) {
3034
+ return rest;
3035
+ }
3036
+ if (!/^\s*(#|$)/.test(line[0])) {
3037
+ return rest;
3038
+ }
3039
+ rest = rest.slice(line[0].length);
3040
+ }
3041
+ };
3042
+ class CustomRequest extends AniListOperation {
3043
+ /**
3044
+ * `custom` sends a caller-authored GraphQL query or mutation document.
3045
+ *
3046
+ * The response follows the same unwrapping rule as every other operation:
3047
+ * a document with a single root field resolves to the bare field value,
3048
+ * while a document with multiple root fields resolves to the full
3049
+ * `{ data }` envelope. Annotate `T` with the shape you expect: the bare
3050
+ * value for single-root-field documents, or the envelope type itself when
3051
+ * the document selects several root fields:
3052
+ *
3053
+ * ```typescript
3054
+ * const viewer = await aniLink.anilist.custom<{ id: number }>("query { Viewer { id } }");
3055
+ *
3056
+ * // Multi-root-field document: T is the full envelope.
3057
+ * const both = await aniLink.anilist.custom<{ data: { Media: { id: number }; User: { id: number } } }>(
3058
+ * "query { Media (id: 1) { id } User (id: 1) { id } }"
3059
+ * );
3060
+ * ```
3061
+ *
3062
+ * @param query - The GraphQL document to execute. It must declare an executable operation: an anonymous shorthand selection (`{ Viewer { id } }`) or a `query`/`mutation` document. AniList's HTTP endpoint does not serve subscriptions, so `subscription` documents are rejected here rather than failing remotely.
3063
+ * @param variables - The variables for the document. This parameter is optional.
3064
+ * @param options - Optional per-request transport settings merged over the instance-level ones for this call only.
3065
+ * @returns A promise that resolves to the unwrapped response data for single-root-field documents, or the full `{ data }` envelope otherwise.
3066
+ * @throws An {@link AniLinkValidationError} when the query is empty or is not an executable document (for example a fragment-only definition).
3067
+ * @throws An `AniLinkError` when the request fails. When AniList returns partial success (some fields resolve while others fail inside an HTTP 200 envelope), the thrown `AniLinkGraphQLError` exposes the resolved portion via its `partialData` field, so the fields that did resolve remain recoverable from the error.
3068
+ * @see https://docs.anilist.co/reference/query
3069
+ * @see https://docs.anilist.co/reference/mutation
3070
+ */
3071
+ async custom(query, variables = {}, options) {
3072
+ if (typeof query !== "string" || !GRAPHQL_OPERATION_PATTERN.test(stripLeadingComments(query))) {
3073
+ throw new AniLinkValidationError([
3074
+ "custom() requires an executable GraphQL document (an anonymous selection or a query/mutation operation)"
3075
+ ]);
3076
+ }
3077
+ return await this.request(query, variables, { transportOptions: options });
3078
+ }
3079
+ /**
3080
+ * `customPage` paginates a caller-authored `Page` document through the
3081
+ * shared engine. Use it for collections whose field combination the
3082
+ * generated page operations do not expose.
3083
+ *
3084
+ * The document must declare the standard AniList `Page` wrapper with
3085
+ * `$page`/`$perPage` `Int` variables and select `pageInfo { hasNextPage }`
3086
+ * (the engine's terminal flag) plus an items array under a key of your
3087
+ * choosing. Because `Page` is the document's single root field, the
3088
+ * response unwraps to the bare `Page` object, so `TPage` is the `Page`
3089
+ * selection's shape, not an envelope:
3090
+ *
3091
+ * ```typescript
3092
+ * const result = await aniLink.anilist.customPage(
3093
+ * `query ($page: Int, $perPage: Int) {
3094
+ * Page(page: $page, perPage: $perPage) {
3095
+ * pageInfo { total currentPage lastPage hasNextPage }
3096
+ * media(type: ANIME, sort: POPULARITY_DESC) { id title { romaji } }
3097
+ * }
3098
+ * }`,
3099
+ * "media",
3100
+ * {},
3101
+ * { perPage: 50, maxPages: 5 }
3102
+ * );
3103
+ * ```
3104
+ *
3105
+ * The traversal reuses every engine guard: `perPage` clamping (AniList caps
3106
+ * it at 50), the `maxPages` bound (default 100), look-ahead `concurrency`
3107
+ * (default 3), `AbortSignal` forwarding, and the `pageInfo.lastPage`
3108
+ * terminal bound. `variables` are forwarded verbatim on every page
3109
+ * request with `page`/`perPage` merged over them.
3110
+ *
3111
+ * @typeParam TPage - The `Page` selection's response shape; must include `pageInfo` (the engine reads `pageInfo.hasNextPage`).
3112
+ * @typeParam K - The key of the items array on `TPage` (e.g. `"media"`).
3113
+ * @param query - The GraphQL document to traverse. It must be a `query` document whose single root field is `Page`, selected with `$page`/`$perPage` variables; mutation documents, non-`Page` documents, and documents with additional root fields are rejected locally.
3114
+ * @param itemsKey - The key of the items array on the `Page` response (e.g. `"media"`, `"characters"`).
3115
+ * @param variables - The variables for the document, forwarded on every page request with `page`/`perPage` merged over them. This parameter is optional.
3116
+ * @param options - Optional `CustomPageOptions`: the `PaginateOptions` controls (`perPage`, `startPage`, `maxPages`, `concurrency`, `signal`, `onPage`, `onHookError`, `diagnostics`) plus per-request transport settings under `transportOptions`, merged over the instance-level ones for this call only.
3117
+ * @returns The collected items, per-page snapshots, page count, and whether the `maxPages` guard or the server-reported `lastPage` bound truncated the run; a `PaginateResult`.
3118
+ * @throws An {@link AniLinkValidationError} when the document is not a `query` selecting the `Page` root field with `$page`/`$perPage` variable references, or when a fetched page response has no `itemsKey` key at all.
3119
+ * @throws An `AniLinkError` when a page request fails.
3120
+ * @see https://docs.anilist.co/reference/object/page
3121
+ * @example
3122
+ * ```typescript
3123
+ * const result = await aniLink.anilist.customPage(
3124
+ * `query ($page: Int, $perPage: Int) {
3125
+ * Page(page: $page, perPage: $perPage) {
3126
+ * pageInfo { hasNextPage }
3127
+ * characters(search: "spike") { id name { full } }
3128
+ * }
3129
+ * }`,
3130
+ * "characters",
3131
+ * {},
3132
+ * { perPage: 50, maxPages: 3 }
3133
+ * );
3134
+ * console.log(result.items.length, result.truncated);
3135
+ * ```
3136
+ */
3137
+ async customPage(query, itemsKey, variables = {}, options) {
3138
+ if (typeof query !== "string") {
3139
+ throw new AniLinkValidationError([CUSTOM_PAGE_VALIDATION_MESSAGE]);
3140
+ }
3141
+ const normalized = stripLeadingComments(query);
3142
+ if (!GRAPHQL_OPERATION_PATTERN.test(normalized) || !GRAPHQL_PAGE_QUERY_PATTERN.test(normalized) || extractQueryRootField({ query }) !== "Page" || !/\$page\b/.test(normalized) || !/\$perPage\b/.test(normalized)) {
3143
+ throw new AniLinkValidationError([CUSTOM_PAGE_VALIDATION_MESSAGE]);
3144
+ }
3145
+ const { transportOptions, ...paginateOptions } = options ?? {};
3146
+ return await paginate(
3147
+ (page, perPage, signal) => this.request(
3148
+ query,
3149
+ { ...variables, page, perPage },
3150
+ { transportOptions: { ...transportOptions, signal } }
3151
+ ),
3152
+ itemsKey,
3153
+ paginateOptions
3154
+ );
3155
+ }
3156
+ }
3157
+
3158
+ function fuzzyDate(options) {
3159
+ const input = {
3160
+ year: options?.year ?? 0,
3161
+ month: options?.month ?? 0,
3162
+ day: options?.day ?? 0
3163
+ };
3164
+ return input;
3165
+ }
3166
+
3167
+ function fuzzyDateInt(options) {
3168
+ const year = options?.year ?? 0;
3169
+ const month = options?.month ?? 0;
3170
+ const day = options?.day ?? 0;
3171
+ return year * 1e4 + month * 100 + day;
3172
+ }
3173
+
3174
+ function flattenMediaListCollection(response) {
3175
+ if (!response || !Array.isArray(response.lists)) {
3176
+ return [];
3177
+ }
3178
+ const byId = /* @__PURE__ */ new Map();
3179
+ for (const group of response.lists) {
3180
+ if (!Array.isArray(group.entries)) {
3181
+ continue;
3182
+ }
3183
+ for (const entry of group.entries) {
3184
+ mergeEntry(byId, entry, group.name, group.isCustomList, group.isSplitCompletedList);
3185
+ }
3186
+ }
3187
+ return Array.from(byId.values());
3188
+ }
3189
+ function mergeEntry(byId, entry, listName, isCustomList, isSplitCompletedList) {
3190
+ if (typeof entry !== "object" || entry === null || typeof entry.id !== "number" || !Number.isFinite(entry.id)) {
3191
+ throw new AniLinkValidationError([
3192
+ `flattenMediaListCollection: a list entry in "${listName}" is malformed \u2014 every entry must be an object with a finite numeric id.`
3193
+ ]);
3194
+ }
3195
+ const existing = byId.get(entry.id);
3196
+ if (existing) {
3197
+ if (!existing.listNames.includes(listName)) {
3198
+ existing.listNames.push(listName);
3199
+ }
3200
+ if (isCustomList) existing.inCustomList = true;
3201
+ if (isSplitCompletedList) existing.inSplitCompletedList = true;
3202
+ return;
3203
+ }
3204
+ byId.set(entry.id, {
3205
+ id: entry.id,
3206
+ userId: entry.userId,
3207
+ mediaId: entry.mediaId,
3208
+ status: entry.status,
3209
+ score: entry.score,
3210
+ progress: entry.progress,
3211
+ listNames: [listName],
3212
+ inCustomList: isCustomList,
3213
+ inSplitCompletedList: isSplitCompletedList
3214
+ });
3215
+ }
3216
+
3217
+ function crossLink(media) {
3218
+ const anilistToMal = /* @__PURE__ */ new Map();
3219
+ const malToAnilist = /* @__PURE__ */ new Map();
3220
+ const unmapped = [];
3221
+ for (const entry of media) {
3222
+ if (typeof entry.idMal !== "number") {
3223
+ unmapped.push(entry);
3224
+ continue;
3225
+ }
3226
+ anilistToMal.set(entry.id, entry.idMal);
3227
+ malToAnilist.set(entry.idMal, entry.id);
3228
+ }
3229
+ return { anilistToMal, malToAnilist, unmapped };
3230
+ }
3231
+
3232
+ const ARM_IDS_URL = "https://arm.haglund.dev/api/v2/ids?include=anilist,myanimelist";
3233
+ const ARM_MAX_IDS_PER_REQUEST = 100;
3234
+ async function mapExternalIds(source, ids, options = {}) {
3235
+ return requestMapExternalIds(source, ids, options, options);
3236
+ }
3237
+ const buildMapExternalIdsFacade = (instanceOptions, stateOwner) => (source, ids, options) => requestMapExternalIds(source, ids, mergeOptions(instanceOptions, options), stateOwner);
3238
+ const requestMapExternalIds = async (source, ids, options, stateOwner) => {
3239
+ const anilistToMal = /* @__PURE__ */ new Map();
3240
+ const malToAnilist = /* @__PURE__ */ new Map();
3241
+ const unmapped = [];
3242
+ if (ids.length === 0) {
3243
+ return { anilistToMal, malToAnilist, unmapped };
3244
+ }
3245
+ for (let offset = 0; offset < ids.length; offset += ARM_MAX_IDS_PER_REQUEST) {
3246
+ const batchIds = ids.slice(offset, offset + ARM_MAX_IDS_PER_REQUEST);
3247
+ const responseBody = await sendRequest(
3248
+ ARM_IDS_URL,
3249
+ "POST",
3250
+ batchIds.map((id) => ({ [source]: id })),
3251
+ void 0,
3252
+ {
3253
+ options,
3254
+ protocol: "rest",
3255
+ stateOwner
3256
+ }
3257
+ );
3258
+ if (!Array.isArray(responseBody) || responseBody.length !== batchIds.length) {
3259
+ throw new TypeError("ARM ID mapping response did not contain one result per input id.");
3260
+ }
3261
+ for (let index = 0; index < batchIds.length; index += 1) {
3262
+ const relation = responseBody[index];
3263
+ const inputId = batchIds[index];
3264
+ const mappedId = source === "anilist" ? relation?.myanimelist : relation?.anilist;
3265
+ if (typeof mappedId !== "number") {
3266
+ unmapped.push(inputId);
3267
+ continue;
3268
+ }
3269
+ if (source === "anilist") {
3270
+ anilistToMal.set(inputId, mappedId);
3271
+ malToAnilist.set(mappedId, inputId);
3272
+ } else {
3273
+ anilistToMal.set(mappedId, inputId);
3274
+ malToAnilist.set(inputId, mappedId);
2696
3275
  }
2697
- const pageItems = Array.isArray(raw) ? raw : [];
2698
- pages.push({ pageInfo: response.pageInfo, items: pageItems });
2699
- items.push(...pageItems);
2700
- safeCallback$1(options?.onPage, "onPage", options?.onHookError, diagnostics, {
2701
- pageInfo: response.pageInfo,
2702
- items: pageItems
2703
- });
2704
3276
  }
2705
- return { items, pages, pageCount: count, truncated };
2706
- } finally {
2707
- dispose();
2708
3277
  }
3278
+ return { anilistToMal, malToAnilist, unmapped };
3279
+ };
3280
+
3281
+ const DEFAULT_INTERVAL_MS = 6e4;
3282
+ const MIN_INTERVAL_MS = 1e4;
3283
+ const DEFAULT_PER_PAGE$1 = 50;
3284
+ const MAX_PER_PAGE$1 = DEFAULT_PER_PAGE$1;
3285
+ const MAX_DRAIN_PAGES = 10;
3286
+ const SEEN_RETENTION_POLLS = 3;
3287
+ const sleepBetweenPolls = (ms, signal) => new Promise((resolve) => {
3288
+ const timeout = setTimeout(() => {
3289
+ signal?.removeEventListener("abort", abort);
3290
+ resolve();
3291
+ }, ms);
3292
+ timeout.unref();
3293
+ const abort = () => {
3294
+ clearTimeout(timeout);
3295
+ resolve();
3296
+ };
3297
+ if (signal?.aborted) {
3298
+ clearTimeout(timeout);
3299
+ resolve();
3300
+ return;
3301
+ }
3302
+ signal?.addEventListener("abort", abort, { once: true });
3303
+ });
3304
+ const dropUndefined = (variables) => {
3305
+ const result = {};
3306
+ for (const [key, value] of Object.entries(variables)) {
3307
+ if (value !== void 0) {
3308
+ result[key] = value;
3309
+ }
3310
+ }
3311
+ return result;
3312
+ };
3313
+ async function drainPoll(ctx) {
3314
+ const { fetchPage, perPage, since, signal, seen } = ctx;
3315
+ const fresh = [];
3316
+ const startPage = ctx.resumeFromPage;
3317
+ const resuming = startPage > 1;
3318
+ const baselineDrain = ctx.baselinePending;
3319
+ ctx.resumeFromPage = 1;
3320
+ let hitSeenFrontier = false;
3321
+ let drainedToCap = false;
3322
+ let page = startPage;
3323
+ let lastDrainedItems = [];
3324
+ let lastDrainedHasNext = false;
3325
+ for (; page < startPage + MAX_DRAIN_PAGES; page++) {
3326
+ const { pageInfo, items } = await fetchPage(page, perPage, signal);
3327
+ lastDrainedItems = items;
3328
+ lastDrainedHasNext = pageInfo.hasNextPage;
3329
+ for (const item of items) {
3330
+ if (seen.has(item.id) || since !== void 0 && item.createdAt <= since) {
3331
+ hitSeenFrontier = true;
3332
+ continue;
3333
+ }
3334
+ if (!ctx.baselinePending) {
3335
+ fresh.push(item);
3336
+ }
3337
+ }
3338
+ for (const item of items) {
3339
+ seen.set(item.id, item.createdAt);
3340
+ }
3341
+ if (hitSeenFrontier || items.length < perPage) break;
3342
+ if (!pageInfo.hasNextPage) break;
3343
+ if (page === startPage + MAX_DRAIN_PAGES - 1) {
3344
+ drainedToCap = true;
3345
+ }
3346
+ }
3347
+ if (drainedToCap) {
3348
+ ctx.resumeFromPage = baselineDrain ? startPage : page;
3349
+ } else if (resuming && hitSeenFrontier && page === startPage && lastDrainedHasNext) {
3350
+ ctx.resumeFromPage = startPage + 1;
3351
+ }
3352
+ ctx.baselinePending = false;
3353
+ const oldestDrained = lastDrainedItems[lastDrainedItems.length - 1];
3354
+ return {
3355
+ fresh,
3356
+ resumeFromPage: ctx.resumeFromPage,
3357
+ watermark: oldestDrained?.createdAt
3358
+ };
2709
3359
  }
2710
- async function* paginatePages(fetchPage, options) {
2711
- const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE$1, DEFAULT_PER_PAGE$1, "perPage");
2712
- const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
2713
- const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES$1, "maxPages");
2714
- const concurrency = resolveCappedInt(
2715
- options?.concurrency,
2716
- MAX_CONCURRENCY,
2717
- DEFAULT_CONCURRENCY$1,
2718
- "concurrency"
3360
+ async function* watchFeed(fetchPage, options) {
3361
+ const intervalMs = Math.max(
3362
+ resolvePositiveInt(options.intervalMs, DEFAULT_INTERVAL_MS, "intervalMs"),
3363
+ MIN_INTERVAL_MS
2719
3364
  );
2720
- yield* streamNumericPages(
3365
+ const perPage = resolveCappedInt(options.perPage, MAX_PER_PAGE$1, DEFAULT_PER_PAGE$1, "perPage");
3366
+ const { since, signal } = options;
3367
+ const ctx = {
2721
3368
  fetchPage,
2722
- (response) => !response.pageInfo.hasNextPage,
2723
- { perPage, startPage, maxPages, concurrency, signal: options?.signal },
2724
- extractLastPageBound
2725
- );
2726
- }
2727
- async function paginateChunks(fetchChunk, itemsKey, options) {
2728
- const perChunk = resolveCappedInt(
2729
- options?.perChunk,
2730
- MAX_PER_CHUNK,
2731
- DEFAULT_PER_CHUNK,
2732
- "perChunk"
2733
- );
2734
- const startChunk = resolvePositiveInt(options?.startChunk, 1, "startChunk");
2735
- const maxChunks = resolvePositiveInt(options?.maxChunks, DEFAULT_MAX_CHUNKS, "maxChunks");
2736
- const concurrency = resolveCappedInt(
2737
- options?.concurrency,
2738
- MAX_CONCURRENCY,
2739
- DEFAULT_CONCURRENCY$1,
2740
- "concurrency"
2741
- );
2742
- const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
2743
- const { signal, dispose } = bridgeAbortSignal(options?.signal);
2744
- try {
2745
- const { responses, count, truncated } = await fetchWithLookAhead(
2746
- (number) => fetchChunk(number, perChunk, signal),
2747
- extractHasMore$1,
2748
- startChunk,
2749
- maxChunks,
2750
- concurrency,
2751
- signal
2752
- );
2753
- const items = [];
2754
- const chunks = [];
2755
- for (const response of responses) {
2756
- const raw = response[itemsKey];
2757
- if (raw === void 0) {
2758
- throw new AniLinkValidationError([
2759
- `paginateChunks: the chunk response has no "${itemsKey}" key. Check the itemsKey argument.`
2760
- ]);
3369
+ perPage,
3370
+ since,
3371
+ signal,
3372
+ seen: /* @__PURE__ */ new Map(),
3373
+ baselinePending: since === void 0,
3374
+ resumeFromPage: 1
3375
+ };
3376
+ while (!signal?.aborted) {
3377
+ const { fresh, resumeFromPage, watermark } = await drainPoll(ctx);
3378
+ ctx.resumeFromPage = resumeFromPage;
3379
+ if (watermark !== void 0) {
3380
+ const pruneBefore = watermark - intervalMs / 1e3 * SEEN_RETENTION_POLLS;
3381
+ for (const [id, createdAt] of ctx.seen) {
3382
+ if (createdAt < pruneBefore) {
3383
+ ctx.seen.delete(id);
3384
+ }
2761
3385
  }
2762
- const chunkItems = Array.isArray(raw) ? raw : [];
2763
- chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
2764
- items.push(...chunkItems);
2765
- safeCallback$1(options?.onChunk, "onChunk", options?.onHookError, diagnostics, {
2766
- hasNextChunk: response.hasNextChunk,
2767
- items: chunkItems
2768
- });
2769
3386
  }
2770
- return { items, chunks, chunkCount: count, truncated };
2771
- } finally {
2772
- dispose();
2773
- }
3387
+ for (let index = fresh.length - 1; index >= 0; index--) {
3388
+ yield fresh[index];
3389
+ }
3390
+ await sleepBetweenPolls(intervalMs, signal);
3391
+ }
3392
+ }
3393
+ function watchNotifications(fetch, options = {}) {
3394
+ const { type, type_in, resetNotificationCount, asHtml, ...shared } = options;
3395
+ let resetPending = resetNotificationCount === true;
3396
+ return watchFeed(async (page, perPage, signal) => {
3397
+ const resetNow = resetPending;
3398
+ const response = await fetch(
3399
+ dropUndefined({
3400
+ page,
3401
+ perPage,
3402
+ type,
3403
+ type_in,
3404
+ resetNotificationCount: resetNow,
3405
+ asHtml
3406
+ }),
3407
+ // Poll requests always bypass the response cache: each poll
3408
+ // needs current data and must never be served a cached page,
3409
+ // so a cache enabled on the client cannot make the watcher
3410
+ // stale.
3411
+ { ...shared.transportOptions, signal, bypassResponseCache: true }
3412
+ );
3413
+ resetPending = false;
3414
+ return { pageInfo: response.pageInfo, items: response.notifications };
3415
+ }, shared);
3416
+ }
3417
+ function watchActivity(fetch, options = {}) {
3418
+ const { userId, messengerId, mediaId, type, isFollowing, sort, ...shared } = options;
3419
+ return watchFeed(async (page, perPage, signal) => {
3420
+ const response = await fetch(
3421
+ // ID_DESC (newest first) is the order the watcher needs:
3422
+ // new items enter at the front, so page 1 is always the
3423
+ // window that contains them. A caller-provided sort
3424
+ // overrides it; see the option's caveat.
3425
+ dropUndefined({
3426
+ page,
3427
+ perPage,
3428
+ userId,
3429
+ messengerId,
3430
+ mediaId,
3431
+ type,
3432
+ isFollowing,
3433
+ sort: sort ?? ["ID_DESC"]
3434
+ }),
3435
+ // Poll requests always bypass the response cache: each poll
3436
+ // needs current data and must never be served a cached page,
3437
+ // so a cache enabled on the client cannot make the watcher
3438
+ // stale.
3439
+ { ...shared.transportOptions, signal, bypassResponseCache: true }
3440
+ );
3441
+ return { pageInfo: response.pageInfo, items: response.activities };
3442
+ }, shared);
2774
3443
  }
2775
3444
 
2776
3445
  const parseCache = /* @__PURE__ */ new Map();
@@ -6077,6 +6746,7 @@ class NotificationsQuery extends AniListOperation {
6077
6746
  variables,
6078
6747
  {
6079
6748
  mappings: NotificationsMappings,
6749
+ requiresAuth: true,
6080
6750
  transportOptions
6081
6751
  }
6082
6752
  );
@@ -8226,12 +8896,13 @@ class SaveMediaListEntryMutation extends AniListOperation {
8226
8896
  }
8227
8897
  }
8228
8898
 
8229
- function op(name, operationClass, options) {
8899
+ function op$1(name, operationClass, options) {
8230
8900
  return {
8231
8901
  name,
8232
8902
  operationClass,
8233
8903
  methodName: name,
8234
- fieldsEnabled: options?.fieldsEnabled ?? false
8904
+ fieldsEnabled: options?.fieldsEnabled ?? false,
8905
+ alwaysKeys: options?.alwaysKeys ?? []
8235
8906
  };
8236
8907
  }
8237
8908
  function opAs(name, operationClass, methodName, options) {
@@ -8239,96 +8910,224 @@ function opAs(name, operationClass, methodName, options) {
8239
8910
  name,
8240
8911
  operationClass,
8241
8912
  methodName,
8242
- fieldsEnabled: options?.fieldsEnabled ?? false
8913
+ fieldsEnabled: options?.fieldsEnabled ?? false,
8914
+ alwaysKeys: options?.alwaysKeys ?? []
8243
8915
  };
8244
8916
  }
8245
8917
  const ANILIST_OPERATION_REGISTRY = {
8246
8918
  query: [
8247
- op("user", UserQuery, { fieldsEnabled: true }),
8248
- op("media", MediaQuery, { fieldsEnabled: true }),
8249
- op("mediaTrend", MediaTrendQuery, { fieldsEnabled: true }),
8250
- op("airingSchedule", AiringScheduleQuery, { fieldsEnabled: true }),
8251
- op("character", CharacterQuery, { fieldsEnabled: true }),
8252
- op("staff", StaffQuery, { fieldsEnabled: true }),
8253
- op("mediaList", MediaListQuery, { fieldsEnabled: true }),
8254
- op("mediaListCollection", MediaListCollectionQuery, { fieldsEnabled: true }),
8255
- op("genreCollection", GenreCollectionQuery),
8256
- op("mediaTagCollection", MediaTagCollectionQuery),
8257
- op("viewer", ViewerQuery),
8258
- op("notification", NotificationQuery, { fieldsEnabled: true }),
8259
- op("studio", StudioQuery, { fieldsEnabled: true }),
8260
- op("review", ReviewQuery, { fieldsEnabled: true }),
8261
- op("activity", ActivityQuery, { fieldsEnabled: true }),
8262
- op("activityReply", ActivityReplyQuery),
8263
- op("following", FollowingQuery),
8264
- op("follower", FollowerQuery),
8265
- op("thread", ThreadQuery, { fieldsEnabled: true }),
8266
- op("threadComment", ThreadCommentQuery, { fieldsEnabled: true }),
8267
- op("recommendation", RecommendationQuery, { fieldsEnabled: true }),
8268
- op("markdown", MarkdownQuery),
8269
- op("aniChartUser", AniChartUserQuery),
8270
- op("siteStatistics", SiteStatisticsQuery, { fieldsEnabled: true }),
8271
- op("externalLinkSourceCollection", ExternalLinkSourceCollectionQuery)
8919
+ op$1("user", UserQuery, { fieldsEnabled: true, alwaysKeys: USER_ALWAYS }),
8920
+ op$1("media", MediaQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_ALWAYS }),
8921
+ op$1("mediaTrend", MediaTrendQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_TREND_ALWAYS }),
8922
+ op$1("airingSchedule", AiringScheduleQuery, {
8923
+ fieldsEnabled: true,
8924
+ alwaysKeys: AIRING_SCHEDULE_ALWAYS
8925
+ }),
8926
+ op$1("character", CharacterQuery, { fieldsEnabled: true, alwaysKeys: CHARACTER_ALWAYS }),
8927
+ op$1("staff", StaffQuery, { fieldsEnabled: true, alwaysKeys: STAFF_ALWAYS }),
8928
+ op$1("mediaList", MediaListQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_LIST_ALWAYS }),
8929
+ op$1("mediaListCollection", MediaListCollectionQuery, {
8930
+ fieldsEnabled: true,
8931
+ alwaysKeys: MEDIA_LIST_COLLECTION_ALWAYS
8932
+ }),
8933
+ op$1("genreCollection", GenreCollectionQuery),
8934
+ op$1("mediaTagCollection", MediaTagCollectionQuery),
8935
+ op$1("viewer", ViewerQuery),
8936
+ op$1("notification", NotificationQuery, { fieldsEnabled: true }),
8937
+ op$1("studio", StudioQuery, { fieldsEnabled: true, alwaysKeys: STUDIO_ALWAYS }),
8938
+ op$1("review", ReviewQuery, { fieldsEnabled: true, alwaysKeys: REVIEW_ALWAYS }),
8939
+ op$1("activity", ActivityQuery, { fieldsEnabled: true }),
8940
+ op$1("activityReply", ActivityReplyQuery),
8941
+ op$1("following", FollowingQuery),
8942
+ op$1("follower", FollowerQuery),
8943
+ op$1("thread", ThreadQuery, { fieldsEnabled: true, alwaysKeys: THREAD_ALWAYS }),
8944
+ op$1("threadComment", ThreadCommentQuery, {
8945
+ fieldsEnabled: true,
8946
+ alwaysKeys: THREAD_COMMENT_ALWAYS
8947
+ }),
8948
+ op$1("recommendation", RecommendationQuery, {
8949
+ fieldsEnabled: true,
8950
+ alwaysKeys: RECOMMENDATION_ALWAYS
8951
+ }),
8952
+ op$1("markdown", MarkdownQuery),
8953
+ op$1("aniChartUser", AniChartUserQuery),
8954
+ op$1("siteStatistics", SiteStatisticsQuery, {
8955
+ fieldsEnabled: true,
8956
+ alwaysKeys: SITE_STATISTICS_ALWAYS
8957
+ }),
8958
+ op$1("externalLinkSourceCollection", ExternalLinkSourceCollectionQuery)
8272
8959
  ],
8273
8960
  page: [
8274
- op("users", UsersQuery, { fieldsEnabled: true }),
8275
- op("medias", MediasQuery, { fieldsEnabled: true }),
8276
- op("characters", CharactersQuery, { fieldsEnabled: true }),
8277
- op("staffs", StaffsQuery, { fieldsEnabled: true }),
8278
- op("studios", StudiosQuery, { fieldsEnabled: true }),
8279
- op("mediaLists", MediaListsQuery, { fieldsEnabled: true }),
8280
- op("airingSchedules", AiringSchedulesQuery, { fieldsEnabled: true }),
8281
- op("mediaTrends", MediaTrendsQuery, { fieldsEnabled: true }),
8282
- op("notifications", NotificationsQuery, { fieldsEnabled: true }),
8283
- op("followers", FollowersQuery, { fieldsEnabled: true }),
8284
- opAs("following", FollowingsQuery, "followings", { fieldsEnabled: true }),
8285
- op("activities", ActivitiesQuery, { fieldsEnabled: true }),
8286
- opAs("activityReplies", ActivityRepliesQuery, "activityReplies", { fieldsEnabled: true }),
8287
- op("threads", ThreadsQuery, { fieldsEnabled: true }),
8288
- opAs("threadComments", ThreadCommentsQuery, "threadComments", { fieldsEnabled: true }),
8289
- op("reviews", ReviewsQuery, { fieldsEnabled: true }),
8290
- opAs("recommendations", RecommendationsQuery, "recommendations", { fieldsEnabled: true }),
8291
- op("likes", LikesQuery, { fieldsEnabled: true })
8961
+ op$1("users", UsersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8962
+ op$1("medias", MediasQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8963
+ op$1("characters", CharactersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8964
+ op$1("staffs", StaffsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8965
+ op$1("studios", StudiosQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8966
+ op$1("mediaLists", MediaListsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8967
+ op$1("airingSchedules", AiringSchedulesQuery, {
8968
+ fieldsEnabled: true,
8969
+ alwaysKeys: PAGE_ALWAYS
8970
+ }),
8971
+ op$1("mediaTrends", MediaTrendsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8972
+ op$1("notifications", NotificationsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8973
+ op$1("followers", FollowersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8974
+ opAs("following", FollowingsQuery, "followings", {
8975
+ fieldsEnabled: true,
8976
+ alwaysKeys: PAGE_ALWAYS
8977
+ }),
8978
+ op$1("activities", ActivitiesQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8979
+ opAs("activityReplies", ActivityRepliesQuery, "activityReplies", {
8980
+ fieldsEnabled: true,
8981
+ alwaysKeys: PAGE_ALWAYS
8982
+ }),
8983
+ op$1("threads", ThreadsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8984
+ opAs("threadComments", ThreadCommentsQuery, "threadComments", {
8985
+ fieldsEnabled: true,
8986
+ alwaysKeys: PAGE_ALWAYS
8987
+ }),
8988
+ op$1("reviews", ReviewsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
8989
+ opAs("recommendations", RecommendationsQuery, "recommendations", {
8990
+ fieldsEnabled: true,
8991
+ alwaysKeys: PAGE_ALWAYS
8992
+ }),
8993
+ op$1("likes", LikesQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS })
8292
8994
  ],
8293
8995
  mutation: [
8294
- op("updateUser", UpdateUserMutation, { fieldsEnabled: true }),
8295
- op("saveMediaListEntry", SaveMediaListEntryMutation, { fieldsEnabled: true }),
8296
- op("updateMediaListEntries", UpdateMediaListEntriesMutation, { fieldsEnabled: true }),
8297
- op("deleteMediaListEntry", DeleteMediaListEntryMutation, { fieldsEnabled: true }),
8298
- op("deleteCustomList", DeleteCustomListMutation, { fieldsEnabled: true }),
8299
- op("saveTextActivity", SaveTextActivityMutation, { fieldsEnabled: true }),
8300
- op("saveMessageActivity", SaveMessageActivityMutation, { fieldsEnabled: true }),
8301
- op("saveListActivity", SaveListActivityMutation, { fieldsEnabled: true }),
8302
- op("deleteActivity", DeleteActivityMutation, { fieldsEnabled: true }),
8303
- op("toggleActivityPin", ToggleActivityPinMutation, { fieldsEnabled: true }),
8304
- op("toggleActivitySubscription", ToggleActivitySubscriptionMutation, {
8996
+ op$1("updateUser", UpdateUserMutation, { fieldsEnabled: true }),
8997
+ op$1("saveMediaListEntry", SaveMediaListEntryMutation, { fieldsEnabled: true }),
8998
+ op$1("updateMediaListEntries", UpdateMediaListEntriesMutation, { fieldsEnabled: true }),
8999
+ op$1("deleteMediaListEntry", DeleteMediaListEntryMutation, { fieldsEnabled: true }),
9000
+ op$1("deleteCustomList", DeleteCustomListMutation, { fieldsEnabled: true }),
9001
+ op$1("saveTextActivity", SaveTextActivityMutation, { fieldsEnabled: true }),
9002
+ op$1("saveMessageActivity", SaveMessageActivityMutation, { fieldsEnabled: true }),
9003
+ op$1("saveListActivity", SaveListActivityMutation, { fieldsEnabled: true }),
9004
+ op$1("deleteActivity", DeleteActivityMutation, { fieldsEnabled: true }),
9005
+ op$1("toggleActivityPin", ToggleActivityPinMutation, { fieldsEnabled: true }),
9006
+ op$1("toggleActivitySubscription", ToggleActivitySubscriptionMutation, {
8305
9007
  fieldsEnabled: true
8306
9008
  }),
8307
- op("saveActivityReply", SaveActivityReplyMutation, { fieldsEnabled: true }),
8308
- op("deleteActivityReply", DeleteActivityReplyMutation, { fieldsEnabled: true }),
8309
- op("toggleLike", ToggleLikeMutation, { fieldsEnabled: true }),
8310
- op("toggleLikeV2", ToggleLikeV2Mutation, { fieldsEnabled: true }),
8311
- op("toggleFollow", ToggleFollowMutation, { fieldsEnabled: true }),
8312
- op("toggleFavourite", ToggleFavouriteMutation, { fieldsEnabled: true }),
8313
- op("updateFavouriteOrder", UpdateFavouriteOrderMutation, { fieldsEnabled: true }),
8314
- op("saveReview", SaveReviewMutation, { fieldsEnabled: true }),
8315
- op("rateReview", RateReviewMutation, { fieldsEnabled: true }),
8316
- op("deleteReview", DeleteReviewMutation, { fieldsEnabled: true }),
8317
- op("saveRecommendation", SaveRecommendationMutation, { fieldsEnabled: true }),
8318
- op("saveThread", SaveThreadMutation, { fieldsEnabled: true }),
8319
- op("deleteThread", DeleteThreadMutation, { fieldsEnabled: true }),
8320
- op("toggleThreadSubscription", ToggleThreadSubscriptionMutation, { fieldsEnabled: true }),
8321
- op("saveThreadComment", SaveThreadCommentMutation, { fieldsEnabled: true }),
8322
- op("deleteThreadComment", DeleteThreadCommentMutation, { fieldsEnabled: true }),
8323
- op("updateAniChartSettings", UpdateAniChartSettingsMutation),
8324
- op("updateAniChartHighlights", UpdateAniChartHighlightsMutation)
9009
+ op$1("saveActivityReply", SaveActivityReplyMutation, { fieldsEnabled: true }),
9010
+ op$1("deleteActivityReply", DeleteActivityReplyMutation, { fieldsEnabled: true }),
9011
+ op$1("toggleLike", ToggleLikeMutation, { fieldsEnabled: true }),
9012
+ op$1("toggleLikeV2", ToggleLikeV2Mutation, { fieldsEnabled: true }),
9013
+ op$1("toggleFollow", ToggleFollowMutation, { fieldsEnabled: true }),
9014
+ op$1("toggleFavourite", ToggleFavouriteMutation, { fieldsEnabled: true }),
9015
+ op$1("updateFavouriteOrder", UpdateFavouriteOrderMutation, { fieldsEnabled: true }),
9016
+ op$1("saveReview", SaveReviewMutation, { fieldsEnabled: true }),
9017
+ op$1("rateReview", RateReviewMutation, { fieldsEnabled: true }),
9018
+ op$1("deleteReview", DeleteReviewMutation, { fieldsEnabled: true }),
9019
+ op$1("saveRecommendation", SaveRecommendationMutation, { fieldsEnabled: true }),
9020
+ op$1("saveThread", SaveThreadMutation, { fieldsEnabled: true }),
9021
+ op$1("deleteThread", DeleteThreadMutation, { fieldsEnabled: true }),
9022
+ op$1("toggleThreadSubscription", ToggleThreadSubscriptionMutation, { fieldsEnabled: true }),
9023
+ op$1("saveThreadComment", SaveThreadCommentMutation, { fieldsEnabled: true }),
9024
+ op$1("deleteThreadComment", DeleteThreadCommentMutation, { fieldsEnabled: true }),
9025
+ op$1("updateAniChartSettings", UpdateAniChartSettingsMutation),
9026
+ op$1("updateAniChartHighlights", UpdateAniChartHighlightsMutation)
8325
9027
  ]
8326
9028
  };
8327
9029
 
9030
+ const sanitizeTokenError = (error, label) => {
9031
+ if (error instanceof AniLinkRestError) {
9032
+ const relabeled = new AniLinkRestError(error.status, error.data, void 0, {
9033
+ rateLimit: error.rateLimit,
9034
+ contentType: error.contentType,
9035
+ requestId: error.requestId
9036
+ });
9037
+ if (error.rawAxiosError === void 0) {
9038
+ relabeled.cause = error;
9039
+ }
9040
+ relabeled.message = `${label} failed with status ${error.status}.`;
9041
+ return relabeled;
9042
+ }
9043
+ if (error instanceof AniLinkApiError) {
9044
+ const relabeled = new AniLinkApiError(error.status, error.data, void 0, {
9045
+ rateLimit: error.rateLimit,
9046
+ contentType: error.contentType,
9047
+ requestId: error.requestId
9048
+ });
9049
+ if (error.rawAxiosError === void 0) {
9050
+ relabeled.cause = error;
9051
+ }
9052
+ relabeled.message = `${label} failed with status ${error.status}.`;
9053
+ return relabeled;
9054
+ }
9055
+ if (error instanceof AniLinkError) {
9056
+ return error;
9057
+ }
9058
+ if (axios.isCancel(error)) {
9059
+ return new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, `${label} was cancelled.`);
9060
+ }
9061
+ if (axios.isAxiosError(error)) {
9062
+ if (error.response?.status !== void 0) {
9063
+ const status = error.response.status;
9064
+ const apiError = new AniLinkApiError(status, error.response.data);
9065
+ apiError.message = `${label} failed with status ${status}.`;
9066
+ return apiError;
9067
+ }
9068
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
9069
+ return new AniLinkNetworkError(AniLinkErrorCodes.TIMEOUT, `${label} timed out.`);
9070
+ }
9071
+ return new AniLinkNetworkError(
9072
+ AniLinkErrorCodes.NETWORK,
9073
+ `${label} failed due to a network error.`
9074
+ );
9075
+ }
9076
+ return new AniLinkError(`${label} failed.`, AniLinkErrorCodes.UNKNOWN);
9077
+ };
9078
+
9079
+ const TOKEN_REQUEST_TIMEOUT_MS = 1e4;
9080
+ const requestTokenGrant = async (descriptor, params, signal, options) => {
9081
+ try {
9082
+ const body = new URLSearchParams(params).toString();
9083
+ return await sendRequest(descriptor.tokenUrl, "POST", body, void 0, {
9084
+ requiresAuth: false,
9085
+ options: {
9086
+ ...options,
9087
+ timeout: options?.timeout ?? TOKEN_REQUEST_TIMEOUT_MS,
9088
+ retry: options?.retry ?? false,
9089
+ signal: signal ?? options?.signal,
9090
+ // Never honor a caller's exposeRawAxiosError here: the
9091
+ // grant body carries client secrets and one-time codes.
9092
+ exposeRawAxiosError: false
9093
+ },
9094
+ contentType: "application/x-www-form-urlencoded"
9095
+ });
9096
+ } catch (error) {
9097
+ throw sanitizeTokenError(error, descriptor.errorLabel);
9098
+ }
9099
+ };
9100
+ const refreshGrantParams = (clientId, clientSecret, refreshToken) => ({
9101
+ grant_type: "refresh_token",
9102
+ client_id: clientId,
9103
+ ...clientSecret !== void 0 ? { client_secret: clientSecret } : {},
9104
+ refresh_token: refreshToken
9105
+ });
9106
+ const computeTokenExpiry = (response, now = Date.now()) => {
9107
+ const { expires_in } = response;
9108
+ if (!Number.isFinite(expires_in) || expires_in <= 0) {
9109
+ throw new TypeError(
9110
+ `Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
9111
+ );
9112
+ }
9113
+ return new Date(now + expires_in * 1e3);
9114
+ };
9115
+ const rewriteAuth = (descriptor, auth, accessToken) => {
9116
+ const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(
9117
+ Object.entries(auth.headers).filter(
9118
+ ([key]) => !descriptor.stripHeaders.includes(key)
9119
+ )
9120
+ ) : void 0;
9121
+ return {
9122
+ token: accessToken,
9123
+ headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
9124
+ };
9125
+ };
8328
9126
  class TokenRefresher {
8329
- exchange;
9127
+ descriptor;
9128
+ clientId;
9129
+ clientSecret;
8330
9130
  refreshToken;
8331
- provider;
8332
9131
  onTokenRefresh;
8333
9132
  onTokenRefreshError;
8334
9133
  onHookError;
@@ -8338,13 +9137,14 @@ class TokenRefresher {
8338
9137
  /**
8339
9138
  * Constructs a refresh coordinator from the wiring's fields.
8340
9139
  *
8341
- * @param options - The exchange adapter, the stored refresh token, the diagnostics names, the auth-swap callback, the optional persistence and failure callbacks, and the diagnostics mode.
9140
+ * @param options - The provider descriptor, the client credentials, the stored refresh token, the diagnostics names, the auth-swap callback, the optional persistence and failure callbacks, and the diagnostics mode.
8342
9141
  * @throws A `TypeError` when `options.diagnostics` is defined but not one of `"warn"`, `"hook"`, or `"silent"`.
8343
9142
  */
8344
9143
  constructor(options) {
8345
- this.exchange = options.exchange;
9144
+ this.descriptor = options.descriptor;
9145
+ this.clientId = options.clientId;
9146
+ this.clientSecret = options.clientSecret;
8346
9147
  this.refreshToken = options.refreshToken;
8347
- this.provider = options.provider;
8348
9148
  this.onTokenRefresh = options.onTokenRefresh;
8349
9149
  this.onTokenRefreshError = options.onTokenRefreshError;
8350
9150
  this.onHookError = options.onHookError;
@@ -8354,13 +9154,13 @@ class TokenRefresher {
8354
9154
  /**
8355
9155
  * Runs one operation attempt under the automatic refresh lifecycle.
8356
9156
  *
8357
- * A 401 from the first attempt — or an {@link AniLinkAuthError} raised
9157
+ * A 401 from the first attempt, or an {@link AniLinkAuthError} raised
8358
9158
  * before any request because no access token is configured, which lets
8359
- * a persisted refresh token bootstrap the client — triggers exactly one
9159
+ * a persisted refresh token bootstrap the client, triggers exactly one
8360
9160
  * refresh (concurrent failures share the in-flight refresh) followed by
8361
- * a single replay with the new auth material. Any other failure — a
8362
- * non-401 first attempt, a failed refresh, or a replay that fails again
8363
- * — surfaces unchanged. A failed refresh grant is reported to
9161
+ * a single replay with the new auth material. Any other failure, a
9162
+ * non-401 first attempt, a failed refresh, or a replay that fails again,
9163
+ * surfaces unchanged. A failed refresh grant is reported to
8364
9164
  * `onHookError` (under the coordinator's `hookName`) before the
8365
9165
  * sanitized error rethrows, so the grant failure is observable through
8366
9166
  * the same channel as every other lifecycle failure. The
@@ -8385,8 +9185,8 @@ class TokenRefresher {
8385
9185
  } catch (refreshError) {
8386
9186
  reportDiagnostic({
8387
9187
  kind: "token-refresh",
8388
- hookName: this.provider.hookName,
8389
- message: `The ${this.provider.providerLabel} token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
9188
+ hookName: this.descriptor.provider.hookName,
9189
+ message: `The ${this.descriptor.provider.providerLabel} token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
8390
9190
  onHookError: this.onHookError,
8391
9191
  diagnostics: this.diagnostics,
8392
9192
  rawError: refreshError,
@@ -8405,7 +9205,7 @@ class TokenRefresher {
8405
9205
  *
8406
9206
  * The auth swap and the callback run inside the shared in-flight promise
8407
9207
  * so concurrent 401s observe one grant, one swap, and one callback
8408
- * invocation — and so the swap always lands in grant order.
9208
+ * invocation, and so the swap always lands in grant order.
8409
9209
  *
8410
9210
  * @returns The effective token response, with `refresh_token` filled in when the provider omitted it.
8411
9211
  */
@@ -8437,69 +9237,29 @@ class TokenRefresher {
8437
9237
  return await this.refreshInFlight;
8438
9238
  }
8439
9239
  /**
8440
- * Runs the refresh grant and stores the rotated refresh token.
9240
+ * Runs the refresh grant against the descriptor's token endpoint and
9241
+ * stores the rotated refresh token.
8441
9242
  *
8442
9243
  * @returns The effective token response.
8443
9244
  */
8444
9245
  async performRefresh() {
8445
- const response = await this.exchange(this.refreshToken);
9246
+ const response = await requestTokenGrant(
9247
+ this.descriptor,
9248
+ refreshGrantParams(this.clientId, this.clientSecret, this.refreshToken)
9249
+ );
8446
9250
  this.refreshToken = response.refresh_token ?? this.refreshToken;
8447
9251
  return { ...response, refresh_token: this.refreshToken };
8448
9252
  }
8449
9253
  }
8450
9254
 
8451
- const sanitizeTokenError = (error, label) => {
8452
- if (error instanceof AniLinkRestError) {
8453
- const relabeled = new AniLinkRestError(error.status, error.data, void 0, {
8454
- rateLimit: error.rateLimit,
8455
- contentType: error.contentType,
8456
- requestId: error.requestId
8457
- });
8458
- if (error.rawAxiosError === void 0) {
8459
- relabeled.cause = error;
8460
- }
8461
- relabeled.message = `${label} failed with status ${error.status}.`;
8462
- return relabeled;
8463
- }
8464
- if (error instanceof AniLinkApiError) {
8465
- const relabeled = new AniLinkApiError(error.status, error.data, void 0, {
8466
- rateLimit: error.rateLimit,
8467
- contentType: error.contentType,
8468
- requestId: error.requestId
8469
- });
8470
- if (error.rawAxiosError === void 0) {
8471
- relabeled.cause = error;
8472
- }
8473
- relabeled.message = `${label} failed with status ${error.status}.`;
8474
- return relabeled;
8475
- }
8476
- if (error instanceof AniLinkError) {
8477
- return error;
8478
- }
8479
- if (axios.isCancel(error)) {
8480
- return new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, `${label} was cancelled.`);
8481
- }
8482
- if (axios.isAxiosError(error)) {
8483
- if (error.response?.status !== void 0) {
8484
- const status = error.response.status;
8485
- const apiError = new AniLinkApiError(status, error.response.data);
8486
- apiError.message = `${label} failed with status ${status}.`;
8487
- return apiError;
8488
- }
8489
- if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
8490
- return new AniLinkNetworkError(AniLinkErrorCodes.TIMEOUT, `${label} timed out.`);
8491
- }
8492
- return new AniLinkNetworkError(
8493
- AniLinkErrorCodes.NETWORK,
8494
- `${label} failed due to a network error.`
8495
- );
8496
- }
8497
- return new AniLinkError(`${label} failed.`, AniLinkErrorCodes.UNKNOWN);
8498
- };
8499
-
8500
- const AUTH_TOKEN_TIMEOUT_MS = 1e4;
8501
9255
  const ANILIST_TOKEN_URL = "https://anilist.co/api/v2/oauth/token";
8502
9256
  const ANILIST_AUTHORIZE_URL = "https://anilist.co/api/v2/oauth/authorize";
9257
+ const ANILIST_TOKEN_GRANT = {
9258
+ tokenUrl: ANILIST_TOKEN_URL,
9259
+ errorLabel: "AniList token request",
9260
+ stripHeaders: [],
9261
+ provider: { hookName: "aniListTokenRefresh", providerLabel: "AniList" }
9262
+ };
8503
9263
  const buildAuthorizationUrl = (clientId, redirectUri, state) => {
8504
9264
  let url = `${ANILIST_AUTHORIZE_URL}?client_id=${encodeURIComponent(
8505
9265
  clientId
@@ -8509,29 +9269,8 @@ const buildAuthorizationUrl = (clientId, redirectUri, state) => {
8509
9269
  }
8510
9270
  return url;
8511
9271
  };
8512
- const normalizeTokenRequestError = (error) => sanitizeTokenError(error, "AniList token request");
8513
- const requestToken = async (params, signal, options) => {
8514
- const mergedOptions = {
8515
- ...options,
8516
- timeout: options?.timeout ?? AUTH_TOKEN_TIMEOUT_MS,
8517
- retry: options?.retry ?? false,
8518
- signal: signal ?? options?.signal,
8519
- // Never honor a caller's exposeRawAxiosError here: the token request
8520
- // body carries the client secret and grant credentials.
8521
- exposeRawAxiosError: false
8522
- };
8523
- try {
8524
- const body = new URLSearchParams(params).toString();
8525
- return await sendRequest(ANILIST_TOKEN_URL, "POST", body, void 0, {
8526
- requiresAuth: false,
8527
- options: mergedOptions,
8528
- contentType: "application/x-www-form-urlencoded"
8529
- });
8530
- } catch (error) {
8531
- throw normalizeTokenRequestError(error);
8532
- }
8533
- };
8534
- const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal, options) => requestToken(
9272
+ const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal, options) => requestTokenGrant(
9273
+ ANILIST_TOKEN_GRANT,
8535
9274
  {
8536
9275
  grant_type: "authorization_code",
8537
9276
  client_id: clientId,
@@ -8547,46 +9286,19 @@ const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal,
8547
9286
  signal,
8548
9287
  options
8549
9288
  );
8550
- const refreshAccessToken = async (clientId, clientSecret, refreshToken, signal, options) => requestToken(
8551
- {
8552
- grant_type: "refresh_token",
8553
- client_id: clientId,
8554
- client_secret: clientSecret,
8555
- refresh_token: refreshToken
8556
- },
9289
+ const refreshAccessToken = async (clientId, clientSecret, refreshToken, signal, options) => requestTokenGrant(
9290
+ ANILIST_TOKEN_GRANT,
9291
+ refreshGrantParams(clientId, clientSecret, refreshToken),
8557
9292
  signal,
8558
9293
  options
8559
9294
  );
8560
- const getTokenExpiry = (response, now = Date.now()) => {
8561
- const { expires_in } = response;
8562
- if (!Number.isFinite(expires_in) || expires_in <= 0) {
8563
- throw new TypeError(
8564
- `Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
8565
- );
8566
- }
8567
- return new Date(now + expires_in * 1e3);
8568
- };
9295
+ const getTokenExpiry = (response, now) => computeTokenExpiry(response, now);
8569
9296
 
8570
- const buildAniListTokenRefresher = (options) => {
8571
- const exchange = (refreshToken) => refreshAccessToken(options.clientId, options.clientSecret, refreshToken);
8572
- return new TokenRefresher({
8573
- exchange,
8574
- refreshToken: options.refreshToken,
8575
- provider: { hookName: "aniListTokenRefresh", providerLabel: "AniList" },
8576
- onTokenRefresh: options.onTokenRefresh,
8577
- onTokenRefreshError: options.onTokenRefreshError,
8578
- onHookError: options.onHookError,
8579
- diagnostics: options.diagnostics,
8580
- applyAccessToken: options.applyAccessToken
8581
- });
8582
- };
8583
- const buildRefreshedAuth$1 = (auth, accessToken) => {
8584
- const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(Object.entries(auth.headers)) : void 0;
8585
- return {
8586
- token: accessToken,
8587
- headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
8588
- };
8589
- };
9297
+ const buildAniListTokenRefresher = (options) => new TokenRefresher({
9298
+ descriptor: ANILIST_TOKEN_GRANT,
9299
+ ...options
9300
+ });
9301
+ const buildRefreshedAuth$1 = (auth, accessToken) => rewriteAuth(ANILIST_TOKEN_GRANT, auth, accessToken);
8590
9302
 
8591
9303
  function validateCategoryMethods(category) {
8592
9304
  for (const entry of ANILIST_OPERATION_REGISTRY[category]) {
@@ -8684,6 +9396,15 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
8684
9396
  writable: false
8685
9397
  });
8686
9398
  let customBound;
9399
+ let customPageBound;
9400
+ const page = pageFacade;
9401
+ const notificationsFetch = (variables, options2) => page.notifications(variables, options2);
9402
+ const activitiesFetch = (variables, options2) => page.activities(variables, options2);
9403
+ const watchFacade = {
9404
+ notifications: (watchOptions) => watchNotifications(notificationsFetch, watchOptions),
9405
+ activity: (watchOptions) => watchActivity(activitiesFetch, watchOptions)
9406
+ };
9407
+ const mapExternalIdsFacade = buildMapExternalIdsFacade(options, sharedStateOwner);
8687
9408
  return Object.defineProperties(
8688
9409
  {
8689
9410
  query: queryFacade,
@@ -8694,7 +9415,9 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
8694
9415
  fuzzyDate,
8695
9416
  fuzzyDateInt,
8696
9417
  flattenMediaListCollection,
8697
- crossLink
9418
+ crossLink,
9419
+ mapExternalIds: mapExternalIdsFacade,
9420
+ watch: watchFacade
8698
9421
  },
8699
9422
  {
8700
9423
  custom: {
@@ -8712,6 +9435,24 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
8712
9435
  }
8713
9436
  return customBound;
8714
9437
  }
9438
+ },
9439
+ customPage: {
9440
+ enumerable: true,
9441
+ configurable: false,
9442
+ get() {
9443
+ if (customPageBound === void 0) {
9444
+ const customPageInstance = new CustomRequest(
9445
+ getAuth(),
9446
+ options,
9447
+ sharedStateOwner
9448
+ );
9449
+ trackOperation?.(customPageInstance);
9450
+ customPageBound = maybeWrap(
9451
+ customPageInstance.customPage.bind(customPageInstance)
9452
+ );
9453
+ }
9454
+ return customPageBound;
9455
+ }
8715
9456
  }
8716
9457
  }
8717
9458
  );
@@ -8721,6 +9462,162 @@ function buildAniListApi(authToken, options, stateOwner, credentials) {
8721
9462
  return buildAniListWiring(authToken, options, stateOwner, credentials);
8722
9463
  }
8723
9464
 
9465
+ const MAL_API_BASE_URL = "https://api.myanimelist.net/v2";
9466
+ const MAL_AUTHORIZE_URL = "https://myanimelist.net/v1/oauth2/authorize";
9467
+ const MAL_TOKEN_URL = "https://myanimelist.net/v1/oauth2/token";
9468
+ const MAL_API_REFERENCE = "https://myanimelist.net/apiconfig/references/api/v2";
9469
+ const DEFAULT_MAL_ANIME_FIELD_KEYS = {
9470
+ id: true,
9471
+ title: true,
9472
+ main_picture: true,
9473
+ synopsis: true,
9474
+ status: true,
9475
+ mean: true,
9476
+ num_episodes: true,
9477
+ media_type: true,
9478
+ start_date: true,
9479
+ broadcast: true,
9480
+ average_episode_duration: true
9481
+ };
9482
+ const DEFAULT_MAL_ANIME_FIELDS = Object.keys(
9483
+ DEFAULT_MAL_ANIME_FIELD_KEYS
9484
+ );
9485
+ const formatMalFields = (fields) => typeof fields === "string" ? fields : fields?.join(",");
9486
+
9487
+ const MAL_TOKEN_GRANT = {
9488
+ tokenUrl: MAL_TOKEN_URL,
9489
+ errorLabel: "MAL token request",
9490
+ stripHeaders: ["X-MAL-CLIENT-ID"],
9491
+ provider: { hookName: "malTokenRefresh", providerLabel: "MAL" }
9492
+ };
9493
+ const validateMalPkceValue = (value, name) => {
9494
+ if (!/^[-A-Za-z0-9._~]{43,128}$/.test(value)) {
9495
+ throw new TypeError(`${name} must contain 43 to 128 RFC 7636 unreserved characters.`);
9496
+ }
9497
+ };
9498
+ const buildMalAuthorizationUrl = (clientId, codeChallenge, state) => {
9499
+ validateMalPkceValue(codeChallenge, "codeChallenge");
9500
+ const params = new URLSearchParams({
9501
+ response_type: "code",
9502
+ client_id: clientId,
9503
+ code_challenge: codeChallenge,
9504
+ code_challenge_method: "plain"
9505
+ });
9506
+ if (state !== void 0) params.set("state", state);
9507
+ return `${MAL_AUTHORIZE_URL}?${params.toString().replaceAll("+", "%20")}`;
9508
+ };
9509
+ const getMalAccessToken = async (request) => {
9510
+ validateMalPkceValue(request.codeVerifier, "codeVerifier");
9511
+ return requestTokenGrant(
9512
+ MAL_TOKEN_GRANT,
9513
+ {
9514
+ client_id: request.clientId,
9515
+ code: request.code,
9516
+ code_verifier: request.codeVerifier,
9517
+ grant_type: "authorization_code",
9518
+ ...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
9519
+ },
9520
+ void 0,
9521
+ request.options
9522
+ );
9523
+ };
9524
+ const refreshMalAccessToken = (request) => requestTokenGrant(
9525
+ MAL_TOKEN_GRANT,
9526
+ refreshGrantParams(request.clientId, request.clientSecret, request.refreshToken),
9527
+ void 0,
9528
+ request.options
9529
+ );
9530
+ const getMalTokenExpiry = (response, now) => computeTokenExpiry(response, now);
9531
+
9532
+ const MAX_PER_PAGE = 100;
9533
+ const DEFAULT_PER_PAGE = MAX_PER_PAGE;
9534
+ const DEFAULT_MAX_PAGES = 100;
9535
+ const DEFAULT_CONCURRENCY = 1;
9536
+ const MAL_DEFAULTS = {
9537
+ naming: "page",
9538
+ maxPerEntry: MAX_PER_PAGE,
9539
+ defaultPerEntry: DEFAULT_PER_PAGE,
9540
+ defaultMaxEntries: DEFAULT_MAX_PAGES,
9541
+ defaultConcurrency: DEFAULT_CONCURRENCY
9542
+ };
9543
+ const safeCallback = (callback, name, onHookError, diagnostics, payload) => {
9544
+ safeInvoke(callback, name, onHookError, diagnostics, payload);
9545
+ };
9546
+ function extractHasMore(response, perPage) {
9547
+ if (!Array.isArray(response.data)) {
9548
+ return false;
9549
+ }
9550
+ if (typeof response.paging === "object" && response.paging !== null) {
9551
+ return response.paging.next != null;
9552
+ }
9553
+ return response.data.length >= perPage;
9554
+ }
9555
+ async function malPaginate(fetchPage, options) {
9556
+ const {
9557
+ perEntry: perPage,
9558
+ startEntry: startPage,
9559
+ maxEntries: maxPages,
9560
+ concurrency,
9561
+ diagnostics
9562
+ } = resolvePaginationOptions(options, MAL_DEFAULTS);
9563
+ const { signal, dispose } = bridgeAbortSignal(options?.signal);
9564
+ try {
9565
+ const { responses, count, truncated } = await fetchWithLookAhead(
9566
+ (page) => fetchPage(page, perPage, signal),
9567
+ (response) => extractHasMore(response, perPage),
9568
+ startPage,
9569
+ maxPages,
9570
+ concurrency,
9571
+ signal
9572
+ );
9573
+ const items = [];
9574
+ const pages = [];
9575
+ for (const response of responses) {
9576
+ const raw = response.data;
9577
+ if (raw === void 0) {
9578
+ throw new AniLinkValidationError([
9579
+ 'malPaginate: the page response has no "data" key. Check the fetchPage closure returns the raw MAL list response.'
9580
+ ]);
9581
+ }
9582
+ const pageItems = Array.isArray(raw) ? raw : [];
9583
+ pages.push({ paging: response.paging, items: pageItems });
9584
+ items.push(...pageItems);
9585
+ safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
9586
+ paging: response.paging,
9587
+ items: pageItems
9588
+ });
9589
+ }
9590
+ return { items, pages, pageCount: count, truncated };
9591
+ } finally {
9592
+ dispose();
9593
+ }
9594
+ }
9595
+ async function* malPaginatePages(fetchPage, options) {
9596
+ const {
9597
+ perEntry: perPage,
9598
+ startEntry: startPage,
9599
+ maxEntries: maxPages,
9600
+ concurrency,
9601
+ diagnostics
9602
+ } = resolvePaginationOptions(options, MAL_DEFAULTS);
9603
+ for await (const response of streamNumericPages(
9604
+ fetchPage,
9605
+ // Invert {@link extractHasMore} so both traversal helpers use the
9606
+ // same terminal condition. Malformed `data` ends traversal in every
9607
+ // branch. When present, MAL's `paging` node is authoritative. A
9608
+ // missing `next` URL ends traversal even on a full page. Without a
9609
+ // `paging` node, a short page ends traversal.
9610
+ (page) => !extractHasMore(page, perPage),
9611
+ { perPage, startPage, maxPages, concurrency, signal: options?.signal }
9612
+ )) {
9613
+ safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
9614
+ paging: response.paging,
9615
+ items: Array.isArray(response.data) ? response.data : []
9616
+ });
9617
+ yield response;
9618
+ }
9619
+ }
9620
+
8724
9621
  const buildQueryString = (params) => {
8725
9622
  const segments = [];
8726
9623
  for (const [key, value] of Object.entries(params)) {
@@ -8748,16 +9645,17 @@ class RestOperation extends BaseOperation {
8748
9645
  /**
8749
9646
  * Sends one REST call through the shared transport pipeline.
8750
9647
  *
8751
- * GET and DELETE calls pass their parameters as a query string; POST, PUT,
8752
- * and PATCH calls send them as a JSON body. Responses are returned verbatim
8753
- * — REST providers have no GraphQL-style envelope, so no unwrapping
8754
- * happens.
9648
+ * GET and DELETE calls pass parameters as a query string. POST, PUT, and
9649
+ * PATCH calls send them as a JSON body. `execute` returns response bodies
9650
+ * verbatim because REST providers have no GraphQL-style envelope to unwrap.
8755
9651
  *
8756
9652
  * @typeParam T - The expected parsed response body.
8757
9653
  * @param path - The endpoint path beginning with `/` (for example `/anime/{id}`); placeholders are substituted from `pathParams` before interpolation into the URL.
8758
9654
  * @param options - The declarative request contract: method, auth requirement, content type, query/body/pathParams, and per-request transport settings.
8759
9655
  * @returns The parsed response body as-is.
8760
- * @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set, an {@link AniLinkValidationError} when a `{placeholder}` in `path` has no matching `pathParams` entry, or a normalized {@link AniLinkError} (typically `AniLinkRestError`) when the request fails.
9656
+ * @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set.
9657
+ * @throws An {@link AniLinkValidationError} when a `{placeholder}` in `path` has no matching `pathParams` entry.
9658
+ * @throws A normalized {@link AniLinkError} (typically `AniLinkRestError`) when the request fails.
8761
9659
  */
8762
9660
  async execute(path, options = {}) {
8763
9661
  const {
@@ -8793,25 +9691,6 @@ class RestOperation extends BaseOperation {
8793
9691
  }
8794
9692
  }
8795
9693
 
8796
- const MAL_API_BASE_URL = "https://api.myanimelist.net/v2";
8797
- const MAL_AUTHORIZE_URL = "https://myanimelist.net/v1/oauth2/authorize";
8798
- const MAL_TOKEN_URL = "https://myanimelist.net/v1/oauth2/token";
8799
- const MAL_API_REFERENCE = "https://myanimelist.net/apiconfig/references/api/v2";
8800
- const DEFAULT_MAL_ANIME_FIELDS = [
8801
- "id",
8802
- "title",
8803
- "main_picture",
8804
- "synopsis",
8805
- "status",
8806
- "mean",
8807
- "num_episodes",
8808
- "media_type",
8809
- "start_date",
8810
- "broadcast",
8811
- "average_episode_duration"
8812
- ];
8813
- const formatMalFields = (fields) => typeof fields === "string" ? fields : fields?.join(",");
8814
-
8815
9694
  class MalAnimeOperation extends RestOperation {
8816
9695
  /** The base URL for MyAnimeList API v2, from {@link MAL_API_BASE_URL}. */
8817
9696
  baseUrl = MAL_API_BASE_URL;
@@ -8824,8 +9703,6 @@ class MalAnimeOperation extends RestOperation {
8824
9703
  "status",
8825
9704
  "num_watched_episodes",
8826
9705
  "score",
8827
- "start_date",
8828
- "finish_date",
8829
9706
  "comments",
8830
9707
  "is_rewatching",
8831
9708
  "num_times_rewatched",
@@ -8865,8 +9742,9 @@ class MalAnimeOperation extends RestOperation {
8865
9742
  * It calls `GET /anime` through `RestOperation.execute` with the `q` keyword
8866
9743
  * plus the `limit`/`offset` paging filters and returns a
8867
9744
  * {@link MalAnimeSearchResponse} page of `MalAnimeSearchEntry` entries
8868
- * shaped by {@link MalRequestOptions.fields}. The facade alias is
8869
- * `MyAnimeListAnimeApi.search` and it is a public read.
9745
+ * shaped by {@link MalRequestOptions.fields}. The facade exposes this
9746
+ * method as `MyAnimeListAnimeApi.search`. It does not require an access
9747
+ * token.
8870
9748
  *
8871
9749
  * @param params - The search inputs; a {@link MalAnimeSearchParams} carrying the keyword plus the optional paging filters.
8872
9750
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -8905,7 +9783,11 @@ class MalAnimeOperation extends RestOperation {
8905
9783
  /**
8906
9784
  * {@link MalAnimeOperation.seasonal} gets the anime of one broadcast season.
8907
9785
  *
8908
- * It calls `GET /anime/season/{year}/{season}` through `RestOperation.execute` and returns a {@link MalSeasonalAnimeResponse} page of `MalSeasonalAnime` entries shaped by {@link MalRequestOptions.fields}. The facade alias is `MyAnimeListAnimeApi.seasonal` and it is a public read.
9786
+ * It calls `GET /anime/season/{year}/{season}` through
9787
+ * `RestOperation.execute` and returns a {@link MalSeasonalAnimeResponse} page
9788
+ * of `MalSeasonalAnime` entries shaped by {@link MalRequestOptions.fields}.
9789
+ * The facade exposes this method as `MyAnimeListAnimeApi.seasonal`. It does
9790
+ * not require an access token.
8909
9791
  *
8910
9792
  * @param params - The seasonal read inputs; a {@link MalSeasonalParams} carrying the year and broadcast window.
8911
9793
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -8936,7 +9818,11 @@ class MalAnimeOperation extends RestOperation {
8936
9818
  /**
8937
9819
  * {@link MalAnimeOperation.ranking} gets one of MyAnimeList's anime ranking lists.
8938
9820
  *
8939
- * It calls `GET /anime/ranking` through `RestOperation.execute` with the `ranking_type` query parameter and returns a {@link MalAnimeRankingResponse} page of `MalRankingEntry` entries shaped by {@link MalRequestOptions.fields}. The facade alias is `MyAnimeListAnimeApi.ranking` and it is a public read.
9821
+ * It calls `GET /anime/ranking` through `RestOperation.execute` with the
9822
+ * `ranking_type` query parameter and returns a {@link MalAnimeRankingResponse}
9823
+ * page of `MalRankingEntry` entries shaped by {@link MalRequestOptions.fields}.
9824
+ * The facade exposes this method as `MyAnimeListAnimeApi.ranking`. It does
9825
+ * not require an access token.
8940
9826
  *
8941
9827
  * @param params - The ranking read inputs; a {@link MalRankingParams} carrying the ranking list to fetch.
8942
9828
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -8999,11 +9885,11 @@ class MalAnimeOperation extends RestOperation {
8999
9885
  * MAL's list-status endpoints require.
9000
9886
  *
9001
9887
  * MAL documents `PATCH /anime/{id}/my_list_status` with an
9002
- * `application/x-www-form-urlencoded` request body, not JSON. Only the
9003
- * known list-status fields are encoded — excess properties from
9004
- * JavaScript callers (typos like `num_watched_episode`) are dropped
9005
- * instead of being sent to MAL as silent no-op fields. Array values
9006
- * (`tags`) are joined into the comma-separated string MAL expects.
9888
+ * `application/x-www-form-urlencoded` request body, not JSON. The encoder
9889
+ * includes only known list-status fields. It drops extra JavaScript
9890
+ * properties, such as the typo `num_watched_episode`, rather than sending
9891
+ * fields MAL would ignore. It joins array values (`tags`) into the
9892
+ * comma-separated string MAL expects.
9007
9893
  *
9008
9894
  * @param payload - The list-status fields to update.
9009
9895
  * @returns The encoded body string, safe to pass as the request `data`.
@@ -9100,9 +9986,10 @@ class MalForumOperation extends RestOperation {
9100
9986
  * {@link MalForumOperation.boards} gets the MyAnimeList forum board tree.
9101
9987
  *
9102
9988
  * It calls `GET /forum/boards` through `RestOperation.execute` and returns
9103
- * a {@link MalForumBoardsResponse} of {@link MalForumCategory} entries,
9104
- * each carrying its {@link MalForumBoard} and subboards. The facade alias
9105
- * is `MyAnimeListForumApi.boards` and it is a public read.
9989
+ * a {@link MalForumBoardsResponse} containing {@link MalForumCategory}
9990
+ * entries, each with {@link MalForumBoard} entries and subboards. The
9991
+ * facade exposes this method as `MyAnimeListForumApi.boards`. It does not
9992
+ * require an access token.
9106
9993
  *
9107
9994
  * @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
9108
9995
  * @returns The forum board tree, a {@link MalForumBoardsResponse}.
@@ -9129,8 +10016,8 @@ class MalForumOperation extends RestOperation {
9129
10016
  * `topic_user_name`/`user_name` creator filters, and the `sort` and
9130
10017
  * `limit`/`offset` paging filters, returning a
9131
10018
  * {@link MalForumTopicsResponse} page of {@link MalForumTopicSummary}
9132
- * entries. The facade alias is `MyAnimeListForumApi.topics` and it is a
9133
- * public read.
10019
+ * entries. The facade exposes this method as `MyAnimeListForumApi.topics`.
10020
+ * It does not require an access token.
9134
10021
  *
9135
10022
  * @param params - The topic-list read inputs; a {@link MalForumTopicsParams} carrying the optional board, keyword, creator, sort, and paging filters.
9136
10023
  * @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9169,8 +10056,9 @@ class MalForumOperation extends RestOperation {
9169
10056
  * It calls `GET /forum/topic/{id}` through `RestOperation.execute` with
9170
10057
  * the `limit`/`offset` post-paging filters and returns a
9171
10058
  * {@link MalForumTopicResponse} whose `data` carries the topic's
9172
- * {@link MalForumTopicDetail} — title, posts, and poll. The facade alias
9173
- * is `MyAnimeListForumApi.topic` and it is a public read.
10059
+ * {@link MalForumTopicDetail}, which contains the title, posts, and poll.
10060
+ * The facade exposes this method as `MyAnimeListForumApi.topic`. It does
10061
+ * not require an access token.
9174
10062
  *
9175
10063
  * @param params - The topic read inputs; a {@link MalForumTopicParams} carrying the topic ID plus the optional post-paging filters.
9176
10064
  * @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9210,8 +10098,6 @@ class MalMangaOperation extends RestOperation {
9210
10098
  "num_chapters_read",
9211
10099
  "num_volumes_read",
9212
10100
  "score",
9213
- "start_date",
9214
- "finish_date",
9215
10101
  "comments",
9216
10102
  "is_rereading",
9217
10103
  "num_times_reread",
@@ -9252,8 +10138,9 @@ class MalMangaOperation extends RestOperation {
9252
10138
  * It calls `GET /manga` through `RestOperation.execute` with the `q` keyword
9253
10139
  * plus the `limit`/`offset` paging filters and returns a
9254
10140
  * {@link MalMangaSearchResponse} page of `MalMangaSearchEntry` entries
9255
- * shaped by {@link MalRequestOptions.fields}. The facade alias is
9256
- * `MyAnimeListMangaApi.search` and it is a public read.
10141
+ * shaped by {@link MalRequestOptions.fields}. The facade exposes this
10142
+ * method as `MyAnimeListMangaApi.search`. It does not require an access
10143
+ * token.
9257
10144
  *
9258
10145
  * @param params - The search inputs; a {@link MalMangaSearchParams} carrying the keyword plus the optional paging filters.
9259
10146
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9295,8 +10182,8 @@ class MalMangaOperation extends RestOperation {
9295
10182
  * It calls `GET /manga/ranking` through `RestOperation.execute` with the
9296
10183
  * `ranking_type` query parameter and returns a {@link MalMangaRankingResponse}
9297
10184
  * page of `MalMangaRankingEntry` entries shaped by
9298
- * {@link MalRequestOptions.fields}. The facade alias is
9299
- * `MyAnimeListMangaApi.ranking` and it is a public read.
10185
+ * {@link MalRequestOptions.fields}. The facade exposes this method as
10186
+ * `MyAnimeListMangaApi.ranking`. It does not require an access token.
9300
10187
  *
9301
10188
  * @param params - The ranking read inputs; a {@link MalMangaRankingParams} carrying the ranking list to fetch.
9302
10189
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9331,11 +10218,11 @@ class MalMangaOperation extends RestOperation {
9331
10218
  * MAL's list-status endpoints require.
9332
10219
  *
9333
10220
  * MAL documents `PATCH /manga/{id}/my_list_status` with an
9334
- * `application/x-www-form-urlencoded` request body, not JSON. Only the
9335
- * known list-status fields are encoded — excess properties from
9336
- * JavaScript callers (typos like `num_chapter_read`) are dropped
9337
- * instead of being sent to MAL as silent no-op fields. Array values
9338
- * (`tags`) are joined into the comma-separated string MAL expects.
10221
+ * `application/x-www-form-urlencoded` request body, not JSON. The encoder
10222
+ * includes only known list-status fields. It drops extra JavaScript
10223
+ * properties, such as the typo `num_chapter_read`, rather than sending
10224
+ * fields MAL would ignore. It joins array values (`tags`) into the
10225
+ * comma-separated string MAL expects.
9339
10226
  *
9340
10227
  * @param payload - The list-status fields to update.
9341
10228
  * @returns The encoded body string, safe to pass as the request `data`.
@@ -9429,17 +10316,17 @@ class MalUserOperation extends RestOperation {
9429
10316
  /** The base URL for MyAnimeList API v2, from {@link MAL_API_BASE_URL}. */
9430
10317
  baseUrl = MAL_API_BASE_URL;
9431
10318
  /**
9432
- * Normalizes a `username` argument for the `@me` check: trimmed and
9433
- * lowercased, so `@ME` and `" @me "` resolve the authenticated user too.
10319
+ * Normalizes a `username` argument for the `@me` check by trimming and
10320
+ * lowercasing it, so `@ME` and `" @me "` resolve the authenticated user too.
9434
10321
  */
9435
10322
  static normalizeUsername(username) {
9436
10323
  return username.trim().toLowerCase();
9437
10324
  }
9438
10325
  /**
9439
10326
  * Validates a normalized username before it is interpolated into a URL
9440
- * path: an empty string would produce a malformed `/users//animelist`
9441
- * path, so it fails fast with a clear client-side message instead of a
9442
- * confusing upstream 404 or URL-parse error.
10327
+ * path. An empty string would produce a malformed `/users//animelist`
10328
+ * path, so it fails fast with a clear client-side message instead of an
10329
+ * upstream 404 or URL-parse error.
9443
10330
  *
9444
10331
  * @param normalized - The trimmed, lowercased username.
9445
10332
  * @throws An {@link AniLinkValidationError} when the username is empty.
@@ -9488,7 +10375,7 @@ class MalUserOperation extends RestOperation {
9488
10375
  * @param params - The profile read inputs; a {@link MalUserGetParams} carrying the username.
9489
10376
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
9490
10377
  * @returns The requested {@link MalUser}.
9491
- * @throws An `AniLinkAuthError` when `username` is `@me` and no access token is configured.
10378
+ * @throws An `AniLinkAuthError` when no access token is configured.
9492
10379
  * @throws An {@link AniLinkValidationError} when `username` is empty or only whitespace.
9493
10380
  * @throws A normalized `AniLinkError` when the request fails.
9494
10381
  * @example
@@ -9510,17 +10397,17 @@ class MalUserOperation extends RestOperation {
9510
10397
  return await this.execute(
9511
10398
  normalized === "@me" ? "/users/@me" : "/users/{username}",
9512
10399
  {
9513
- // `@me` resolves the authenticated user, which only a bearer
9514
- // token can identify — a client ID alone cannot — so fail fast
9515
- // like `me`.
9516
- requiresAuth: normalized === "@me",
10400
+ // Only a bearer token can identify the authenticated user at
10401
+ // `@me`; a client ID alone cannot. Reject the request early,
10402
+ // as `me` does.
10403
+ requiresAuth: true,
9517
10404
  transportOptions,
9518
10405
  // `buildQueryString` skips undefined/null values, so the
9519
10406
  // optional filters can be passed straight through.
9520
10407
  query: {
9521
10408
  fields: formatMalFields(fields)
9522
10409
  },
9523
- // The trimmed username, not the raw argument: surrounding
10410
+ // The trimmed username, not the raw argument. Surrounding
9524
10411
  // whitespace would otherwise be percent-encoded into the
9525
10412
  // path and answered with a 404.
9526
10413
  pathParams: normalized === "@me" ? void 0 : { username: normalized }
@@ -9530,7 +10417,13 @@ class MalUserOperation extends RestOperation {
9530
10417
  /**
9531
10418
  * {@link MalUserOperation.animeList} gets a user's anime list, one page at a time.
9532
10419
  *
9533
- * It calls `GET /users/{username}/animelist` through `RestOperation.execute` and returns a {@link MalUserAnimeListResponse} page of `MalUserAnimeListEntry` entries shaped by {@link MalRequestOptions.fields}. The facade alias is `MyAnimeListUserApi.animeList` and it is a public read: `username` accepts a user name or `@me`, with `@me` and private lists requiring an access token (a client ID alone cannot resolve `@me`). The `@me` check is case-insensitive and ignores surrounding whitespace.
10420
+ * It calls `GET /users/{username}/animelist` through
10421
+ * `RestOperation.execute` and returns a {@link MalUserAnimeListResponse}
10422
+ * page of `MalUserAnimeListEntry` entries shaped by
10423
+ * {@link MalRequestOptions.fields}. The facade exposes this method as
10424
+ * `MyAnimeListUserApi.animeList`. `username` accepts a user name or `@me`;
10425
+ * `@me` and private lists require an access token. The `@me` check ignores
10426
+ * case and surrounding whitespace.
9534
10427
  *
9535
10428
  * @param params - The anime-list read inputs; a {@link MalUserAnimeListParams} carrying the username plus the optional status, sort, and paging filters.
9536
10429
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9557,8 +10450,8 @@ class MalUserOperation extends RestOperation {
9557
10450
  return await this.execute(
9558
10451
  normalized === "@me" ? "/users/@me/animelist" : "/users/{username}/animelist",
9559
10452
  {
9560
- // `@me` resolves the authenticated user, which only a bearer token
9561
- // can identify — a client ID alone cannot — so fail fast like `me`.
10453
+ // Only a bearer token can identify the authenticated user at
10454
+ // `@me`; a client ID alone cannot. Require one, as `me` does.
9562
10455
  requiresAuth: normalized === "@me",
9563
10456
  transportOptions,
9564
10457
  // `buildQueryString` skips undefined/null values, so the
@@ -9570,7 +10463,7 @@ class MalUserOperation extends RestOperation {
9570
10463
  limit,
9571
10464
  offset
9572
10465
  },
9573
- // The trimmed username, not the raw argument: surrounding
10466
+ // The trimmed username, not the raw argument. Surrounding
9574
10467
  // whitespace would otherwise be percent-encoded into the
9575
10468
  // path and answered with a 404.
9576
10469
  pathParams: normalized === "@me" ? void 0 : { username: normalized }
@@ -9580,7 +10473,13 @@ class MalUserOperation extends RestOperation {
9580
10473
  /**
9581
10474
  * {@link MalUserOperation.mangaList} gets a user's manga list, one page at a time.
9582
10475
  *
9583
- * It calls `GET /users/{username}/mangalist` through `RestOperation.execute` and returns a {@link MalUserMangaListResponse} page of `MalUserMangaListEntry` entries shaped by {@link MalRequestOptions.fields}. The facade alias is `MyAnimeListUserApi.mangaList` and it is a public read: `username` accepts a user name or `@me`, with `@me` and private lists requiring an access token (a client ID alone cannot resolve `@me`). The `@me` check is case-insensitive and ignores surrounding whitespace.
10476
+ * It calls `GET /users/{username}/mangalist` through
10477
+ * `RestOperation.execute` and returns a {@link MalUserMangaListResponse}
10478
+ * page of `MalUserMangaListEntry` entries shaped by
10479
+ * {@link MalRequestOptions.fields}. The facade exposes this method as
10480
+ * `MyAnimeListUserApi.mangaList`. `username` accepts a user name or `@me`;
10481
+ * `@me` and private lists require an access token. The `@me` check ignores
10482
+ * case and surrounding whitespace.
9584
10483
  *
9585
10484
  * @param params - The manga-list read inputs; a {@link MalUserMangaListParams} carrying the username plus the optional status, sort, and paging filters.
9586
10485
  * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
@@ -9607,8 +10506,8 @@ class MalUserOperation extends RestOperation {
9607
10506
  return await this.execute(
9608
10507
  normalized === "@me" ? "/users/@me/mangalist" : "/users/{username}/mangalist",
9609
10508
  {
9610
- // `@me` resolves the authenticated user, which only a bearer token
9611
- // can identify — a client ID alone cannot — so fail fast like `me`.
10509
+ // Only a bearer token can identify the authenticated user at
10510
+ // `@me`; a client ID alone cannot. Require one, as `me` does.
9612
10511
  requiresAuth: normalized === "@me",
9613
10512
  transportOptions,
9614
10513
  // `buildQueryString` skips undefined/null values, so the
@@ -9620,7 +10519,7 @@ class MalUserOperation extends RestOperation {
9620
10519
  limit,
9621
10520
  offset
9622
10521
  },
9623
- // The trimmed username, not the raw argument: surrounding
10522
+ // The trimmed username, not the raw argument. Surrounding
9624
10523
  // whitespace would otherwise be percent-encoded into the
9625
10524
  // path and answered with a 404.
9626
10525
  pathParams: normalized === "@me" ? void 0 : { username: normalized }
@@ -9629,180 +10528,60 @@ class MalUserOperation extends RestOperation {
9629
10528
  }
9630
10529
  }
9631
10530
 
9632
- const MAX_PER_PAGE = 100;
9633
- const DEFAULT_PER_PAGE = MAX_PER_PAGE;
9634
- const DEFAULT_MAX_PAGES = 100;
9635
- const DEFAULT_CONCURRENCY = 1;
9636
- const safeCallback = (callback, name, onHookError, diagnostics, payload) => {
9637
- safeInvoke(callback, name, onHookError, diagnostics, payload);
9638
- };
9639
- function extractHasMore(response, perPage) {
9640
- return Array.isArray(response.data) && response.data.length >= perPage;
9641
- }
9642
- async function malPaginate(fetchPage, options) {
9643
- const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE, "perPage");
9644
- const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
9645
- const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES, "maxPages");
9646
- const concurrency = resolveCappedInt(
9647
- options?.concurrency,
9648
- MAX_CONCURRENCY,
9649
- DEFAULT_CONCURRENCY,
9650
- "concurrency"
9651
- );
9652
- const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
9653
- const { signal, dispose } = bridgeAbortSignal(options?.signal);
9654
- try {
9655
- const { responses, count, truncated } = await fetchWithLookAhead(
9656
- (page) => fetchPage(page, perPage, signal),
9657
- (response) => extractHasMore(response, perPage),
9658
- startPage,
9659
- maxPages,
9660
- concurrency,
9661
- signal
9662
- );
9663
- const items = [];
9664
- const pages = [];
9665
- for (const response of responses) {
9666
- const raw = response.data;
9667
- if (raw === void 0) {
9668
- throw new AniLinkValidationError([
9669
- 'malPaginate: the page response has no "data" key. Check the fetchPage closure returns the raw MAL list response.'
9670
- ]);
9671
- }
9672
- const pageItems = Array.isArray(raw) ? raw : [];
9673
- pages.push({ paging: response.paging, items: pageItems });
9674
- items.push(...pageItems);
9675
- safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
9676
- paging: response.paging,
9677
- items: pageItems
9678
- });
9679
- }
9680
- return { items, pages, pageCount: count, truncated };
9681
- } finally {
9682
- dispose();
9683
- }
9684
- }
9685
- async function* malPaginatePages(fetchPage, options) {
9686
- const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE, "perPage");
9687
- const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
9688
- const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES, "maxPages");
9689
- const concurrency = resolveCappedInt(
9690
- options?.concurrency,
9691
- MAX_CONCURRENCY,
9692
- DEFAULT_CONCURRENCY,
9693
- "concurrency"
9694
- );
9695
- const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
9696
- for await (const response of streamNumericPages(
9697
- fetchPage,
9698
- // A short page is MAL's own end-of-list signal (its `paging.next` is
9699
- // derived from the same offset math), so the traversal ends there
9700
- // instead of launching a request the server has already said is
9701
- // empty. A malformed `data` array ends the traversal the same way
9702
- // instead of looping forever.
9703
- (page) => !Array.isArray(page.data) || page.data.length < perPage,
9704
- { perPage, startPage, maxPages, concurrency, signal: options?.signal }
9705
- )) {
9706
- safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
9707
- paging: response.paging,
9708
- items: Array.isArray(response.data) ? response.data : []
9709
- });
9710
- yield response;
9711
- }
10531
+ function op(name, operationClass) {
10532
+ return { name, operationClass, methodName: name };
9712
10533
  }
9713
-
9714
- const MAL_AUTH_TIMEOUT_MS = 1e4;
9715
- const buildMalAuthorizationUrl = (clientId, codeChallenge, state) => {
9716
- const params = new URLSearchParams({
9717
- response_type: "code",
9718
- client_id: clientId,
9719
- code_challenge: codeChallenge,
9720
- code_challenge_method: "plain"
9721
- });
9722
- if (state !== void 0) params.set("state", state);
9723
- return `${MAL_AUTHORIZE_URL}?${params.toString().replaceAll("+", "%20")}`;
9724
- };
9725
- const normalizeMalTokenError = (error) => sanitizeTokenError(error, "MAL token request");
9726
- const requestMalToken = async (params, options) => {
9727
- try {
9728
- const body = new URLSearchParams(params).toString();
9729
- return await sendRequest(MAL_TOKEN_URL, "POST", body, void 0, {
9730
- requiresAuth: false,
9731
- options: {
9732
- ...options,
9733
- timeout: options?.timeout ?? MAL_AUTH_TIMEOUT_MS,
9734
- retry: options?.retry ?? false,
9735
- exposeRawAxiosError: false
9736
- },
9737
- contentType: "application/x-www-form-urlencoded"
9738
- });
9739
- } catch (error) {
9740
- throw normalizeMalTokenError(error);
9741
- }
9742
- };
9743
- const getMalAccessToken = (request) => requestMalToken(
9744
- {
9745
- client_id: request.clientId,
9746
- code: request.code,
9747
- code_verifier: request.codeVerifier,
9748
- grant_type: "authorization_code",
9749
- ...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
9750
- },
9751
- request.options
9752
- );
9753
- const refreshMalAccessToken = (request) => requestMalToken(
9754
- {
9755
- client_id: request.clientId,
9756
- grant_type: "refresh_token",
9757
- refresh_token: request.refreshToken,
9758
- ...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
9759
- },
9760
- request.options
9761
- );
9762
- const getMalTokenExpiry = (response, now = Date.now()) => {
9763
- const { expires_in } = response;
9764
- if (!Number.isFinite(expires_in) || expires_in <= 0) {
9765
- throw new TypeError(
9766
- `Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
9767
- );
9768
- }
9769
- return new Date(now + expires_in * 1e3);
10534
+ const MAL_OPERATION_REGISTRY = {
10535
+ anime: [
10536
+ op("get", MalAnimeOperation),
10537
+ op("search", MalAnimeOperation),
10538
+ op("seasonal", MalAnimeOperation),
10539
+ op("ranking", MalAnimeOperation),
10540
+ op("suggestions", MalAnimeOperation),
10541
+ op("updateMyListStatus", MalAnimeOperation),
10542
+ op("deleteFromList", MalAnimeOperation)
10543
+ ],
10544
+ manga: [
10545
+ op("get", MalMangaOperation),
10546
+ op("search", MalMangaOperation),
10547
+ op("ranking", MalMangaOperation),
10548
+ op("updateMyListStatus", MalMangaOperation),
10549
+ op("deleteFromList", MalMangaOperation)
10550
+ ],
10551
+ user: [
10552
+ op("me", MalUserOperation),
10553
+ op("get", MalUserOperation),
10554
+ op("animeList", MalUserOperation),
10555
+ op("mangaList", MalUserOperation)
10556
+ ],
10557
+ forum: [
10558
+ op("boards", MalForumOperation),
10559
+ op("topics", MalForumOperation),
10560
+ op("topic", MalForumOperation)
10561
+ ]
9770
10562
  };
9771
10563
 
9772
- const buildMalTokenRefresher = (options) => {
9773
- const exchange = (refreshToken) => refreshMalAccessToken({
9774
- clientId: options.clientId,
9775
- refreshToken,
9776
- clientSecret: options.clientSecret
9777
- });
9778
- return new TokenRefresher({
9779
- exchange,
9780
- refreshToken: options.refreshToken,
9781
- provider: { hookName: "malTokenRefresh", providerLabel: "MAL" },
9782
- onTokenRefresh: options.onTokenRefresh,
9783
- onTokenRefreshError: options.onTokenRefreshError,
9784
- onHookError: options.onHookError,
9785
- diagnostics: options.diagnostics,
9786
- applyAccessToken: options.applyAccessToken
9787
- });
9788
- };
9789
- const buildRefreshedAuth = (auth, accessToken) => {
9790
- const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(
9791
- Object.entries(auth.headers).filter(([key]) => key !== "X-MAL-CLIENT-ID")
9792
- ) : void 0;
9793
- return {
9794
- token: accessToken,
9795
- headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
9796
- };
9797
- };
10564
+ const buildMalTokenRefresher = (options) => new TokenRefresher({
10565
+ descriptor: MAL_TOKEN_GRANT,
10566
+ ...options
10567
+ });
10568
+ const buildRefreshedAuth = (auth, accessToken) => rewriteAuth(MAL_TOKEN_GRANT, auth, accessToken);
9798
10569
 
9799
10570
  function buildMyAnimeListApi(credentials, stateOwner) {
9800
10571
  const { auth, options } = resolveMalCredentials(credentials);
9801
10572
  const sharedStateOwner = stateOwner ?? {};
9802
- const anime = new MalAnimeOperation(auth, options, sharedStateOwner);
9803
- const manga = new MalMangaOperation(auth, options, sharedStateOwner);
9804
- const user = new MalUserOperation(auth, options, sharedStateOwner);
9805
- const forum = new MalForumOperation(auth, options, sharedStateOwner);
10573
+ const groups = Object.keys(MAL_OPERATION_REGISTRY);
10574
+ const wired = [];
10575
+ for (const group of groups) {
10576
+ for (const entry of MAL_OPERATION_REGISTRY[group]) {
10577
+ const operationClass = entry.operationClass;
10578
+ wired.push({
10579
+ group,
10580
+ entry,
10581
+ instance: new operationClass(auth, options, sharedStateOwner)
10582
+ });
10583
+ }
10584
+ }
9806
10585
  const refresher = isNonBlank(credentials?.refreshToken) && isNonBlank(credentials?.clientId) ? buildMalTokenRefresher({
9807
10586
  clientId: credentials.clientId.trim(),
9808
10587
  refreshToken: credentials.refreshToken.trim(),
@@ -9812,83 +10591,34 @@ function buildMyAnimeListApi(credentials, stateOwner) {
9812
10591
  onHookError: credentials.onHookError,
9813
10592
  diagnostics: credentials.diagnostics,
9814
10593
  applyAccessToken: (accessToken) => {
9815
- for (const operation of [anime, manga, user, forum]) {
9816
- operation.updateAuth(
9817
- buildRefreshedAuth(operation.getAuth(), accessToken)
9818
- );
10594
+ for (const { instance } of wired) {
10595
+ instance.updateAuth(buildRefreshedAuth(instance.getAuth(), accessToken));
9819
10596
  }
9820
10597
  }
9821
10598
  }) : void 0;
9822
- if (refresher === void 0) {
9823
- return {
9824
- anime: {
9825
- get: anime.get.bind(anime),
9826
- search: anime.search.bind(anime),
9827
- seasonal: anime.seasonal.bind(anime),
9828
- ranking: anime.ranking.bind(anime),
9829
- suggestions: anime.suggestions.bind(anime),
9830
- updateMyListStatus: anime.updateMyListStatus.bind(anime),
9831
- deleteFromList: anime.deleteFromList.bind(anime)
9832
- },
9833
- manga: {
9834
- get: manga.get.bind(manga),
9835
- search: manga.search.bind(manga),
9836
- ranking: manga.ranking.bind(manga),
9837
- updateMyListStatus: manga.updateMyListStatus.bind(manga),
9838
- deleteFromList: manga.deleteFromList.bind(manga)
9839
- },
9840
- user: {
9841
- me: user.me.bind(user),
9842
- get: user.get.bind(user),
9843
- animeList: user.animeList.bind(user),
9844
- mangaList: user.mangaList.bind(user)
9845
- },
9846
- forum: {
9847
- boards: forum.boards.bind(forum),
9848
- topics: forum.topics.bind(forum),
9849
- topic: forum.topic.bind(forum)
9850
- },
9851
- // The pagination helpers are provider-owned pure functions over
9852
- // the shared engine — no transport state to bind, so they are
9853
- // exposed directly on both branches.
9854
- paginate: malPaginate,
9855
- paginatePages: malPaginatePages
9856
- };
10599
+ const maybeWrap = refresher === void 0 ? (method) => method : (method) => (...args) => refresher.executeWithRefresh(() => method(...args));
10600
+ const groupMembers = Object.fromEntries(groups.map((group) => [group, {}]));
10601
+ for (const { group, entry, instance } of wired) {
10602
+ const method = instance[entry.methodName];
10603
+ groupMembers[group][entry.name] = maybeWrap(
10604
+ method.bind(instance)
10605
+ );
9857
10606
  }
9858
- const wrap = (method) => (...args) => refresher.executeWithRefresh(() => method(...args));
9859
10607
  return {
9860
- anime: {
9861
- get: wrap(anime.get.bind(anime)),
9862
- search: wrap(anime.search.bind(anime)),
9863
- seasonal: wrap(anime.seasonal.bind(anime)),
9864
- ranking: wrap(anime.ranking.bind(anime)),
9865
- suggestions: wrap(anime.suggestions.bind(anime)),
9866
- updateMyListStatus: wrap(anime.updateMyListStatus.bind(anime)),
9867
- deleteFromList: wrap(anime.deleteFromList.bind(anime))
9868
- },
9869
- manga: {
9870
- get: wrap(manga.get.bind(manga)),
9871
- search: wrap(manga.search.bind(manga)),
9872
- ranking: wrap(manga.ranking.bind(manga)),
9873
- updateMyListStatus: wrap(manga.updateMyListStatus.bind(manga)),
9874
- deleteFromList: wrap(manga.deleteFromList.bind(manga))
9875
- },
9876
- user: {
9877
- me: wrap(user.me.bind(user)),
9878
- get: wrap(user.get.bind(user)),
9879
- animeList: wrap(user.animeList.bind(user)),
9880
- mangaList: wrap(user.mangaList.bind(user))
9881
- },
9882
- forum: {
9883
- boards: wrap(forum.boards.bind(forum)),
9884
- topics: wrap(forum.topics.bind(forum)),
9885
- topic: wrap(forum.topic.bind(forum))
10608
+ ...groupMembers,
10609
+ auth: {
10610
+ buildAuthorizationUrl: buildMalAuthorizationUrl,
10611
+ exchangeCode: getMalAccessToken
9886
10612
  },
10613
+ // The pagination helpers are provider-owned pure functions over
10614
+ // the shared engine. They need no transport state, so they are
10615
+ // exposed directly alongside the wired groups.
9887
10616
  paginate: malPaginate,
9888
10617
  paginatePages: malPaginatePages
9889
10618
  };
9890
10619
  }
9891
10620
 
10621
+ const registerProviderFactory = (factory, resolveCredentials, acceptsLegacyOptions = false) => Object.assign(factory, { resolveCredentials, acceptsLegacyOptions });
9892
10622
  const buildAniListClient = (credentials, legacyOptions, stateOwner) => {
9893
10623
  const resolved = resolveAniListCredentials(credentials);
9894
10624
  return buildAniListApi(
@@ -9900,8 +10630,8 @@ const buildAniListClient = (credentials, legacyOptions, stateOwner) => {
9900
10630
  };
9901
10631
  const buildMalClient = (credentials, _legacyOptions, stateOwner) => buildMyAnimeListApi(credentials, stateOwner);
9902
10632
  const PROVIDER_FACTORIES = {
9903
- anilist: buildAniListClient,
9904
- mal: buildMalClient
10633
+ anilist: registerProviderFactory(buildAniListClient, resolveAniListCredentials, true),
10634
+ mal: registerProviderFactory(buildMalClient, resolveMalCredentials)
9905
10635
  };
9906
10636
  function buildProviderClients(credentials = {}, legacyOptions) {
9907
10637
  const clientHookError = credentials.onHookError;
@@ -9921,20 +10651,27 @@ function buildProviderClients(credentials = {}, legacyOptions) {
9921
10651
  ...needsDiagnostics ? { diagnostics: clientDiagnostics } : {}
9922
10652
  };
9923
10653
  };
9924
- const anilistStateOwner = {};
9925
- const malStateOwner = {};
9926
- return {
9927
- anilist: PROVIDER_FACTORIES.anilist(
9928
- withDefaultHook(credentials.anilist),
9929
- legacyOptions,
9930
- anilistStateOwner
9931
- ),
9932
- mal: PROVIDER_FACTORIES.mal(withDefaultHook(credentials.mal), void 0, malStateOwner),
9933
- stateOwners: { anilist: anilistStateOwner, mal: malStateOwner }
9934
- };
10654
+ const clients = {};
10655
+ const stateOwners = {};
10656
+ const responseCaches = {};
10657
+ for (const providerId of Object.keys(PROVIDER_FACTORIES)) {
10658
+ const factory = PROVIDER_FACTORIES[providerId];
10659
+ const slot = withDefaultHook(credentials[providerId]);
10660
+ const stateOwner = {};
10661
+ const client = factory(
10662
+ slot,
10663
+ factory.acceptsLegacyOptions ? legacyOptions : void 0,
10664
+ stateOwner
10665
+ );
10666
+ const effectiveOptions = factory.resolveCredentials(slot).options ?? (factory.acceptsLegacyOptions ? legacyOptions : void 0);
10667
+ clients[providerId] = client;
10668
+ stateOwners[providerId] = stateOwner;
10669
+ responseCaches[providerId] = effectiveOptions?.responseCache;
10670
+ }
10671
+ return { ...clients, stateOwners, responseCaches };
9935
10672
  }
9936
10673
 
9937
- const snapshotTransportState = (owner) => {
10674
+ const snapshotTransportState = (owner, responseCache) => {
9938
10675
  const circuitScopes = peekCircuitStates(owner);
9939
10676
  const circuit = circuitScopes === void 0 ? [] : Array.from(
9940
10677
  circuitScopes,
@@ -9950,6 +10687,7 @@ const snapshotTransportState = (owner) => {
9950
10687
  const deadlines = peekPaceDeadlines(owner);
9951
10688
  const paceDeadlines = deadlines === void 0 ? [] : Array.from(deadlines, ([host, deadlineMs]) => Object.freeze({ host, deadlineMs }));
9952
10689
  return Object.freeze({
10690
+ capturedAt: Date.now(),
9953
10691
  circuit: Object.freeze(circuit),
9954
10692
  ...budget !== void 0 ? {
9955
10693
  retryBudget: Object.freeze({
@@ -9957,21 +10695,24 @@ const snapshotTransportState = (owner) => {
9957
10695
  windowEndsAt: budget.windowEndsAt
9958
10696
  })
9959
10697
  } : {},
9960
- paceDeadlines: Object.freeze(paceDeadlines)
10698
+ paceDeadlines: Object.freeze(paceDeadlines),
10699
+ ...responseCache !== void 0 ? { responseCache: responseCache.stats() } : {}
9961
10700
  });
9962
10701
  };
9963
10702
 
9964
10703
  class AniLink {
9965
10704
  /**
9966
- * The AniList GraphQL API surface, a {@link AniListApi} composed from the
10705
+ * The AniList GraphQL API, a {@link AniListApi} composed from the
9967
10706
  * query, mutation, custom, and helper groups.
9968
10707
  * @public
9969
10708
  */
9970
10709
  anilist;
9971
10710
  /** The MyAnimeList REST API methods, a {@link MyAnimeListApi} exposed under the `mal` namespace. */
9972
10711
  mal;
9973
- /** The per-provider state owners the clients key their shared transport state (breaker, budget, pacing) through. */
10712
+ /** The per-provider state owners that hold each client's shared transport state (breaker, budget, pacing). */
9974
10713
  stateOwners;
10714
+ /** The per-provider response caches resolved from the transport options, when enabled, for the cache-stats snapshot. */
10715
+ responseCaches;
9975
10716
  /**
9976
10717
  * Creates a new {@link AniLink} instance. The `authToken` parameter is optional and only
9977
10718
  * required for authenticated queries and mutations; without it only public queries are
@@ -9981,9 +10722,9 @@ class AniLink {
9981
10722
  * Alternatively, pass a per-provider {@link AniLinkCredentials} object: each provider
9982
10723
  * owns its own credentials shape, and credentials given under one key are
9983
10724
  * never applied to another provider's requests.
9984
- * @param {string | AniLinkCredentials} [authToken] - The authentication token to use for AniList API requests, or a per-provider credentials object (`{ anilist?: …, mal?: … }`).
9985
- * @param {AniLinkOptions} [options] - Transport settings scoped to this instance: `timeout`, `signal` cancellation, automatic retries under the default policy (`retry: false` opts out), `paceWithRateLimit` pacing (on by default), opt-in `circuitBreaker` fast-fail, the `onError`/`onRetry`/`onRequestStart`/`onResponse` observability hooks, and `exposeRawAxiosError` debugging. Options never leak between instances. Only valid when the first argument is a token string or omitted; combining a credentials object with a second argument throws, because the credentials form carries its own per-provider transport settings and a second argument would be silently dropped.
9986
- * @throws {TypeError} When a per-provider credentials object is combined with a second `options` argument. The credentials form carries transport settings inside each provider slot, so the second argument would be silently ignored — the constructor rejects the ambiguous call instead.
10725
+ * @param {string | AniLinkCredentials} [authToken] - The authentication token to use for AniList API requests, or a per-provider credentials object (`{ anilist?: ..., mal?: ... }`).
10726
+ * @param {AniLinkOptions} [options] - Transport settings scoped to this instance: `timeout`, `signal` cancellation, automatic retries under the default policy (`retry: false` opts out), `paceWithRateLimit` pacing (on by default), opt-in `circuitBreaker` fast-fail, the `onError`/`onRetry`/`onRequestStart`/`onResponse` observability hooks, and `exposeRawAxiosError` debugging. In the legacy `(token, options)` form, these settings apply only to AniList. Configure MAL transport settings in the credentials-object form under `mal`. Options never leak between instances. Only valid when the first argument is a token string or omitted; combining a credentials object with a second argument throws, because the credentials form carries its own per-provider transport settings and a second argument would be silently dropped.
10727
+ * @throws {TypeError} When a per-provider credentials object is combined with a second `options` argument. The credentials form carries transport settings inside each provider slot, so the second argument would be silently ignored. The constructor rejects the ambiguous call instead.
9987
10728
  * @public
9988
10729
  * @example
9989
10730
  * ```typescript
@@ -10022,23 +10763,32 @@ class AniLink {
10022
10763
  this.anilist = clients.anilist;
10023
10764
  this.mal = clients.mal;
10024
10765
  this.stateOwners = clients.stateOwners;
10766
+ this.responseCaches = clients.responseCaches ?? {
10767
+ anilist: void 0,
10768
+ mal: void 0
10769
+ };
10025
10770
  }
10026
10771
  /**
10027
10772
  * Returns a read-only, point-in-time snapshot of each provider client's
10028
- * shared transport state — the circuit-breaker scopes, retry-budget
10029
- * window, and rate-limit pacing deadlines keyed through that client's
10030
- * state owner — so "is the breaker open right now?", "how many budget
10031
- * retries are spent?", and "when does the pacing deadline elapse?" can
10032
- * be answered without pre-wiring lifecycle hooks.
10773
+ * shared transport state: the circuit-breaker scopes, retry-budget
10774
+ * window, rate-limit pacing deadlines keyed through that client's
10775
+ * state owner, and (when the provider's transport options enable a
10776
+ * response cache) the cache's live entry count and lifetime
10777
+ * hit/miss/expiration/eviction counters. Callers can check whether a
10778
+ * breaker is open, how many budget retries are spent, when pacing ends,
10779
+ * and how the cache performs
10780
+ * without wiring lifecycle hooks and without holding the
10781
+ * {@link ResponseCache} instance.
10033
10782
  *
10034
10783
  * Each provider's snapshot is built by {@link snapshotTransportState},
10035
- * which owns the read-only contract: deep-frozen copies that never
10036
- * alias the live mutable state, and a build step that never mutates
10037
- * the state it observes. Polling on a schedule is therefore safe
10784
+ * which owns the read-only contract. It returns deep-frozen copies that
10785
+ * never alias or mutate the live state, so polling on a schedule is safe
10038
10786
  * alongside live traffic.
10039
10787
  *
10040
10788
  * @returns A frozen per-provider {@link TransportStateSnapshot} pair:
10041
- * `{ anilist: {...}, mal: {...} }`.
10789
+ * `{ anilist: {...}, mal: {...} }`. Each snapshot carries `capturedAt`,
10790
+ * the epoch-millisecond build time, so a polled snapshot records when
10791
+ * it was taken.
10042
10792
  * @example
10043
10793
  * ```typescript
10044
10794
  * const aniLink = new AniLink("token", {
@@ -10060,14 +10810,18 @@ class AniLink {
10060
10810
  * for (const deadline of state.anilist.paceDeadlines) {
10061
10811
  * console.log("pacing until", new Date(deadline.deadlineMs).toISOString(), "for", deadline.host);
10062
10812
  * }
10813
+ * if (state.anilist.responseCache) {
10814
+ * console.log("cache entries:", state.anilist.responseCache.entries);
10815
+ * }
10063
10816
  * ```
10064
10817
  */
10065
10818
  getTransportState() {
10819
+ const caches = this.responseCaches ?? { anilist: void 0, mal: void 0 };
10066
10820
  return Object.freeze({
10067
- anilist: snapshotTransportState(this.stateOwners.anilist),
10068
- mal: snapshotTransportState(this.stateOwners.mal)
10821
+ anilist: snapshotTransportState(this.stateOwners.anilist, caches.anilist),
10822
+ mal: snapshotTransportState(this.stateOwners.mal, caches.mal)
10069
10823
  });
10070
10824
  }
10071
10825
  }
10072
10826
 
10073
- export { ANILIST_AUTHORIZE_URL, ANILIST_TOKEN_URL, AniLink, AniLinkApiError, AniLinkAuthError, AniLinkError, AniLinkErrorCodes, AniLinkGraphQLError, AniLinkNetworkError, AniLinkRestError, AniLinkValidationError, MAL_API_BASE_URL, MAL_API_REFERENCE, MAL_AUTHORIZE_URL, MAL_TOKEN_URL, ResponseCache, buildAuthorizationUrl, buildMalAuthorizationUrl, buildMyAnimeListApi, buildProviderClients, crossLink, destroyCachedAgents, fuzzyDate, fuzzyDateInt, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, malPaginate, malPaginatePages, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken, snapshotTransportState };
10827
+ export { ANILIST_AUTHORIZE_URL, ANILIST_TOKEN_URL, AniLink, AniLinkApiError, AniLinkAuthError, AniLinkError, AniLinkErrorCodes, AniLinkGraphQLError, AniLinkNetworkError, AniLinkRestError, AniLinkValidationError, MAL_API_BASE_URL, MAL_API_REFERENCE, MAL_AUTHORIZE_URL, MAL_TOKEN_URL, ResponseCache, buildAuthorizationUrl, buildMalAuthorizationUrl, buildMyAnimeListApi, buildProviderClients, crossLink, destroyCachedAgents, fuzzyDate, fuzzyDateInt, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, malPaginate, malPaginatePages, mapExternalIds, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken, snapshotTransportState };