omp-plugin-duplicate-detector 0.1.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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Persistent disk cache for pre-tokenized source shards.
3
+ * Provides atomic writes, fail-open error handling, config-aware workspace keying,
4
+ * high-density binary compression, and byte-budgeted LRU cache pruning.
5
+ */
6
+
7
+ import { Database, type Statement } from "bun:sqlite";
8
+ import * as crypto from "node:crypto";
9
+ import * as fsSync from "node:fs";
10
+ import * as fs from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import * as zlib from "node:zlib";
14
+ import {
15
+ reconstructFramesFromTokens,
16
+ type SerializedSourceShard,
17
+ type SerializedToken,
18
+ type SourceAwareIndexOptions,
19
+ type SourceFrame,
20
+ } from "./source-aware-index";
21
+ import type { WorkspaceOptions } from "./worker-protocol";
22
+
23
+ const DEFAULT_MAX_CACHE_BYTES = 250 * 1024 * 1024; // 250 MB
24
+
25
+ export interface DiskCacheOptions {
26
+ /** Root directory of the workspace */
27
+ rootDir: string;
28
+ /** Custom base cache directory (defaults to OS user cache directory) */
29
+ cacheDir?: string;
30
+ /** Detector configuration used to compute configuration fingerprint */
31
+ config?: WorkspaceOptions | SourceAwareIndexOptions;
32
+ /** Maximum cache size in bytes before pruning (default: 250 MB) */
33
+ maxBytes?: number;
34
+ }
35
+
36
+ /**
37
+ * Resolves the OS user cache directory for duplicate detector shards.
38
+ * Unix/macOS: $XDG_CACHE_HOME/omp/duplicate-detector or ~/.cache/omp/duplicate-detector
39
+ * Windows: %LOCALAPPDATA%/omp/duplicate-detector
40
+ */
41
+ export function getDefaultCacheDir(): string {
42
+ if (process.platform === "win32") {
43
+ const localAppData = process.env.LOCALAPPDATA;
44
+ if (localAppData) {
45
+ return path.join(localAppData, "omp", "duplicate-detector");
46
+ }
47
+ return path.join(
48
+ os.homedir(),
49
+ "AppData",
50
+ "Local",
51
+ "omp",
52
+ "duplicate-detector",
53
+ );
54
+ }
55
+ const xdgCacheHome = process.env.XDG_CACHE_HOME;
56
+ if (xdgCacheHome) {
57
+ return path.join(xdgCacheHome, "omp", "duplicate-detector");
58
+ }
59
+ return path.join(os.homedir(), ".cache", "omp", "duplicate-detector");
60
+ }
61
+
62
+ /**
63
+ * Computes a deterministic fingerprint for detector configuration options that affect tokenization.
64
+ */
65
+ export function computeConfigFingerprint(
66
+ config?: WorkspaceOptions | SourceAwareIndexOptions,
67
+ ): string {
68
+ if (!config) return "default";
69
+
70
+ let sortedFormats: Record<string, string[]> | undefined;
71
+ if (config.formatsExts) {
72
+ sortedFormats = {};
73
+ for (const key of Object.keys(config.formatsExts).sort()) {
74
+ sortedFormats[key] = (config.formatsExts[key] ?? []).slice().sort();
75
+ }
76
+ }
77
+
78
+ const canonical = {
79
+ minTokens: config.minTokens ?? 40,
80
+ minLines: config.minLines ?? 5,
81
+ maxLines: config.maxLines ?? 500,
82
+ crossFormats: config.crossFormats ?? false,
83
+ formatsExts: sortedFormats,
84
+ };
85
+
86
+ return crypto
87
+ .createHash("sha256")
88
+ .update(JSON.stringify(canonical))
89
+ .digest("hex")
90
+ .slice(0, 16);
91
+ }
92
+
93
+ /**
94
+ * Computes the workspace SQLite cache database path keyed by canonical workspace path and config fingerprint.
95
+ */
96
+ export function computeWorkspaceCachePath(
97
+ baseDir: string,
98
+ rootDir: string,
99
+ configFingerprint: string,
100
+ ): string {
101
+ const canonicalPath = path.resolve(rootDir);
102
+ const workspaceHash = crypto
103
+ .createHash("sha256")
104
+ .update(canonicalPath)
105
+ .digest("hex")
106
+ .slice(0, 16);
107
+ return path.join(baseDir, `${workspaceHash}_${configFingerprint}.sqlite`);
108
+ }
109
+
110
+ /**
111
+ * Computes a shard key hash of (sourceRelPath, contentHash, configFingerprint).
112
+ */
113
+ export function computeShardKey(
114
+ sourceRelPath: string,
115
+ contentHash: string,
116
+ configFingerprint: string,
117
+ ): string {
118
+ const normalizedRelPath = sourceRelPath.replace(/\\/g, "/");
119
+ return crypto
120
+ .createHash("sha256")
121
+ .update(`${normalizedRelPath}:${contentHash}:${configFingerprint}`)
122
+ .digest("hex");
123
+ }
124
+
125
+ /** Current binary format magic identifier ('DUP3') */
126
+ export const CACHE_FORMAT_MAGIC = "DUP3";
127
+
128
+ /** Current binary format & SQLite schema version */
129
+ export const CACHE_FORMAT_VERSION = 3;
130
+
131
+ /**
132
+ * Encodes a SerializedSourceShard into a high-density, zlib-compressed binary buffer (DUP3 format).
133
+ */
134
+ function packBinaryShard(shard: SerializedSourceShard): Buffer {
135
+ return packBinaryShardV3(shard, shard.tokens ?? []);
136
+ }
137
+
138
+ /**
139
+ * Packs token sequence into ultra-compact DUP3 binary format using dictionary-encoded
140
+ * token hashes and columnar delta-encoded coordinates.
141
+ */
142
+ function packBinaryShardV3(
143
+ shard: SerializedSourceShard,
144
+ tokens: SerializedToken[],
145
+ ): Buffer {
146
+ const srcIdBuf = Buffer.from(shard.sourceId, "utf8");
147
+ const formatBuf = Buffer.from(shard.format, "utf8");
148
+ const hashBuf = Buffer.from(shard.contentHash, "utf8");
149
+ const tokenCount = tokens.length;
150
+ const minTokens = shard.minTokens ?? 40;
151
+
152
+ // Build dictionary of unique 20-character token hashes
153
+ const dict = new Map<string, number>();
154
+ const tokenIndices = new Uint16Array(tokenCount);
155
+ for (let i = 0; i < tokenCount; i++) {
156
+ const h = tokens[i]!.hash;
157
+ let idx = dict.get(h);
158
+ if (idx === undefined) {
159
+ idx = dict.size;
160
+ dict.set(h, idx);
161
+ }
162
+ tokenIndices[i] = idx;
163
+ }
164
+
165
+ const dictCount = dict.size;
166
+ const dictPayloadLen = dictCount * 10;
167
+ // Columnar: dictIdx(2) + deltaLine(2) + col(2) + deltaRange(4) + len(2) = 12 bytes/token
168
+ const columnsPayloadLen = tokenCount * (2 + 2 + 2 + 4 + 2);
169
+
170
+ const headerLen =
171
+ 4 + // magic 'DUP3'
172
+ 2 + // version (3)
173
+ 2 +
174
+ formatBuf.length +
175
+ 2 +
176
+ hashBuf.length +
177
+ 4 + // size
178
+ 4 + // lines
179
+ 4 + // tokenCount
180
+ 8 + // updatedAt
181
+ 2 + // minTokens
182
+ 2 +
183
+ srcIdBuf.length +
184
+ 2 + // dictCount
185
+ 4; // tokenCount in payload
186
+
187
+ const buf = Buffer.allocUnsafe(
188
+ headerLen + dictPayloadLen + columnsPayloadLen,
189
+ );
190
+
191
+ let pos = 0;
192
+ buf.write("DUP3", pos, 4, "ascii");
193
+ pos += 4;
194
+ buf.writeUInt16LE(3, pos);
195
+ pos += 2;
196
+
197
+ buf.writeUInt16LE(formatBuf.length, pos);
198
+ pos += 2;
199
+ formatBuf.copy(buf, pos);
200
+ pos += formatBuf.length;
201
+
202
+ buf.writeUInt16LE(hashBuf.length, pos);
203
+ pos += 2;
204
+ hashBuf.copy(buf, pos);
205
+ pos += hashBuf.length;
206
+
207
+ buf.writeUInt32LE(shard.size, pos);
208
+ pos += 4;
209
+ buf.writeUInt32LE(shard.lines, pos);
210
+ pos += 4;
211
+ buf.writeUInt32LE(shard.tokenCount, pos);
212
+ pos += 4;
213
+ buf.writeDoubleLE(shard.updatedAt ?? Date.now(), pos);
214
+ pos += 8;
215
+
216
+ buf.writeUInt16LE(minTokens, pos);
217
+ pos += 2;
218
+
219
+ buf.writeUInt16LE(srcIdBuf.length, pos);
220
+ pos += 2;
221
+ srcIdBuf.copy(buf, pos);
222
+ pos += srcIdBuf.length;
223
+
224
+ buf.writeUInt16LE(dictCount, pos);
225
+ pos += 2;
226
+ buf.writeUInt32LE(tokenCount, pos);
227
+ pos += 4;
228
+
229
+ // Write dictionary table
230
+ for (const h of dict.keys()) {
231
+ const hexHash = h.length === 20 ? h : h.padEnd(20, "0");
232
+ buf.write(hexHash, pos, 10, "hex");
233
+ pos += 10;
234
+ }
235
+
236
+ // Columnar byte streams:
237
+ const dictIdxOffset = pos;
238
+ const deltaLineOffset = dictIdxOffset + tokenCount * 2;
239
+ const colOffset = deltaLineOffset + tokenCount * 2;
240
+ const deltaRangeOffset = colOffset + tokenCount * 2;
241
+ const lenOffset = deltaRangeOffset + tokenCount * 4;
242
+
243
+ let prevLine = 1;
244
+ let prevRangeStart = 0;
245
+
246
+ for (let i = 0; i < tokenCount; i++) {
247
+ const t = tokens[i]!;
248
+ const curLine = t.line;
249
+ const curCol = t.column;
250
+ const curRange0 = t.range[0];
251
+ const curRange1 = t.range[1];
252
+ const tokLen = Math.max(0, curRange1 - curRange0);
253
+
254
+ buf.writeUInt16LE(tokenIndices[i]!, dictIdxOffset + i * 2);
255
+ buf.writeUInt16LE(
256
+ Math.min(65535, Math.max(0, curLine - prevLine)),
257
+ deltaLineOffset + i * 2,
258
+ );
259
+ buf.writeUInt16LE(Math.min(65535, Math.max(0, curCol)), colOffset + i * 2);
260
+ buf.writeUInt32LE(
261
+ Math.max(0, curRange0 - prevRangeStart),
262
+ deltaRangeOffset + i * 4,
263
+ );
264
+ buf.writeUInt16LE(Math.min(65535, tokLen), lenOffset + i * 2);
265
+
266
+ prevLine = curLine;
267
+ prevRangeStart = curRange0;
268
+ }
269
+
270
+ pos = lenOffset + tokenCount * 2;
271
+ return zlib.deflateRawSync(buf.subarray(0, pos));
272
+ }
273
+
274
+ /**
275
+ * Decodes a zlib-compressed binary buffer into a SerializedSourceShard.
276
+ * Returns null if invalid, corrupted, or version mismatch (fails open).
277
+ */
278
+ export function unpackBinaryShard(
279
+ compressed: Buffer,
280
+ ): SerializedSourceShard | null {
281
+ try {
282
+ const buf = zlib.inflateRawSync(compressed);
283
+ if (buf.length < 6) return null;
284
+ const magic = buf.toString("ascii", 0, 4);
285
+ if (magic !== CACHE_FORMAT_MAGIC) {
286
+ return null;
287
+ }
288
+ return unpackBinaryShardV3(buf);
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ function unpackBinaryShardV3(buf: Buffer): SerializedSourceShard | null {
295
+ let pos = 4;
296
+ const version = buf.readUInt16LE(pos);
297
+ pos += 2;
298
+ if (version !== 3) return null;
299
+
300
+ const formatLen = buf.readUInt16LE(pos);
301
+ pos += 2;
302
+ const format = buf.toString("utf8", pos, pos + formatLen);
303
+ pos += formatLen;
304
+
305
+ const hashLen = buf.readUInt16LE(pos);
306
+ pos += 2;
307
+ const contentHash = buf.toString("utf8", pos, pos + hashLen);
308
+ pos += hashLen;
309
+
310
+ const size = buf.readUInt32LE(pos);
311
+ pos += 4;
312
+ const lines = buf.readUInt32LE(pos);
313
+ pos += 4;
314
+ const tokenCount = buf.readUInt32LE(pos);
315
+ pos += 4;
316
+ const updatedAt = buf.readDoubleLE(pos);
317
+ pos += 8;
318
+
319
+ const minTokens = buf.readUInt16LE(pos);
320
+ pos += 2;
321
+
322
+ const srcLen = buf.readUInt16LE(pos);
323
+ pos += 2;
324
+ const sourceId = buf.toString("utf8", pos, pos + srcLen);
325
+ pos += srcLen;
326
+
327
+ const dictCount = buf.readUInt16LE(pos);
328
+ pos += 2;
329
+ const tokensPayloadCount = buf.readUInt32LE(pos);
330
+ pos += 4;
331
+
332
+ // Read dictionary table
333
+ const dict = new Array<string>(dictCount);
334
+ for (let i = 0; i < dictCount; i++) {
335
+ dict[i] = buf.toString("hex", pos, pos + 10);
336
+ pos += 10;
337
+ }
338
+
339
+ const dictIdxOffset = pos;
340
+ const deltaLineOffset = dictIdxOffset + tokensPayloadCount * 2;
341
+ const colOffset = deltaLineOffset + tokensPayloadCount * 2;
342
+ const deltaRangeOffset = colOffset + tokensPayloadCount * 2;
343
+ const lenOffset = deltaRangeOffset + tokensPayloadCount * 4;
344
+
345
+ const tokens: SerializedToken[] = new Array(tokensPayloadCount);
346
+ let prevLine = 1;
347
+ let prevRangeStart = 0;
348
+
349
+ for (let i = 0; i < tokensPayloadCount; i++) {
350
+ const dictIdx = buf.readUInt16LE(dictIdxOffset + i * 2);
351
+ const hash = dict[dictIdx] || "";
352
+ const deltaLine = buf.readUInt16LE(deltaLineOffset + i * 2);
353
+ const col = buf.readUInt16LE(colOffset + i * 2);
354
+ const deltaRange = buf.readUInt32LE(deltaRangeOffset + i * 4);
355
+ const tokLen = buf.readUInt16LE(lenOffset + i * 2);
356
+
357
+ const line = prevLine + deltaLine;
358
+ const rangeStart = prevRangeStart + deltaRange;
359
+ const rangeEnd = rangeStart + tokLen;
360
+
361
+ tokens[i] = {
362
+ hash,
363
+ line,
364
+ column: col,
365
+ position: i,
366
+ range: [rangeStart, rangeEnd],
367
+ };
368
+
369
+ prevLine = line;
370
+ prevRangeStart = rangeStart;
371
+ }
372
+
373
+ let memoizedFrames: SourceFrame[] | null = null;
374
+
375
+ return {
376
+ version: 1,
377
+ sourceId,
378
+ contentHash,
379
+ format,
380
+ size,
381
+ lines,
382
+ tokenCount,
383
+ minTokens,
384
+ updatedAt,
385
+ tokens,
386
+ get frames(): SourceFrame[] {
387
+ if (!memoizedFrames) {
388
+ memoizedFrames = reconstructFramesFromTokens(
389
+ tokens,
390
+ sourceId,
391
+ minTokens || 40,
392
+ );
393
+ }
394
+ return memoizedFrames;
395
+ },
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Manages persistent SQLite database caching, hydration, and lifecycle of tokenized source shards.
401
+ */
402
+ export class DiskCacheManager {
403
+ readonly rootDir: string;
404
+ readonly baseCacheDir: string;
405
+ readonly dbPath: string;
406
+ readonly workspaceCacheDir: string;
407
+ readonly configFingerprint: string;
408
+ readonly maxBytes: number;
409
+
410
+ #db: Database | null = null;
411
+ #getStmt: Statement | null = null;
412
+ #saveStmt: Statement | null = null;
413
+ #updateMtimeStmt: Statement | null = null;
414
+ #deleteStmt: Statement | null = null;
415
+ #totalSizeStmt: Statement | null = null;
416
+ #oldestShardsStmt: Statement | null = null;
417
+ #deleteAllStmt: Statement | null = null;
418
+ #closed = false;
419
+
420
+ constructor(options: DiskCacheOptions) {
421
+ this.rootDir = path.resolve(options.rootDir);
422
+ this.baseCacheDir = options.cacheDir
423
+ ? path.resolve(options.cacheDir)
424
+ : getDefaultCacheDir();
425
+ this.configFingerprint = computeConfigFingerprint(options.config);
426
+ this.dbPath = computeWorkspaceCachePath(
427
+ this.baseCacheDir,
428
+ this.rootDir,
429
+ this.configFingerprint,
430
+ );
431
+ this.workspaceCacheDir = this.baseCacheDir;
432
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES;
433
+ }
434
+
435
+ #getDb(): Database | null {
436
+ if (this.#closed) return null;
437
+ if (this.#db) return this.#db;
438
+
439
+ try {
440
+ const dir = path.dirname(this.dbPath);
441
+ if (!fsSync.existsSync(dir)) {
442
+ fsSync.mkdirSync(dir, { recursive: true });
443
+ }
444
+
445
+ const db = new Database(this.dbPath, { create: true });
446
+ db.exec("PRAGMA journal_mode = WAL;");
447
+ db.exec("PRAGMA synchronous = NORMAL;");
448
+ db.exec("PRAGMA temp_store = MEMORY;");
449
+
450
+ // Clear cache and reset schema on version difference
451
+ const versionRow = db.query("PRAGMA user_version;").get() as
452
+ | { user_version: number }
453
+ | undefined;
454
+ const schemaVersion = versionRow?.user_version ?? 0;
455
+ if (schemaVersion !== CACHE_FORMAT_VERSION) {
456
+ db.exec("DROP TABLE IF EXISTS shards;");
457
+ db.exec(`PRAGMA user_version = ${CACHE_FORMAT_VERSION};`);
458
+ }
459
+
460
+ db.exec(`
461
+ CREATE TABLE IF NOT EXISTS shards (
462
+ rel_path TEXT NOT NULL PRIMARY KEY,
463
+ content_hash TEXT NOT NULL,
464
+ payload BLOB NOT NULL,
465
+ mtime REAL NOT NULL
466
+ );
467
+ CREATE INDEX IF NOT EXISTS idx_shards_content_hash ON shards(content_hash);
468
+ CREATE INDEX IF NOT EXISTS idx_shards_mtime ON shards(mtime);
469
+ `);
470
+
471
+ this.#getStmt = db.prepare(
472
+ "SELECT payload, content_hash FROM shards WHERE rel_path = ?1",
473
+ );
474
+ this.#saveStmt = db.prepare(`
475
+ INSERT INTO shards (rel_path, content_hash, payload, mtime)
476
+ VALUES (?1, ?2, ?3, ?4)
477
+ ON CONFLICT(rel_path) DO UPDATE SET
478
+ content_hash = excluded.content_hash,
479
+ payload = excluded.payload,
480
+ mtime = excluded.mtime
481
+ `);
482
+ this.#updateMtimeStmt = db.prepare(
483
+ "UPDATE shards SET mtime = ?1 WHERE rel_path = ?2",
484
+ );
485
+ this.#deleteStmt = db.prepare("DELETE FROM shards WHERE rel_path = ?1");
486
+ this.#totalSizeStmt = db.prepare(
487
+ "SELECT COALESCE(SUM(LENGTH(payload)), 0) as total FROM shards",
488
+ );
489
+ this.#oldestShardsStmt = db.prepare(
490
+ "SELECT rel_path, LENGTH(payload) as size FROM shards ORDER BY mtime ASC",
491
+ );
492
+ this.#deleteAllStmt = db.prepare("DELETE FROM shards");
493
+
494
+ this.#db = db;
495
+ return db;
496
+ } catch {
497
+ // Fail open on SQLite creation or permission errors
498
+ return null;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Retrieves a serialized shard from the SQLite cache if present and valid.
504
+ * Returns null on cache miss or corrupted/invalid shard (fails open).
505
+ */
506
+ async getShard(
507
+ relPath: string,
508
+ contentHash: string,
509
+ ): Promise<SerializedSourceShard | null> {
510
+ try {
511
+ const db = this.#getDb();
512
+ if (!db || !this.#getStmt) return null;
513
+
514
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
515
+ const row = this.#getStmt.get(normalizedRelPath) as
516
+ | {
517
+ payload: Uint8Array | Buffer;
518
+ content_hash: string;
519
+ }
520
+ | null
521
+ | undefined;
522
+
523
+ if (!row || row.content_hash !== contentHash) {
524
+ return null;
525
+ }
526
+
527
+ const payloadBuf = Buffer.isBuffer(row.payload)
528
+ ? row.payload
529
+ : Buffer.from(
530
+ row.payload.buffer,
531
+ row.payload.byteOffset,
532
+ row.payload.byteLength,
533
+ );
534
+
535
+ const shard = unpackBinaryShard(payloadBuf);
536
+ if (
537
+ shard &&
538
+ shard.contentHash === contentHash &&
539
+ typeof shard.sourceId === "string" &&
540
+ Array.isArray(shard.frames)
541
+ ) {
542
+ try {
543
+ this.#updateMtimeStmt?.run(Date.now(), normalizedRelPath);
544
+ } catch {
545
+ // Non-fatal
546
+ }
547
+ return shard;
548
+ }
549
+
550
+ // If shard was corrupted or outdated version, clean up the invalid row
551
+ try {
552
+ this.#deleteStmt?.run(normalizedRelPath);
553
+ } catch {
554
+ // Non-fatal
555
+ }
556
+
557
+ return null;
558
+ } catch {
559
+ // Fail open on any error
560
+ return null;
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Atomically saves a pre-tokenized shard to SQLite cache table.
566
+ * Fails open without throwing on I/O errors.
567
+ */
568
+ async saveShard(
569
+ shard: SerializedSourceShard,
570
+ relPath?: string,
571
+ ): Promise<void> {
572
+ try {
573
+ const db = this.#getDb();
574
+ if (!db || !this.#saveStmt) return;
575
+
576
+ const targetRelPath =
577
+ relPath ??
578
+ (path.isAbsolute(shard.sourceId)
579
+ ? path.relative(this.rootDir, shard.sourceId)
580
+ : shard.sourceId);
581
+
582
+ const normalizedRelPath = targetRelPath.replace(/\\/g, "/");
583
+ const payload = packBinaryShard(shard);
584
+
585
+ this.#saveStmt.run(
586
+ normalizedRelPath,
587
+ shard.contentHash,
588
+ payload,
589
+ Date.now(),
590
+ );
591
+ } catch {
592
+ // Fail open: cache write failures should not disrupt indexing
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Prunes the oldest shards in the SQLite cache if total payload size exceeds budget.
598
+ */
599
+ async prune(maxBytes?: number): Promise<void> {
600
+ const budget = maxBytes !== undefined ? maxBytes : this.maxBytes;
601
+
602
+ try {
603
+ const db = this.#getDb();
604
+ if (!db) return;
605
+
606
+ if (budget <= 0) {
607
+ this.#deleteAllStmt?.run();
608
+ try {
609
+ db.exec("VACUUM;");
610
+ } catch {
611
+ // Ignore vacuum errors
612
+ }
613
+ return;
614
+ }
615
+
616
+ const totalRow = this.#totalSizeStmt?.get() as
617
+ | { total: number }
618
+ | null
619
+ | undefined;
620
+ let totalSize = totalRow?.total ?? 0;
621
+
622
+ if (totalSize <= budget) {
623
+ return;
624
+ }
625
+
626
+ const oldestShards = (this.#oldestShardsStmt?.all() ?? []) as Array<{
627
+ rel_path: string;
628
+ size: number;
629
+ }>;
630
+
631
+ let deletedAny = false;
632
+ for (const entry of oldestShards) {
633
+ if (totalSize <= budget) {
634
+ break;
635
+ }
636
+ try {
637
+ this.#deleteStmt?.run(entry.rel_path);
638
+ totalSize -= entry.size;
639
+ deletedAny = true;
640
+ } catch {
641
+ // Ignore individual deletion errors
642
+ }
643
+ }
644
+
645
+ if (deletedAny) {
646
+ try {
647
+ db.exec("VACUUM;");
648
+ } catch {
649
+ // Ignore vacuum errors
650
+ }
651
+ }
652
+ } catch {
653
+ // Fail open on pruning errors
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Clears all cached shards in the current workspace cache database.
659
+ */
660
+ async clear(): Promise<void> {
661
+ try {
662
+ if (this.#db) {
663
+ try {
664
+ this.#db.close();
665
+ } catch {}
666
+ this.#db = null;
667
+ this.#getStmt = null;
668
+ this.#saveStmt = null;
669
+ this.#updateMtimeStmt = null;
670
+ this.#deleteStmt = null;
671
+ this.#totalSizeStmt = null;
672
+ this.#oldestShardsStmt = null;
673
+ this.#deleteAllStmt = null;
674
+ }
675
+
676
+ await fs.unlink(this.dbPath).catch(() => {});
677
+ await fs.unlink(`${this.dbPath}-wal`).catch(() => {});
678
+ await fs.unlink(`${this.dbPath}-shm`).catch(() => {});
679
+ } catch {
680
+ // Fail open
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Safely closes the SQLite database connection.
686
+ */
687
+ close(): void {
688
+ this.#closed = true;
689
+ if (this.#db) {
690
+ try {
691
+ this.#db.close();
692
+ } catch {}
693
+ this.#db = null;
694
+ this.#getStmt = null;
695
+ this.#saveStmt = null;
696
+ this.#updateMtimeStmt = null;
697
+ this.#deleteStmt = null;
698
+ this.#totalSizeStmt = null;
699
+ this.#oldestShardsStmt = null;
700
+ this.#deleteAllStmt = null;
701
+ }
702
+ }
703
+ }