@sevn/reqcache 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * 1. Primeira chamada -> faz o fetch e grava a entrada da rota no array.
14
14
  * 2. Durante o TTL -> devolve os dados do cache, sem tocar na rede.
15
15
  * 3. Após expirar -> SÓ revalida quando o client chamar de novo (lazy).
16
- * Se houver `monotonicKey`, só aceita o novo dado quando o valor numérico
17
- * dessa chave for MAIOR que o guardado; senão mantém o antigo.
16
+ * Se houver `monotonicKey`, só aceita o novo dado quando TODAS as chaves
17
+ * tiverem AVANÇADO; senão mantém o antigo.
18
18
  *
19
19
  * Proteção de espaço (limite de ~5 MB do localStorage):
20
20
  * - `maxEntries`: ao gravar, se passar do limite, remove a rota menos
@@ -31,13 +31,70 @@ interface StorageLike {
31
31
  setItem(key: string, value: string): void;
32
32
  removeItem(key: string): void;
33
33
  }
34
+ /**
35
+ * Como comparar o valor de uma chave monotônica:
36
+ * - `"number"` — numérica. Aceita `number` e string numérica ("2232575810").
37
+ * - `"date"` — por timestamp. Aceita o que o `Date.parse` resolver
38
+ * ("2026-10-04T18:23:00Z") e também epoch em ms.
39
+ * - `"string"` — lexicográfica.
40
+ */
41
+ type MonotonicType = "number" | "date" | "string";
42
+ /**
43
+ * Chave(s) monotônica(s). Três formas:
44
+ * - `"summary.ballots_counted"` — uma chave, tipo detectado pelo valor.
45
+ * - `["idg", "summary.last_updated"]` — várias, tipo detectado pelo valor.
46
+ * - `{ idg: "number", "summary.last_updated": "date" }` — várias, com o tipo
47
+ * declarado por nome de chave (recomendado: não depende de adivinhação).
48
+ */
49
+ type MonotonicKey = string | string[] | Record<string, MonotonicType>;
50
+ /**
51
+ * Qualidade de rede estimada pelo browser (Network Information API).
52
+ * É uma ESTIMATIVA baseada em latência e throughput recentes, não o rádio do
53
+ * aparelho: um 4g congestionado é reportado como "2g" — que é exatamente o
54
+ * caso que interessa aqui.
55
+ */
56
+ type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
57
+ /**
58
+ * Timeout de UMA tentativa de fetch, em ms, por qualidade de rede.
59
+ * `desconhecida` é o que vale em SSR e nos browsers sem a Network Information
60
+ * API (Safari e Firefox).
61
+ */
62
+ type TimeoutTiers = Partial<Record<EffectiveConnectionType | "desconhecida", number>>;
63
+ /**
64
+ * Falha de HTTP com o status preservado. É o que permite decidir se vale a
65
+ * pena tentar o próximo domínio (503, sim) ou não (404).
66
+ */
67
+ declare class HttpError extends Error {
68
+ readonly status: number;
69
+ readonly url: string;
70
+ constructor(status: number, url: string);
71
+ }
34
72
  interface GetFetchOptions {
35
73
  /**
36
- * Caminho da chave numérica monotônica. Suporta aninhamento com ponto.
37
- * Ex.: "total", "resultado.votosApurados", "data.candidato.votos".
38
- * Se o valor novo NÃO for maior que o antigo, mantém o dado em cache.
74
+ * Caminho(s) da chave monotônica. Suporta aninhamento com ponto.
75
+ *
76
+ * monotonicKey: "resultado.votosApurados"
77
+ * monotonicKey: ["idg", "summary.last_updated"]
78
+ * monotonicKey: { idg: "number", "summary.last_updated": "date" }
79
+ *
80
+ * Com mais de uma chave, TODAS são comparadas e o dado novo só é aceito
81
+ * quando todas concordam que ele é mais recente — se qualquer uma vier
82
+ * igual ou menor, a resposta é descartada e o dado em cache é mantido.
83
+ * A comparação é sempre estrita: o valor novo precisa ser MAIOR que o
84
+ * guardado, nunca igual.
85
+ *
86
+ * O tipo de cada chave é resolvido nesta ordem:
87
+ * 1. declarado na forma de mapa;
88
+ * 2. conhecido pelo NOME da chave (`idg` é número, `last_updated` é data —
89
+ * veja `TIPOS_POR_NOME`, extensível via `monotonicTypes` na config);
90
+ * 3. detectado pelo valor: número, string que converte para número finito,
91
+ * string que o `Date.parse` resolve e, por fim, lexicográfica.
92
+ *
93
+ * TODAS as chaves precisam ter crescido. Uma chave incomparável (ausente de
94
+ * um dos lados, ilegível ou fora do tipo esperado) conta como "não cresceu"
95
+ * e derruba a resposta nova junto com as demais.
39
96
  */
40
- monotonicKey?: string;
97
+ monotonicKey?: MonotonicKey;
41
98
  /** Opções nativas repassadas ao fetch (headers, method, body, signal...). */
42
99
  fetchOptions?: RequestInit;
43
100
  /**
@@ -58,6 +115,21 @@ interface GetFetchOptions {
58
115
  * (`url` + `fallbackUrls`). Útil para observabilidade/log.
59
116
  */
60
117
  onFallback?: (failedUrl: string, error: unknown) => void;
118
+ /**
119
+ * Timeout de CADA tentativa, em ms. Quando omitido, sai da tabela por rede
120
+ * (`timeoutTiers` na config). Vale por candidato, não pela fila toda: com
121
+ * dois `fallbackUrls`, o pior caso é 3x esse valor.
122
+ */
123
+ timeoutMs?: number;
124
+ /**
125
+ * Status HTTP que NÃO devem acionar o fallback. Padrão: `[404]`.
126
+ *
127
+ * Um 404 é uma resposta correta do servidor — "esse recurso não existe" —,
128
+ * não uma indisponibilidade. Repetir o mesmo caminho em outro domínio tende
129
+ * a devolver o mesmo 404, só que N vezes mais devagar, então o erro sobe na
130
+ * hora. Passe `[]` para voltar ao comportamento antigo (tentar todos).
131
+ */
132
+ statusSemFallback?: number[];
61
133
  }
