omp-plugin-duplicate-detector 0.1.0 → 0.2.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/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
+ export 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,7 +388,6 @@ 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;
415
392
  #totalSizeStmt: Statement | null = null;
416
393
  #oldestShardsStmt: Statement | null = null;
@@ -419,13 +396,20 @@ export class DiskCacheManager {
419
396
 
420
397
  constructor(options: DiskCacheOptions) {
421
398
  this.rootDir = path.resolve(options.rootDir);
399
+ this.repositoryKey =
400
+ options.repositoryKey ??
401
+ crypto
402
+ .createHash("sha256")
403
+ .update(this.rootDir)
404
+ .digest("hex")
405
+ .slice(0, 16);
422
406
  this.baseCacheDir = options.cacheDir
423
407
  ? path.resolve(options.cacheDir)
424
408
  : getDefaultCacheDir();
425
409
  this.configFingerprint = computeConfigFingerprint(options.config);
426
410
  this.dbPath = computeWorkspaceCachePath(
427
411
  this.baseCacheDir,
428
- this.rootDir,
412
+ this.repositoryKey,
429
413
  this.configFingerprint,
430
414
  );
431
415
  this.workspaceCacheDir = this.baseCacheDir;
@@ -436,64 +420,75 @@ export class DiskCacheManager {
436
420
  if (this.#closed) return null;
437
421
  if (this.#db) return this.#db;
438
422
 
423
+ let db: Database | null = null;
439
424
  try {
440
425
  const dir = path.dirname(this.dbPath);
441
426
  if (!fsSync.existsSync(dir)) {
442
427
  fsSync.mkdirSync(dir, { recursive: true });
443
428
  }
444
429
 
445
- const db = new Database(this.dbPath, { create: true });
446
- db.exec("PRAGMA journal_mode = WAL;");
430
+ db = new Database(this.dbPath, { create: true });
431
+ db.exec("PRAGMA busy_timeout = 2000;");
432
+ const journalRow = db.query("PRAGMA journal_mode = WAL;").get() as
433
+ | { journal_mode?: string }
434
+ | undefined;
435
+ if (journalRow?.journal_mode?.toLowerCase() !== "wal") {
436
+ try {
437
+ db.close();
438
+ } catch {}
439
+ return null;
440
+ }
447
441
  db.exec("PRAGMA synchronous = NORMAL;");
448
442
  db.exec("PRAGMA temp_store = MEMORY;");
449
-
450
- // Clear cache and reset schema on version difference
451
443
  const versionRow = db.query("PRAGMA user_version;").get() as
452
444
  | { user_version: number }
453
445
  | undefined;
454
446
  const schemaVersion = versionRow?.user_version ?? 0;
455
447
  if (schemaVersion !== CACHE_FORMAT_VERSION) {
456
- db.exec("DROP TABLE IF EXISTS shards;");
448
+ try {
449
+ db.exec("DELETE FROM shards;");
450
+ } catch {}
457
451
  db.exec(`PRAGMA user_version = ${CACHE_FORMAT_VERSION};`);
458
452
  }
459
453
 
460
454
  db.exec(`
461
455
  CREATE TABLE IF NOT EXISTS shards (
462
- rel_path TEXT NOT NULL PRIMARY KEY,
456
+ rel_path TEXT NOT NULL,
463
457
  content_hash TEXT NOT NULL,
464
458
  payload BLOB NOT NULL,
465
- mtime REAL NOT NULL
459
+ mtime REAL NOT NULL,
460
+ PRIMARY KEY (rel_path, content_hash)
466
461
  );
467
- CREATE INDEX IF NOT EXISTS idx_shards_content_hash ON shards(content_hash);
468
462
  CREATE INDEX IF NOT EXISTS idx_shards_mtime ON shards(mtime);
469
463
  `);
470
-
471
464
  this.#getStmt = db.prepare(
472
- "SELECT payload, content_hash FROM shards WHERE rel_path = ?1",
465
+ "SELECT payload FROM shards WHERE rel_path = ?1 AND content_hash = ?2",
473
466
  );
474
467
  this.#saveStmt = db.prepare(`
475
468
  INSERT INTO shards (rel_path, content_hash, payload, mtime)
476
469
  VALUES (?1, ?2, ?3, ?4)
477
- ON CONFLICT(rel_path) DO UPDATE SET
478
- content_hash = excluded.content_hash,
479
- payload = excluded.payload,
470
+ ON CONFLICT(rel_path, content_hash) DO UPDATE SET
480
471
  mtime = excluded.mtime
481
472
  `);
482
- this.#updateMtimeStmt = db.prepare(
483
- "UPDATE shards SET mtime = ?1 WHERE rel_path = ?2",
473
+ this.#deleteStmt = db.prepare(
474
+ "DELETE FROM shards WHERE rel_path = ?1 AND content_hash = ?2",
484
475
  );
485
- this.#deleteStmt = db.prepare("DELETE FROM shards WHERE rel_path = ?1");
486
476
  this.#totalSizeStmt = db.prepare(
487
477
  "SELECT COALESCE(SUM(LENGTH(payload)), 0) as total FROM shards",
488
478
  );
489
479
  this.#oldestShardsStmt = db.prepare(
490
- "SELECT rel_path, LENGTH(payload) as size FROM shards ORDER BY mtime ASC",
480
+ "SELECT rowid, LENGTH(payload) as size FROM shards ORDER BY mtime ASC LIMIT ?1",
491
481
  );
492
482
  this.#deleteAllStmt = db.prepare("DELETE FROM shards");
493
483
 
494
484
  this.#db = db;
495
485
  return db;
496
486
  } catch {
487
+ if (db) {
488
+ try {
489
+ db.close();
490
+ } catch {}
491
+ }
497
492
  // Fail open on SQLite creation or permission errors
498
493
  return null;
499
494
  }
@@ -501,6 +496,7 @@ export class DiskCacheManager {
501
496
 
502
497
  /**
503
498
  * Retrieves a serialized shard from the SQLite cache if present and valid.
499
+ * Pure read-only operation: does not issue write transactions on cache hits.
504
500
  * Returns null on cache miss or corrupted/invalid shard (fails open).
505
501
  */
506
502
  async getShard(
@@ -512,15 +508,14 @@ export class DiskCacheManager {
512
508
  if (!db || !this.#getStmt) return null;
513
509
 
514
510
  const normalizedRelPath = relPath.replace(/\\/g, "/");
515
- const row = this.#getStmt.get(normalizedRelPath) as
511
+ const row = this.#getStmt.get(normalizedRelPath, contentHash) as
516
512
  | {
517
513
  payload: Uint8Array | Buffer;
518
- content_hash: string;
519
514
  }
520
515
  | null
521
516
  | undefined;
522
517
 
523
- if (!row || row.content_hash !== contentHash) {
518
+ if (!row) {
524
519
  return null;
525
520
  }
526
521
 
@@ -536,24 +531,11 @@ export class DiskCacheManager {
536
531
  if (
537
532
  shard &&
538
533
  shard.contentHash === contentHash &&
539
- typeof shard.sourceId === "string" &&
540
534
  Array.isArray(shard.frames)
541
535
  ) {
542
- try {
543
- this.#updateMtimeStmt?.run(Date.now(), normalizedRelPath);
544
- } catch {
545
- // Non-fatal
546
- }
547
536
  return shard;
548
537
  }
549
538
 
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
539
  return null;
558
540
  } catch {
559
541
  // Fail open on any error
@@ -592,9 +574,71 @@ export class DiskCacheManager {
592
574
  // Fail open: cache write failures should not disrupt indexing
593
575
  }
594
576
  }
577
+ /**
578
+ * Deletes a cached shard by relPath and contentHash if present.
579
+ */
580
+ async deleteShard(relPath: string, contentHash: string): Promise<void> {
581
+ try {
582
+ const db = this.#getDb();
583
+ if (!db || !this.#deleteStmt) return;
584
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
585
+ this.#deleteStmt.run(normalizedRelPath, contentHash);
586
+ } catch {
587
+ // Fail open
588
+ }
589
+ }
595
590
 
596
591
  /**
597
- * Prunes the oldest shards in the SQLite cache if total payload size exceeds budget.
592
+ * Atomically saves a batch of pre-tokenized shards inside a single SQLite transaction.
593
+ */
594
+ async saveShards(
595
+ items: Array<{ shard: SerializedSourceShard; relPath?: string }>,
596
+ ): Promise<void> {
597
+ if (items.length === 0) return;
598
+ try {
599
+ const db = this.#getDb();
600
+ if (!db || !this.#saveStmt) return;
601
+
602
+ const stmt = this.#saveStmt;
603
+ const root = this.rootDir;
604
+ const now = Date.now();
605
+
606
+ // Pre-pack payloads outside transaction to minimize write lock time
607
+ const prepared: Array<{
608
+ relPath: string;
609
+ hash: string;
610
+ payload: Buffer;
611
+ }> = [];
612
+ for (const item of items) {
613
+ const targetRelPath =
614
+ item.relPath ??
615
+ (path.isAbsolute(item.shard.sourceId)
616
+ ? path.relative(root, item.shard.sourceId)
617
+ : item.shard.sourceId);
618
+ const normalizedRelPath = targetRelPath.replace(/\\/g, "/");
619
+ const payload = packBinaryShard(item.shard);
620
+ prepared.push({
621
+ relPath: normalizedRelPath,
622
+ hash: item.shard.contentHash,
623
+ payload,
624
+ });
625
+ }
626
+
627
+ const tx = db.transaction((entries: typeof prepared) => {
628
+ for (const entry of entries) {
629
+ stmt.run(entry.relPath, entry.hash, entry.payload, now);
630
+ }
631
+ });
632
+
633
+ tx(prepared);
634
+ } catch {
635
+ // Fail open
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Prunes oldest shards if total payload size exceeds budget using atomic windowed DELETE.
641
+ * Avoids concurrent VACUUM calls during active sessions.
598
642
  */
599
643
  async prune(maxBytes?: number): Promise<void> {
600
644
  const budget = maxBytes !== undefined ? maxBytes : this.maxBytes;
@@ -605,48 +649,44 @@ export class DiskCacheManager {
605
649
 
606
650
  if (budget <= 0) {
607
651
  this.#deleteAllStmt?.run();
608
- try {
609
- db.exec("VACUUM;");
610
- } catch {
611
- // Ignore vacuum errors
612
- }
613
652
  return;
614
653
  }
615
654
 
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
- }
655
+ // Iteratively prune oldest shards in bounded windows
656
+ for (let iter = 0; iter < 10; iter++) {
657
+ const totalRow = this.#totalSizeStmt?.get() as
658
+ | { total: number }
659
+ | null
660
+ | undefined;
661
+ const totalSize = totalRow?.total ?? 0;
625
662
 
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
663
  if (totalSize <= budget) {
634
664
  break;
635
665
  }
636
- try {
637
- this.#deleteStmt?.run(entry.rel_path);
638
- totalSize -= entry.size;
639
- deletedAny = true;
640
- } catch {
641
- // Ignore individual deletion errors
666
+
667
+ const excess = totalSize - budget;
668
+ const rows = (this.#oldestShardsStmt?.all(100) ?? []) as Array<{
669
+ rowid: number;
670
+ size: number;
671
+ }>;
672
+
673
+ if (rows.length === 0) break;
674
+
675
+ const rowidsToDelete: number[] = [];
676
+ let freed = 0;
677
+ for (const r of rows) {
678
+ rowidsToDelete.push(r.rowid);
679
+ freed += r.size;
680
+ if (freed >= excess) break;
642
681
  }
643
- }
644
682
 
645
- if (deletedAny) {
646
- try {
647
- db.exec("VACUUM;");
648
- } catch {
649
- // Ignore vacuum errors
683
+ if (rowidsToDelete.length > 0) {
684
+ const deleteBatchStmt = db.prepare(
685
+ `DELETE FROM shards WHERE rowid IN (${rowidsToDelete.join(",")})`,
686
+ );
687
+ deleteBatchStmt.run();
688
+ } else {
689
+ break;
650
690
  }
651
691
  }
652
692
  } catch {
@@ -655,27 +695,14 @@ export class DiskCacheManager {
655
695
  }
656
696
 
657
697
  /**
658
- * Clears all cached shards in the current workspace cache database.
698
+ * Clears all cached shards in the current workspace cache database non-destructively.
659
699
  */
660
700
  async clear(): Promise<void> {
661
701
  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;
702
+ const db = this.#getDb();
703
+ if (db && this.#deleteAllStmt) {
704
+ this.#deleteAllStmt.run();
674
705
  }
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
706
  } catch {
680
707
  // Fail open
681
708
  }
@@ -693,7 +720,6 @@ export class DiskCacheManager {
693
720
  this.#db = null;
694
721
  this.#getStmt = null;
695
722
  this.#saveStmt = null;
696
- this.#updateMtimeStmt = null;
697
723
  this.#deleteStmt = null;
698
724
  this.#totalSizeStmt = null;
699
725
  this.#oldestShardsStmt = null;
@@ -701,3 +727,28 @@ export class DiskCacheManager {
701
727
  }
702
728
  }
703
729
  }
730
+
731
+ /**
732
+ * Removes legacy pre-v4 cache files from the cache directory.
733
+ */
734
+ export async function cleanupLegacyCacheFiles(
735
+ customCacheDir?: string,
736
+ ): Promise<void> {
737
+ const baseDir = customCacheDir ?? getDefaultCacheDir();
738
+ try {
739
+ if (!fsSync.existsSync(baseDir)) return;
740
+ const entries = await fs.readdir(baseDir);
741
+ for (const entry of entries) {
742
+ if (
743
+ entry.endsWith(".sqlite") &&
744
+ !entry.includes(`_v${CACHE_FORMAT_VERSION}.sqlite`)
745
+ ) {
746
+ await fs.unlink(path.join(baseDir, entry)).catch(() => {});
747
+ await fs.unlink(path.join(baseDir, `${entry}-wal`)).catch(() => {});
748
+ await fs.unlink(path.join(baseDir, `${entry}-shm`)).catch(() => {});
749
+ }
750
+ }
751
+ } catch {
752
+ // Fail open
753
+ }
754
+ }