directory-indexer 0.3.0 → 1.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/dist/storage.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- import { ensureDirectory } from "./utils.js";
2
+ import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "path";
4
4
  function escapeLike(value) {
5
5
  return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
@@ -8,10 +8,11 @@ function directoryLikeClause(column, dirPath) {
8
8
  const escaped = escapeLike(dirPath);
9
9
  return {
10
10
  clause: `(${column} = ? OR ${column} LIKE ? ESCAPE '\\' OR ${column} LIKE ? ESCAPE '\\')`,
11
- params: [dirPath, `${escaped}/%`, `${escaped}\\%`]
11
+ params: [dirPath, `${escaped}/%`, `${escaped}\\\\%`]
12
12
  };
13
13
  }
14
14
  const openStorageInstances = /* @__PURE__ */ new Set();
15
+ const sharedStorage = /* @__PURE__ */ new Map();
15
16
  function closeAllStorage() {
16
17
  for (const instance of openStorageInstances) {
17
18
  try {
@@ -19,6 +20,7 @@ function closeAllStorage() {
19
20
  } catch {
20
21
  }
21
22
  }
23
+ sharedStorage.clear();
22
24
  }
23
25
  class StorageError extends Error {
24
26
  constructor(message, cause) {
@@ -31,27 +33,33 @@ class QdrantClient {
31
33
  constructor(config) {
32
34
  this.config = config;
33
35
  }
36
+ qdrantFetch(path, init) {
37
+ const headers = { "Content-Type": "application/json" };
38
+ if (this.config.storage.qdrantApiKey) {
39
+ headers["api-key"] = this.config.storage.qdrantApiKey;
40
+ }
41
+ return fetch(`${this.config.storage.qdrantEndpoint}${path}`, { ...init, headers });
42
+ }
34
43
  async healthCheck() {
35
44
  try {
36
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/healthz`);
45
+ const response = await this.qdrantFetch("/healthz");
37
46
  return response.ok;
38
47
  } catch {
39
48
  return false;
40
49
  }
41
50
  }
42
- async createCollection() {
51
+ async createCollection(dimension) {
43
52
  const collectionName = this.config.storage.qdrantCollection;
44
53
  try {
45
- const checkResponse = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}`);
54
+ const checkResponse = await this.qdrantFetch(`/collections/${collectionName}`);
46
55
  if (checkResponse.ok) {
47
56
  return;
48
57
  }
49
- const createResponse = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}`, {
58
+ const createResponse = await this.qdrantFetch(`/collections/${collectionName}`, {
50
59
  method: "PUT",
51
- headers: { "Content-Type": "application/json" },
52
60
  body: JSON.stringify({
53
61
  vectors: {
54
- size: 768,
62
+ size: dimension,
55
63
  distance: "Cosine"
56
64
  }
57
65
  })
@@ -66,9 +74,8 @@ class QdrantClient {
66
74
  async upsertPoints(points) {
67
75
  const collectionName = this.config.storage.qdrantCollection;
68
76
  try {
69
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points`, {
77
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points`, {
70
78
  method: "PUT",
71
- headers: { "Content-Type": "application/json" },
72
79
  body: JSON.stringify({ points })
73
80
  });
74
81
  if (!response.ok) {
@@ -90,11 +97,13 @@ class QdrantClient {
90
97
  if (filter) {
91
98
  searchBody.filter = filter;
92
99
  }
93
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points/search`, {
100
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points/search`, {
94
101
  method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
102
  body: JSON.stringify(searchBody)
97
103
  });
104
+ if (response.status === 404) {
105
+ return [];
106
+ }
98
107
  if (!response.ok) {
99
108
  const errorBody = await response.text();
100
109
  throw new Error(`Failed to search points: ${response.statusText} - ${errorBody}`);
@@ -113,11 +122,13 @@ class QdrantClient {
113
122
  async deletePoints(ids) {
114
123
  const collectionName = this.config.storage.qdrantCollection;
115
124
  try {
116
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points/delete`, {
125
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points/delete`, {
117
126
  method: "POST",
118
- headers: { "Content-Type": "application/json" },
119
127
  body: JSON.stringify({ points: ids })
120
128
  });
129
+ if (response.status === 404) {
130
+ return;
131
+ }
121
132
  if (!response.ok) {
122
133
  throw new Error(`Failed to delete points: ${response.statusText}`);
123
134
  }
@@ -128,9 +139,8 @@ class QdrantClient {
128
139
  async deletePointsByFilePath(filePath) {
129
140
  const collectionName = this.config.storage.qdrantCollection;
130
141
  try {
131
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points/delete`, {
142
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points/delete`, {
132
143
  method: "POST",
133
- headers: { "Content-Type": "application/json" },
134
144
  body: JSON.stringify({
135
145
  filter: {
136
146
  must: [
@@ -142,6 +152,9 @@ class QdrantClient {
142
152
  }
143
153
  })
144
154
  });
155
+ if (response.status === 404) {
156
+ return;
157
+ }
145
158
  if (!response.ok) {
146
159
  const errorText = await response.text();
147
160
  throw new Error(`Failed to delete points by file path: ${response.status} ${response.statusText} - ${errorText}`);
@@ -157,11 +170,13 @@ class QdrantClient {
157
170
  if (filter) {
158
171
  countBody.filter = filter;
159
172
  }
160
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points/count`, {
173
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points/count`, {
161
174
  method: "POST",
162
- headers: { "Content-Type": "application/json" },
163
175
  body: JSON.stringify(countBody)
164
176
  });
177
+ if (response.status === 404) {
178
+ return 0;
179
+ }
165
180
  if (!response.ok) {
166
181
  throw new Error(`Failed to count points: ${response.statusText}`);
167
182
  }
@@ -174,29 +189,40 @@ class QdrantClient {
174
189
  async scrollPoints(filter, limit = 1e3) {
175
190
  const collectionName = this.config.storage.qdrantCollection;
176
191
  try {
177
- const scrollBody = {
178
- limit,
179
- with_payload: true,
180
- with_vector: false
181
- };
182
- if (filter) {
183
- scrollBody.filter = filter;
184
- }
185
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}/points/scroll`, {
186
- method: "POST",
187
- headers: { "Content-Type": "application/json" },
188
- body: JSON.stringify(scrollBody)
189
- });
190
- if (!response.ok) {
191
- throw new Error(`Failed to scroll points: ${response.statusText}`);
192
- }
193
- const data = await response.json();
194
- return data.result.points.map((item) => ({
195
- id: item.id,
196
- vector: [],
197
- payload: item.payload,
198
- score: 0
199
- }));
192
+ const points = [];
193
+ let offset = null;
194
+ do {
195
+ const scrollBody = {
196
+ limit,
197
+ with_payload: true,
198
+ with_vector: false
199
+ };
200
+ if (filter) {
201
+ scrollBody.filter = filter;
202
+ }
203
+ if (offset !== null) {
204
+ scrollBody.offset = offset;
205
+ }
206
+ const response = await this.qdrantFetch(`/collections/${collectionName}/points/scroll`, {
207
+ method: "POST",
208
+ body: JSON.stringify(scrollBody)
209
+ });
210
+ if (response.status === 404) {
211
+ return [];
212
+ }
213
+ if (!response.ok) {
214
+ throw new Error(`Failed to scroll points: ${response.statusText}`);
215
+ }
216
+ const data = await response.json();
217
+ points.push(...data.result.points.map((item) => ({
218
+ id: item.id,
219
+ vector: [],
220
+ payload: item.payload,
221
+ score: 0
222
+ })));
223
+ offset = data.result.next_page_offset ?? null;
224
+ } while (offset !== null);
225
+ return points;
200
226
  } catch (error) {
201
227
  throw new StorageError(`Failed to scroll points in Qdrant`, error);
202
228
  }
@@ -204,7 +230,7 @@ class QdrantClient {
204
230
  async getCollectionInfo() {
205
231
  const collectionName = this.config.storage.qdrantCollection;
206
232
  try {
207
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}`);
233
+ const response = await this.qdrantFetch(`/collections/${collectionName}`);
208
234
  if (!response.ok) {
209
235
  if (response.status === 404) {
210
236
  return null;
@@ -212,9 +238,14 @@ class QdrantClient {
212
238
  throw new Error(`Failed to get collection info: ${response.statusText}`);
213
239
  }
214
240
  const data = await response.json();
215
- return {
241
+ const vectors = data.result?.config?.params?.vectors;
242
+ const info = {
216
243
  vectors_count: data.result?.points_count || data.result?.vectors_count || 0
217
244
  };
245
+ if (vectors && typeof vectors === "object" && typeof vectors.size === "number") {
246
+ info.vectorSize = vectors.size;
247
+ }
248
+ return info;
218
249
  } catch (error) {
219
250
  throw new StorageError(`Failed to get collection info from Qdrant`, error);
220
251
  }
@@ -222,11 +253,8 @@ class QdrantClient {
222
253
  async deleteCollection() {
223
254
  const collectionName = this.config.storage.qdrantCollection;
224
255
  try {
225
- const response = await fetch(`${this.config.storage.qdrantEndpoint}/collections/${collectionName}`, {
226
- method: "DELETE",
227
- headers: this.config.storage.qdrantApiKey ? {
228
- "api-key": this.config.storage.qdrantApiKey
229
- } : {}
256
+ const response = await this.qdrantFetch(`/collections/${collectionName}`, {
257
+ method: "DELETE"
230
258
  });
231
259
  if (!response.ok && response.status !== 404) {
232
260
  throw new Error(`Failed to delete collection: ${response.statusText}`);
@@ -245,7 +273,7 @@ class SQLiteStorage {
245
273
  db;
246
274
  initializeDatabase() {
247
275
  try {
248
- ensureDirectory(dirname(this.config.storage.sqlitePath));
276
+ mkdirSync(dirname(this.config.storage.sqlitePath), { recursive: true });
249
277
  const db = new Database(this.config.storage.sqlitePath);
250
278
  db.exec(`
251
279
  CREATE TABLE IF NOT EXISTS directories (
@@ -269,6 +297,11 @@ class SQLiteStorage {
269
297
  CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
270
298
  CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
271
299
  CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
300
+
301
+ CREATE TABLE IF NOT EXISTS meta (
302
+ key TEXT PRIMARY KEY,
303
+ value TEXT
304
+ );
272
305
  `);
273
306
  db.pragma("journal_mode = WAL");
274
307
  db.pragma("busy_timeout = 5000");
@@ -292,6 +325,21 @@ class SQLiteStorage {
292
325
  throw new StorageError(`Failed to get directory record`, error);
293
326
  }
294
327
  }
328
+ async getDirectoryByCaseInsensitive(path) {
329
+ try {
330
+ const stmt = this.db.prepare("SELECT * FROM directories WHERE path = ? COLLATE NOCASE");
331
+ const row = stmt.get(path);
332
+ if (!row) return null;
333
+ return {
334
+ id: row.id,
335
+ path: row.path,
336
+ status: row.status,
337
+ indexedAt: new Date(row.indexed_at)
338
+ };
339
+ } catch (error) {
340
+ throw new StorageError(`Failed to get directory record`, error);
341
+ }
342
+ }
295
343
  async upsertDirectory(path, status) {
296
344
  try {
297
345
  const stmt = this.db.prepare(`
@@ -312,7 +360,7 @@ class SQLiteStorage {
312
360
  id: row.id,
313
361
  path: row.path,
314
362
  size: row.size,
315
- modifiedTime: new Date(row.modified_time * 1e3),
363
+ modifiedTime: new Date(row.modified_time),
316
364
  hash: row.hash,
317
365
  parentDirs: JSON.parse(row.parent_dirs),
318
366
  chunks: row.chunks_json ? JSON.parse(row.chunks_json) : [],
@@ -362,7 +410,7 @@ class SQLiteStorage {
362
410
  id: row.id,
363
411
  path: row.path,
364
412
  size: row.size,
365
- modifiedTime: new Date(row.modified_time * 1e3),
413
+ modifiedTime: new Date(row.modified_time),
366
414
  hash: row.hash,
367
415
  parentDirs: JSON.parse(row.parent_dirs),
368
416
  chunks: row.chunks_json ? JSON.parse(row.chunks_json) : [],
@@ -380,16 +428,52 @@ class SQLiteStorage {
380
428
  const result = this.db.prepare(`DELETE FROM files WHERE ${clause}`).run(...params);
381
429
  return result.changes;
382
430
  }
431
+ getMeta(key) {
432
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
433
+ return row?.value ?? null;
434
+ }
435
+ setMeta(key, value) {
436
+ this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
437
+ }
383
438
  close() {
384
439
  openStorageInstances.delete(this);
385
440
  this.db.close();
386
441
  }
387
442
  }
388
443
  async function initializeStorage(config) {
444
+ const dbPath = config.storage.sqlitePath;
445
+ const cached = sharedStorage.get(dbPath);
446
+ if (cached) {
447
+ return cached;
448
+ }
389
449
  const sqlite = new SQLiteStorage(config);
390
450
  const qdrant = new QdrantClient(config);
391
- await qdrant.createCollection();
392
- return { sqlite, qdrant };
451
+ const storage = { sqlite, qdrant };
452
+ sharedStorage.set(dbPath, storage);
453
+ return storage;
454
+ }
455
+ function closeSharedStorageForPath(dbPath) {
456
+ const shared = sharedStorage.get(dbPath);
457
+ if (!shared) {
458
+ return;
459
+ }
460
+ sharedStorage.delete(dbPath);
461
+ shared.sqlite.close();
462
+ }
463
+ async function ensureCollectionDimensions(sqlite, qdrant, config, dimension) {
464
+ const info = await qdrant.getCollectionInfo();
465
+ if (!info) {
466
+ await qdrant.createCollection(dimension);
467
+ } else if (info.vectorSize !== void 0 && info.vectorSize !== dimension) {
468
+ const provider = sqlite.getMeta("embedding_provider");
469
+ const recordedDims = sqlite.getMeta("embedding_dims");
470
+ const recorded = provider && recordedDims ? ` (meta table records provider '${provider}' with ${recordedDims} dimensions)` : "";
471
+ throw new StorageError(
472
+ `Qdrant collection '${config.storage.qdrantCollection}' was created with ${info.vectorSize}-dimensional vectors but the active embedding provider produces ${dimension}-dimensional vectors${recorded}. Run \`directory-indexer reset\` to delete the existing collection, then re-index.`
473
+ );
474
+ }
475
+ sqlite.setMeta("embedding_provider", config.embedding.provider);
476
+ sqlite.setMeta("embedding_dims", String(dimension));
393
477
  }
394
478
  async function initDatabase(dbPath) {
395
479
  return new Database(dbPath);
@@ -425,6 +509,7 @@ async function getResetPreview(config) {
425
509
  }
426
510
  async function clearDatabase(config) {
427
511
  try {
512
+ closeSharedStorageForPath(config.storage.sqlitePath);
428
513
  if (!await import("fs").then((fs) => fs.existsSync(config.storage.sqlitePath))) {
429
514
  return true;
430
515
  }
@@ -589,13 +674,12 @@ async function checkQdrantConsistency(sqlite, config) {
589
674
  }
590
675
  const collectionName = config.storage.qdrantCollection;
591
676
  try {
592
- const response = await fetch(`${config.storage.qdrantEndpoint}/collections/${collectionName}`);
593
- if (!response.ok) {
677
+ const collectionInfo = await qdrant.getCollectionInfo();
678
+ if (!collectionInfo) {
594
679
  issues.push(`Vector collection '${collectionName}' not found (normal during first-time setup)`);
595
680
  return { isConsistent: false, issues };
596
681
  }
597
- const collectionInfo = await response.json();
598
- const qdrantPointCount = collectionInfo.result?.points_count || 0;
682
+ const qdrantPointCount = collectionInfo.vectors_count || 0;
599
683
  const sqliteChunkCount = totalChunks.count || 0;
600
684
  if (Math.abs(qdrantPointCount - sqliteChunkCount) > 0) {
601
685
  if (qdrantPointCount > sqliteChunkCount) {
@@ -625,7 +709,7 @@ async function getIndexStatus() {
625
709
  const filesCount = filesStmt.get();
626
710
  const chunksStmt = sqlite.db.prepare("SELECT SUM(json_array_length(chunks_json)) as count FROM files WHERE chunks_json IS NOT NULL");
627
711
  const chunksCount = chunksStmt.get();
628
- const lastIndexedStmt = sqlite.db.prepare("SELECT MAX(indexed_at) as last_indexed FROM directories WHERE indexed_at > 0");
712
+ const lastIndexedStmt = sqlite.db.prepare("SELECT MAX(indexed_at) as last_indexed FROM directories WHERE indexed_at > 0 AND status = 'completed'");
629
713
  const lastIndexedResult = lastIndexedStmt.get();
630
714
  const errorsStmt = sqlite.db.prepare("SELECT errors_json FROM files WHERE errors_json IS NOT NULL");
631
715
  const errorRows = errorsStmt.all();
@@ -638,19 +722,14 @@ async function getIndexStatus() {
638
722
  allErrors.push("Failed to parse error JSON");
639
723
  }
640
724
  });
641
- const directoriesDetailStmt = sqlite.db.prepare(`
642
- SELECT
643
- d.path,
644
- d.status,
645
- d.indexed_at,
646
- (SELECT COUNT(*) FROM files f WHERE f.path = d.path OR f.path LIKE REPLACE(d.path, '\\', '\\\\') || '/%' ESCAPE '\\' OR f.path LIKE REPLACE(d.path, '\\', '\\\\') || '\\%' ESCAPE '\\') as files_count,
647
- (SELECT COALESCE(SUM(json_array_length(f.chunks_json)), 0) FROM files f WHERE (f.path = d.path OR f.path LIKE REPLACE(d.path, '\\', '\\\\') || '/%' ESCAPE '\\' OR f.path LIKE REPLACE(d.path, '\\', '\\\\') || '\\%' ESCAPE '\\') AND f.chunks_json IS NOT NULL) as chunks_count
648
- FROM directories d
649
- ORDER BY d.indexed_at DESC
650
- `);
725
+ const directoriesDetailStmt = sqlite.db.prepare("SELECT path, status, indexed_at FROM directories ORDER BY indexed_at DESC");
651
726
  const directoryDetails = directoriesDetailStmt.all();
652
727
  const directories = directoryDetails.map((row) => {
653
728
  const { clause, params } = directoryLikeClause("path", row.path);
729
+ const filesByDirStmt = sqlite.db.prepare(`SELECT COUNT(*) as count FROM files WHERE ${clause}`);
730
+ const filesCount2 = filesByDirStmt.get(...params);
731
+ const chunksByDirStmt = sqlite.db.prepare(`SELECT COALESCE(SUM(json_array_length(chunks_json)), 0) as count FROM files WHERE ${clause} AND chunks_json IS NOT NULL`);
732
+ const chunksCount2 = chunksByDirStmt.get(...params);
654
733
  const errorsByDirStmt = sqlite.db.prepare(`
655
734
  SELECT errors_json FROM files
656
735
  WHERE ${clause} AND errors_json IS NOT NULL
@@ -668,8 +747,8 @@ async function getIndexStatus() {
668
747
  return {
669
748
  path: row.path,
670
749
  status: row.status,
671
- filesCount: row.files_count,
672
- chunksCount: row.chunks_count,
750
+ filesCount: filesCount2.count,
751
+ chunksCount: chunksCount2.count,
673
752
  lastIndexed: row.indexed_at && row.indexed_at > 0 ? new Date(row.indexed_at).toISOString() : null,
674
753
  errors: dirErrorsList
675
754
  };
@@ -715,6 +794,8 @@ export {
715
794
  clearDatabase,
716
795
  clearVectorCollection,
717
796
  closeAllStorage,
797
+ closeSharedStorageForPath,
798
+ ensureCollectionDimensions,
718
799
  getIndexStatus,
719
800
  getResetPreview,
720
801
  initDatabase,