redmine-context 1.0.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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Implementação em DISCO do {@link CacheStore} (M3-02, ADR-004).
3
+ *
4
+ * Persiste cada entrada como um arquivo JSON num layout isolado por instância:
5
+ *
6
+ * - attachment: `<cache_dir>/<instance_hash>/attachments/<id>-<digest8>/<hash>.json`
7
+ * - issue: `<cache_dir>/issues/<issue_id>/<hash>.json`
8
+ *
9
+ * O `<id>-<digest8>/` segue exatamente o path do ADR-004; `<hash>` é o SHA-256
10
+ * (hex) da chave serializada, garantindo que configurações de extrator distintas
11
+ * (versão/modelo/params) coexistam sob o mesmo anexo sem colidir. Nomes de
12
+ * arquivo/diretório derivam APENAS de `id`/`digest`/`hash` — nunca de conteúdo
13
+ * externo não sanitizado — pois todos esses campos são hex ou inteiros do
14
+ * contrato ({@link serializeCacheKey}).
15
+ *
16
+ * O `cache_dir` padrão vem de `env-paths` (diretório de cache do usuário por SO),
17
+ * com override pela opção {@link DiskCacheStoreOptions.cacheDir} (que o config/a
18
+ * CLI pode preencher). Diretórios são criados SOB DEMANDA no primeiro `put`.
19
+ *
20
+ * Atomicidade: cada `put` escreve num arquivo `.tmp` único e só então faz
21
+ * `rename` para o destino — o `rename` é atômico no mesmo filesystem, de modo que
22
+ * um leitor nunca observa um JSON parcial (crash-safety intra-arquivo).
23
+ *
24
+ * ESCOPO (nota da suíte de contrato, review #129): o lock por chave é HERDADO da
25
+ * implementação in-memory ({@link InMemoryCacheStore}) e vale apenas INTRA-processo
26
+ * — locking CROSS-processo NÃO é escopo desta issue (fica para uma issue de lock
27
+ * de disco). GC/LRU e índice de entradas são #45/#46: {@link gc} apenas aciona o
28
+ * hook configurado (no-op quando ausente).
29
+ */
30
+ import { type CacheKey, type CacheStore, type CacheStoreOptions } from './contract.js';
31
+ import { type CacheEntryType } from './disk-index.js';
32
+ /** Opções do {@link DiskCacheStore}. */
33
+ export interface DiskCacheStoreOptions extends CacheStoreOptions {
34
+ /**
35
+ * Raiz do cache em disco. Default: diretório de cache do usuário via
36
+ * `env-paths` ({@link defaultCacheDir}). Preenchível por config/CLI ou testes.
37
+ */
38
+ cacheDir?: string;
39
+ /**
40
+ * Teto GLOBAL do cache em bytes para o GC/LRU (#47, ADR-004). Ao ser estourado
41
+ * após um `put` ou num `gc()`, o store despeja entradas por `last_accessed_at`
42
+ * com quotas separadas: originais (recuperáveis) de forma agressiva, extrações
43
+ * (caras) só quando necessário. Default {@link DEFAULT_MAX_BYTES} (2 GB);
44
+ * `Infinity` desabilita o despejo.
45
+ */
46
+ maxBytes?: number;
47
+ }
48
+ /**
49
+ * Raiz de cache padrão do projeto (diretório de cache do usuário por SO, via
50
+ * `env-paths`). Ex.: `~/Library/Caches/redmine-context` no macOS.
51
+ *
52
+ * @returns Caminho absoluto do diretório de cache padrão.
53
+ */
54
+ export declare function defaultCacheDir(): string;
55
+ /**
56
+ * {@link CacheStore} persistente em disco (ADR-004). Entradas sobrevivem à
57
+ * reinicialização do processo: uma nova instância apontando para o mesmo
58
+ * `cacheDir` enxerga tudo o que foi gravado.
59
+ *
60
+ * @typeParam V - Tipo do valor cacheado (serializado como JSON).
61
+ */
62
+ export declare class DiskCacheStore<V = unknown> implements CacheStore<V> {
63
+ private readonly root;
64
+ private readonly onGc;
65
+ /** Teto global (bytes) do GC/LRU; `Infinity` desabilita o despejo (#47). */
66
+ private readonly maxBytes;
67
+ /**
68
+ * Total de bytes indexados conhecido (curto-circuito do GC no put — review
69
+ * #137). `undefined` = ainda não computado: a primeira passada completa
70
+ * inicializa; depois, puts/invalidates/evictions mantêm incrementalmente.
71
+ */
72
+ private knownBytes;
73
+ /**
74
+ * Provedor de lock por chave — HERDADO do in-memory (intra-processo). Não é
75
+ * usado para armazenar valores, apenas para serializar seções críticas por
76
+ * chave com o mesmo comportamento (stale-lock TTL, aviso) do contrato.
77
+ */
78
+ private readonly locks;
79
+ private readonly logger;
80
+ /** Índice por instância (M3-05.1) — alimenta o GC/LRU (#47). */
81
+ private readonly index;
82
+ /** @param options - Ver {@link DiskCacheStoreOptions}. */
83
+ constructor(options?: DiskCacheStoreOptions);
84
+ /** @inheritdoc */
85
+ get(key: CacheKey): Promise<V | undefined>;
86
+ /**
87
+ * @inheritdoc
88
+ * @param options - `type` classifica a entrada no índice por instância
89
+ * (`extraction` default; `original` para bytes baixados) — quotas do GC #47.
90
+ */
91
+ put(key: CacheKey, value: V, options?: {
92
+ type?: CacheEntryType;
93
+ }): Promise<void>;
94
+ /** @inheritdoc */
95
+ invalidate(key: CacheKey): Promise<void>;
96
+ /** @inheritdoc */
97
+ lock<T>(key: CacheKey, critical: () => Promise<T>): Promise<T>;
98
+ /** @inheritdoc */
99
+ gc(): Promise<void>;
100
+ /** Diretórios de instância existentes no disco (exclui `issues/`). */
101
+ private listInstanceDirs;
102
+ /**
103
+ * Resolve o caminho absoluto do arquivo de valor de uma chave, seguindo o
104
+ * layout do ADR-004. O nome do arquivo é o SHA-256 hex da chave serializada,
105
+ * distinguindo configurações de extrator sob o mesmo `<id>-<digest8>/`.
106
+ */
107
+ /** Partes do caminho de uma chave attachment: dir `<id>-<digest8>` e arquivo. */
108
+ private attachmentParts;
109
+ private pathFor;
110
+ /**
111
+ * Executa o GC/LRU (#47): despeja o necessário para respeitar {@link maxBytes}
112
+ * (com quotas separadas — ver {@link planGc}) e, em seguida, aciona o hook de
113
+ * observação, se configurado, com a contagem REAL de entradas PÓS-despejo.
114
+ */
115
+ private runGc;
116
+ /**
117
+ * Coleta as entradas indexadas de TODAS as instâncias, aplica a política pura
118
+ * ({@link planGc}) e remove (arquivo + registro) o que ela decidir. No-op
119
+ * quando o teto é `Infinity` (despejo desabilitado) ou nada excede o limite.
120
+ */
121
+ private enforceQuotas;
122
+ /** Ajusta o total conhecido após uma escrita/remoção pontual. */
123
+ private adjustKnownBytes;
124
+ /**
125
+ * Remove com segurança UMA entrada de attachment: apaga o arquivo de valor
126
+ * (apenas se resolver DENTRO de `<cache_dir>/<instance_hash>/` — guard
127
+ * anti-traversal do {@link resolveEvictionTarget}) e tira o registro do índice,
128
+ * mantendo o `index.json` consistente pós-GC.
129
+ */
130
+ private evict;
131
+ /** Conta recursivamente os arquivos de valor (`.json`) sob `dir`. */
132
+ private countEntries;
133
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Implementação em DISCO do {@link CacheStore} (M3-02, ADR-004).
3
+ *
4
+ * Persiste cada entrada como um arquivo JSON num layout isolado por instância:
5
+ *
6
+ * - attachment: `<cache_dir>/<instance_hash>/attachments/<id>-<digest8>/<hash>.json`
7
+ * - issue: `<cache_dir>/issues/<issue_id>/<hash>.json`
8
+ *
9
+ * O `<id>-<digest8>/` segue exatamente o path do ADR-004; `<hash>` é o SHA-256
10
+ * (hex) da chave serializada, garantindo que configurações de extrator distintas
11
+ * (versão/modelo/params) coexistam sob o mesmo anexo sem colidir. Nomes de
12
+ * arquivo/diretório derivam APENAS de `id`/`digest`/`hash` — nunca de conteúdo
13
+ * externo não sanitizado — pois todos esses campos são hex ou inteiros do
14
+ * contrato ({@link serializeCacheKey}).
15
+ *
16
+ * O `cache_dir` padrão vem de `env-paths` (diretório de cache do usuário por SO),
17
+ * com override pela opção {@link DiskCacheStoreOptions.cacheDir} (que o config/a
18
+ * CLI pode preencher). Diretórios são criados SOB DEMANDA no primeiro `put`.
19
+ *
20
+ * Atomicidade: cada `put` escreve num arquivo `.tmp` único e só então faz
21
+ * `rename` para o destino — o `rename` é atômico no mesmo filesystem, de modo que
22
+ * um leitor nunca observa um JSON parcial (crash-safety intra-arquivo).
23
+ *
24
+ * ESCOPO (nota da suíte de contrato, review #129): o lock por chave é HERDADO da
25
+ * implementação in-memory ({@link InMemoryCacheStore}) e vale apenas INTRA-processo
26
+ * — locking CROSS-processo NÃO é escopo desta issue (fica para uma issue de lock
27
+ * de disco). GC/LRU e índice de entradas são #45/#46: {@link gc} apenas aciona o
28
+ * hook configurado (no-op quando ausente).
29
+ */
30
+ import { createHash, randomBytes } from 'node:crypto';
31
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
32
+ import { dirname, join } from 'node:path';
33
+ import envPaths from 'env-paths';
34
+ import { serializeCacheKey, } from './contract.js';
35
+ import { InMemoryCacheStore } from './memory.js';
36
+ import { DiskCacheIndex, INDEX_FILE_NAME } from './disk-index.js';
37
+ import { DEFAULT_MAX_BYTES, planGc, resolveEvictionTarget } from './gc.js';
38
+ /** Comprimento (chars hex) do digest usado no path do anexo (ADR-004). */
39
+ const DIGEST_PATH_LENGTH = 8;
40
+ /** Subdiretório da camada de issue (bundle/metadados baratos). */
41
+ const ISSUES_DIR = 'issues';
42
+ /** Subdiretório da camada de attachment sob cada instância. */
43
+ const ATTACHMENTS_DIR = 'attachments';
44
+ /** Extensão dos arquivos de valor persistidos. */
45
+ const VALUE_EXTENSION = '.json';
46
+ /**
47
+ * Raiz de cache padrão do projeto (diretório de cache do usuário por SO, via
48
+ * `env-paths`). Ex.: `~/Library/Caches/redmine-context` no macOS.
49
+ *
50
+ * @returns Caminho absoluto do diretório de cache padrão.
51
+ */
52
+ export function defaultCacheDir() {
53
+ return envPaths('redmine-context').cache;
54
+ }
55
+ /**
56
+ * {@link CacheStore} persistente em disco (ADR-004). Entradas sobrevivem à
57
+ * reinicialização do processo: uma nova instância apontando para o mesmo
58
+ * `cacheDir` enxerga tudo o que foi gravado.
59
+ *
60
+ * @typeParam V - Tipo do valor cacheado (serializado como JSON).
61
+ */
62
+ export class DiskCacheStore {
63
+ root;
64
+ onGc;
65
+ /** Teto global (bytes) do GC/LRU; `Infinity` desabilita o despejo (#47). */
66
+ maxBytes;
67
+ /**
68
+ * Total de bytes indexados conhecido (curto-circuito do GC no put — review
69
+ * #137). `undefined` = ainda não computado: a primeira passada completa
70
+ * inicializa; depois, puts/invalidates/evictions mantêm incrementalmente.
71
+ */
72
+ knownBytes;
73
+ /**
74
+ * Provedor de lock por chave — HERDADO do in-memory (intra-processo). Não é
75
+ * usado para armazenar valores, apenas para serializar seções críticas por
76
+ * chave com o mesmo comportamento (stale-lock TTL, aviso) do contrato.
77
+ */
78
+ locks;
79
+ logger;
80
+ /** Índice por instância (M3-05.1) — alimenta o GC/LRU (#47). */
81
+ index;
82
+ /** @param options - Ver {@link DiskCacheStoreOptions}. */
83
+ constructor(options = {}) {
84
+ this.root = options.cacheDir ?? defaultCacheDir();
85
+ this.onGc = options.onGc;
86
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
87
+ this.logger = options.logger ?? { warn: () => undefined };
88
+ this.index = new DiskCacheIndex(this.root, this.logger);
89
+ // Repassa apenas as opções de lock; o GC é responsabilidade deste store.
90
+ this.locks = new InMemoryCacheStore({
91
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
92
+ ...(options.staleLockTtlMs !== undefined ? { staleLockTtlMs: options.staleLockTtlMs } : {}),
93
+ });
94
+ }
95
+ /** @inheritdoc */
96
+ async get(key) {
97
+ let raw;
98
+ try {
99
+ raw = await readFile(this.pathFor(key), 'utf8');
100
+ }
101
+ catch (cause) {
102
+ if (isNotFound(cause)) {
103
+ return undefined;
104
+ }
105
+ throw cause;
106
+ }
107
+ if (key.kind === 'attachment') {
108
+ const parts = this.attachmentParts(key);
109
+ this.index.recordAccess(key.instanceHash, join(parts.dirName, parts.fileName));
110
+ }
111
+ try {
112
+ return JSON.parse(raw);
113
+ }
114
+ catch {
115
+ // Arquivo corrompido (ex.: crash antes de um rename antigo): trata como
116
+ // cache-miss auto-curável — o próximo put sobrescreve. Nunca crasha nem
117
+ // se confunde com erro de IO real (fix review #131).
118
+ this.logger.warn(`cache: entrada corrompida ignorada (${this.pathFor(key)})`);
119
+ return undefined;
120
+ }
121
+ }
122
+ /**
123
+ * @inheritdoc
124
+ * @param options - `type` classifica a entrada no índice por instância
125
+ * (`extraction` default; `original` para bytes baixados) — quotas do GC #47.
126
+ */
127
+ async put(key, value, options = {}) {
128
+ const filePath = this.pathFor(key);
129
+ await mkdir(dirname(filePath), { recursive: true });
130
+ // Escreve-e-renomeia: o destino só passa a existir de forma completa após o
131
+ // rename atômico; leitores nunca observam um JSON pela metade.
132
+ const serialized = JSON.stringify(value);
133
+ const tmpPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`;
134
+ await writeFile(tmpPath, serialized);
135
+ await rename(tmpPath, filePath);
136
+ if (key.kind === 'attachment') {
137
+ const parts = this.attachmentParts(key);
138
+ const size = Buffer.byteLength(serialized);
139
+ const previousSize = await this.index.recordPut(key.instanceHash, join(parts.dirName, parts.fileName), {
140
+ key: serializeCacheKey(key),
141
+ size,
142
+ type: options.type ?? 'extraction',
143
+ lastAccessedAt: new Date().toISOString(),
144
+ });
145
+ this.adjustKnownBytes(size - previousSize);
146
+ }
147
+ await this.runGc('put');
148
+ }
149
+ /** @inheritdoc */
150
+ async invalidate(key) {
151
+ await rm(this.pathFor(key), { force: true });
152
+ if (key.kind === 'attachment') {
153
+ const parts = this.attachmentParts(key);
154
+ const removedSize = await this.index.remove(key.instanceHash, join(parts.dirName, parts.fileName));
155
+ this.adjustKnownBytes(-removedSize);
156
+ }
157
+ }
158
+ /** @inheritdoc */
159
+ async lock(key, critical) {
160
+ return this.locks.lock(key, critical);
161
+ }
162
+ /** @inheritdoc */
163
+ async gc() {
164
+ // Flush do buffer de last_accessed_at (M3-05.1) antes de observar o estado,
165
+ // e reconciliação de órfãos (crash entre rename e recordPut — review #135).
166
+ await this.index.flushAll();
167
+ for (const instanceHash of await this.listInstanceDirs()) {
168
+ await this.index.reconcile(instanceHash);
169
+ }
170
+ await this.runGc('manual');
171
+ }
172
+ /** Diretórios de instância existentes no disco (exclui `issues/`). */
173
+ async listInstanceDirs() {
174
+ let entries;
175
+ try {
176
+ entries = await readdir(this.root, { withFileTypes: true });
177
+ }
178
+ catch (cause) {
179
+ if (isNotFound(cause)) {
180
+ return [];
181
+ }
182
+ throw cause;
183
+ }
184
+ return entries.filter((e) => e.isDirectory() && e.name !== ISSUES_DIR).map((e) => e.name);
185
+ }
186
+ /**
187
+ * Resolve o caminho absoluto do arquivo de valor de uma chave, seguindo o
188
+ * layout do ADR-004. O nome do arquivo é o SHA-256 hex da chave serializada,
189
+ * distinguindo configurações de extrator sob o mesmo `<id>-<digest8>/`.
190
+ */
191
+ /** Partes do caminho de uma chave attachment: dir `<id>-<digest8>` e arquivo. */
192
+ attachmentParts(key) {
193
+ const fileName = createHash('sha256').update(serializeCacheKey(key)).digest('hex') + VALUE_EXTENSION;
194
+ const hexDigest = /^[0-9a-fA-F]{8,64}$/.test(key.digest)
195
+ ? key.digest.toLowerCase()
196
+ : createHash('sha256').update(key.digest).digest('hex');
197
+ return { dirName: `${key.attachmentId}-${hexDigest.slice(0, DIGEST_PATH_LENGTH)}`, fileName };
198
+ }
199
+ pathFor(key) {
200
+ const fileName = createHash('sha256').update(serializeCacheKey(key)).digest('hex') + VALUE_EXTENSION;
201
+ if (key.kind === 'attachment') {
202
+ // O digest vem da API (conteúdo EXTERNO): só entra no path se for hex
203
+ // puro; qualquer outra forma (fallback id+filesize+created_on de
204
+ // Redmine antigo, ou valor malicioso com `../`) é normalizada por
205
+ // sha256 — traversal impossível por construção (fix review #131).
206
+ const hexDigest = /^[0-9a-fA-F]{8,64}$/.test(key.digest)
207
+ ? key.digest.toLowerCase()
208
+ : createHash('sha256').update(key.digest).digest('hex');
209
+ const digest8 = hexDigest.slice(0, DIGEST_PATH_LENGTH);
210
+ const dir = join(this.root, key.instanceHash, ATTACHMENTS_DIR, `${key.attachmentId}-${digest8}`);
211
+ return join(dir, fileName);
212
+ }
213
+ return join(this.root, ISSUES_DIR, String(key.issueId), fileName);
214
+ }
215
+ /**
216
+ * Executa o GC/LRU (#47): despeja o necessário para respeitar {@link maxBytes}
217
+ * (com quotas separadas — ver {@link planGc}) e, em seguida, aciona o hook de
218
+ * observação, se configurado, com a contagem REAL de entradas PÓS-despejo.
219
+ */
220
+ async runGc(reason) {
221
+ await this.enforceQuotas(reason === 'manual');
222
+ if (this.onGc === undefined) {
223
+ return;
224
+ }
225
+ await this.onGc({ reason, entryCount: await this.countEntries(this.root) });
226
+ }
227
+ /**
228
+ * Coleta as entradas indexadas de TODAS as instâncias, aplica a política pura
229
+ * ({@link planGc}) e remove (arquivo + registro) o que ela decidir. No-op
230
+ * quando o teto é `Infinity` (despejo desabilitado) ou nada excede o limite.
231
+ */
232
+ async enforceQuotas(force = false) {
233
+ if (!Number.isFinite(this.maxBytes)) {
234
+ return;
235
+ }
236
+ // Curto-circuito (review #137): com o total conhecido abaixo do teto, um
237
+ // put não paga coleta+sort de todas as entradas. gc() manual força a
238
+ // passada completa (que também reconcilia o total).
239
+ if (!force && this.knownBytes !== undefined && this.knownBytes <= this.maxBytes) {
240
+ return;
241
+ }
242
+ const candidates = [];
243
+ for (const instanceHash of await this.listInstanceDirs()) {
244
+ const entries = await this.index.entriesOf(instanceHash);
245
+ for (const [recordKey, entry] of entries) {
246
+ candidates.push({
247
+ instanceHash,
248
+ recordKey,
249
+ size: entry.size,
250
+ type: entry.type,
251
+ lastAccessedAt: entry.lastAccessedAt,
252
+ });
253
+ }
254
+ }
255
+ const { remove } = planGc(candidates, { maxBytes: this.maxBytes });
256
+ for (const candidate of remove) {
257
+ await this.evict(candidate.instanceHash, candidate.recordKey);
258
+ }
259
+ const total = candidates.reduce((sum, c) => sum + c.size, 0);
260
+ const evicted = remove.reduce((sum, c) => sum + c.size, 0);
261
+ this.knownBytes = total - evicted;
262
+ }
263
+ /** Ajusta o total conhecido após uma escrita/remoção pontual. */
264
+ adjustKnownBytes(delta) {
265
+ if (this.knownBytes !== undefined) {
266
+ this.knownBytes = Math.max(0, this.knownBytes + delta);
267
+ }
268
+ }
269
+ /**
270
+ * Remove com segurança UMA entrada de attachment: apaga o arquivo de valor
271
+ * (apenas se resolver DENTRO de `<cache_dir>/<instance_hash>/` — guard
272
+ * anti-traversal do {@link resolveEvictionTarget}) e tira o registro do índice,
273
+ * mantendo o `index.json` consistente pós-GC.
274
+ */
275
+ async evict(instanceHash, recordKey) {
276
+ const instanceRoot = join(this.root, instanceHash);
277
+ const target = resolveEvictionTarget(instanceRoot, join(ATTACHMENTS_DIR, recordKey));
278
+ if (target === undefined) {
279
+ // Registro apontando para fora da instância: nunca apaga; só avisa.
280
+ this.logger.warn(`cache: GC recusou remover caminho fora da instância (${recordKey})`);
281
+ return;
282
+ }
283
+ await rm(target, { force: true });
284
+ await this.index.remove(instanceHash, recordKey);
285
+ }
286
+ /** Conta recursivamente os arquivos de valor (`.json`) sob `dir`. */
287
+ async countEntries(dir) {
288
+ let entries;
289
+ try {
290
+ entries = await readdir(dir, { withFileTypes: true });
291
+ }
292
+ catch (cause) {
293
+ if (isNotFound(cause)) {
294
+ return 0;
295
+ }
296
+ throw cause;
297
+ }
298
+ let count = 0;
299
+ for (const entry of entries) {
300
+ if (entry.isDirectory()) {
301
+ count += await this.countEntries(join(dir, entry.name));
302
+ }
303
+ else if (entry.isFile() && entry.name.endsWith(VALUE_EXTENSION) && entry.name !== INDEX_FILE_NAME) {
304
+ count += 1;
305
+ }
306
+ }
307
+ return count;
308
+ }
309
+ }
310
+ /** Verifica se um erro do fs é "arquivo não encontrado" (ENOENT). */
311
+ function isNotFound(cause) {
312
+ return (typeof cause === 'object' && cause !== null && cause.code === 'ENOENT');
313
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Política PURA de GC/LRU do cache em disco (M3-05.2, #47, ADR-004).
3
+ *
4
+ * Este módulo NÃO toca no filesystem: recebe uma lista de entradas candidatas
5
+ * (tamanho, tipo e `last_accessed_at`, vindas do índice por instância do #46) e
6
+ * DECIDE quais remover para respeitar o teto global (default 2 GB) e as quotas
7
+ * SEPARADAS por tipo. A integração (leitura do índice, remoção dos arquivos e do
8
+ * registro) fica no {@link ../cache/disk.js DiskCacheStore}. Separar a decisão da
9
+ * execução deixa a política totalmente testável com fakes de tamanho.
10
+ *
11
+ * Estratégia de quotas (ADR-004, item 4):
12
+ * - Originais (`type: 'original'`): recuperáveis do Redmine → remoção AGRESSIVA.
13
+ * Podem ocupar no máximo {@link DEFAULT_MAX_ORIGINAL_FRACTION} (50%) do teto;
14
+ * ao estourar essa quota, saem primeiro (LRU), mesmo que o teto GLOBAL ainda
15
+ * não tenha sido violado.
16
+ * - Extrações (`type: 'extraction'`): caras de reproduzir → remoção CONSERVADORA.
17
+ * Só são removidas quando, após despejar TODOS os originais, o total ainda
18
+ * excede o teto global. A extração mais recentemente acessada é a última a sair.
19
+ *
20
+ * O critério de idade é sempre `last_accessed_at` (LRU): mais antigo sai primeiro;
21
+ * empates são desfeitos de forma determinística por `instanceHash`+`recordKey`.
22
+ */
23
+ import type { CacheEntryType } from './disk-index.js';
24
+ /** Teto global default do cache em bytes (2 GB — ADR-004). */
25
+ export declare const DEFAULT_MAX_BYTES: number;
26
+ /** Fração máxima do teto que os originais podem ocupar (quota agressiva). */
27
+ export declare const DEFAULT_MAX_ORIGINAL_FRACTION = 0.5;
28
+ /**
29
+ * Uma entrada candidata ao GC. Carrega o suficiente para (a) decidir a remoção
30
+ * pela política e (b) localizar o arquivo/registro na integração.
31
+ */
32
+ export interface GcCandidate {
33
+ /** Instância dona da entrada — namespace do índice e do diretório. */
34
+ instanceHash: string;
35
+ /** Chave de registro no índice: caminho relativo dentro de `attachments/`. */
36
+ recordKey: string;
37
+ /** Tamanho do valor em bytes (do índice). */
38
+ size: number;
39
+ /** Classificação para as quotas separadas. */
40
+ type: CacheEntryType;
41
+ /** ISO 8601 do último acesso conhecido — critério LRU. */
42
+ lastAccessedAt: string;
43
+ }
44
+ /** Parâmetros da política de GC. */
45
+ export interface GcPolicyOptions {
46
+ /** Teto global do cache em bytes. */
47
+ maxBytes: number;
48
+ /**
49
+ * Fração máxima do teto ocupável por originais (0..1). Default
50
+ * {@link DEFAULT_MAX_ORIGINAL_FRACTION}.
51
+ */
52
+ maxOriginalFraction?: number;
53
+ }
54
+ /** Resultado da política: as entradas a remover, já em ordem de despejo (LRU). */
55
+ export interface GcDecision {
56
+ /** Entradas a remover, da mais antiga (primeira a sair) para a mais recente. */
57
+ remove: GcCandidate[];
58
+ }
59
+ /**
60
+ * Decide quais entradas remover para respeitar teto global e quotas separadas.
61
+ *
62
+ * @param candidates - Todas as entradas atualmente no cache (qualquer instância).
63
+ * @param options - Ver {@link GcPolicyOptions}.
64
+ * @returns As entradas a despejar (vazio se nada precisa sair) — ver {@link GcDecision}.
65
+ * @example
66
+ * const { remove } = planGc(entries, { maxBytes: 2 * 1024 ** 3 });
67
+ */
68
+ export declare function planGc(candidates: readonly GcCandidate[], options: GcPolicyOptions): GcDecision;
69
+ /**
70
+ * Guard anti-traversal do despejo: resolve o caminho ABSOLUTO de um alvo de
71
+ * remoção e devolve `undefined` se ele cair FORA de `<instanceRoot>/` (ex.: um
72
+ * `recordKey` com `../`). Garante que o GC só apaga dentro da instância.
73
+ *
74
+ * @param instanceRoot - Raiz absoluta da instância (`<cache_dir>/<instance_hash>`).
75
+ * @param relativePath - Caminho relativo à raiz da instância (inclui `attachments/`).
76
+ * @returns Caminho absoluto seguro, ou `undefined` se escaparia da instância.
77
+ */
78
+ export declare function resolveEvictionTarget(instanceRoot: string, relativePath: string): string | undefined;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Política PURA de GC/LRU do cache em disco (M3-05.2, #47, ADR-004).
3
+ *
4
+ * Este módulo NÃO toca no filesystem: recebe uma lista de entradas candidatas
5
+ * (tamanho, tipo e `last_accessed_at`, vindas do índice por instância do #46) e
6
+ * DECIDE quais remover para respeitar o teto global (default 2 GB) e as quotas
7
+ * SEPARADAS por tipo. A integração (leitura do índice, remoção dos arquivos e do
8
+ * registro) fica no {@link ../cache/disk.js DiskCacheStore}. Separar a decisão da
9
+ * execução deixa a política totalmente testável com fakes de tamanho.
10
+ *
11
+ * Estratégia de quotas (ADR-004, item 4):
12
+ * - Originais (`type: 'original'`): recuperáveis do Redmine → remoção AGRESSIVA.
13
+ * Podem ocupar no máximo {@link DEFAULT_MAX_ORIGINAL_FRACTION} (50%) do teto;
14
+ * ao estourar essa quota, saem primeiro (LRU), mesmo que o teto GLOBAL ainda
15
+ * não tenha sido violado.
16
+ * - Extrações (`type: 'extraction'`): caras de reproduzir → remoção CONSERVADORA.
17
+ * Só são removidas quando, após despejar TODOS os originais, o total ainda
18
+ * excede o teto global. A extração mais recentemente acessada é a última a sair.
19
+ *
20
+ * O critério de idade é sempre `last_accessed_at` (LRU): mais antigo sai primeiro;
21
+ * empates são desfeitos de forma determinística por `instanceHash`+`recordKey`.
22
+ */
23
+ import { resolve, sep } from 'node:path';
24
+ /** Teto global default do cache em bytes (2 GB — ADR-004). */
25
+ export const DEFAULT_MAX_BYTES = 2 * 1024 * 1024 * 1024;
26
+ /** Fração máxima do teto que os originais podem ocupar (quota agressiva). */
27
+ export const DEFAULT_MAX_ORIGINAL_FRACTION = 0.5;
28
+ /**
29
+ * Decide quais entradas remover para respeitar teto global e quotas separadas.
30
+ *
31
+ * @param candidates - Todas as entradas atualmente no cache (qualquer instância).
32
+ * @param options - Ver {@link GcPolicyOptions}.
33
+ * @returns As entradas a despejar (vazio se nada precisa sair) — ver {@link GcDecision}.
34
+ * @example
35
+ * const { remove } = planGc(entries, { maxBytes: 2 * 1024 ** 3 });
36
+ */
37
+ export function planGc(candidates, options) {
38
+ const maxBytes = options.maxBytes;
39
+ const fraction = options.maxOriginalFraction ?? DEFAULT_MAX_ORIGINAL_FRACTION;
40
+ const originalsCap = maxBytes * fraction;
41
+ // Ordem LRU crescente: o mais antigo (primeiro a sair) na frente. Empate
42
+ // determinístico por instância+registro para reprodutibilidade.
43
+ const lruAscending = [...candidates].sort(compareLru);
44
+ const removed = new Set();
45
+ let totalSize = sum(candidates);
46
+ let originalsSize = sum(candidates.filter((c) => c.type === 'original'));
47
+ // Passo A — quota dos originais: podem ocupar no máx `originalsCap`.
48
+ for (const candidate of lruAscending) {
49
+ if (originalsSize <= originalsCap) {
50
+ break;
51
+ }
52
+ if (candidate.type !== 'original' || removed.has(candidate)) {
53
+ continue;
54
+ }
55
+ removed.add(candidate);
56
+ originalsSize -= candidate.size;
57
+ totalSize -= candidate.size;
58
+ }
59
+ // Passo B — teto global: originais LRU primeiro (recuperáveis)...
60
+ for (const candidate of lruAscending) {
61
+ if (totalSize <= maxBytes) {
62
+ break;
63
+ }
64
+ if (candidate.type !== 'original' || removed.has(candidate)) {
65
+ continue;
66
+ }
67
+ removed.add(candidate);
68
+ totalSize -= candidate.size;
69
+ }
70
+ // ...e só então extrações LRU (caras), se ainda exceder.
71
+ for (const candidate of lruAscending) {
72
+ if (totalSize <= maxBytes) {
73
+ break;
74
+ }
75
+ if (candidate.type !== 'extraction' || removed.has(candidate)) {
76
+ continue;
77
+ }
78
+ removed.add(candidate);
79
+ totalSize -= candidate.size;
80
+ }
81
+ return { remove: lruAscending.filter((candidate) => removed.has(candidate)) };
82
+ }
83
+ /**
84
+ * Guard anti-traversal do despejo: resolve o caminho ABSOLUTO de um alvo de
85
+ * remoção e devolve `undefined` se ele cair FORA de `<instanceRoot>/` (ex.: um
86
+ * `recordKey` com `../`). Garante que o GC só apaga dentro da instância.
87
+ *
88
+ * @param instanceRoot - Raiz absoluta da instância (`<cache_dir>/<instance_hash>`).
89
+ * @param relativePath - Caminho relativo à raiz da instância (inclui `attachments/`).
90
+ * @returns Caminho absoluto seguro, ou `undefined` se escaparia da instância.
91
+ */
92
+ export function resolveEvictionTarget(instanceRoot, relativePath) {
93
+ const base = resolve(instanceRoot);
94
+ const target = resolve(base, relativePath);
95
+ // Estritamente ABAIXO da raiz da instância: nunca a própria raiz nem irmãos.
96
+ if (target.startsWith(base + sep)) {
97
+ return target;
98
+ }
99
+ return undefined;
100
+ }
101
+ /** Soma dos tamanhos de um conjunto de candidatas. */
102
+ function sum(candidates) {
103
+ let total = 0;
104
+ for (const candidate of candidates) {
105
+ total += candidate.size;
106
+ }
107
+ return total;
108
+ }
109
+ /** Comparador LRU crescente com desempate determinístico. */
110
+ function compareLru(a, b) {
111
+ const ta = Date.parse(a.lastAccessedAt);
112
+ const tb = Date.parse(b.lastAccessedAt);
113
+ if (ta !== tb) {
114
+ return ta - tb;
115
+ }
116
+ if (a.instanceHash !== b.instanceHash) {
117
+ return a.instanceHash < b.instanceHash ? -1 : 1;
118
+ }
119
+ if (a.recordKey !== b.recordKey) {
120
+ return a.recordKey < b.recordKey ? -1 : 1;
121
+ }
122
+ return 0;
123
+ }