@untangle-code/storage-local 0.0.0-stage → 0.1.1

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 (65) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +226 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/abort.d.ts +18 -0
  6. package/dist/abort.d.ts.map +1 -0
  7. package/dist/abort.js +14 -0
  8. package/dist/abort.js.map +1 -0
  9. package/dist/analysis-cache.d.ts +81 -0
  10. package/dist/analysis-cache.d.ts.map +1 -0
  11. package/dist/analysis-cache.js +322 -0
  12. package/dist/analysis-cache.js.map +1 -0
  13. package/dist/checkpoint-store.d.ts +22 -0
  14. package/dist/checkpoint-store.d.ts.map +1 -0
  15. package/dist/checkpoint-store.js +123 -0
  16. package/dist/checkpoint-store.js.map +1 -0
  17. package/dist/embedding-cache.d.ts +38 -0
  18. package/dist/embedding-cache.d.ts.map +1 -0
  19. package/dist/embedding-cache.js +229 -0
  20. package/dist/embedding-cache.js.map +1 -0
  21. package/dist/fs-errors.d.ts +4 -0
  22. package/dist/fs-errors.d.ts.map +1 -0
  23. package/dist/fs-errors.js +14 -0
  24. package/dist/fs-errors.js.map +1 -0
  25. package/dist/index.d.ts +15 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +23 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/layout.d.ts +50 -0
  30. package/dist/layout.d.ts.map +1 -0
  31. package/dist/layout.js +99 -0
  32. package/dist/layout.js.map +1 -0
  33. package/dist/local-file-system.d.ts +37 -0
  34. package/dist/local-file-system.d.ts.map +1 -0
  35. package/dist/local-file-system.js +193 -0
  36. package/dist/local-file-system.js.map +1 -0
  37. package/dist/lock-provider.d.ts +59 -0
  38. package/dist/lock-provider.d.ts.map +1 -0
  39. package/dist/lock-provider.js +318 -0
  40. package/dist/lock-provider.js.map +1 -0
  41. package/dist/real-time.d.ts +16 -0
  42. package/dist/real-time.d.ts.map +1 -0
  43. package/dist/real-time.js +19 -0
  44. package/dist/real-time.js.map +1 -0
  45. package/dist/search-journey-store.d.ts +34 -0
  46. package/dist/search-journey-store.d.ts.map +1 -0
  47. package/dist/search-journey-store.js +530 -0
  48. package/dist/search-journey-store.js.map +1 -0
  49. package/dist/storage.d.ts +42 -0
  50. package/dist/storage.d.ts.map +1 -0
  51. package/dist/storage.js +34 -0
  52. package/dist/storage.js.map +1 -0
  53. package/package.json +60 -3
  54. package/src/abort.ts +26 -0
  55. package/src/analysis-cache.ts +404 -0
  56. package/src/checkpoint-store.ts +155 -0
  57. package/src/embedding-cache.ts +282 -0
  58. package/src/fs-errors.ts +19 -0
  59. package/src/index.ts +30 -0
  60. package/src/layout.ts +149 -0
  61. package/src/local-file-system.ts +232 -0
  62. package/src/lock-provider.ts +381 -0
  63. package/src/real-time.ts +18 -0
  64. package/src/search-journey-store.ts +638 -0
  65. package/src/storage.ts +98 -0
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_ANALYSIS_CACHE_MAX_BYTES, LocalAnalysisCache, LocalContentCache, } from "./analysis-cache.js";
2
+ import { LocalCheckpointStore } from "./checkpoint-store.js";
3
+ import { DEFAULT_EMBEDDING_CACHE_MAX_BYTES, LocalEmbeddingCache } from "./embedding-cache.js";
4
+ import { buildStateDirLayout, ensureStateDirLayout } from "./layout.js";
5
+ import { LocalFileSystem } from "./local-file-system.js";
6
+ import { FileLockProvider } from "./lock-provider.js";
7
+ import { LocalSearchJourneyStore } from "./search-journey-store.js";
8
+ /**
9
+ * Builds the `.untangle/` layout under `stateRoot` (creating every directory
10
+ * if needed) and returns the wired-up local storage adapters. `stateRoot`
11
+ * becomes the ONLY root the returned `FileSystemPort` will resolve paths
12
+ * within (rule 3: "kein Schreiben ausserhalb des Zustandsverzeichnisses").
13
+ */
14
+ export async function openLocalStorage(stateRoot, options, callOptions) {
15
+ const fs = new LocalFileSystem([stateRoot]);
16
+ const layout = buildStateDirLayout(stateRoot);
17
+ await ensureStateDirLayout(fs, layout, callOptions);
18
+ const embeddingCache = new LocalEmbeddingCache(fs, layout.cache.embeddings, options.clock, options.random, {
19
+ maxBytes: options.embeddingCacheMaxBytes ?? DEFAULT_EMBEDDING_CACHE_MAX_BYTES,
20
+ });
21
+ const analysisMaxBytes = options.analysisCacheMaxBytes ?? DEFAULT_ANALYSIS_CACHE_MAX_BYTES;
22
+ const analysisCache = new LocalAnalysisCache(new LocalContentCache(fs, layout.cache.analysis.parse, options.clock, options.random, {
23
+ maxBytes: analysisMaxBytes,
24
+ }), new LocalContentCache(fs, layout.cache.analysis.chunk, options.clock, options.random, {
25
+ maxBytes: analysisMaxBytes,
26
+ }), new LocalContentCache(fs, layout.cache.analysis.metrics, options.clock, options.random, {
27
+ maxBytes: analysisMaxBytes,
28
+ }));
29
+ const checkpoints = new LocalCheckpointStore(fs, layout.jobs, options.random);
30
+ const locks = new FileLockProvider(fs, layout.locks, options.clock, options.random, options.host);
31
+ const searchJourneys = new LocalSearchJourneyStore(fs, layout.productComplexity.sessions, options.random, locks, options.clock);
32
+ return { layout, fs, embeddingCache, analysisCache, checkpoints, locks, searchJourneys };
33
+ }
34
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.js","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAgBA,OAAO,EACL,gCAAgC,EAChC,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,iCAAiC,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC9F,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAuB,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAwBpE;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,SAAiB,EACjB,OAA4B,EAC5B,WAA6B;IAE7B,MAAM,EAAE,GAAG,IAAI,eAAe,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IAC5C,MAAM,MAAM,GAAG,mBAAmB,CAAC,SAAS,CAAC,CAAC;IAC9C,MAAM,oBAAoB,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC;IAEpD,MAAM,cAAc,GAAG,IAAI,mBAAmB,CAC5C,EAAE,EACF,MAAM,CAAC,KAAK,CAAC,UAAU,EACvB,OAAO,CAAC,KAAK,EACb,OAAO,CAAC,MAAM,EACd;QACE,QAAQ,EAAE,OAAO,CAAC,sBAAsB,IAAI,iCAAiC;KAC9E,CACF,CAAC;IACF,MAAM,gBAAgB,GAAG,OAAO,CAAC,qBAAqB,IAAI,gCAAgC,CAAC;IAC3F,MAAM,aAAa,GAAG,IAAI,kBAAkB,CAC1C,IAAI,iBAAiB,CAAC,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE;QACpF,QAAQ,EAAE,gBAAgB;KAC3B,CAAC,EACF,IAAI,iBAAiB,CAAC,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE;QACpF,QAAQ,EAAE,gBAAgB;KAC3B,CAAC,EACF,IAAI,iBAAiB,CAAC,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE;QACtF,QAAQ,EAAE,gBAAgB;KAC3B,CAAC,CACH,CAAC;IACF,MAAM,WAAW,GAAG,IAAI,oBAAoB,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9E,MAAM,KAAK,GAAG,IAAI,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAClG,MAAM,cAAc,GAAG,IAAI,uBAAuB,CAChD,EAAE,EACF,MAAM,CAAC,iBAAiB,CAAC,QAAQ,EACjC,OAAO,CAAC,MAAM,EACd,KAAK,EACL,OAAO,CAAC,KAAK,CACd,CAAC;IAEF,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC;AAC3F,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@untangle-code/storage-local",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Untangle local state under .untangle/: cache (embeddings/parser/git-blobs), atomic checkpoint mirror, file-based LockProvider, path-sandboxed FileSystemPort adapter.",
7
+ "license": "AGPL-3.0-only",
8
+ "homepage": "https://github.com/lboel/untangle",
9
+ "bugs": {
10
+ "url": "https://github.com/lboel/untangle/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/lboel/untangle.git",
15
+ "directory": "packages/storage-local"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": "^22.13.0 || >=24.0.0"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/**/*.test.*",
32
+ "!dist/**/test-helpers.*",
33
+ "!dist/**/test-support.*",
34
+ "!dist/**/test-support/**",
35
+ "!dist/**/testing/**",
36
+ "!dist/**/__tests__/**",
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/test-helpers.ts",
40
+ "!src/**/test-support.ts",
41
+ "!src/**/test-support/**",
42
+ "!src/**/testing/**",
43
+ "LICENSE",
44
+ "NOTICE",
45
+ "README.md",
46
+ "THIRD_PARTY_LICENSES.md"
47
+ ],
48
+ "dependencies": {
49
+ "@untangle-code/domain": "0.1.1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "22.19.7",
53
+ "typescript": "5.9.3",
54
+ "vitest": "3.2.7",
55
+ "@untangle-code/testkit": "0.1.1"
56
+ },
57
+ "scripts": {
58
+ "build": "node ../../scripts/tsc-build.mjs tsconfig.json",
59
+ "typecheck": "tsc -p tsconfig.typecheck.json",
60
+ "test": "vitest run",
61
+ "clean": "rm -rf dist *.tsbuildinfo"
62
+ }
6
63
  }
