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.
- package/README.md +117 -0
- package/dist/detector-worker.js +13897 -0
- package/package.json +105 -0
- package/src/config-loader.ts +336 -0
- package/src/coordinator.ts +536 -0
- package/src/detector-worker.ts +828 -0
- package/src/disk-cache.ts +703 -0
- package/src/duplicate-ledger.ts +144 -0
- package/src/index.ts +807 -0
- package/src/jscpd-engine.ts +797 -0
- package/src/project-state.ts +129 -0
- package/src/source-aware-index.ts +919 -0
- package/src/test-detector.ts +337 -0
- package/src/tui-notification.ts +464 -0
- package/src/worker-protocol.ts +330 -0
- package/types/global.d.ts +19 -0
- package/types/jscpd.d.ts +139 -0
|
@@ -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
|
+
}
|