@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 +115 -1
- package/dist/index.d.ts +77 -7
- package/dist/index.esm.js +215 -11
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +215 -11
- package/dist/index.js.map +1 -1
- package/dist/request-cache.d.ts +76 -6
- package/package.json +1 -1
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
|
|
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
|
|
17
|
-
*
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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?:
|
|
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
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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, só 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 {
|