@sevn/reqcache 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,78 @@ const resultado = await requestCache.getFetch(
43
43
  Se, na revalidação, a API devolver um `votosApurados` **menor ou igual** ao
44
44
  guardado, a resposta nova é descartada e o dado antigo é mantido.
45
45
 
46
+ ### Várias chaves ao mesmo tempo
47
+
48
+ `monotonicKey` também aceita uma lista. Todas as chaves são comparadas e o
49
+ dado novo só entra quando **todas** cresceram:
50
+
51
+ ```ts
52
+ const resultado = await requestCache.getFetch(url, 10_000, {
53
+ monotonicKey: ["idg", "summary.last_updated"],
54
+ });
55
+ ```
56
+
57
+ Serve para quando a chave usada até então para de avançar (ex.: a apuração
58
+ termina e `ballots_counted` congela, mas o arquivo continua sendo atualizado
59
+ com as tags de eleito e 2º turno). Apontando também para campos que seguem
60
+ avançando, a tela não trava.
61
+
62
+ ### Como o tipo de cada chave é decidido
63
+
64
+ Nesta ordem:
65
+
66
+ 1. **Declarado** na chamada, se você usar a forma de mapa:
67
+ `monotonicKey: { idg: "number", "summary.last_updated": "date" }`.
68
+ 2. **Conhecido pelo nome da chave** — a lib já traz uma tabela:
69
+
70
+ | Nome | Tipo |
71
+ | ---- | ---- |
72
+ | `idg` | número |
73
+ | `versao` | número |
74
+ | `ballots_counted` | número |
75
+ | `last_updated` | data |
76
+
77
+ A busca é pelo caminho completo e, se não achar, pelo último trecho dele —
78
+ então `"summary.last_updated"` e `"dados.meta.last_updated"` caem em
79
+ `last_updated`. Para registrar os campos do seu projeto:
80
+
81
+ ```ts
82
+ const cache = new RequestCache({
83
+ monotonicTypes: { data_apuracao: "date", sequencial: "number" },
84
+ });
85
+ ```
86
+
87
+ 3. **Detectado pelo valor**, para nomes fora da tabela:
88
+
89
+ | Valor | Comparação |
90
+ | ----- | ---------- |
91
+ | `number` | numérica |
92
+ | string que vira número finito (`"2232575810"`) | numérica, não lexicográfica (evita `"9" > "10"`) |
93
+ | string que o `Date.parse` resolve (`"2026-10-04T18:23:00Z"`) | por timestamp |
94
+ | demais strings | lexicográfica |
95
+
96
+ A ordem não pode ser trocada: `Date.parse` aceita strings numéricas curtas
97
+ como data, então um id curto viraria data se a checagem de data viesse
98
+ primeiro. No sentido inverso não há risco —
99
+ `Number("2026-10-04T18:23:00Z")` é `NaN`.
100
+
101
+ A comparação é sempre **estrita**: o valor novo precisa ser maior que o
102
+ guardado, nunca igual.
103
+
104
+ **Todas as chaves precisam ter crescido.** Basta uma não crescer para a
105
+ resposta nova ser descartada e o cache mantido (só a expiração é renovada).
106
+
107
+ Uma chave **incomparável** conta como "não cresceu" e derruba a resposta junto
108
+ com as demais. Ela é incomparável quando está ausente de um dos lados, é
109
+ ilegível (string vazia ou em branco), não bate com o tipo conhecido/declarado
110
+ ou — só quando o tipo foi detectado pelo valor — tem tipos divergentes entre as
111
+ versões.
112
+
113
+ Consequência a considerar ao montar a lista: se um dos campos sumir ou for
114
+ renomeado pelo back, a lib passa a rejeitar toda resposta e a tela congela no
115
+ último dado válido. Aponte `monotonicKey` só para campos que você tem certeza
116
+ que vêm sempre preenchidos.
117
+
46
118
  ## Redundância entre domínios (fallback)
47
119
 
48
120
  Se o endpoint principal cair, a lib pode tentar automaticamente uma lista de
@@ -71,6 +143,37 @@ await requestCache.getFetch(url, 10_000, {
71
143
  });
72
144
  ```
73
145
 
146
+ ## Modo debug
147
+
148
+ Para ver exatamente o que a lib está fazendo — se uma chamada foi atendida
149
+ pelo cache, disparou uma requisição de rede, entrou em deduplicação, etc. —
150
+ ligue o modo debug:
151
+
152
+ ```ts
153
+ const cache = new RequestCache({ debug: true });
154
+
155
+ await cache.getFetch(url, 10_000); // imprime no console cada passo
156
+ ```
157
+
158
+ Útil para identificar se estão sendo feitas mais chamadas de rede do que o
159
+ necessário. Pode ser ligado/desligado em runtime, sem recriar a instância:
160
+
161
+ ```ts
162
+ cache.setDebug(true);
163
+ // ...
164
+ cache.setDebug(false);
165
+ ```
166
+
167
+ Por padrão os logs vão para `console.debug`, prefixados com `[reqcache]`. Para
168
+ mandar para outro lugar (ex.: seu sistema de logging), passe um `logger`:
169
+
170
+ ```ts
171
+ const cache = new RequestCache({
172
+ debug: true,
173
+ logger: (mensagem, detalhes) => meuLogger.debug(mensagem, detalhes),
174
+ });
175
+ ```
176
+
74
177
  ## Configuração
75
178
 
76
179
  ```ts
@@ -82,11 +185,22 @@ const cache = new RequestCache({
82
185
  });
83
186
  ```
84
187
 
188
+ ### Opções de `new RequestCache(config)`
189
+
190
+ | Opção | Tipo | Padrão | Descrição |
191
+ | ------------ | --------------------------- | ------------ | ----------------------------------------------------------------- |
192
+ | `storage` | `StorageLike` | localStorage | Storage customizado (permite trocar por IndexedDB, por exemplo). |
193
+ | `storageKey` | `string` | `"reqcache"` | Chave única onde o cache é guardado. |
194
+ | `maxEntries` | `number` | `100` | Máximo de rotas em cache; ao exceder, remove a menos usada (LRU). |
195
+ | `debug` | `boolean` | `false` | Liga logs detalhados de cada operação. Veja "Modo debug" acima. |
196
+ | `logger` | `(msg, detalhes) => void` | `console.debug` | Logger customizado usado quando `debug: true`. |
197
+ | `monotonicTypes` | `Record<string, "number"\|"date"\|"string">` | — | Tipos monotônicos por nome de chave, somados à tabela embutida. |
198
+
85
199
  ### Opções de `getFetch(url, ttlMs, options)`
86
200
 
87
201
  | Opção | Tipo | Padrão | Descrição |
88
202
  | -------------- | ------------- | ------ | ---------------------------------------------------------------- |
89
- | `monotonicKey` | `string` | — | Caminho da chave numérica ("a.b.c") que pode crescer. |
203
+ | `monotonicKey` | `string \| string[] \| Record<string, "number"\|"date"\|"string">` | — | Caminho(s) da chave que só pode crescer ("a.b.c"). Com mais de uma, todas precisam avançar. |
90
204
  | `fetchOptions` | `RequestInit` | — | Repassado ao `fetch` nativo (headers, method, signal...). |
91
205
  | `staleOnError` | `boolean` | `true` | Devolve o dado antigo se a revalidação falhar. |
92
206
  | `fetcher` | `typeof fetch`| `fetch`| `fetch` customizado (útil para testes). |
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,48 @@ 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>;
34
50
  interface GetFetchOptions {
35
51
  /**
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.
52
+ * Caminho(s) da chave monotônica. Suporta aninhamento com ponto.
53
+ *
54
+ * monotonicKey: "resultado.votosApurados"
55
+ * monotonicKey: ["idg", "summary.last_updated"]
56
+ * monotonicKey: { idg: "number", "summary.last_updated": "date" }
57
+ *
58
+ * Com mais de uma chave, TODAS são comparadas e o dado novo só é aceito
59
+ * quando todas concordam que ele é mais recente — se qualquer uma vier
60
+ * igual ou menor, a resposta é descartada e o dado em cache é mantido.
61
+ * A comparação é sempre estrita: o valor novo precisa ser MAIOR que o
62
+ * guardado, nunca igual.
63
+ *
64
+ * O tipo de cada chave é resolvido nesta ordem:
65
+ * 1. declarado na forma de mapa;
66
+ * 2. conhecido pelo NOME da chave (`idg` é número, `last_updated` é data —
67
+ * veja `TIPOS_POR_NOME`, extensível via `monotonicTypes` na config);
68
+ * 3. detectado pelo valor: número, string que converte para número finito,
69
+ * string que o `Date.parse` resolve e, por fim, lexicográfica.
70
+ *
71
+ * TODAS as chaves precisam ter crescido. Uma chave incomparável (ausente de
72
+ * um dos lados, ilegível ou fora do tipo esperado) conta como "não cresceu"
73
+ * e derruba a resposta nova junto com as demais.
39
74
  */
40
- monotonicKey?: string;
75
+ monotonicKey?: MonotonicKey;
41
76
  /** Opções nativas repassadas ao fetch (headers, method, body, signal...). */
42
77
  fetchOptions?: RequestInit;
43
78
  /**
@@ -67,6 +102,10 @@ interface CacheEntry<T = unknown> {
67
102
  expiresAt: number;
68
103
  lastAccess: number;
69
104
  }
105
+ /** Uma linha de log do modo debug. */
106
+ interface DebugLogFn {
107
+ (message: string, details?: Record<string, unknown>): void;
108
+ }
70
109
  interface RequestCacheConfig {
71
110
  /** Storage a usar. Padrão: localStorage (se disponível). */
72
111
  storage?: StorageLike;
@@ -74,6 +113,30 @@ interface RequestCacheConfig {
74
113
  storageKey?: string;
75
114
  /** Máximo de rotas em cache. Excedeu -> remove a menos usada (LRU). Padrão 100. */
76
115
  maxEntries?: number;
116
+ /**
117
+ * Se true, imprime logs detalhados de cada operação (fetch de rede,
118
+ * cache hit, deduplicação, fallback, LRU, etc.). Padrão false.
119
+ * Útil para identificar se estão sendo feitas mais chamadas de rede
120
+ * do que o necessário. Pode ser ligado/desligado em runtime com
121
+ * `setDebug()`.
122
+ */
123
+ debug?: boolean;
124
+ /** Logger customizado usado quando `debug` está ligado. Padrão: `console.debug`. */
125
+ logger?: DebugLogFn;
126
+ /**
127
+ * Tipos monotônicos adicionais, por nome de chave. Somado (e com prioridade
128
+ * sobre) a tabela embutida `TIPOS_POR_NOME`, para que o chamador possa
129
+ * continuar passando `monotonicKey` como lista de caminhos e ainda assim ter
130
+ * a comparação certa:
131
+ *
132
+ * new RequestCache({ monotonicTypes: { data_apuracao: "date" } })
133
+ * ...
134
+ * getFetch(url, 10_000, { monotonicKey: ["idg", "data_apuracao"] })
135
+ *
136
+ * Aceita o caminho completo ("summary.last_updated") ou só o último trecho
137
+ * ("last_updated").
138
+ */
139
+ monotonicTypes?: Record<string, MonotonicType>;
77
140
  }
78
141
  declare class RequestCache {
79
142
  private storage;
@@ -82,7 +145,12 @@ declare class RequestCache {
82
145
  private inflight;
83
146
  /** Relógio de acesso monotônico: sempre cresce, mesmo com acessos no mesmo ms. */
84
147
  private tick;
148
+ private debug;
149
+ private logger;
150
+ private monotonicTypes;
85
151
  constructor(config?: RequestCacheConfig);
152
+ /** Liga/desliga o modo debug em runtime, sem precisar recriar a instância. */
153
+ setDebug(enabled: boolean): void;
86
154
  /**
87
155
  * Busca uma URL usando cache.
88
156
  * @param url Rota da requisição (também é a chave do cache).
@@ -102,6 +170,8 @@ declare class RequestCache {
102
170
  clear(): void;
103
171
  /** Retorna um número de acesso estritamente crescente (para o LRU). */
104
172
  private nextAccess;
173
+ /** Emite uma linha de log, só quando o modo debug está ligado. */
174
+ private log;
105
175
  /**
106
176
  * Tenta cada URL da lista em ordem (primário, depois os `fallbackUrls`).
107
177
  * Devolve o JSON da primeira que responder OK; se todas falharem, lança o
@@ -120,4 +190,4 @@ declare class RequestCache {
120
190
  declare const requestCache: RequestCache;
121
191
 
122
192
  export { RequestCache, requestCache };
123
- export type { CacheEntry, GetFetchOptions, RequestCacheConfig, StorageLike };
193
+ export type { CacheEntry, DebugLogFn, GetFetchOptions, MonotonicKey, MonotonicType, RequestCacheConfig, StorageLike };
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
@@ -28,13 +28,20 @@
28
28
  */
29
29
  class RequestCache {
30
30
  constructor(config = {}) {
31
- var _a, _b, _c;
31
+ var _a, _b, _c, _d, _e, _f;
32
32
  this.inflight = new Map();
33
33
  /** Relógio de acesso monotônico: sempre cresce, mesmo com acessos no mesmo ms. */
34
34
  this.tick = 0;
35
35
  this.storageKey = (_a = config.storageKey) !== null && _a !== void 0 ? _a : "reqcache";
36
36
  this.maxEntries = (_b = config.maxEntries) !== null && _b !== void 0 ? _b : 100;
37
37
  this.storage = (_c = config.storage) !== null && _c !== void 0 ? _c : getDefaultStorage();
38
+ this.debug = (_d = config.debug) !== null && _d !== void 0 ? _d : false;
39
+ this.logger = (_e = config.logger) !== null && _e !== void 0 ? _e : defaultLogger;
40
+ this.monotonicTypes = { ...TIPOS_POR_NOME, ...((_f = config.monotonicTypes) !== null && _f !== void 0 ? _f : {}) };
41
+ }
42
+ /** Liga/desliga o modo debug em runtime, sem precisar recriar a instância. */
43
+ setDebug(enabled) {
44
+ this.debug = enabled;
38
45
  }
39
46
  /**
40
47
  * Busca uma URL usando cache.
@@ -50,13 +57,17 @@ class RequestCache {
50
57
  if (cached && now < cached.expiresAt) {
51
58
  cached.lastAccess = this.nextAccess(); // marca uso p/ o LRU
52
59
  this.writeAll(all);
60
+ this.log("cache hit", { url, expiresAt: new Date(cached.expiresAt).toISOString() });
53
61
  return cached.data;
54
62
  }
55
63
  // 2. Deduplicação: se já há uma requisição em andamento p/ essa rota,
56
64
  // todas as chamadas concorrentes aguardam a mesma Promise.
57
65
  const pending = this.inflight.get(url);
58
- if (pending)
66
+ if (pending) {
67
+ this.log("dedup: aguardando requisição em andamento", { url });
59
68
  return pending;
69
+ }
70
+ this.log(cached ? "cache expirado, revalidando" : "cache miss", { url });
60
71
  const promise = this.revalidate(url, ttlMs, options, cached !== null && cached !== void 0 ? cached : null)
61
72
  .finally(() => this.inflight.delete(url));
62
73
  this.inflight.set(url, promise);
@@ -77,7 +88,10 @@ class RequestCache {
77
88
  const all = this.readAll();
78
89
  const mantidas = all.filter((e) => e.expiresAt > limite);
79
90
  this.writeAll(mantidas);
80
- return all.length - mantidas.length;
91
+ const removidas = all.length - mantidas.length;
92
+ if (removidas > 0)
93
+ this.log("cleanup: rotas expiradas removidas", { removidas });
94
+ return removidas;
81
95
  }
82
96
  /** Esvazia todo o cache. */
83
97
  clear() {
@@ -96,6 +110,12 @@ class RequestCache {
96
110
  this.tick = Math.max(Date.now(), this.tick + 1);
97
111
  return this.tick;
98
112
  }
113
+ /** Emite uma linha de log, só quando o modo debug está ligado. */
114
+ log(message, details) {
115
+ if (!this.debug)
116
+ return;
117
+ this.logger(message, details);
118
+ }
99
119
  /**
100
120
  * Tenta cada URL da lista em ordem (primário, depois os `fallbackUrls`).
101
121
  * Devolve o JSON da primeira que responder OK; se todas falharem, lança o
@@ -105,13 +125,16 @@ class RequestCache {
105
125
  var _a;
106
126
  let ultimoErro;
107
127
  for (const candidato of candidatos) {
128
+ this.log("fetch de rede", { url: candidato });
108
129
  try {
109
130
  const res = await fetcher(candidato, options.fetchOptions);
110
131
  if (!res.ok)
111
132
  throw new Error(`HTTP ${res.status} ao buscar ${candidato}`);
133
+ this.log("fetch OK", { url: candidato, status: res.status });
112
134
  return (await res.json());
113
135
  }
114
136
  catch (err) {
137
+ this.log("fetch falhou", { url: candidato, erro: String(err) });
115
138
  ultimoErro = err;
116
139
  (_a = options.onFallback) === null || _a === void 0 ? void 0 : _a.call(options, candidato, err);
117
140
  }
@@ -129,6 +152,7 @@ class RequestCache {
129
152
  catch (err) {
130
153
  // Todos os domínios falharam. Se temos dado antigo e staleOnError, devolve o antigo.
131
154
  if (cached && ((_c = options.staleOnError) !== null && _c !== void 0 ? _c : true)) {
155
+ this.log("staleOnError: devolvendo dado antigo", { url });
132
156
  this.upsert({
133
157
  ...cached,
134
158
  expiresAt: Date.now() + ttlMs,
@@ -138,13 +162,16 @@ class RequestCache {
138
162
  }
139
163
  throw err;
140
164
  }
141
- // 3. Regra monotônica: só aceita o novo dado se a chave numérica CRESCEU.
165
+ // 3. Regra monotônica: só aceita o novo dado se TODAS as chaves que
166
+ // conseguiram votar tiverem avançado.
142
167
  if (cached && options.monotonicKey) {
143
- const oldVal = getPath(cached.data, options.monotonicKey);
144
- const newVal = getPath(fresh, options.monotonicKey);
145
- const ambosNumeros = typeof oldVal === "number" && typeof newVal === "number";
146
- if (ambosNumeros && newVal <= oldVal) {
147
- // Valor não aumentou -> mantém o dado antigo, só renova a expiração.
168
+ const veredito = avaliarMonotonicidade(cached.data, fresh, options.monotonicKey, this.monotonicTypes);
169
+ if (!veredito.avancou) {
170
+ // Alguma chave não avançou -> mantém o dado antigo, renova a expiração.
171
+ this.log("regra monotônica: valor não cresceu, mantendo dado antigo", {
172
+ url,
173
+ chaves: veredito.detalhes,
174
+ });
148
175
  this.upsert({
149
176
  ...cached,
150
177
  expiresAt: Date.now() + ttlMs,
@@ -155,6 +182,7 @@ class RequestCache {
155
182
  }
156
183
  // 4. Grava e devolve o dado novo.
157
184
  const now = Date.now();
185
+ this.log("cache gravado", { url, ttlMs });
158
186
  this.upsert({
159
187
  rota: url,
160
188
  data: fresh,
@@ -179,6 +207,7 @@ class RequestCache {
179
207
  if (all[i].lastAccess < all[idxMaisAntigo].lastAccess)
180
208
  idxMaisAntigo = i;
181
209
  }
210
+ this.log("LRU: removendo rota menos usada", { url: all[idxMaisAntigo].rota });
182
211
  all.splice(idxMaisAntigo, 1);
183
212
  }
184
213
  }
@@ -207,6 +236,10 @@ class RequestCache {
207
236
  if (isQuotaError(err) && all.length > 0) {
208
237
  const reduzido = [...all].sort((a, b) => a.lastAccess - b.lastAccess);
209
238
  reduzido.splice(0, Math.ceil(reduzido.length / 2)); // descarta metade
239
+ this.log("quota excedida: descartando metade das entradas mais antigas", {
240
+ totalAntes: all.length,
241
+ totalDepois: reduzido.length,
242
+ });
210
243
  try {
211
244
  this.storage.setItem(this.storageKey, JSON.stringify(reduzido));
212
245
  }
@@ -227,11 +260,182 @@ function getPath(obj, path) {
227
260
  return undefined;
228
261
  }, obj);
229
262
  }
263
+ /**
264
+ * Descobre como comparar um valor. A ordem das checagens NÃO pode ser trocada:
265
+ * `Date.parse` aceita strings numéricas curtas como data (`Date.parse("9")` cai
266
+ * em setembro, `Date.parse("10")` em outubro), então um `idg` curto viraria
267
+ * data se a checagem de data viesse antes da numérica. No sentido inverso não
268
+ * há risco: `Number("2026-10-04T18:23:00Z")` é NaN.
269
+ *
270
+ * Devolve `null` quando o valor é incomparável.
271
+ */
272
+ function classificar(valor) {
273
+ if (typeof valor === "number") {
274
+ return Number.isFinite(valor) ? { tipo: "number", ordem: valor } : null;
275
+ }
276
+ if (typeof valor !== "string")
277
+ return null;
278
+ // String vazia ou em branco é incomparável: `Number("")` e `Number(" ")`
279
+ // devolvem 0, e um campo vazio viraria um "zero" comparável.
280
+ if (valor.trim() === "")
281
+ return null;
282
+ const numero = Number(valor);
283
+ if (Number.isFinite(numero))
284
+ return { tipo: "number", ordem: numero };
285
+ const data = Date.parse(valor);
286
+ if (!Number.isNaN(data))
287
+ return { tipo: "date", ordem: data };
288
+ return { tipo: "string", ordem: valor };
289
+ }
290
+ /**
291
+ * Classifica um valor segundo um tipo DECLARADO pelo chamador (forma de mapa).
292
+ * Como o tipo veio declarado, não há adivinhação: o valor ou serve, ou a chave
293
+ * é incomparável (e derruba a resposta nova). É também mais tolerante que a
294
+ * detecção por valor — com
295
+ * `"number"`, `100` e `"101"` comparam entre si sem problema, já que o
296
+ * chamador afirmou que aquele campo é numérico.
297
+ */
298
+ function classificarComTipo(valor, tipo) {
299
+ // String vazia ou em branco nunca é comparável: `Number("")` e `Number(" ")`
300
+ // devolvem 0, e um campo vazio viraria um "zero" comparável.
301
+ const texto = typeof valor === "string" ? valor.trim() : null;
302
+ if (texto === "")
303
+ return null;
304
+ if (tipo === "number") {
305
+ if (typeof valor === "number") {
306
+ return Number.isFinite(valor) ? { tipo, ordem: valor } : null;
307
+ }
308
+ if (texto === null)
309
+ return null;
310
+ const numero = Number(texto);
311
+ return Number.isFinite(numero) ? { tipo, ordem: numero } : null;
312
+ }
313
+ if (tipo === "date") {
314
+ // Um número aqui é lido como epoch em ms.
315
+ if (typeof valor === "number") {
316
+ return Number.isFinite(valor) ? { tipo, ordem: valor } : null;
317
+ }
318
+ if (texto === null)
319
+ return null;
320
+ const data = Date.parse(texto);
321
+ return Number.isNaN(data) ? null : { tipo, ordem: data };
322
+ }
323
+ return texto === null ? null : { tipo, ordem: texto };
324
+ }
325
+ /**
326
+ * Tipos conhecidos por NOME de chave. É o que permite ao front continuar
327
+ * passando só os caminhos — `["idg", "summary.last_updated"]` — e ainda assim
328
+ * ter `idg` comparado como número e `last_updated` como data, sem depender de
329
+ * adivinhação pelo valor.
330
+ *
331
+ * A busca é feita pelo caminho completo e, se não achar, pelo último trecho
332
+ * dele: "summary.last_updated" cai em "last_updated". Nomes fora desta tabela
333
+ * (e de `monotonicTypes`) continuam sendo detectados pelo valor.
334
+ */
335
+ const TIPOS_POR_NOME = {
336
+ idg: "number",
337
+ versao: "number",
338
+ ballots_counted: "number",
339
+ last_updated: "date",
340
+ };
341
+ /**
342
+ * Procura o tipo de uma chave na tabela de nomes: primeiro pelo caminho
343
+ * completo ("summary.last_updated"), depois só pelo último trecho
344
+ * ("last_updated").
345
+ */
346
+ function tipoConhecido(chave, tabela) {
347
+ if (chave in tabela)
348
+ return tabela[chave];
349
+ const ultimo = chave.slice(chave.lastIndexOf(".") + 1);
350
+ return ultimo in tabela ? tabela[ultimo] : null;
351
+ }
352
+ /**
353
+ * Reduz as três formas aceitas de `monotonicKey` a uma lista única, já com o
354
+ * tipo de cada chave resolvido: o declarado no mapa vence; senão vale o que a
355
+ * tabela de nomes souber; senão fica `null` e o tipo é detectado pelo valor.
356
+ */
357
+ function normalizarChaves(monotonicKey, tabela) {
358
+ const semTipo = (chave) => {
359
+ const conhecido = tipoConhecido(chave, tabela);
360
+ return conhecido
361
+ ? { chave, tipo: conhecido, origem: "conhecido" }
362
+ : { chave, tipo: null, origem: "detectado" };
363
+ };
364
+ if (typeof monotonicKey === "string")
365
+ return [semTipo(monotonicKey)];
366
+ if (Array.isArray(monotonicKey))
367
+ return monotonicKey.map(semTipo);
368
+ return Object.entries(monotonicKey).map(([chave, tipo]) => ({
369
+ chave,
370
+ tipo,
371
+ origem: "declarado",
372
+ }));
373
+ }
374
+ /** `a > b`, já sabendo que os dois lados são do mesmo tipo. */
375
+ function maior(a, b) {
376
+ return typeof a === "string" ? a > String(b) : a > Number(b);
377
+ }
378
+ /**
379
+ * Compara todas as chaves monotônicas entre o dado em cache e o dado novo.
380
+ *
381
+ * Verificação estrita: o dado novo só entra quando TODAS as chaves cresceram.
382
+ * Basta uma que não cresça para a resposta ser descartada — e uma chave
383
+ * incomparável (ausente de um dos lados, ilegível, fora do tipo esperado ou
384
+ * com tipos divergentes entre as versões) também não cresceu, então também
385
+ * derruba a resposta. Só entra o que comprovadamente cresceu em todas.
386
+ */
387
+ function avaliarMonotonicidade(oldData, newData, monotonicKey, tabela) {
388
+ const detalhes = [];
389
+ // Sem nenhuma chave configurada não há regra a aplicar, e o dado novo passa.
390
+ let todasCresceram = true;
391
+ // Avalia TODAS as chaves: nenhuma decide sozinha e nenhuma interrompe o laço,
392
+ // para que o log de debug mostre o estado de cada uma.
393
+ for (const { chave, tipo, origem } of normalizarChaves(monotonicKey, tabela)) {
394
+ const oldVal = getPath(oldData, chave);
395
+ const newVal = getPath(newData, chave);
396
+ const oldCmp = tipo ? classificarComTipo(oldVal, tipo) : classificar(oldVal);
397
+ const newCmp = tipo ? classificarComTipo(newVal, tipo) : classificar(newVal);
398
+ // Quando o tipo é conhecido, ele é quem manda — `100` e `"101"` sob
399
+ // "number" são o mesmo campo. Quando foi detectado pelo valor, tipos
400
+ // divergentes entre as versões (número de um lado, string do outro; string
401
+ // numérica vs. string de data) são sinal de que o campo mudou de forma.
402
+ const divergente = tipo === null &&
403
+ (typeof oldVal !== typeof newVal || (oldCmp === null || oldCmp === void 0 ? void 0 : oldCmp.tipo) !== (newCmp === null || newCmp === void 0 ? void 0 : newCmp.tipo));
404
+ // Incomparável não é "neutro": se a lib não consegue afirmar que a chave
405
+ // cresceu, ela não cresceu, e a resposta nova cai.
406
+ if (oldCmp === null || newCmp === null || divergente) {
407
+ todasCresceram = false;
408
+ detalhes.push({ chave, tipo: null, origem, oldVal, newVal, voto: "incomparável" });
409
+ continue;
410
+ }
411
+ const avancou = maior(newCmp.ordem, oldCmp.ordem);
412
+ if (!avancou)
413
+ todasCresceram = false;
414
+ detalhes.push({
415
+ chave,
416
+ tipo: newCmp.tipo,
417
+ origem,
418
+ oldVal,
419
+ newVal,
420
+ voto: avancou ? "avançou" : "não avançou",
421
+ });
422
+ }
423
+ return { avancou: todasCresceram, detalhes };
424
+ }
230
425
  function isQuotaError(err) {
231
426
  return (err instanceof Error &&
232
427
  (err.name === "QuotaExceededError" ||
233
428
  err.name === "NS_ERROR_DOM_QUOTA_REACHED"));
234
429
  }
430
+ /** Logger padrão do modo debug: imprime no console com um prefixo fixo. */
431
+ function defaultLogger(message, details) {
432
+ if (details) {
433
+ console.debug(`[reqcache] ${message}`, details);
434
+ }
435
+ else {
436
+ console.debug(`[reqcache] ${message}`);
437
+ }
438
+ }
235
439
  /** Retorna localStorage se existir e funcionar; senão null (SSR, modo privado...). */
236
440
  function getDefaultStorage() {
237
441
  try {