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.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- 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;
|
package/dist/cache/gc.js
ADDED
|
@@ -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
|
+
}
|