package/src/abort.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared `AbortSignal` handling (spec.md §22: "public APIs MUST support
3
+ * abort signals"; rule 9: every long-running method respects `AbortSignal`
4
+ * and aborts with `JOB_CANCELLED`).
5
+ *
6
+ * Every port method in this package is `async`, so throwing synchronously
7
+ * here still surfaces as a rejected Promise to the caller — unlike
8
+ * `@untangle-code/testkit`'s `abortedRejection` (which exists specifically
9
+ * for a few NON-`async` methods where a synchronous throw would be a bug).
10
+ */
11
+ import type { PortCallOptions } from "@untangle-code/domain";
12
+ import { UntangleError } from "@untangle-code/domain";
13
+
14
+ /** Throws `UntangleError("JOB_CANCELLED", …)` when `options.signal` is already aborted. */
15
+ export function assertNotAborted(options: PortCallOptions | undefined, operation: string): void {
16
+ if (options?.signal?.aborted) {
17
+ throw new UntangleError("JOB_CANCELLED", `"${operation}" was aborted`, { operation });
18
+ }
19
+ }
20
+
21
+ /** Builds a `PortCallOptions` from an optional `AbortSignal` without ever setting
22
+ * `signal: undefined` explicitly — required because domain's `PortCallOptions` is
23
+ * checked under `exactOptionalPropertyTypes`. */
24
+ export function portOptions(signal: AbortSignal | undefined): PortCallOptions | undefined {
25
+ return signal === undefined ? undefined : { signal };
26
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Inhaltsadressierte Caches fuer Parse, Chunk und Komplexitaet (WP-100,
3
+ * docs/INDEX-SHARING.md §5) — unter `.untangle/cache/analysis/`.
4
+ *
5
+ * WARUM ES SIE GIBT: Parsen, Chunken und Messen sind reine Funktionen des
6
+ * Dateiinhalts. Ohne Cache rechnet jeder Lauf sie neu, obwohl sich zwischen
7
+ * zwei Laeufen (und zwischen zwei Worktrees desselben Repositoriums)
8
+ * typischerweise nur wenige Prozent der Dateien unterscheiden. Der
9
+ * Einbettungs-Cache (spec.md §9.4) ist bereits inhaltsadressiert; diese drei
10
+ * Caches schliessen die Luecke davor.
11
+ *
12
+ * DER SCHLUESSEL IST DIE GANZE SICHERHEIT. Ein Cache, der ein falsches
13
+ * Ergebnis liefert, ist schlimmer als kein Cache: das falsche Ergebnis ist
14
+ * nicht sichtbar. Der Schluesselbau steht deshalb in `@untangle-code/domain`
15
+ * (analysis-cache-key.ts) und traegt jede Eingabe, von der der gespeicherte
16
+ * Wert abhaengt.
17
+ *
18
+ * ABLAGE: ein JSON je Eintrag, zwei Hexzeichen tief geshardet
19
+ * (`<hash[0:2]>/<hash>.json`), plus ein `index.json` je Cache mit Groesse und
20
+ * letztem Zugriff — die Buchfuehrung fuer die Verdraengung nach Groesse (LRU).
21
+ *
22
+ * UNTERSCHIED ZUM EINBETTUNGS-CACHE: dessen Manifest wird bei JEDER Mutation
23
+ * geschrieben. Das waere hier falsch: drei Caches mal zwei Zugriffe je Datei
24
+ * ergaeben sechs Manifest-Schreibvorgaenge je Datei und fraessen genau die
25
+ * Zeit auf, die der Cache spart. Das Manifest wird deshalb im Speicher
26
+ * gefuehrt und alle {@link MANIFEST_FLUSH_INTERVAL} Mutationen sowie bei
27
+ * {@link LocalContentCache.flush} geschrieben. PREIS: bricht der Lauf hart ab,
28
+ * koennen geschriebene Eintraege im Manifest fehlen. Das kostet Treffer und
29
+ * laesst Dateien ungezaehlt liegen — es liefert NIE ein falsches Ergebnis,
30
+ * weil ein Wert ausschliesslich ueber das Manifest gefunden wird.
31
+ *
32
+ * Lesen degradiert kontrolliert (architecture.md §12: lokale Caches sind
33
+ * loeschbar und rekonstruierbar): fehlende Datei, kaputtes JSON, kaputtes
34
+ * Manifest — alles ist ein Fehlschlag des Nachschlagens, kein geworfener
35
+ * Fehler.
36
+ */
37
+ import * as nodePath from "node:path";
38
+
39
+ import type {
40
+ AnalysisCacheKind,
41
+ Clock,
42
+ FileSystemPort,
43
+ PortCallOptions,
44
+ Random,
45
+ } from "@untangle-code/domain";
46
+ import { sha256Hex } from "@untangle-code/domain";
47
+
48
+ import { assertNotAborted } from "./abort.js";
49
+ import { isNotFoundError } from "./fs-errors.js";
50
+
51
+ // Der Schluesselbau liegt in `@untangle-code/domain`, weil ihn BEIDE Seiten
52
+ // brauchen: dieses Paket legt ab, `@untangle-code/core` schlaegt nach. Zwei
53
+ // Kopien desselben Schluessels waeren die sicherste Art, den Cache eines Tages
54
+ // an der falschen Stelle treffen zu lassen. Hier nur weitergereicht, damit ein
55
+ // Aufrufer beides aus einem Paket bekommt.
56
+ export {
57
+ ANALYSIS_CACHE_KINDS,
58
+ buildChunkCacheKey,
59
+ buildMetricsCacheKey,
60
+ buildParseCacheKey,
61
+ type AnalysisCacheKind,
62
+ type AnalysisIdentityKeyPartsV1,
63
+ } from "@untangle-code/domain";
64
+
65
+ /** 256 MiB je Cache — eine Untangle-Vorgabe, keine Spec-Zahl. */
66
+ export const DEFAULT_ANALYSIS_CACHE_MAX_BYTES = 256 * 1024 * 1024;
67
+
68
+ /** Nach so vielen Mutationen wird das Manifest geschrieben. */
69
+ export const MANIFEST_FLUSH_INTERVAL = 64;
70
+
71
+ const MANIFEST_FILE_NAME = "index.json";
72
+
73
+ export interface ContentCacheStatsV1 {
74
+ entryCount: number;
75
+ totalBytes: number;
76
+ /** Treffer seit dem Oeffnen dieses Cache-Objekts. */
77
+ hits: number;
78
+ /** Fehlschlaege seit dem Oeffnen dieses Cache-Objekts. */
79
+ misses: number;
80
+ /** Trefferquote in [0,1]; 0, wenn nichts nachgeschlagen wurde. */
81
+ hitRate: number;
82
+ /** Wie viele Eintraege die Groessengrenze verdraengt hat. */
83
+ evictions: number;
84
+ }
85
+
86
+ interface ManifestEntryV1 {
87
+ fileName: string;
88
+ sizeBytes: number;
89
+ lastAccessEpochMs: number;
90
+ }
91
+
92
+ interface ManifestV1 {
93
+ version: 1;
94
+ entries: Record<string, ManifestEntryV1>;
95
+ }
96
+
97
+ function encode(text: string): Uint8Array {
98
+ return new TextEncoder().encode(text);
99
+ }
100
+
101
+ function signalOptions(options: PortCallOptions | undefined): PortCallOptions | undefined {
102
+ return options?.signal ? { signal: options.signal } : undefined;
103
+ }
104
+
105
+ function totalBytesOf(manifest: ManifestV1): number {
106
+ let total = 0;
107
+ for (const entry of Object.values(manifest.entries)) {
108
+ total += entry.sizeBytes;
109
+ }
110
+ return total;
111
+ }
112
+
113
+ function pickEvictionCandidate(manifest: ManifestV1, protectedKey: string): string | null {
114
+ const keys = Object.keys(manifest.entries);
115
+ let best: string | null = null;
116
+ let bestAccess = Number.POSITIVE_INFINITY;
117
+ for (const key of keys) {
118
+ if (key === protectedKey && keys.length === 1) {
119
+ // Der eben geschriebene Eintrag ist der letzte — ihn zu verdraengen
120
+ // hiesse, `set()` stillschweigend wirkungslos zu machen.
121
+ continue;
122
+ }
123
+ const entry = manifest.entries[key];
124
+ if (entry !== undefined && entry.lastAccessEpochMs < bestAccess) {
125
+ bestAccess = entry.lastAccessEpochMs;
126
+ best = key;
127
+ }
128
+ }
129
+ return best;
130
+ }
131
+
132
+ /**
133
+ * Ein inhaltsadressierter JSON-Cache mit Groessengrenze, Verdraengung und
134
+ * Trefferquote. Der Schluessel ist eine undurchsichtige Zeichenkette; wer ihn
135
+ * baut, verantwortet seine Vollstaendigkeit (siehe Kopf dieser Datei).
136
+ */
137
+ export class LocalContentCache {
138
+ private readonly maxBytes: number;
139
+ private manifest: ManifestV1 | null = null;
140
+ private dirty = 0;
141
+ private hits = 0;
142
+ private misses = 0;
143
+ private evictions = 0;
144
+
145
+ public constructor(
146
+ private readonly fs: FileSystemPort,
147
+ private readonly dir: string,
148
+ private readonly clock: Clock,
149
+ private readonly random: Random,
150
+ options?: { maxBytes?: number },
151
+ ) {
152
+ this.maxBytes = options?.maxBytes ?? DEFAULT_ANALYSIS_CACHE_MAX_BYTES;
153
+ }
154
+
155
+ private manifestPath(): string {
156
+ return nodePath.join(this.dir, MANIFEST_FILE_NAME);
157
+ }
158
+
159
+ private entryFileName(logicalKey: string): string {
160
+ const hash = sha256Hex(logicalKey);
161
+ return nodePath.join(hash.slice(0, 2), `${hash}.json`);
162
+ }
163
+
164
+ private entryPath(fileName: string): string {
165
+ return nodePath.join(this.dir, fileName);
166
+ }
167
+
168
+ private async loadManifest(options: PortCallOptions | undefined): Promise<ManifestV1> {
169
+ if (this.manifest !== null) {
170
+ return this.manifest;
171
+ }
172
+ let text: string;
173
+ try {
174
+ text = await this.fs.readTextFile(this.manifestPath(), options);
175
+ } catch (error) {
176
+ if (isNotFoundError(error)) {
177
+ this.manifest = { version: 1, entries: {} };
178
+ return this.manifest;
179
+ }
180
+ throw error;
181
+ }
182
+ try {
183
+ const parsed = JSON.parse(text) as Partial<ManifestV1>;
184
+ this.manifest =
185
+ parsed.entries === undefined
186
+ ? { version: 1, entries: {} }
187
+ : { version: 1, entries: parsed.entries };
188
+ } catch {
189
+ // Kaputtes Manifest — leerer Cache statt geworfener Fehler.
190
+ this.manifest = { version: 1, entries: {} };
191
+ }
192
+ return this.manifest;
193
+ }
194
+
195
+ private async persistManifest(options: PortCallOptions | undefined): Promise<void> {
196
+ const manifest = this.manifest;
197
+ if (manifest === null) {
198
+ return;
199
+ }
200
+ await this.fs.mkdir(this.dir, { recursive: true, ...signalOptions(options) });
201
+ const tempPath = `${this.manifestPath()}.${this.random.uuid()}.tmp`;
202
+ await this.fs.writeFile(tempPath, encode(JSON.stringify(manifest)), options);
203
+ await this.fs.rename(tempPath, this.manifestPath(), options);
204
+ this.dirty = 0;
205
+ }
206
+
207
+ private async touched(options: PortCallOptions | undefined): Promise<void> {
208
+ this.dirty++;
209
+ if (this.dirty >= MANIFEST_FLUSH_INTERVAL) {
210
+ await this.persistManifest(options);
211
+ }
212
+ }
213
+
214
+ /** Schreibt das Manifest, falls Mutationen ausstehen. Am Ende eines Laufs aufrufen. */
215
+ public async flush(options?: PortCallOptions): Promise<void> {
216
+ if (this.dirty > 0) {
217
+ await this.persistManifest(options);
218
+ }
219
+ }
220
+
221
+ /** Der Wert, oder `null` fuer "nicht im Cache". Wirft nicht fuer kaputte Eintraege. */
222
+ public async get(logicalKey: string, options?: PortCallOptions): Promise<unknown | null> {
223
+ assertNotAborted(options, "analysis_cache_get");
224
+ const manifest = await this.loadManifest(options);
225
+ const entry = manifest.entries[logicalKey];
226
+ if (entry === undefined) {
227
+ this.misses++;
228
+ return null;
229
+ }
230
+ let text: string;
231
+ try {
232
+ text = await this.fs.readTextFile(this.entryPath(entry.fileName), options);
233
+ } catch (error) {
234
+ if (!isNotFoundError(error)) {
235
+ throw error;
236
+ }
237
+ delete manifest.entries[logicalKey];
238
+ await this.touched(options);
239
+ this.misses++;
240
+ return null;
241
+ }
242
+ let value: unknown;
243
+ try {
244
+ value = JSON.parse(text);
245
+ } catch {
246
+ delete manifest.entries[logicalKey];
247
+ await this.touched(options);
248
+ this.misses++;
249
+ return null;
250
+ }
251
+ entry.lastAccessEpochMs = this.clock.nowEpochMs();
252
+ this.dirty++;
253
+ this.hits++;
254
+ return value;
255
+ }
256
+
257
+ public async set(logicalKey: string, value: unknown, options?: PortCallOptions): Promise<void> {
258
+ assertNotAborted(options, "analysis_cache_set");
259
+ const fileName = this.entryFileName(logicalKey);
260
+ const entryPath = this.entryPath(fileName);
261
+ const bytes = encode(JSON.stringify(value));
262
+
263
+ await this.fs.mkdir(nodePath.dirname(entryPath), {
264
+ recursive: true,
265
+ ...signalOptions(options),
266
+ });
267
+ const tempPath = `${entryPath}.${this.random.uuid()}.tmp`;
268
+ await this.fs.writeFile(tempPath, bytes, options);
269
+ await this.fs.rename(tempPath, entryPath, options);
270
+
271
+ const manifest = await this.loadManifest(options);
272
+ manifest.entries[logicalKey] = {
273
+ fileName,
274
+ sizeBytes: bytes.length,
275
+ lastAccessEpochMs: this.clock.nowEpochMs(),
276
+ };
277
+ await this.evictUntilWithinLimit(manifest, logicalKey, options);
278
+ await this.touched(options);
279
+ }
280
+
281
+ private async evictUntilWithinLimit(
282
+ manifest: ManifestV1,
283
+ justWrittenKey: string,
284
+ options: PortCallOptions | undefined,
285
+ ): Promise<void> {
286
+ let total = totalBytesOf(manifest);
287
+ while (total > this.maxBytes) {
288
+ const victim = pickEvictionCandidate(manifest, justWrittenKey);
289
+ if (victim === null) {
290
+ break;
291
+ }
292
+ const entry = manifest.entries[victim];
293
+ delete manifest.entries[victim];
294
+ if (entry !== undefined) {
295
+ total -= entry.sizeBytes;
296
+ this.evictions++;
297
+ try {
298
+ await this.fs.remove(this.entryPath(entry.fileName), options);
299
+ } catch {
300
+ // Der Manifest-Eintrag ist so oder so weg; ein spaeterer `get`
301
+ // trifft nicht mehr.
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ public async stats(options?: PortCallOptions): Promise<ContentCacheStatsV1> {
308
+ assertNotAborted(options, "analysis_cache_stats");
309
+ const manifest = await this.loadManifest(options);
310
+ const lookups = this.hits + this.misses;
311
+ return {
312
+ entryCount: Object.keys(manifest.entries).length,
313
+ totalBytes: totalBytesOf(manifest),
314
+ hits: this.hits,
315
+ misses: this.misses,
316
+ hitRate: lookups === 0 ? 0 : this.hits / lookups,
317
+ evictions: this.evictions,
318
+ };
319
+ }
320
+
321
+ /** Loescht ausschliesslich das eigene Verzeichnis dieses Caches. */
322
+ public async clear(options?: PortCallOptions): Promise<void> {
323
+ assertNotAborted(options, "analysis_cache_clear");
324
+ await this.fs.remove(this.dir, { recursive: true, ...signalOptions(options) });
325
+ this.manifest = { version: 1, entries: {} };
326
+ this.dirty = 0;
327
+ }
328
+ }
329
+
330
+ /** Trefferquoten aller drei Caches, einzeln und zusammen. */
331
+ export interface AnalysisCacheStatsV1 {
332
+ parse: ContentCacheStatsV1;
333
+ chunk: ContentCacheStatsV1;
334
+ metrics: ContentCacheStatsV1;
335
+ totalHits: number;
336
+ totalMisses: number;
337
+ /** Trefferquote ueber alle drei Caches in [0,1]. */
338
+ hitRate: number;
339
+ }
340
+
341
+ /**
342
+ * Die drei Caches als ein Gegenstand — genau die Form, die
343
+ * `@untangle-code/core` als `AnalysisCacheLike` erwartet.
344
+ */
345
+ export class LocalAnalysisCache {
346
+ public constructor(
347
+ public readonly parse: LocalContentCache,
348
+ public readonly chunk: LocalContentCache,
349
+ public readonly metrics: LocalContentCache,
350
+ ) {}
351
+
352
+ private of(kind: AnalysisCacheKind): LocalContentCache {
353
+ if (kind === "parse") return this.parse;
354
+ if (kind === "chunk") return this.chunk;
355
+ return this.metrics;
356
+ }
357
+
358
+ public async get(
359
+ kind: AnalysisCacheKind,
360
+ key: string,
361
+ options?: PortCallOptions,
362
+ ): Promise<unknown | null> {
363
+ return this.of(kind).get(key, options);
364
+ }
365
+
366
+ public async set(
367
+ kind: AnalysisCacheKind,
368
+ key: string,
369
+ value: unknown,
370
+ options?: PortCallOptions,
371
+ ): Promise<void> {
372
+ await this.of(kind).set(key, value, options);
373
+ }
374
+
375
+ /** Schreibt die Manifeste aller drei Caches. */
376
+ public async flush(options?: PortCallOptions): Promise<void> {
377
+ await this.parse.flush(options);
378
+ await this.chunk.flush(options);
379
+ await this.metrics.flush(options);
380
+ }
381
+
382
+ public async stats(options?: PortCallOptions): Promise<AnalysisCacheStatsV1> {
383
+ const parse = await this.parse.stats(options);
384
+ const chunk = await this.chunk.stats(options);
385
+ const metrics = await this.metrics.stats(options);
386
+ const totalHits = parse.hits + chunk.hits + metrics.hits;
387
+ const totalMisses = parse.misses + chunk.misses + metrics.misses;
388
+ const lookups = totalHits + totalMisses;
389
+ return {
390
+ parse,
391
+ chunk,
392
+ metrics,
393
+ totalHits,
394
+ totalMisses,
395
+ hitRate: lookups === 0 ? 0 : totalHits / lookups,
396
+ };
397
+ }
398
+
399
+ public async clear(options?: PortCallOptions): Promise<void> {
400
+ await this.parse.clear(options);
401
+ await this.chunk.clear(options);
402
+ await this.metrics.clear(options);
403
+ }
404
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Local checkpoint mirror (architecture.md §12, §13.3; spec.md §11.2:
3
+ * "Nach jedem konfigurierten Dateibatch MUSS ein Checkpoint gespeichert
4
+ * werden" / "Bei Neustart MUSS der Job ab dem letzten bestätigten
5
+ * Checkpoint fortsetzbar sein").
6
+ *
7
+ * Every write is temp-file-then-rename: the checkpoint at `jobs/<jobId>.json`
8
+ * is only ever created or replaced by a single atomic `rename` onto that
9
+ * exact path, so a process crash mid-write leaves, at worst, an orphaned
10
+ * `*.tmp` file next to it — the real path itself is always either the
11
+ * previous good checkpoint or the new one, never a half-written mix of both
12
+ * (see README "Entscheidungen" for the crash-simulation test).
13
+ *
14
+ * `jobs/latest/<hash>.json` is a small secondary index — "which job is the
15
+ * latest checkpoint for this repo+ref" — so `loadLatest` does not have to
16
+ * list and parse every checkpoint file. It is written the same atomic way,
17
+ * AFTER the checkpoint itself, so a crash between the two writes leaves the
18
+ * pointer stale (pointing at the previous job) rather than pointing at a
19
+ * checkpoint that does not exist — `loadLatest` degrades to `null` if the
20
+ * pointer cannot be trusted at all (spec.md §25.2: reads degrade optional
21
+ * data in a controlled way), while `load(jobId)` on an actually corrupted
22
+ * checkpoint body throws instead, because THAT is the resume-critical record
23
+ * itself, not an accelerator index over it.
24
+ */
25
+ import * as nodePath from "node:path";
26
+
27
+ import type {
28
+ CheckpointStorePort,
29
+ FileSystemPort,
30
+ IndexCheckpointV1,
31
+ PortCallOptions,
32
+ Random,
33
+ } from "@untangle-code/domain";
34
+ import { ID_COMPONENT_SEPARATOR, sha256Hex, UntangleError } from "@untangle-code/domain";
35
+
36
+ import { assertNotAborted } from "./abort.js";
37
+ import { isNotFoundError } from "./fs-errors.js";
38
+
39
+ const POINTER_DIR_NAME = "latest";
40
+
41
+ interface LatestPointerV1 {
42
+ jobId: string;
43
+ }
44
+
45
+ function encode(text: string): Uint8Array {
46
+ return new TextEncoder().encode(text);
47
+ }
48
+
49
+ function signalOptions(options: PortCallOptions | undefined): PortCallOptions | undefined {
50
+ return options?.signal ? { signal: options.signal } : undefined;
51
+ }
52
+
53
+ /** Local, atomically-mirrored `CheckpointStorePort` under `jobs/`. */
54
+ export class LocalCheckpointStore implements CheckpointStorePort {
55
+ public constructor(
56
+ private readonly fs: FileSystemPort,
57
+ private readonly jobsDir: string,
58
+ private readonly random: Random,
59
+ ) {}
60
+
61
+ private pathForJob(jobId: string): string {
62
+ return nodePath.join(this.jobsDir, `${jobId}.json`);
63
+ }
64
+
65
+ /**
66
+ * Both components are variable-length, so a plain concatenation would be
67
+ * ambiguous (e.g. `repoId="ab"` + `refId="c"` vs. `repoId="a"` +
68
+ * `refId="bc"`) — joined with the same separator domain's own identity
69
+ * model uses for exactly this reason (`identity.ts`'s doc comment).
70
+ */
71
+ private pointerPathFor(repoId: string, refId: string): string {
72
+ const hash = sha256Hex(`${repoId}${ID_COMPONENT_SEPARATOR}${refId}`);
73
+ return nodePath.join(this.jobsDir, POINTER_DIR_NAME, `${hash}.json`);
74
+ }
75
+
76
+ private async writeAtomic(
77
+ finalPath: string,
78
+ content: string,
79
+ options: PortCallOptions | undefined,
80
+ ): Promise<void> {
81
+ const dir = nodePath.dirname(finalPath);
82
+ await this.fs.mkdir(dir, { recursive: true, ...signalOptions(options) });
83
+ const tempPath = `${finalPath}.${this.random.uuid()}.tmp`;
84
+ await this.fs.writeFile(tempPath, encode(content), options);
85
+ await this.fs.rename(tempPath, finalPath, options);
86
+ }
87
+
88
+ public async save(checkpoint: IndexCheckpointV1, options?: PortCallOptions): Promise<void> {
89
+ assertNotAborted(options, "checkpoint_save");
90
+ await this.writeAtomic(this.pathForJob(checkpoint.jobId), JSON.stringify(checkpoint), options);
91
+ const pointer: LatestPointerV1 = { jobId: checkpoint.jobId };
92
+ await this.writeAtomic(
93
+ this.pointerPathFor(checkpoint.repoId, checkpoint.refId),
94
+ JSON.stringify(pointer),
95
+ options,
96
+ );
97
+ }
98
+
99
+ public async load(jobId: string, options?: PortCallOptions): Promise<IndexCheckpointV1 | null> {
100
+ assertNotAborted(options, "checkpoint_load");
101
+ let text: string;
102
+ try {
103
+ text = await this.fs.readTextFile(this.pathForJob(jobId), options);
104
+ } catch (error) {
105
+ if (isNotFoundError(error)) {
106
+ return null;
107
+ }
108
+ throw error;
109
+ }
110
+ try {
111
+ return JSON.parse(text) as IndexCheckpointV1;
112
+ } catch (parseError) {
113
+ // Should never happen given atomic temp+rename writes — this is a
114
+ // defensive, loud failure (fail-closed, spec.md §25.2) for the one
115
+ // record a resume actually depends on, not a "missing optional data"
116
+ // case that could silently degrade to "start over".
117
+ throw new UntangleError(
118
+ "INDEX_INCOMPLETE",
119
+ `checkpoint "${jobId}" is corrupted and cannot be safely resumed from`,
120
+ { operation: "checkpoint_load", context: { jobId }, cause: parseError },
121
+ );
122
+ }
123
+ }
124
+
125
+ public async loadLatest(
126
+ repoId: string,
127
+ refId: string,
128
+ options?: PortCallOptions,
129
+ ): Promise<IndexCheckpointV1 | null> {
130
+ assertNotAborted(options, "checkpoint_load_latest");
131
+ let text: string;
132
+ try {
133
+ text = await this.fs.readTextFile(this.pointerPathFor(repoId, refId), options);
134
+ } catch (error) {
135
+ if (isNotFoundError(error)) {
136
+ return null;
137
+ }
138
+ throw error;
139
+ }
140
+ let pointer: LatestPointerV1;
141
+ try {
142
+ pointer = JSON.parse(text) as LatestPointerV1;
143
+ } catch {
144
+ // The pointer is an accelerator index, not the resume record itself —
145
+ // degrade to "no known latest" rather than throwing (spec.md §25.2).
146
+ return null;
147
+ }
148
+ return this.load(pointer.jobId, options);
149
+ }
150
+
151
+ public async delete(jobId: string, options?: PortCallOptions): Promise<void> {
152
+ assertNotAborted(options, "checkpoint_delete");
153
+ await this.fs.remove(this.pathForJob(jobId), options);
154
+ }
155
+ }