omp-plugin-duplicate-detector 0.1.1 → 0.2.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.
package/src/disk-cache.ts CHANGED
@@ -21,10 +21,13 @@ import {
21
21
  import type { WorkspaceOptions } from "./worker-protocol";
22
22
 
23
23
  const DEFAULT_MAX_CACHE_BYTES = 250 * 1024 * 1024; // 250 MB
24
+ const TOKENIZER_CACHE_VERSION = "4.0";
24
25
 
25
26
  export interface DiskCacheOptions {
26
27
  /** Root directory of the workspace */
27
28
  rootDir: string;
29
+ /** Stable repository key (from resolveRepositoryContext) */
30
+ repositoryKey?: string;
28
31
  /** Custom base cache directory (defaults to OS user cache directory) */
29
32
  cacheDir?: string;
30
33
  /** Detector configuration used to compute configuration fingerprint */
@@ -65,7 +68,7 @@ export function getDefaultCacheDir(): string {
65
68
  export function computeConfigFingerprint(
66
69
  config?: WorkspaceOptions | SourceAwareIndexOptions,
67
70
  ): string {
68
- if (!config) return "default";
71
+ if (!config) return `default_${TOKENIZER_CACHE_VERSION}`;
69
72
 
70
73
  let sortedFormats: Record<string, string[]> | undefined;
71
74
  if (config.formatsExts) {
@@ -76,6 +79,7 @@ export function computeConfigFingerprint(
76
79
  }
77
80
 
78
81
  const canonical = {
82
+ version: TOKENIZER_CACHE_VERSION,
79
83
  minTokens: config.minTokens ?? 40,
80
84
  minLines: config.minLines ?? 5,
81
85
  maxLines: config.maxLines ?? 500,
@@ -91,20 +95,26 @@ export function computeConfigFingerprint(
91
95
  }
92
96
 
93
97
  /**
94
- * Computes the workspace SQLite cache database path keyed by canonical workspace path and config fingerprint.
98
+ * Computes the workspace SQLite cache database path keyed by repository identity and config fingerprint.
95
99
  */
96
100
  export function computeWorkspaceCachePath(
97
101
  baseDir: string,
98
- rootDir: string,
102
+ repositoryKeyOrRootDir: string,
99
103
  configFingerprint: string,
100
104
  ): 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`);
105
+ const isKey = /^[0-9a-f]{16}$/i.test(repositoryKeyOrRootDir);
106
+ const repoKey = isKey
107
+ ? repositoryKeyOrRootDir
108
+ : crypto
109
+ .createHash("sha256")
110
+ .update(path.resolve(repositoryKeyOrRootDir))
111
+ .digest("hex")
112
+ .slice(0, 16);
113
+
114
+ return path.join(
115
+ baseDir,
116
+ `${repoKey}_${configFingerprint}_v${CACHE_FORMAT_VERSION}.sqlite`,
117
+ );
108
118
  }
109
119
 
110
120
  /**
@@ -126,7 +136,7 @@ export function computeShardKey(
126
136
  export const CACHE_FORMAT_MAGIC = "DUP3";
127
137
 
128
138
  /** Current binary format & SQLite schema version */
129
- export const CACHE_FORMAT_VERSION = 3;
139
+ export const CACHE_FORMAT_VERSION = 4;
130
140
 
131
141
  /**
132
142
  * Encodes a SerializedSourceShard into a high-density, zlib-compressed binary buffer (DUP3 format).
@@ -143,132 +153,94 @@ function packBinaryShardV3(
143
153
  shard: SerializedSourceShard,
144
154
  tokens: SerializedToken[],
145
155
  ): 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
156
  const tokenCount = tokens.length;
150
- const minTokens = shard.minTokens ?? 40;
157
+ const dictionary: string[] = [];
158
+ const dictMap = new Map<string, number>();
151
159
 
152
- // Build dictionary of unique 20-character token hashes
153
- const dict = new Map<string, number>();
154
- const tokenIndices = new Uint16Array(tokenCount);
155
160
  for (let i = 0; i < tokenCount; i++) {
156
161
  const h = tokens[i]!.hash;
157
- let idx = dict.get(h);
158
- if (idx === undefined) {
159
- idx = dict.size;
160
- dict.set(h, idx);
162
+ if (!dictMap.has(h)) {
163
+ dictMap.set(h, dictionary.length);
164
+ dictionary.push(h);
161
165
  }
162
- tokenIndices[i] = idx;
163
166
  }
164
167
 
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
- );
168
+ const dictCount = dictionary.length;
169
+ const dictEntries: Buffer[] = new Array(dictCount);
170
+ for (let i = 0; i < dictCount; i++) {
171
+ const hex = dictionary[i]!;
172
+ dictEntries[i] = Buffer.from(hex, "hex");
173
+ }
174
+ const dictBuf = Buffer.concat(dictEntries);
190
175
 
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;
176
+ const indicesBuf = Buffer.allocUnsafe(tokenCount * 2);
177
+ for (let i = 0; i < tokenCount; i++) {
178
+ const idx = dictMap.get(tokens[i]!.hash)!;
179
+ indicesBuf.writeUInt16LE(idx, i * 2);
234
180
  }
235
181
 
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;
182
+ const colBytes = tokenCount * 4;
183
+ const dLinesBuf = Buffer.allocUnsafe(colBytes);
184
+ const dColsBuf = Buffer.allocUnsafe(colBytes);
185
+ const dPosBuf = Buffer.allocUnsafe(colBytes);
186
+ const dLenBuf = Buffer.allocUnsafe(colBytes);
242
187
 
243
- let prevLine = 1;
244
- let prevRangeStart = 0;
188
+ let prevLine = 0;
189
+ let prevCol = 0;
190
+ let prevPos = 0;
245
191
 
246
192
  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;
193
+ const tok = tokens[i]!;
194
+ const dLine = tok.line - prevLine;
195
+ const dCol = tok.column - prevCol;
196
+ const dPos = tok.position - prevPos;
197
+ const len =
198
+ Array.isArray(tok.range) && tok.range.length >= 2
199
+ ? tok.range[1] - tok.range[0]
200
+ : 0;
201
+
202
+ dLinesBuf.writeInt32LE(dLine, i * 4);
203
+ dColsBuf.writeInt32LE(dCol, i * 4);
204
+ dPosBuf.writeInt32LE(dPos, i * 4);
205
+ dLenBuf.writeUInt32LE(len, i * 4);
206
+
207
+ prevLine = tok.line;
208
+ prevCol = tok.column;
209
+ prevPos = tok.position;
268
210
  }
269
211
 
270
- pos = lenOffset + tokenCount * 2;
271
- return zlib.deflateRawSync(buf.subarray(0, pos));
212
+ const meta = {
213
+ sourceId: shard.sourceId,
214
+ contentHash: shard.contentHash,
215
+ format: shard.format,
216
+ size: shard.size,
217
+ lines: shard.lines,
218
+ tokenCount: shard.tokenCount,
219
+ minTokens: shard.minTokens,
220
+ updatedAt: shard.updatedAt ?? Date.now(),
221
+ };
222
+ const metaJson = Buffer.from(JSON.stringify(meta), "utf-8");
223
+
224
+ const HEADER_SIZE = 16;
225
+ const header = Buffer.allocUnsafe(HEADER_SIZE);
226
+ header.write(CACHE_FORMAT_MAGIC, 0, 4, "ascii");
227
+ header.writeUInt16LE(CACHE_FORMAT_VERSION, 4);
228
+ header.writeUInt16LE(metaJson.length, 6);
229
+ header.writeUInt32LE(tokenCount, 8);
230
+ header.writeUInt32LE(dictCount, 12);
231
+
232
+ const rawUncompressed = Buffer.concat([
233
+ header,
234
+ metaJson,
235
+ dictBuf,
236
+ indicesBuf,
237
+ dLinesBuf,
238
+ dColsBuf,
239
+ dPosBuf,
240
+ dLenBuf,
241
+ ]);
242
+
243
+ return zlib.deflateSync(rawUncompressed, { level: 6 });
272
244
  }
273
245
 
274
246
  /**
@@ -279,121 +251,126 @@ export function unpackBinaryShard(
279
251
  compressed: Buffer,
280
252
  ): SerializedSourceShard | null {
281
253
  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;
254
+ const raw = zlib.inflateSync(compressed);
255
+ if (raw.length < 16) return null;
256
+
257
+ const magic = raw.toString("ascii", 0, 4);
258
+ if (magic === CACHE_FORMAT_MAGIC) {
259
+ return unpackBinaryShardV3(raw);
287
260
  }
288
- return unpackBinaryShardV3(buf);
261
+ return null;
289
262
  } catch {
290
263
  return null;
291
264
  }
292
265
  }
293
266
 
294
267
  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
- }
268
+ try {
269
+ const version = buf.readUInt16LE(4);
270
+ if (version < 3 || version > CACHE_FORMAT_VERSION) return null;
271
+
272
+ const metaLen = buf.readUInt16LE(6);
273
+ const tokenCount = buf.readUInt32LE(8);
274
+ const dictCount = buf.readUInt32LE(12);
275
+
276
+ let offset = 16;
277
+ const metaJsonBuf = buf.subarray(offset, offset + metaLen);
278
+ offset += metaLen;
279
+
280
+ const meta = JSON.parse(metaJsonBuf.toString("utf-8"));
281
+ const TOKEN_HASH_RAW_BYTES = 10;
282
+ const dictByteLen = dictCount * TOKEN_HASH_RAW_BYTES;
283
+ if (buf.length < offset + dictByteLen) return null;
284
+
285
+ const dictionary: string[] = new Array(dictCount);
286
+ for (let i = 0; i < dictCount; i++) {
287
+ dictionary[i] = buf
288
+ .subarray(offset + i * 10, offset + (i + 1) * 10)
289
+ .toString("hex");
290
+ }
291
+ offset += dictByteLen;
338
292
 
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
- };
293
+ const indicesByteLen = tokenCount * 2;
294
+ if (buf.length < offset + indicesByteLen) return null;
295
+ const indices = new Uint16Array(tokenCount);
296
+ for (let i = 0; i < tokenCount; i++) {
297
+ indices[i] = buf.readUInt16LE(offset + i * 2);
298
+ }
299
+ offset += indicesByteLen;
368
300
 
369
- prevLine = line;
370
- prevRangeStart = rangeStart;
371
- }
301
+ const colBytes = tokenCount * 4;
302
+ if (buf.length < offset + colBytes * 4) return null;
372
303
 
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
- };
304
+ const dLines = new Int32Array(tokenCount);
305
+ const dCols = new Int32Array(tokenCount);
306
+ const dPos = new Int32Array(tokenCount);
307
+ const dLens = new Uint32Array(tokenCount);
308
+
309
+ for (let i = 0; i < tokenCount; i++) {
310
+ dLines[i] = buf.readInt32LE(offset + i * 4);
311
+ }
312
+ offset += colBytes;
313
+
314
+ for (let i = 0; i < tokenCount; i++) {
315
+ dCols[i] = buf.readInt32LE(offset + i * 4);
316
+ }
317
+ offset += colBytes;
318
+
319
+ for (let i = 0; i < tokenCount; i++) {
320
+ dPos[i] = buf.readInt32LE(offset + i * 4);
321
+ }
322
+ offset += colBytes;
323
+
324
+ for (let i = 0; i < tokenCount; i++) {
325
+ dLens[i] = buf.readUInt32LE(offset + i * 4);
326
+ }
327
+ offset += colBytes;
328
+
329
+ const tokens: SerializedToken[] = new Array(tokenCount);
330
+ let curLine = 0;
331
+ let curCol = 0;
332
+ let curPos = 0;
333
+
334
+ for (let i = 0; i < tokenCount; i++) {
335
+ curLine += dLines[i]!;
336
+ curCol += dCols[i]!;
337
+ curPos += dPos[i]!;
338
+ const len = dLens[i]!;
339
+ const dictIdx = indices[i]!;
340
+ const hash = dictionary[dictIdx] ?? "";
341
+
342
+ tokens[i] = {
343
+ hash,
344
+ line: curLine,
345
+ column: curCol,
346
+ position: curPos,
347
+ range: [curPos, curPos + len],
348
+ };
349
+ }
350
+
351
+ const minTokens = meta.minTokens ?? 40;
352
+ const frames: SourceFrame[] = reconstructFramesFromTokens(
353
+ tokens,
354
+ meta.sourceId,
355
+ minTokens,
356
+ );
357
+
358
+ return {
359
+ version,
360
+ sourceId: meta.sourceId,
361
+ contentHash: meta.contentHash,
362
+ format: meta.format,
363
+ size: meta.size,
364
+ lines: meta.lines,
365
+ tokenCount,
366
+ minTokens,
367
+ updatedAt: meta.updatedAt,
368
+ tokens,
369
+ frames,
370
+ };
371
+ } catch {
372
+ return null;
373
+ }
397
374
  }
398
375
 
399
376
  /**
@@ -401,6 +378,7 @@ function unpackBinaryShardV3(buf: Buffer): SerializedSourceShard | null {
401
378
  */
402
379
  export class DiskCacheManager {
403
380
  readonly rootDir: string;
381
+ readonly repositoryKey: string;
404
382
  readonly baseCacheDir: string;
405
383
  readonly dbPath: string;
406
384
  readonly workspaceCacheDir: string;
@@ -410,8 +388,8 @@ export class DiskCacheManager {
410
388
  #db: Database | null = null;
411
389
  #getStmt: Statement | null = null;
412
390
  #saveStmt: Statement | null = null;
413
- #updateMtimeStmt: Statement | null = null;
414
391
  #deleteStmt: Statement | null = null;
392
+ #deleteByRelPathStmt: Statement | null = null;
415
393
  #totalSizeStmt: Statement | null = null;
416
394
  #oldestShardsStmt: Statement | null = null;
417
395
  #deleteAllStmt: Statement | null = null;
@@ -419,13 +397,20 @@ export class DiskCacheManager {
419
397
 
420
398
  constructor(options: DiskCacheOptions) {
421
399
  this.rootDir = path.resolve(options.rootDir);
400
+ this.repositoryKey =
401
+ options.repositoryKey ??
402
+ crypto
403
+ .createHash("sha256")
404
+ .update(this.rootDir)
405
+ .digest("hex")
406
+ .slice(0, 16);
422
407
  this.baseCacheDir = options.cacheDir
423
408
  ? path.resolve(options.cacheDir)
424
409
  : getDefaultCacheDir();
425
410
  this.configFingerprint = computeConfigFingerprint(options.config);
426
411
  this.dbPath = computeWorkspaceCachePath(
427
412
  this.baseCacheDir,
428
- this.rootDir,
413
+ this.repositoryKey,
429
414
  this.configFingerprint,
430
415
  );
431
416
  this.workspaceCacheDir = this.baseCacheDir;
@@ -436,64 +421,78 @@ export class DiskCacheManager {
436
421
  if (this.#closed) return null;
437
422
  if (this.#db) return this.#db;
438
423
 
424
+ let db: Database | null = null;
439
425
  try {
440
426
  const dir = path.dirname(this.dbPath);
441
427
  if (!fsSync.existsSync(dir)) {
442
428
  fsSync.mkdirSync(dir, { recursive: true });
443
429
  }
444
430
 
445
- const db = new Database(this.dbPath, { create: true });
446
- db.exec("PRAGMA journal_mode = WAL;");
431
+ db = new Database(this.dbPath, { create: true });
432
+ db.exec("PRAGMA busy_timeout = 2000;");
433
+ const journalRow = db.query("PRAGMA journal_mode = WAL;").get() as
434
+ | { journal_mode?: string }
435
+ | undefined;
436
+ if (journalRow?.journal_mode?.toLowerCase() !== "wal") {
437
+ try {
438
+ db.close();
439
+ } catch {}
440
+ return null;
441
+ }
447
442
  db.exec("PRAGMA synchronous = NORMAL;");
448
443
  db.exec("PRAGMA temp_store = MEMORY;");
449
-
450
- // Clear cache and reset schema on version difference
451
444
  const versionRow = db.query("PRAGMA user_version;").get() as
452
445
  | { user_version: number }
453
446
  | undefined;
454
447
  const schemaVersion = versionRow?.user_version ?? 0;
455
448
  if (schemaVersion !== CACHE_FORMAT_VERSION) {
456
- db.exec("DROP TABLE IF EXISTS shards;");
449
+ try {
450
+ db.exec("DELETE FROM shards;");
451
+ } catch {}
457
452
  db.exec(`PRAGMA user_version = ${CACHE_FORMAT_VERSION};`);
458
453
  }
459
454
 
460
455
  db.exec(`
461
456
  CREATE TABLE IF NOT EXISTS shards (
462
- rel_path TEXT NOT NULL PRIMARY KEY,
457
+ rel_path TEXT NOT NULL,
463
458
  content_hash TEXT NOT NULL,
464
459
  payload BLOB NOT NULL,
465
- mtime REAL NOT NULL
460
+ mtime REAL NOT NULL,
461
+ PRIMARY KEY (rel_path, content_hash)
466
462
  );
467
- CREATE INDEX IF NOT EXISTS idx_shards_content_hash ON shards(content_hash);
468
463
  CREATE INDEX IF NOT EXISTS idx_shards_mtime ON shards(mtime);
469
464
  `);
470
-
471
465
  this.#getStmt = db.prepare(
472
- "SELECT payload, content_hash FROM shards WHERE rel_path = ?1",
466
+ "SELECT payload FROM shards WHERE rel_path = ?1 AND content_hash = ?2",
473
467
  );
474
468
  this.#saveStmt = db.prepare(`
475
469
  INSERT INTO shards (rel_path, content_hash, payload, mtime)
476
470
  VALUES (?1, ?2, ?3, ?4)
477
- ON CONFLICT(rel_path) DO UPDATE SET
478
- content_hash = excluded.content_hash,
479
- payload = excluded.payload,
471
+ ON CONFLICT(rel_path, content_hash) DO UPDATE SET
480
472
  mtime = excluded.mtime
481
473
  `);
482
- this.#updateMtimeStmt = db.prepare(
483
- "UPDATE shards SET mtime = ?1 WHERE rel_path = ?2",
474
+ this.#deleteStmt = db.prepare(
475
+ "DELETE FROM shards WHERE rel_path = ?1 AND content_hash = ?2",
476
+ );
477
+ this.#deleteByRelPathStmt = db.prepare(
478
+ "DELETE FROM shards WHERE rel_path = ?1",
484
479
  );
485
- this.#deleteStmt = db.prepare("DELETE FROM shards WHERE rel_path = ?1");
486
480
  this.#totalSizeStmt = db.prepare(
487
481
  "SELECT COALESCE(SUM(LENGTH(payload)), 0) as total FROM shards",
488
482
  );
489
483
  this.#oldestShardsStmt = db.prepare(
490
- "SELECT rel_path, LENGTH(payload) as size FROM shards ORDER BY mtime ASC",
484
+ "SELECT rowid, LENGTH(payload) as size FROM shards ORDER BY mtime ASC LIMIT ?1",
491
485
  );
492
486
  this.#deleteAllStmt = db.prepare("DELETE FROM shards");
493
487
 
494
488
  this.#db = db;
495
489
  return db;
496
490
  } catch {
491
+ if (db) {
492
+ try {
493
+ db.close();
494
+ } catch {}
495
+ }
497
496
  // Fail open on SQLite creation or permission errors
498
497
  return null;
499
498
  }
@@ -501,6 +500,7 @@ export class DiskCacheManager {
501
500
 
502
501
  /**
503
502
  * Retrieves a serialized shard from the SQLite cache if present and valid.
503
+ * Pure read-only operation: does not issue write transactions on cache hits.
504
504
  * Returns null on cache miss or corrupted/invalid shard (fails open).
505
505
  */
506
506
  async getShard(
@@ -512,15 +512,14 @@ export class DiskCacheManager {
512
512
  if (!db || !this.#getStmt) return null;
513
513
 
514
514
  const normalizedRelPath = relPath.replace(/\\/g, "/");
515
- const row = this.#getStmt.get(normalizedRelPath) as
515
+ const row = this.#getStmt.get(normalizedRelPath, contentHash) as
516
516
  | {
517
517
  payload: Uint8Array | Buffer;
518
- content_hash: string;
519
518
  }
520
519
  | null
521
520
  | undefined;
522
521
 
523
- if (!row || row.content_hash !== contentHash) {
522
+ if (!row) {
524
523
  return null;
525
524
  }
526
525
 
@@ -536,24 +535,11 @@ export class DiskCacheManager {
536
535
  if (
537
536
  shard &&
538
537
  shard.contentHash === contentHash &&
539
- typeof shard.sourceId === "string" &&
540
538
  Array.isArray(shard.frames)
541
539
  ) {
542
- try {
543
- this.#updateMtimeStmt?.run(Date.now(), normalizedRelPath);
544
- } catch {
545
- // Non-fatal
546
- }
547
540
  return shard;
548
541
  }
549
542
 
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
543
  return null;
558
544
  } catch {
559
545
  // Fail open on any error
@@ -592,9 +578,84 @@ export class DiskCacheManager {
592
578
  // Fail open: cache write failures should not disrupt indexing
593
579
  }
594
580
  }
581
+ /**
582
+ * Deletes a cached shard by relPath and contentHash if present.
583
+ */
584
+ async deleteShard(relPath: string, contentHash: string): Promise<void> {
585
+ try {
586
+ const db = this.#getDb();
587
+ if (!db || !this.#deleteStmt) return;
588
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
589
+ this.#deleteStmt.run(normalizedRelPath, contentHash);
590
+ } catch {
591
+ // Fail open
592
+ }
593
+ }
594
+ /**
595
+ * Deletes all cached shards for a given relPath regardless of contentHash.
596
+ */
597
+ async deleteByRelPath(relPath: string): Promise<void> {
598
+ try {
599
+ const db = this.#getDb();
600
+ if (!db || !this.#deleteByRelPathStmt) return;
601
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
602
+ this.#deleteByRelPathStmt.run(normalizedRelPath);
603
+ } catch {
604
+ // Fail open
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Atomically saves a batch of pre-tokenized shards inside a single SQLite transaction.
610
+ */
611
+ async saveShards(
612
+ items: Array<{ shard: SerializedSourceShard; relPath?: string }>,
613
+ ): Promise<void> {
614
+ if (items.length === 0) return;
615
+ try {
616
+ const db = this.#getDb();
617
+ if (!db || !this.#saveStmt) return;
618
+
619
+ const stmt = this.#saveStmt;
620
+ const root = this.rootDir;
621
+ const now = Date.now();
622
+
623
+ // Pre-pack payloads outside transaction to minimize write lock time
624
+ const prepared: Array<{
625
+ relPath: string;
626
+ hash: string;
627
+ payload: Buffer;
628
+ }> = [];
629
+ for (const item of items) {
630
+ const targetRelPath =
631
+ item.relPath ??
632
+ (path.isAbsolute(item.shard.sourceId)
633
+ ? path.relative(root, item.shard.sourceId)
634
+ : item.shard.sourceId);
635
+ const normalizedRelPath = targetRelPath.replace(/\\/g, "/");
636
+ const payload = packBinaryShard(item.shard);
637
+ prepared.push({
638
+ relPath: normalizedRelPath,
639
+ hash: item.shard.contentHash,
640
+ payload,
641
+ });
642
+ }
643
+
644
+ const tx = db.transaction((entries: typeof prepared) => {
645
+ for (const entry of entries) {
646
+ stmt.run(entry.relPath, entry.hash, entry.payload, now);
647
+ }
648
+ });
649
+
650
+ tx(prepared);
651
+ } catch {
652
+ // Fail open
653
+ }
654
+ }
595
655
 
596
656
  /**
597
- * Prunes the oldest shards in the SQLite cache if total payload size exceeds budget.
657
+ * Prunes oldest shards if total payload size exceeds budget using atomic windowed DELETE.
658
+ * Avoids concurrent VACUUM calls during active sessions.
598
659
  */
599
660
  async prune(maxBytes?: number): Promise<void> {
600
661
  const budget = maxBytes !== undefined ? maxBytes : this.maxBytes;
@@ -605,48 +666,44 @@ export class DiskCacheManager {
605
666
 
606
667
  if (budget <= 0) {
607
668
  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
669
  return;
624
670
  }
625
671
 
626
- const oldestShards = (this.#oldestShardsStmt?.all() ?? []) as Array<{
627
- rel_path: string;
628
- size: number;
629
- }>;
672
+ // Iteratively prune oldest shards in bounded windows
673
+ for (let iter = 0; iter < 10; iter++) {
674
+ const totalRow = this.#totalSizeStmt?.get() as
675
+ | { total: number }
676
+ | null
677
+ | undefined;
678
+ const totalSize = totalRow?.total ?? 0;
630
679
 
631
- let deletedAny = false;
632
- for (const entry of oldestShards) {
633
680
  if (totalSize <= budget) {
634
681
  break;
635
682
  }
636
- try {
637
- this.#deleteStmt?.run(entry.rel_path);
638
- totalSize -= entry.size;
639
- deletedAny = true;
640
- } catch {
641
- // Ignore individual deletion errors
683
+
684
+ const excess = totalSize - budget;
685
+ const rows = (this.#oldestShardsStmt?.all(100) ?? []) as Array<{
686
+ rowid: number;
687
+ size: number;
688
+ }>;
689
+
690
+ if (rows.length === 0) break;
691
+
692
+ const rowidsToDelete: number[] = [];
693
+ let freed = 0;
694
+ for (const r of rows) {
695
+ rowidsToDelete.push(r.rowid);
696
+ freed += r.size;
697
+ if (freed >= excess) break;
642
698
  }
643
- }
644
699
 
645
- if (deletedAny) {
646
- try {
647
- db.exec("VACUUM;");
648
- } catch {
649
- // Ignore vacuum errors
700
+ if (rowidsToDelete.length > 0) {
701
+ const deleteBatchStmt = db.prepare(
702
+ `DELETE FROM shards WHERE rowid IN (${rowidsToDelete.join(",")})`,
703
+ );
704
+ deleteBatchStmt.run();
705
+ } else {
706
+ break;
650
707
  }
651
708
  }
652
709
  } catch {
@@ -655,27 +712,14 @@ export class DiskCacheManager {
655
712
  }
656
713
 
657
714
  /**
658
- * Clears all cached shards in the current workspace cache database.
715
+ * Clears all cached shards in the current workspace cache database non-destructively.
659
716
  */
660
717
  async clear(): Promise<void> {
661
718
  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;
719
+ const db = this.#getDb();
720
+ if (db && this.#deleteAllStmt) {
721
+ this.#deleteAllStmt.run();
674
722
  }
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
723
  } catch {
680
724
  // Fail open
681
725
  }
@@ -693,11 +737,36 @@ export class DiskCacheManager {
693
737
  this.#db = null;
694
738
  this.#getStmt = null;
695
739
  this.#saveStmt = null;
696
- this.#updateMtimeStmt = null;
697
740
  this.#deleteStmt = null;
741
+ this.#deleteByRelPathStmt = null;
698
742
  this.#totalSizeStmt = null;
699
743
  this.#oldestShardsStmt = null;
700
744
  this.#deleteAllStmt = null;
701
745
  }
702
746
  }
703
747
  }
748
+
749
+ /**
750
+ * Removes legacy pre-v4 cache files from the cache directory.
751
+ */
752
+ export async function cleanupLegacyCacheFiles(
753
+ customCacheDir?: string,
754
+ ): Promise<void> {
755
+ const baseDir = customCacheDir ?? getDefaultCacheDir();
756
+ try {
757
+ if (!fsSync.existsSync(baseDir)) return;
758
+ const entries = await fs.readdir(baseDir);
759
+ for (const entry of entries) {
760
+ if (
761
+ entry.endsWith(".sqlite") &&
762
+ !entry.includes(`_v${CACHE_FORMAT_VERSION}.sqlite`)
763
+ ) {
764
+ await fs.unlink(path.join(baseDir, entry)).catch(() => {});
765
+ await fs.unlink(path.join(baseDir, `${entry}-wal`)).catch(() => {});
766
+ await fs.unlink(path.join(baseDir, `${entry}-shm`)).catch(() => {});
767
+ }
768
+ }
769
+ } catch {
770
+ // Fail open
771
+ }
772
+ }