bluera-knowledge 0.9.30 → 0.9.32
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/.claude/commands/code-review.md +15 -0
- package/.claude/skills/code-review-repo/skill.md +62 -0
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -0
- package/dist/{chunk-NJUMU4X2.js → chunk-6PBP5DVD.js} +33 -2
- package/dist/chunk-6PBP5DVD.js.map +1 -0
- package/dist/{chunk-DNOIM7BO.js → chunk-RST4XGRL.js} +2 -2
- package/dist/{chunk-SZNTYLYT.js → chunk-WT2DAEO7.js} +2 -2
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/workers/background-worker-cli.js +2 -2
- package/package.json +1 -1
- package/src/cli/commands/crawl.ts +6 -1
- package/src/cli/commands/store.test.ts +68 -1
- package/src/cli/commands/store.ts +9 -3
- package/src/db/lance.test.ts +90 -0
- package/src/db/lance.ts +23 -0
- package/src/services/index.ts +11 -1
- package/src/services/services.test.ts +38 -2
- package/src/services/store.service.test.ts +28 -0
- package/src/services/store.service.ts +4 -0
- package/tests/integration/e2e-workflow.test.ts +2 -0
- package/dist/chunk-NJUMU4X2.js.map +0 -1
- /package/dist/{chunk-DNOIM7BO.js.map → chunk-RST4XGRL.js.map} +0 -0
- /package/dist/{chunk-SZNTYLYT.js.map → chunk-WT2DAEO7.js.map} +0 -0
package/package.json
CHANGED
|
@@ -82,6 +82,7 @@ export function createCrawlCommand(getOptions: () => GlobalOptions): Command {
|
|
|
82
82
|
const webChunker = ChunkingService.forContentType('web');
|
|
83
83
|
let pagesIndexed = 0;
|
|
84
84
|
let chunksCreated = 0;
|
|
85
|
+
let exitCode = 0;
|
|
85
86
|
|
|
86
87
|
// Listen for progress events
|
|
87
88
|
crawler.on('progress', (progress: CrawlProgress) => {
|
|
@@ -184,10 +185,14 @@ export function createCrawlCommand(getOptions: () => GlobalOptions): Command {
|
|
|
184
185
|
} else {
|
|
185
186
|
console.error(`Error: ${message}`);
|
|
186
187
|
}
|
|
187
|
-
|
|
188
|
+
exitCode = 6;
|
|
188
189
|
} finally {
|
|
189
190
|
await crawler.stop();
|
|
190
191
|
await destroyServices(services);
|
|
191
192
|
}
|
|
193
|
+
|
|
194
|
+
if (exitCode !== 0) {
|
|
195
|
+
process.exit(exitCode);
|
|
196
|
+
}
|
|
192
197
|
});
|
|
193
198
|
}
|
|
@@ -935,7 +935,7 @@ describe('store command execution', () => {
|
|
|
935
935
|
});
|
|
936
936
|
});
|
|
937
937
|
|
|
938
|
-
it('routes source to path for repo stores', async () => {
|
|
938
|
+
it('routes source to path for repo stores with local path', async () => {
|
|
939
939
|
const mockStore: RepoStore = {
|
|
940
940
|
id: createStoreId('store-2'),
|
|
941
941
|
name: 'repo-store',
|
|
@@ -967,6 +967,73 @@ describe('store command execution', () => {
|
|
|
967
967
|
});
|
|
968
968
|
});
|
|
969
969
|
|
|
970
|
+
it('routes URL source to url for repo stores (Bug #1 fix)', async () => {
|
|
971
|
+
const mockStore: RepoStore = {
|
|
972
|
+
id: createStoreId('store-2'),
|
|
973
|
+
name: 'repo-url-store',
|
|
974
|
+
type: 'repo',
|
|
975
|
+
path: '/cloned/repo/path',
|
|
976
|
+
url: 'https://github.com/user/repo',
|
|
977
|
+
createdAt: new Date(),
|
|
978
|
+
updatedAt: new Date(),
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
mockServices.store.create.mockResolvedValue({
|
|
982
|
+
success: true,
|
|
983
|
+
data: mockStore,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
const command = createStoreCommand(getOptions);
|
|
987
|
+
const createCommand = command.commands.find(c => c.name() === 'create');
|
|
988
|
+
const actionHandler = createCommand?._actionHandler;
|
|
989
|
+
|
|
990
|
+
createCommand.parseOptions(['--type', 'repo', '--source', 'https://github.com/user/repo']);
|
|
991
|
+
await actionHandler!(['repo-url-store']);
|
|
992
|
+
|
|
993
|
+
// URL should be routed to 'url' parameter, not 'path'
|
|
994
|
+
expect(mockServices.store.create).toHaveBeenCalledWith({
|
|
995
|
+
name: 'repo-url-store',
|
|
996
|
+
type: 'repo',
|
|
997
|
+
path: undefined,
|
|
998
|
+
url: 'https://github.com/user/repo',
|
|
999
|
+
description: undefined,
|
|
1000
|
+
tags: undefined,
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it('routes http:// URL source to url for repo stores', async () => {
|
|
1005
|
+
const mockStore: RepoStore = {
|
|
1006
|
+
id: createStoreId('store-2'),
|
|
1007
|
+
name: 'repo-http-store',
|
|
1008
|
+
type: 'repo',
|
|
1009
|
+
path: '/cloned/repo/path',
|
|
1010
|
+
url: 'http://internal-git.example.com/repo',
|
|
1011
|
+
createdAt: new Date(),
|
|
1012
|
+
updatedAt: new Date(),
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
mockServices.store.create.mockResolvedValue({
|
|
1016
|
+
success: true,
|
|
1017
|
+
data: mockStore,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const command = createStoreCommand(getOptions);
|
|
1021
|
+
const createCommand = command.commands.find(c => c.name() === 'create');
|
|
1022
|
+
const actionHandler = createCommand?._actionHandler;
|
|
1023
|
+
|
|
1024
|
+
createCommand.parseOptions(['--type', 'repo', '--source', 'http://internal-git.example.com/repo']);
|
|
1025
|
+
await actionHandler!(['repo-http-store']);
|
|
1026
|
+
|
|
1027
|
+
expect(mockServices.store.create).toHaveBeenCalledWith({
|
|
1028
|
+
name: 'repo-http-store',
|
|
1029
|
+
type: 'repo',
|
|
1030
|
+
path: undefined,
|
|
1031
|
+
url: 'http://internal-git.example.com/repo',
|
|
1032
|
+
description: undefined,
|
|
1033
|
+
tags: undefined,
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
970
1037
|
it('routes source to url for web stores', async () => {
|
|
971
1038
|
const mockStore: WebStore = {
|
|
972
1039
|
id: createStoreId('store-3'),
|
|
@@ -54,12 +54,15 @@ export function createStoreCommand(getOptions: () => GlobalOptions): Command {
|
|
|
54
54
|
}) => {
|
|
55
55
|
const globalOpts = getOptions();
|
|
56
56
|
const services = await createServices(globalOpts.config, globalOpts.dataDir);
|
|
57
|
+
let exitCode = 0;
|
|
57
58
|
try {
|
|
59
|
+
// Detect if source is a URL (for repo stores that should clone from remote)
|
|
60
|
+
const isUrl = options.source.startsWith('http://') || options.source.startsWith('https://');
|
|
58
61
|
const result = await services.store.create({
|
|
59
62
|
name,
|
|
60
63
|
type: options.type,
|
|
61
|
-
path: options.type
|
|
62
|
-
url: options.type === 'web' ? options.source : undefined,
|
|
64
|
+
path: options.type === 'file' || (options.type === 'repo' && !isUrl) ? options.source : undefined,
|
|
65
|
+
url: options.type === 'web' || (options.type === 'repo' && isUrl) ? options.source : undefined,
|
|
63
66
|
description: options.description,
|
|
64
67
|
tags: options.tags?.split(',').map((t) => t.trim()),
|
|
65
68
|
});
|
|
@@ -72,11 +75,14 @@ export function createStoreCommand(getOptions: () => GlobalOptions): Command {
|
|
|
72
75
|
}
|
|
73
76
|
} else {
|
|
74
77
|
console.error(`Error: ${result.error.message}`);
|
|
75
|
-
|
|
78
|
+
exitCode = 1;
|
|
76
79
|
}
|
|
77
80
|
} finally {
|
|
78
81
|
await destroyServices(services);
|
|
79
82
|
}
|
|
83
|
+
if (exitCode !== 0) {
|
|
84
|
+
process.exit(exitCode);
|
|
85
|
+
}
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
store
|
package/src/db/lance.test.ts
CHANGED
|
@@ -292,6 +292,96 @@ describe('LanceStore', () => {
|
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
describe('close', () => {
|
|
296
|
+
it('clears tables and connection', async () => {
|
|
297
|
+
const closeStoreId = createStoreId('close-test-store');
|
|
298
|
+
const closeStore = new LanceStore(tempDir);
|
|
299
|
+
await closeStore.initialize(closeStoreId);
|
|
300
|
+
|
|
301
|
+
const doc = {
|
|
302
|
+
id: createDocumentId('close-doc'),
|
|
303
|
+
content: 'test',
|
|
304
|
+
vector: new Array(384).fill(0.1),
|
|
305
|
+
metadata: {
|
|
306
|
+
type: 'file' as const,
|
|
307
|
+
storeId: closeStoreId,
|
|
308
|
+
indexedAt: new Date(),
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
await closeStore.addDocuments(closeStoreId, [doc]);
|
|
313
|
+
|
|
314
|
+
// Close should not throw
|
|
315
|
+
expect(() => closeStore.close()).not.toThrow();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('handles close when never initialized', () => {
|
|
319
|
+
const uninitializedStore = new LanceStore(tempDir);
|
|
320
|
+
|
|
321
|
+
// Should not throw even when never initialized
|
|
322
|
+
expect(() => uninitializedStore.close()).not.toThrow();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('closeAsync', () => {
|
|
327
|
+
it('returns a promise that resolves after cleanup', async () => {
|
|
328
|
+
const asyncCloseStoreId = createStoreId('async-close-test');
|
|
329
|
+
const asyncStore = new LanceStore(tempDir);
|
|
330
|
+
await asyncStore.initialize(asyncCloseStoreId);
|
|
331
|
+
|
|
332
|
+
const doc = {
|
|
333
|
+
id: createDocumentId('async-close-doc'),
|
|
334
|
+
content: 'test content for async close',
|
|
335
|
+
vector: new Array(384).fill(0.1),
|
|
336
|
+
metadata: {
|
|
337
|
+
type: 'file' as const,
|
|
338
|
+
storeId: asyncCloseStoreId,
|
|
339
|
+
indexedAt: new Date(),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await asyncStore.addDocuments(asyncCloseStoreId, [doc]);
|
|
344
|
+
|
|
345
|
+
// closeAsync should return a promise
|
|
346
|
+
const result = asyncStore.closeAsync();
|
|
347
|
+
expect(result).toBeInstanceOf(Promise);
|
|
348
|
+
await expect(result).resolves.not.toThrow();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('handles closeAsync when never initialized', async () => {
|
|
352
|
+
const uninitializedStore = new LanceStore(tempDir);
|
|
353
|
+
|
|
354
|
+
// Should not throw even when never initialized
|
|
355
|
+
await expect(uninitializedStore.closeAsync()).resolves.not.toThrow();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('allows native threads time to complete', async () => {
|
|
359
|
+
const timedStoreId = createStoreId('timed-close-test');
|
|
360
|
+
const timedStore = new LanceStore(tempDir);
|
|
361
|
+
await timedStore.initialize(timedStoreId);
|
|
362
|
+
|
|
363
|
+
const doc = {
|
|
364
|
+
id: createDocumentId('timed-doc'),
|
|
365
|
+
content: 'test',
|
|
366
|
+
vector: new Array(384).fill(0.1),
|
|
367
|
+
metadata: {
|
|
368
|
+
type: 'file' as const,
|
|
369
|
+
storeId: timedStoreId,
|
|
370
|
+
indexedAt: new Date(),
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
await timedStore.addDocuments(timedStoreId, [doc]);
|
|
375
|
+
|
|
376
|
+
const startTime = Date.now();
|
|
377
|
+
await timedStore.closeAsync();
|
|
378
|
+
const elapsed = Date.now() - startTime;
|
|
379
|
+
|
|
380
|
+
// Should take at least some time for native cleanup
|
|
381
|
+
expect(elapsed).toBeGreaterThanOrEqual(50);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
295
385
|
describe('multiple documents operations', () => {
|
|
296
386
|
it('adds multiple documents at once', async () => {
|
|
297
387
|
const multiStoreId = createStoreId('multi-doc-store');
|
package/src/db/lance.ts
CHANGED
|
@@ -145,6 +145,29 @@ export class LanceStore {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
close(): void {
|
|
149
|
+
this.tables.clear();
|
|
150
|
+
if (this.connection !== null) {
|
|
151
|
+
this.connection.close();
|
|
152
|
+
this.connection = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Async close that allows native code cleanup time.
|
|
158
|
+
* Use this before process.exit() to prevent mutex crashes.
|
|
159
|
+
*/
|
|
160
|
+
async closeAsync(): Promise<void> {
|
|
161
|
+
this.tables.clear();
|
|
162
|
+
if (this.connection !== null) {
|
|
163
|
+
this.connection.close();
|
|
164
|
+
this.connection = null;
|
|
165
|
+
// Allow native threads time to complete cleanup
|
|
166
|
+
// LanceDB's native code has background threads that need time
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
private getTableName(storeId: StoreId): string {
|
|
149
172
|
return `documents_${storeId}`;
|
|
150
173
|
}
|
package/src/services/index.ts
CHANGED
|
@@ -79,6 +79,16 @@ export async function createServices(
|
|
|
79
79
|
*/
|
|
80
80
|
export async function destroyServices(services: ServiceContainer): Promise<void> {
|
|
81
81
|
logger.info('Shutting down services');
|
|
82
|
-
|
|
82
|
+
try {
|
|
83
|
+
// Use async close to allow native threads time to cleanup
|
|
84
|
+
await services.lance.closeAsync();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
logger.error({ error: e }, 'Error closing LanceStore');
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await services.pythonBridge.stop();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
logger.error({ error: e }, 'Error stopping Python bridge');
|
|
92
|
+
}
|
|
83
93
|
await shutdownLogger();
|
|
84
94
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { destroyServices, type ServiceContainer } from './index.js';
|
|
3
3
|
import type { PythonBridge } from '../crawl/bridge.js';
|
|
4
|
+
import type { LanceStore } from '../db/lance.js';
|
|
4
5
|
|
|
5
6
|
describe('destroyServices', () => {
|
|
6
7
|
let mockPythonBridge: { stop: ReturnType<typeof vi.fn> };
|
|
8
|
+
let mockLance: { closeAsync: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
|
7
9
|
let mockServices: ServiceContainer;
|
|
8
10
|
|
|
9
11
|
beforeEach(() => {
|
|
@@ -11,8 +13,14 @@ describe('destroyServices', () => {
|
|
|
11
13
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
12
14
|
};
|
|
13
15
|
|
|
16
|
+
mockLance = {
|
|
17
|
+
closeAsync: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
close: vi.fn(),
|
|
19
|
+
};
|
|
20
|
+
|
|
14
21
|
mockServices = {
|
|
15
22
|
pythonBridge: mockPythonBridge as unknown as PythonBridge,
|
|
23
|
+
lance: mockLance as unknown as LanceStore,
|
|
16
24
|
} as unknown as ServiceContainer;
|
|
17
25
|
});
|
|
18
26
|
|
|
@@ -25,8 +33,8 @@ describe('destroyServices', () => {
|
|
|
25
33
|
it('handles stop errors gracefully', async () => {
|
|
26
34
|
mockPythonBridge.stop.mockRejectedValue(new Error('stop failed'));
|
|
27
35
|
|
|
28
|
-
// destroyServices
|
|
29
|
-
await expect(destroyServices(mockServices)).
|
|
36
|
+
// destroyServices catches and logs errors instead of propagating (Bug #2 fix)
|
|
37
|
+
await expect(destroyServices(mockServices)).resolves.not.toThrow();
|
|
30
38
|
});
|
|
31
39
|
|
|
32
40
|
it('is idempotent - multiple calls work correctly', async () => {
|
|
@@ -35,4 +43,32 @@ describe('destroyServices', () => {
|
|
|
35
43
|
|
|
36
44
|
expect(mockPythonBridge.stop).toHaveBeenCalledTimes(2);
|
|
37
45
|
});
|
|
46
|
+
|
|
47
|
+
it('calls closeAsync on LanceStore for native cleanup', async () => {
|
|
48
|
+
await destroyServices(mockServices);
|
|
49
|
+
|
|
50
|
+
expect(mockLance.closeAsync).toHaveBeenCalledTimes(1);
|
|
51
|
+
// Should use async version, not sync
|
|
52
|
+
expect(mockLance.close).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles LanceStore closeAsync errors gracefully', async () => {
|
|
56
|
+
mockLance.closeAsync.mockRejectedValue(new Error('closeAsync failed'));
|
|
57
|
+
|
|
58
|
+
// Should not throw even if closeAsync fails
|
|
59
|
+
await expect(destroyServices(mockServices)).resolves.not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('waits for LanceStore async cleanup before returning', async () => {
|
|
63
|
+
let closeCompleted = false;
|
|
64
|
+
mockLance.closeAsync.mockImplementation(async () => {
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
66
|
+
closeCompleted = true;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await destroyServices(mockServices);
|
|
70
|
+
|
|
71
|
+
// Should have waited for closeAsync to complete
|
|
72
|
+
expect(closeCompleted).toBe(true);
|
|
73
|
+
});
|
|
38
74
|
});
|
|
@@ -237,6 +237,34 @@ describe('StoreService', () => {
|
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
describe('name validation', () => {
|
|
241
|
+
it('returns error when name is empty string', async () => {
|
|
242
|
+
const result = await storeService.create({
|
|
243
|
+
name: '',
|
|
244
|
+
type: 'file',
|
|
245
|
+
path: tempDir
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(result.success).toBe(false);
|
|
249
|
+
if (!result.success) {
|
|
250
|
+
expect(result.error.message).toContain('Store name cannot be empty');
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('returns error when name is whitespace only', async () => {
|
|
255
|
+
const result = await storeService.create({
|
|
256
|
+
name: ' ',
|
|
257
|
+
type: 'file',
|
|
258
|
+
path: tempDir
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(false);
|
|
262
|
+
if (!result.success) {
|
|
263
|
+
expect(result.error.message).toContain('Store name cannot be empty');
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
240
268
|
describe('duplicate name handling', () => {
|
|
241
269
|
it('returns error when creating store with duplicate name', async () => {
|
|
242
270
|
const dir1 = await mkdtemp(join(tmpdir(), 'dup1-'));
|
|
@@ -37,6 +37,10 @@ export class StoreService {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async create(input: CreateStoreInput): Promise<Result<Store>> {
|
|
40
|
+
if (!input.name || input.name.trim() === '') {
|
|
41
|
+
return err(new Error('Store name cannot be empty'));
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
const existing = await this.getByName(input.name);
|
|
41
45
|
if (existing !== undefined) {
|
|
42
46
|
return err(new Error(`Store with name "${input.name}" already exists`));
|