62
134
  /** Uma entrada do cache. `rota` é a chave identificadora. */
63
135
  interface CacheEntry<T = unknown> {
@@ -88,6 +160,29 @@ interface RequestCacheConfig {
88
160
  debug?: boolean;
89
161
  /** Logger customizado usado quando `debug` está ligado. Padrão: `console.debug`. */
90
162
  logger?: DebugLogFn;
163
+ /**
164
+ * Tipos monotônicos adicionais, por nome de chave. Somado (e com prioridade
165
+ * sobre) a tabela embutida `TIPOS_POR_NOME`, para que o chamador possa
166
+ * continuar passando `monotonicKey` como lista de caminhos e ainda assim ter
167
+ * a comparação certa:
168
+ *
169
+ * new RequestCache({ monotonicTypes: { data_apuracao: "date" } })
170
+ * ...
171
+ * getFetch(url, 10_000, { monotonicKey: ["idg", "data_apuracao"] })
172
+ *
173
+ * Aceita o caminho completo ("summary.last_updated") ou só o último trecho
174
+ * ("last_updated").
175
+ */
176
+ monotonicTypes?: Record<string, MonotonicType>;
177
+ /**
178
+ * Timeout de cada tentativa de fetch, por qualidade de rede do client.
179
+ * O que for passado aqui é mesclado sobre `TIMEOUTS_POR_REDE`:
180
+ *
181
+ * new RequestCache({ timeoutTiers: { "slow-2g": 120_000 } })
182
+ *
183
+ * Um `timeoutMs` na chamada tem prioridade sobre esta tabela.
184
+ */
185
+ timeoutTiers?: TimeoutTiers;
91
186
  }
92
187
  declare class RequestCache {
93
188
  private storage;
@@ -98,6 +193,8 @@ declare class RequestCache {
98
193
  private tick;
99
194
  private debug;
100
195
  private logger;
196
+ private monotonicTypes;
197
+ private timeoutTiers;
101
198
  constructor(config?: RequestCacheConfig);
102
199
  /** Liga/desliga o modo debug em runtime, sem precisar recriar a instância. */
103
200
  setDebug(enabled: boolean): void;
@@ -122,10 +219,27 @@ declare class RequestCache {
122
219
  private nextAccess;
123
220
  /** Emite uma linha de log, só quando o modo debug está ligado. */
124
221
  private log;
222
+ /**
223
+ * Timeout de UMA tentativa, em ms. A ordem de prioridade é:
224
+ * 1. `timeoutMs` passado na chamada;
225
+ * 2. o tier da rede atual do client (`navigator.connection`);
226
+ * 3. o tier `desconhecida`, usado em SSR, Safari e Firefox.
227
+ *
228
+ * A rede é relida a cada tentativa de propósito: numa apuração o client pode
229
+ * sair do wi-fi e cair no 3g no meio da fila de fallback.
230
+ */
231
+ private resolverTimeout;
125
232
  /**
126
233
  * Tenta cada URL da lista em ordem (primário, depois os `fallbackUrls`).
127
234
  * Devolve o JSON da primeira que responder OK; se todas falharem, lança o
128
235
  * último erro.
236
+ *
237
+ * Duas situações encerram a fila antes do fim, em vez de seguir para o
238
+ * próximo candidato:
239
+ * - um status listado em `statusSemFallback` (padrão: 404). É resposta do
240
+ * servidor, não indisponibilidade — o outro domínio diria o mesmo.
241
+ * - o `signal` do chamador abortado. Foi cancelamento de quem pediu;
242
+ * insistir só trocaria o erro real pelo AbortError do último candidato.
129
243
  */
130
244
  private fetchComRedundancia;
131
245
  private revalidate;
@@ -136,8 +250,21 @@ declare class RequestCache {
136
250
  private readAll;
137
251
  private writeAll;
138
252
  }
253
+ /**
254
+ * Tier list de timeout por qualidade de rede, em ms, aplicado a CADA tentativa.
255
+ *
256
+ * 4g / desconhecida 30 s — base
257
+ * 3g 45 s — 1,5x
258
+ * 2g 60 s — 2x
259
+ * slow-2g 90 s — 3x; borda de cobertura, interior
260
+ *
261
+ * A folga cresce com a rede ruim para não matar uma resposta que ainda viria
262
+ * num client lento. O custo aparece em `fallbackUrls`, porque a fila é
263
+ * sequencial: o pior caso é N x o tier — veja `timeoutMs` em GetFetchOptions.
264
+ */
265
+ declare const TIMEOUTS_POR_REDE: Record<EffectiveConnectionType | "desconhecida", number>;
139
266
  /** Instância pronta para uso, caso não queira configurar nada. */
140
267
  declare const requestCache: RequestCache;
141
268
 
142
- export { RequestCache, requestCache };
143
- export type { CacheEntry, DebugLogFn, GetFetchOptions, RequestCacheConfig, StorageLike };
269
+ export { HttpError, RequestCache, TIMEOUTS_POR_REDE, requestCache };
270
+ export type { CacheEntry, DebugLogFn, EffectiveConnectionType, GetFetchOptions, MonotonicKey, MonotonicType, RequestCacheConfig, StorageLike, TimeoutTiers };
package/dist/index.esm.js CHANGED
@@ -13,8 +13,8 @@
13
13
  * 1. Primeira chamada -> faz o fetch e grava a entrada da rota no array.
14
14
  * 2. Durante o TTL -> devolve os dados do cache, sem tocar na rede.
15
15
  * 3. Após expirar -> SÓ revalida quando o client chamar de novo (lazy).
16
- * Se houver `monotonicKey`, só aceita o novo dado quando o valor numérico
17
- * dessa chave for MAIOR que o guardado; senão mantém o antigo.
16
+ * Se houver `monotonicKey`, só aceita o novo dado quando TODAS as chaves
17
+ * tiverem AVANÇADO; senão mantém o antigo.
18
18
  *
19
19
  * Proteção de espaço (limite de ~5 MB do localStorage):
20
20
  * - `maxEntries`: ao gravar, se passar do limite, remove a rota menos
@@ -26,9 +26,21 @@
26
26
  * - Deduplicação: chamadas simultâneas à mesma rota disparam 1 só fetch.
27
27
  * - staleOnError: se a rede/API falhar, devolve o último dado válido.
28
28
  */
29
+ /**
30
+ * Falha de HTTP com o status preservado. É o que permite decidir se vale a
31
+ * pena tentar o próximo domínio (503, sim) ou não (404).
32
+ */
33
+ class HttpError extends Error {
34
+ constructor(status, url) {
35
+ super(`HTTP ${status} ao buscar ${url}`);
36
+ this.status = status;
37
+ this.url = url;
38
+ this.name = "HttpError";
39
+ }
40
+ }
29
41
  class RequestCache {
30
42
  constructor(config = {}) {
31
- var _a, _b, _c, _d, _e;
43
+ var _a, _b, _c, _d, _e, _f, _g;
32
44
  this.inflight = new Map();
33
45
  /** Relógio de acesso monotônico: sempre cresce, mesmo com acessos no mesmo ms. */
34
46
  this.tick = 0;
@@ -37,6 +49,8 @@ class RequestCache {
37
49
  this.storage = (_c = config.storage) !== null && _c !== void 0 ? _c : getDefaultStorage();
38
50
  this.debug = (_d = config.debug) !== null && _d !== void 0 ? _d : false;
39
51
  this.logger = (_e = config.logger) !== null && _e !== void 0 ? _e : defaultLogger;
52
+ this.monotonicTypes = { ...TIPOS_POR_NOME, ...((_f = config.monotonicTypes) !== null && _f !== void 0 ? _f : {}) };
53
+ this.timeoutTiers = { ...TIMEOUTS_POR_REDE, ...((_g = config.timeoutTiers) !== null && _g !== void 0 ? _g : {}) };
40
54
  }
41
55
  /** Liga/desliga o modo debug em runtime, sem precisar recriar a instância. */
42
56
  setDebug(enabled) {
@@ -115,27 +129,78 @@ class RequestCache {
115
129
  return;
116
130
  this.logger(message, details);
117
131
  }
132
+ /**
133
+ * Timeout de UMA tentativa, em ms. A ordem de prioridade é:
134
+ * 1. `timeoutMs` passado na chamada;
135
+ * 2. o tier da rede atual do client (`navigator.connection`);
136
+ * 3. o tier `desconhecida`, usado em SSR, Safari e Firefox.
137
+ *
138
+ * A rede é relida a cada tentativa de propósito: numa apuração o client pode
139
+ * sair do wi-fi e cair no 3g no meio da fila de fallback.
140
+ */
141
+ resolverTimeout(options) {
142
+ var _a;
143
+ if (typeof options.timeoutMs === "number")
144
+ return options.timeoutMs;
145
+ return this.timeoutTiers[(_a = detectarRede()) !== null && _a !== void 0 ? _a : "desconhecida"];
146
+ }
118
147
  /**
119
148
  * Tenta cada URL da lista em ordem (primário, depois os `fallbackUrls`).
120
149
  * Devolve o JSON da primeira que responder OK; se todas falharem, lança o
121
150
  * último erro.
151
+ *
152
+ * Duas situações encerram a fila antes do fim, em vez de seguir para o
153
+ * próximo candidato:
154
+ * - um status listado em `statusSemFallback` (padrão: 404). É resposta do
155
+ * servidor, não indisponibilidade — o outro domínio diria o mesmo.
156
+ * - o `signal` do chamador abortado. Foi cancelamento de quem pediu;
157
+ * insistir só trocaria o erro real pelo AbortError do último candidato.
122
158
  */
123
159
  async fetchComRedundancia(candidatos, fetcher, options) {
124
- var _a;
160
+ var _a, _b, _c, _d, _e;
161
+ const semFallback = new Set((_a = options.statusSemFallback) !== null && _a !== void 0 ? _a : STATUS_SEM_FALLBACK_PADRAO);
162
+ const externo = (_c = (_b = options.fetchOptions) === null || _b === void 0 ? void 0 : _b.signal) !== null && _c !== void 0 ? _c : null;
125
163
  let ultimoErro;
126
164
  for (const candidato of candidatos) {
127
- this.log("fetch de rede", { url: candidato });
165
+ if (externo === null || externo === void 0 ? void 0 : externo.aborted) {
166
+ this.log("cancelado pelo chamador: fila interrompida", { url: candidato });
167
+ throw (_d = externo.reason) !== null && _d !== void 0 ? _d : new Error("Requisição cancelada pelo chamador");
168
+ }
169
+ const timeoutMs = this.resolverTimeout(options);
170
+ const tentativa = criarSinal(timeoutMs, externo);
171
+ this.log("fetch de rede", { url: candidato, timeoutMs, rede: detectarRede() });
128
172
  try {
129
- const res = await fetcher(candidato, options.fetchOptions);
173
+ const res = await fetcher(candidato, {
174
+ ...options.fetchOptions,
175
+ signal: tentativa.signal,
176
+ });
130
177
  if (!res.ok)
131
- throw new Error(`HTTP ${res.status} ao buscar ${candidato}`);
178
+ throw new HttpError(res.status, candidato);
179
+ const json = (await res.json());
132
180
  this.log("fetch OK", { url: candidato, status: res.status });
133
- return (await res.json());
181
+ return json;
134
182
  }
135
- catch (err) {
183
+ catch (bruto) {
184
+ // Um abort disparado pelo nosso timer chega aqui como AbortError
185
+ // genérico; troca por um erro que diz o que de fato aconteceu.
186
+ const err = tentativa.estourou()
187
+ ? erroDeTimeout(candidato, timeoutMs)
188
+ : bruto;
136
189
  this.log("fetch falhou", { url: candidato, erro: String(err) });
137
190
  ultimoErro = err;
138
- (_a = options.onFallback) === null || _a === void 0 ? void 0 : _a.call(options, candidato, err);
191
+ (_e = options.onFallback) === null || _e === void 0 ? void 0 : _e.call(options, candidato, err);
192
+ if (err instanceof HttpError && semFallback.has(err.status)) {
193
+ this.log("status sem fallback: fila interrompida", {
194
+ url: candidato,
195
+ status: err.status,
196
+ });
197
+ throw err;
198
+ }
199
+ }
200
+ finally {
201
+ // Obrigatório: sem isso sobra um timer de minutos pendurado por
202
+ // tentativa, segurando o event loop no Node e vazando no browser.
203
+ tentativa.cancelar();
139
204
  }
140
205
  }
141
206
  throw ultimoErro;
@@ -161,17 +226,15 @@ class RequestCache {
161
226
  }
162
227
  throw err;
163
228
  }
164
- // 3. Regra monotônica: só aceita o novo dado se a chave numérica CRESCEU.
229
+ // 3. Regra monotônica: só aceita o novo dado se TODAS as chaves que
230
+ // conseguiram votar tiverem avançado.
165
231
  if (cached && options.monotonicKey) {
166
- const oldVal = getPath(cached.data, options.monotonicKey);
167
- const newVal = getPath(fresh, options.monotonicKey);
168
- const ambosNumeros = typeof oldVal === "number" && typeof newVal === "number";
169
- if (ambosNumeros && newVal <= oldVal) {
170
- // Valor não aumentou -> mantém o dado antigo, só renova a expiração.
232
+ const veredito = avaliarMonotonicidade(cached.data, fresh, options.monotonicKey, this.monotonicTypes);
233
+ if (!veredito.avancou) {
234
+ // Alguma chave não avançou -> mantém o dado antigo, renova a expiração.
171
235
  this.log("regra monotônica: valor não cresceu, mantendo dado antigo", {
172
236
  url,
173
- oldVal,
174
- newVal,
237
+ chaves: veredito.detalhes,
175
238
  });
176
239
  this.upsert({
177
240
  ...cached,
@@ -261,6 +324,260 @@ function getPath(obj, path) {
261
324
  return undefined;
262
325
  }, obj);
263
326
  }
327
+ /**
328
+ * Descobre como comparar um valor. A ordem das checagens NÃO pode ser trocada:
329
+ * `Date.parse` aceita strings numéricas curtas como data (`Date.parse("9")` cai
330
+ * em setembro, `Date.parse("10")` em outubro), então um `idg` curto viraria
331
+ * data se a checagem de data viesse antes da numérica. No sentido inverso não
332
+ * há risco: `Number("2026-10-04T18:23:00Z")` é NaN.
333
+ *
334
+ * Devolve `null` quando o valor é incomparável.
335
+ */
336
+ function classificar(valor) {
337
+ if (typeof valor === "number") {
338
+ return Number.isFinite(valor) ? { tipo: "number", ordem: valor } : null;
339
+ }
340
+ if (typeof valor !== "string")
341
+ return null;
342
+ // String vazia ou em branco é incomparável: `Number("")` e `Number(" ")`
343
+ // devolvem 0, e um campo vazio viraria um "zero" comparável.
344
+ if (valor.trim() === "")
345
+ return null;
346
+ const numero = Number(valor);
347
+ if (Number.isFinite(numero))
348
+ return { tipo: "number", ordem: numero };
349
+ const data = Date.parse(valor);
350
+ if (!Number.isNaN(data))
351
+ return { tipo: "date", ordem: data };
352
+ return { tipo: "string", ordem: valor };
353
+ }
354
+ /**
355
+ * Classifica um valor segundo um tipo DECLARADO pelo chamador (forma de mapa).
356
+ * Como o tipo veio declarado, não há adivinhação: o valor ou serve, ou a chave
357
+ * é incomparável (e derruba a resposta nova). É também mais tolerante que a
358
+ * detecção por valor — com
359
+ * `"number"`, `100` e `"101"` comparam entre si sem problema, já que o
360
+ * chamador afirmou que aquele campo é numérico.
361
+ */
362
+ function classificarComTipo(valor, tipo) {
363
+ // String vazia ou em branco nunca é comparável: `Number("")` e `Number(" ")`
364
+ // devolvem 0, e um campo vazio viraria um "zero" comparável.
365
+ const texto = typeof valor === "string" ? valor.trim() : null;
366
+ if (texto === "")
367
+ return null;
368
+ if (tipo === "number") {
369
+ if (typeof valor === "number") {
370
+ return Number.isFinite(valor) ? { tipo, ordem: valor } : null;
371
+ }
372
+ if (texto === null)
373
+ return null;
374
+ const numero = Number(texto);
375
+ return Number.isFinite(numero) ? { tipo, ordem: numero } : null;
376
+ }
377
+ if (tipo === "date") {
378
+ // Um número aqui é lido como epoch em ms.
379
+ if (typeof valor === "number") {
380
+ return Number.isFinite(valor) ? { tipo, ordem: valor } : null;
381
+ }
382
+ if (texto === null)
383
+ return null;
384
+ const data = Date.parse(texto);
385
+ return Number.isNaN(data) ? null : { tipo, ordem: data };
386
+ }
387
+ return texto === null ? null : { tipo, ordem: texto };
388
+ }
389
+ /**
390
+ * Tipos conhecidos por NOME de chave. É o que permite ao front continuar
391
+ * passando só os caminhos — `["idg", "summary.last_updated"]` — e ainda assim
392
+ * ter `idg` comparado como número e `last_updated` como data, sem depender de
393
+ * adivinhação pelo valor.
394
+ *
395
+ * A busca é feita pelo caminho completo e, se não achar, pelo último trecho
396
+ * dele: "summary.last_updated" cai em "last_updated". Nomes fora desta tabela
397
+ * (e de `monotonicTypes`) continuam sendo detectados pelo valor.
398
+ */
399
+ const TIPOS_POR_NOME = {
400
+ idg: "number",
401
+ versao: "number",
402
+ ballots_counted: "number",
403
+ last_updated: "date",
404
+ };
405
+ /**
406
+ * Procura o tipo de uma chave na tabela de nomes: primeiro pelo caminho
407
+ * completo ("summary.last_updated"), depois só pelo último trecho
408
+ * ("last_updated").
409
+ */
410
+ function tipoConhecido(chave, tabela) {
411
+ if (chave in tabela)
412
+ return tabela[chave];
413
+ const ultimo = chave.slice(chave.lastIndexOf(".") + 1);
414
+ return ultimo in tabela ? tabela[ultimo] : null;
415
+ }
416
+ /**
417
+ * Reduz as três formas aceitas de `monotonicKey` a uma lista única, já com o
418
+ * tipo de cada chave resolvido: o declarado no mapa vence; senão vale o que a
419
+ * tabela de nomes souber; senão fica `null` e o tipo é detectado pelo valor.
420
+ */
421
+ function normalizarChaves(monotonicKey, tabela) {
422
+ const semTipo = (chave) => {
423
+ const conhecido = tipoConhecido(chave, tabela);
424
+ return conhecido
425
+ ? { chave, tipo: conhecido, origem: "conhecido" }
426
+ : { chave, tipo: null, origem: "detectado" };
427
+ };
428
+ if (typeof monotonicKey === "string")
429
+ return [semTipo(monotonicKey)];
430
+ if (Array.isArray(monotonicKey))
431
+ return monotonicKey.map(semTipo);
432
+ return Object.entries(monotonicKey).map(([chave, tipo]) => ({
433
+ chave,
434
+ tipo,
435
+ origem: "declarado",
436
+ }));
437
+ }
438
+ /** `a > b`, já sabendo que os dois lados são do mesmo tipo. */
439
+ function maior(a, b) {
440
+ return typeof a === "string" ? a > String(b) : a > Number(b);
441
+ }
442
+ /**
443
+ * Compara todas as chaves monotônicas entre o dado em cache e o dado novo.
444
+ *
445
+ * Verificação estrita: o dado novo só entra quando TODAS as chaves cresceram.
446
+ * Basta uma que não cresça para a resposta ser descartada — e uma chave
447
+ * incomparável (ausente de um dos lados, ilegível, fora do tipo esperado ou
448
+ * com tipos divergentes entre as versões) também não cresceu, então também
449
+ * derruba a resposta. Só entra o que comprovadamente cresceu em todas.
450
+ */
451
+ function avaliarMonotonicidade(oldData, newData, monotonicKey, tabela) {
452
+ const detalhes = [];
453
+ // Sem nenhuma chave configurada não há regra a aplicar, e o dado novo passa.
454
+ let todasCresceram = true;
455
+ // Avalia TODAS as chaves: nenhuma decide sozinha e nenhuma interrompe o laço,
456
+ // para que o log de debug mostre o estado de cada uma.
457
+ for (const { chave, tipo, origem } of normalizarChaves(monotonicKey, tabela)) {
458
+ const oldVal = getPath(oldData, chave);
459
+ const newVal = getPath(newData, chave);
460
+ const oldCmp = tipo ? classificarComTipo(oldVal, tipo) : classificar(oldVal);
461
+ const newCmp = tipo ? classificarComTipo(newVal, tipo) : classificar(newVal);
462
+ // Quando o tipo é conhecido, ele é quem manda — `100` e `"101"` sob
463
+ // "number" são o mesmo campo. Quando foi detectado pelo valor, tipos
464
+ // divergentes entre as versões (número de um lado, string do outro; string
465
+ // numérica vs. string de data) são sinal de que o campo mudou de forma.
466
+ const divergente = tipo === null &&
467
+ (typeof oldVal !== typeof newVal || (oldCmp === null || oldCmp === void 0 ? void 0 : oldCmp.tipo) !== (newCmp === null || newCmp === void 0 ? void 0 : newCmp.tipo));
468
+ // Incomparável não é "neutro": se a lib não consegue afirmar que a chave
469
+ // cresceu, ela não cresceu, e a resposta nova cai.
470
+ if (oldCmp === null || newCmp === null || divergente) {
471
+ todasCresceram = false;
472
+ detalhes.push({ chave, tipo: null, origem, oldVal, newVal, voto: "incomparável" });
473
+ continue;
474
+ }
475
+ const avancou = maior(newCmp.ordem, oldCmp.ordem);
476
+ if (!avancou)
477
+ todasCresceram = false;
478
+ detalhes.push({
479
+ chave,
480
+ tipo: newCmp.tipo,
481
+ origem,
482
+ oldVal,
483
+ newVal,
484
+ voto: avancou ? "avançou" : "não avançou",
485
+ });
486
+ }
487
+ return { avancou: todasCresceram, detalhes };
488
+ }
489
+ /** Status que, por padrão, NÃO acionam o fallback: o servidor já respondeu. */
490
+ const STATUS_SEM_FALLBACK_PADRAO = [404];
491
+ /**
492
+ * Tier list de timeout por qualidade de rede, em ms, aplicado a CADA tentativa.
493
+ *
494
+ * 4g / desconhecida 30 s — base
495
+ * 3g 45 s — 1,5x
496
+ * 2g 60 s — 2x
497
+ * slow-2g 90 s — 3x; borda de cobertura, interior
498
+ *
499
+ * A folga cresce com a rede ruim para não matar uma resposta que ainda viria
500
+ * num client lento. O custo aparece em `fallbackUrls`, porque a fila é
501
+ * sequencial: o pior caso é N x o tier — veja `timeoutMs` em GetFetchOptions.
502
+ */
503
+ const TIMEOUTS_POR_REDE = {
504
+ "slow-2g": 90000,
505
+ "2g": 60000,
506
+ "3g": 45000,
507
+ "4g": 30000,
508
+ desconhecida: 30000,
509
+ };
510
+ const REDES_CONHECIDAS = ["slow-2g", "2g", "3g", "4g"];
511
+ /**
512
+ * Lê `navigator.connection.effectiveType` (Network Information API).
513
+ * Só Chromium e Android implementam; Safari, Firefox e SSR devolvem `null` e
514
+ * caem no tier `desconhecida`.
515
+ */
516
+ function detectarRede() {
517
+ var _a, _b;
518
+ try {
519
+ if (typeof navigator === "undefined")
520
+ return null;
521
+ const nav = navigator;
522
+ const conexao = (_b = (_a = nav.connection) !== null && _a !== void 0 ? _a : nav.mozConnection) !== null && _b !== void 0 ? _b : nav.webkitConnection;
523
+ const tipo = conexao === null || conexao === void 0 ? void 0 : conexao.effectiveType;
524
+ if (!tipo)
525
+ return null;
526
+ return REDES_CONHECIDAS.indexOf(tipo) >= 0
527
+ ? tipo
528
+ : null;
529
+ }
530
+ catch (_c) {
531
+ return null;
532
+ }
533
+ }
534
+ /** Erro dedicado do timeout, para não subir como um `AbortError` sem contexto. */
535
+ function erroDeTimeout(url, timeoutMs) {
536
+ const err = new Error(`Timeout de ${timeoutMs}ms ao buscar ${url}`);
537
+ err.name = "TimeoutError";
538
+ return err;
539
+ }
540
+ /**
541
+ * Monta o `signal` de UMA tentativa: o timeout da vez encadeado com o `signal`
542
+ * que o chamador passou em `fetchOptions`, para que cancelar por fora continue
543
+ * funcionando.
544
+ *
545
+ * Feito na mão com `AbortController` em vez de `AbortSignal.any` +
546
+ * `AbortSignal.timeout`, que só existem em Safari 17.4+ e Node 20+. Também
547
+ * evita o problema de um `AbortSignal.timeout` passado pelo chamador: o
548
+ * relógio dele começa na criação, então viraria um orçamento da fila inteira,
549
+ * deixando os fallbacks sem tempo nenhum.
550
+ */
551
+ function criarSinal(timeoutMs, externo) {
552
+ if (!(timeoutMs > 0) || typeof AbortController === "undefined") {
553
+ return {
554
+ signal: externo !== null && externo !== void 0 ? externo : undefined,
555
+ estourou: () => false,
556
+ cancelar: () => { },
557
+ };
558
+ }
559
+ const ctrl = new AbortController();
560
+ let porTimeout = false;
561
+ const timer = setTimeout(() => {
562
+ porTimeout = true;
563
+ ctrl.abort();
564
+ }, timeoutMs);
565
+ const repassar = () => ctrl.abort();
566
+ if (externo) {
567
+ if (externo.aborted)
568
+ ctrl.abort();
569
+ else
570
+ externo.addEventListener("abort", repassar);
571
+ }
572
+ return {
573
+ signal: ctrl.signal,
574
+ estourou: () => porTimeout,
575
+ cancelar: () => {
576
+ clearTimeout(timer);
577
+ externo === null || externo === void 0 ? void 0 : externo.removeEventListener("abort", repassar);
578
+ },
579
+ };
580
+ }
264
581
  function isQuotaError(err) {
265
582
  return (err instanceof Error &&
266
583
  (err.name === "QuotaExceededError" ||
@@ -293,5 +610,5 @@ function getDefaultStorage() {
293
610
  /** Instância pronta para uso, caso não queira configurar nada. */
294
611
  const requestCache = new RequestCache();
295
612
 
296
- export { RequestCache, requestCache };
613
+ export { HttpError, RequestCache, TIMEOUTS_POR_REDE, requestCache };
297
614
  //# sourceMappingURL=index.esm.js.map