@sparkleideas/plugins 3.0.0-alpha.10
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 +401 -0
- package/__tests__/collection-manager.test.ts +332 -0
- package/__tests__/dependency-graph.test.ts +434 -0
- package/__tests__/enhanced-plugin-registry.test.ts +488 -0
- package/__tests__/plugin-registry.test.ts +368 -0
- package/__tests__/ruvector-bridge.test.ts +2429 -0
- package/__tests__/ruvector-integration.test.ts +1602 -0
- package/__tests__/ruvector-migrations.test.ts +1099 -0
- package/__tests__/ruvector-quantization.test.ts +846 -0
- package/__tests__/ruvector-streaming.test.ts +1088 -0
- package/__tests__/sdk.test.ts +325 -0
- package/__tests__/security.test.ts +348 -0
- package/__tests__/utils/ruvector-test-utils.ts +860 -0
- package/examples/plugin-creator/index.ts +636 -0
- package/examples/plugin-creator/plugin-creator.test.ts +312 -0
- package/examples/ruvector/README.md +288 -0
- package/examples/ruvector/attention-patterns.ts +394 -0
- package/examples/ruvector/basic-usage.ts +288 -0
- package/examples/ruvector/docker-compose.yml +75 -0
- package/examples/ruvector/gnn-analysis.ts +501 -0
- package/examples/ruvector/hyperbolic-hierarchies.ts +557 -0
- package/examples/ruvector/init-db.sql +119 -0
- package/examples/ruvector/quantization.ts +680 -0
- package/examples/ruvector/self-learning.ts +447 -0
- package/examples/ruvector/semantic-search.ts +576 -0
- package/examples/ruvector/streaming-large-data.ts +507 -0
- package/examples/ruvector/transactions.ts +594 -0
- package/examples/ruvector-plugins/hook-pattern-library.ts +486 -0
- package/examples/ruvector-plugins/index.ts +79 -0
- package/examples/ruvector-plugins/intent-router.ts +354 -0
- package/examples/ruvector-plugins/mcp-tool-optimizer.ts +424 -0
- package/examples/ruvector-plugins/reasoning-bank.ts +657 -0
- package/examples/ruvector-plugins/ruvector-plugins.test.ts +518 -0
- package/examples/ruvector-plugins/semantic-code-search.ts +498 -0
- package/examples/ruvector-plugins/shared/index.ts +20 -0
- package/examples/ruvector-plugins/shared/vector-utils.ts +257 -0
- package/examples/ruvector-plugins/sona-learning.ts +445 -0
- package/package.json +97 -0
- package/src/collections/collection-manager.ts +661 -0
- package/src/collections/index.ts +56 -0
- package/src/collections/official/index.ts +1040 -0
- package/src/core/base-plugin.ts +416 -0
- package/src/core/plugin-interface.ts +215 -0
- package/src/hooks/index.ts +685 -0
- package/src/index.ts +378 -0
- package/src/integrations/agentic-flow.ts +743 -0
- package/src/integrations/index.ts +88 -0
- package/src/integrations/ruvector/ARCHITECTURE.md +1245 -0
- package/src/integrations/ruvector/attention-advanced.ts +1040 -0
- package/src/integrations/ruvector/attention-executor.ts +782 -0
- package/src/integrations/ruvector/attention-mechanisms.ts +757 -0
- package/src/integrations/ruvector/attention.ts +1063 -0
- package/src/integrations/ruvector/gnn.ts +3050 -0
- package/src/integrations/ruvector/hyperbolic.ts +1948 -0
- package/src/integrations/ruvector/index.ts +394 -0
- package/src/integrations/ruvector/migrations/001_create_extension.sql +135 -0
- package/src/integrations/ruvector/migrations/002_create_vector_tables.sql +259 -0
- package/src/integrations/ruvector/migrations/003_create_indices.sql +328 -0
- package/src/integrations/ruvector/migrations/004_create_functions.sql +598 -0
- package/src/integrations/ruvector/migrations/005_create_attention_functions.sql +654 -0
- package/src/integrations/ruvector/migrations/006_create_gnn_functions.sql +728 -0
- package/src/integrations/ruvector/migrations/007_create_hyperbolic_functions.sql +762 -0
- package/src/integrations/ruvector/migrations/index.ts +35 -0
- package/src/integrations/ruvector/migrations/migrations.ts +647 -0
- package/src/integrations/ruvector/quantization.ts +2036 -0
- package/src/integrations/ruvector/ruvector-bridge.ts +2000 -0
- package/src/integrations/ruvector/self-learning.ts +2376 -0
- package/src/integrations/ruvector/streaming.ts +1737 -0
- package/src/integrations/ruvector/types.ts +1945 -0
- package/src/providers/index.ts +643 -0
- package/src/registry/dependency-graph.ts +568 -0
- package/src/registry/enhanced-plugin-registry.ts +994 -0
- package/src/registry/plugin-registry.ts +604 -0
- package/src/sdk/index.ts +563 -0
- package/src/security/index.ts +594 -0
- package/src/types/index.ts +446 -0
- package/src/workers/index.ts +700 -0
- package/tmp.json +0 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +23 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuVector Streaming Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for streaming features including:
|
|
5
|
+
* - Streaming large result sets
|
|
6
|
+
* - Backpressure handling
|
|
7
|
+
* - Stream batch inserts
|
|
8
|
+
* - Cursor-based iteration
|
|
9
|
+
*
|
|
10
|
+
* @module @sparkleideas/plugins/__tests__/ruvector-streaming
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
import { Readable, Writable, Transform } from 'stream';
|
|
15
|
+
import { pipeline } from 'stream/promises';
|
|
16
|
+
import {
|
|
17
|
+
randomVector,
|
|
18
|
+
normalizedVector,
|
|
19
|
+
randomVectors,
|
|
20
|
+
createTestConfig,
|
|
21
|
+
createMockPgPool,
|
|
22
|
+
measureAsync,
|
|
23
|
+
type MockPgPool,
|
|
24
|
+
} from './utils/ruvector-test-utils.js';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Stream Utilities
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Simulates a database cursor for streaming results
|
|
32
|
+
*/
|
|
33
|
+
interface Cursor<T> {
|
|
34
|
+
read(batchSize: number): Promise<T[]>;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
position: number;
|
|
37
|
+
exhausted: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a mock cursor over a dataset
|
|
42
|
+
*/
|
|
43
|
+
function createCursor<T>(data: T[]): Cursor<T> {
|
|
44
|
+
let position = 0;
|
|
45
|
+
let exhausted = false;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async read(batchSize: number): Promise<T[]> {
|
|
49
|
+
if (exhausted) return [];
|
|
50
|
+
|
|
51
|
+
const batch = data.slice(position, position + batchSize);
|
|
52
|
+
position += batch.length;
|
|
53
|
+
|
|
54
|
+
if (position >= data.length) {
|
|
55
|
+
exhausted = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Simulate async database read
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
60
|
+
|
|
61
|
+
return batch;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async close(): Promise<void> {
|
|
65
|
+
exhausted = true;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
get position() {
|
|
69
|
+
return position;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
get exhausted() {
|
|
73
|
+
return exhausted;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Vector search result for streaming
|
|
80
|
+
*/
|
|
81
|
+
interface StreamSearchResult {
|
|
82
|
+
id: string;
|
|
83
|
+
score: number;
|
|
84
|
+
distance: number;
|
|
85
|
+
vector?: number[];
|
|
86
|
+
metadata?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stream of search results from cursor
|
|
91
|
+
*/
|
|
92
|
+
function createSearchResultStream(
|
|
93
|
+
cursor: Cursor<StreamSearchResult>,
|
|
94
|
+
batchSize: number = 100
|
|
95
|
+
): Readable {
|
|
96
|
+
return new Readable({
|
|
97
|
+
objectMode: true,
|
|
98
|
+
async read() {
|
|
99
|
+
try {
|
|
100
|
+
const batch = await cursor.read(batchSize);
|
|
101
|
+
|
|
102
|
+
if (batch.length === 0) {
|
|
103
|
+
this.push(null); // End of stream
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const item of batch) {
|
|
108
|
+
if (!this.push(item)) {
|
|
109
|
+
// Backpressure - pause reading
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.destroy(error as Error);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Transform stream that filters results by score threshold
|
|
122
|
+
*/
|
|
123
|
+
function createScoreFilterTransform(minScore: number): Transform {
|
|
124
|
+
return new Transform({
|
|
125
|
+
objectMode: true,
|
|
126
|
+
transform(chunk: StreamSearchResult, encoding, callback) {
|
|
127
|
+
if (chunk.score >= minScore) {
|
|
128
|
+
this.push(chunk);
|
|
129
|
+
}
|
|
130
|
+
callback();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Transform stream that enriches results with additional data
|
|
137
|
+
*/
|
|
138
|
+
function createEnrichmentTransform(
|
|
139
|
+
enrichFn: (result: StreamSearchResult) => Promise<StreamSearchResult>
|
|
140
|
+
): Transform {
|
|
141
|
+
return new Transform({
|
|
142
|
+
objectMode: true,
|
|
143
|
+
async transform(chunk: StreamSearchResult, encoding, callback) {
|
|
144
|
+
try {
|
|
145
|
+
const enriched = await enrichFn(chunk);
|
|
146
|
+
this.push(enriched);
|
|
147
|
+
callback();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
callback(error as Error);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Batch write stream for inserting vectors
|
|
157
|
+
*/
|
|
158
|
+
interface BatchWriteStream extends Writable {
|
|
159
|
+
batchCount: number;
|
|
160
|
+
totalWritten: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createBatchWriteStream(
|
|
164
|
+
insertFn: (batch: Array<{ id: string; vector: number[]; metadata?: Record<string, unknown> }>) => Promise<void>,
|
|
165
|
+
batchSize: number = 100
|
|
166
|
+
): BatchWriteStream {
|
|
167
|
+
let batch: Array<{ id: string; vector: number[]; metadata?: Record<string, unknown> }> = [];
|
|
168
|
+
let batchCount = 0;
|
|
169
|
+
let totalWritten = 0;
|
|
170
|
+
|
|
171
|
+
const stream = new Writable({
|
|
172
|
+
objectMode: true,
|
|
173
|
+
|
|
174
|
+
async write(chunk, encoding, callback) {
|
|
175
|
+
batch.push(chunk);
|
|
176
|
+
|
|
177
|
+
if (batch.length >= batchSize) {
|
|
178
|
+
try {
|
|
179
|
+
await insertFn(batch);
|
|
180
|
+
totalWritten += batch.length;
|
|
181
|
+
batchCount++;
|
|
182
|
+
batch = [];
|
|
183
|
+
callback();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
callback(error as Error);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
callback();
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async final(callback) {
|
|
193
|
+
if (batch.length > 0) {
|
|
194
|
+
try {
|
|
195
|
+
await insertFn(batch);
|
|
196
|
+
totalWritten += batch.length;
|
|
197
|
+
batchCount++;
|
|
198
|
+
batch = [];
|
|
199
|
+
callback();
|
|
200
|
+
} catch (error) {
|
|
201
|
+
callback(error as Error);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
callback();
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
}) as BatchWriteStream;
|
|
208
|
+
|
|
209
|
+
Object.defineProperty(stream, 'batchCount', {
|
|
210
|
+
get: () => batchCount,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
Object.defineProperty(stream, 'totalWritten', {
|
|
214
|
+
get: () => totalWritten,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return stream;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Vector generator stream
|
|
222
|
+
*/
|
|
223
|
+
function createVectorGeneratorStream(
|
|
224
|
+
count: number,
|
|
225
|
+
dimensions: number = 384,
|
|
226
|
+
generateMetadata: boolean = true
|
|
227
|
+
): Readable {
|
|
228
|
+
let generated = 0;
|
|
229
|
+
|
|
230
|
+
return new Readable({
|
|
231
|
+
objectMode: true,
|
|
232
|
+
read() {
|
|
233
|
+
if (generated >= count) {
|
|
234
|
+
this.push(null);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const vector = {
|
|
239
|
+
id: `gen-${Date.now()}-${generated}`,
|
|
240
|
+
vector: normalizedVector(dimensions),
|
|
241
|
+
...(generateMetadata && {
|
|
242
|
+
metadata: {
|
|
243
|
+
index: generated,
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
batch: Math.floor(generated / 100),
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
generated++;
|
|
251
|
+
this.push(vector);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Simulates slow consumer for backpressure testing
|
|
258
|
+
*/
|
|
259
|
+
function createSlowConsumer(delayMs: number): Writable {
|
|
260
|
+
return new Writable({
|
|
261
|
+
objectMode: true,
|
|
262
|
+
async write(chunk, encoding, callback) {
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
264
|
+
callback();
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Mock Database Operations
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
interface MockStreamingClient {
|
|
274
|
+
searchStream(query: number[], options: {
|
|
275
|
+
k: number;
|
|
276
|
+
metric: 'cosine' | 'euclidean';
|
|
277
|
+
batchSize?: number;
|
|
278
|
+
includeVector?: boolean;
|
|
279
|
+
}): Readable;
|
|
280
|
+
|
|
281
|
+
insertStream(options: {
|
|
282
|
+
tableName: string;
|
|
283
|
+
batchSize?: number;
|
|
284
|
+
}): BatchWriteStream;
|
|
285
|
+
|
|
286
|
+
createCursor(query: string, params?: unknown[]): Promise<Cursor<Record<string, unknown>>>;
|
|
287
|
+
|
|
288
|
+
data: Map<string, { vector: number[]; metadata?: Record<string, unknown> }>;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createMockStreamingClient(): MockStreamingClient {
|
|
292
|
+
const data = new Map<string, { vector: number[]; metadata?: Record<string, unknown> }>();
|
|
293
|
+
|
|
294
|
+
// Pre-populate with test data
|
|
295
|
+
for (let i = 0; i < 10000; i++) {
|
|
296
|
+
data.set(`vec-${i}`, {
|
|
297
|
+
vector: normalizedVector(384),
|
|
298
|
+
metadata: { index: i, category: i % 10 },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
data,
|
|
304
|
+
|
|
305
|
+
searchStream(query, options) {
|
|
306
|
+
// Generate mock search results
|
|
307
|
+
const results: StreamSearchResult[] = [];
|
|
308
|
+
|
|
309
|
+
for (const [id, { vector, metadata }] of data) {
|
|
310
|
+
const dot = query.reduce((sum, v, i) => sum + v * vector[i], 0);
|
|
311
|
+
const score = (dot + 1) / 2; // Normalize to 0-1
|
|
312
|
+
|
|
313
|
+
results.push({
|
|
314
|
+
id,
|
|
315
|
+
score,
|
|
316
|
+
distance: 1 - score,
|
|
317
|
+
...(options.includeVector && { vector }),
|
|
318
|
+
metadata,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Sort by score descending
|
|
323
|
+
results.sort((a, b) => b.score - a.score);
|
|
324
|
+
|
|
325
|
+
// Limit to k results
|
|
326
|
+
const topK = results.slice(0, options.k);
|
|
327
|
+
|
|
328
|
+
// Create cursor and stream
|
|
329
|
+
const cursor = createCursor(topK);
|
|
330
|
+
return createSearchResultStream(cursor, options.batchSize);
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
insertStream(options) {
|
|
334
|
+
return createBatchWriteStream(async (batch) => {
|
|
335
|
+
for (const item of batch) {
|
|
336
|
+
data.set(item.id, {
|
|
337
|
+
vector: item.vector,
|
|
338
|
+
metadata: item.metadata,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}, options.batchSize);
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async createCursor(query, params) {
|
|
345
|
+
// Simple mock cursor that returns all data in pages
|
|
346
|
+
const allData = Array.from(data.entries()).map(([id, { vector, metadata }]) => ({
|
|
347
|
+
id,
|
|
348
|
+
vector,
|
|
349
|
+
metadata,
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
return createCursor(allData);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============================================================================
|
|
358
|
+
// Test Suites
|
|
359
|
+
// ============================================================================
|
|
360
|
+
|
|
361
|
+
describe('RuVector Streaming', () => {
|
|
362
|
+
let client: MockStreamingClient;
|
|
363
|
+
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
client = createMockStreamingClient();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ==========================================================================
|
|
369
|
+
// Streaming Search Results Tests
|
|
370
|
+
// ==========================================================================
|
|
371
|
+
|
|
372
|
+
describe('Streaming Search Results', () => {
|
|
373
|
+
it('should stream large result sets', async () => {
|
|
374
|
+
const query = normalizedVector(384);
|
|
375
|
+
const k = 1000;
|
|
376
|
+
|
|
377
|
+
const stream = client.searchStream(query, {
|
|
378
|
+
k,
|
|
379
|
+
metric: 'cosine',
|
|
380
|
+
batchSize: 100,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const results: StreamSearchResult[] = [];
|
|
384
|
+
|
|
385
|
+
for await (const result of stream) {
|
|
386
|
+
results.push(result);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Note: Limited by mock data size, should return up to min(k, dataSize)
|
|
390
|
+
expect(results.length).toBeGreaterThan(0);
|
|
391
|
+
expect(results.length).toBeLessThanOrEqual(k);
|
|
392
|
+
expect(results[0].score).toBeGreaterThanOrEqual(results[results.length - 1].score);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should respect batch size during streaming', async () => {
|
|
396
|
+
const query = normalizedVector(384);
|
|
397
|
+
const batchSize = 50;
|
|
398
|
+
const k = 200;
|
|
399
|
+
|
|
400
|
+
const stream = client.searchStream(query, {
|
|
401
|
+
k,
|
|
402
|
+
metric: 'cosine',
|
|
403
|
+
batchSize,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
let totalResults = 0;
|
|
407
|
+
|
|
408
|
+
// Track batch reads through the cursor
|
|
409
|
+
const results: StreamSearchResult[] = [];
|
|
410
|
+
|
|
411
|
+
for await (const result of stream) {
|
|
412
|
+
results.push(result);
|
|
413
|
+
totalResults++;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Results should be streamed in batches up to k items
|
|
417
|
+
expect(totalResults).toBeGreaterThan(0);
|
|
418
|
+
expect(totalResults).toBeLessThanOrEqual(k);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should include vectors when requested', async () => {
|
|
422
|
+
const query = normalizedVector(384);
|
|
423
|
+
|
|
424
|
+
const stream = client.searchStream(query, {
|
|
425
|
+
k: 10,
|
|
426
|
+
metric: 'cosine',
|
|
427
|
+
includeVector: true,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const results: StreamSearchResult[] = [];
|
|
431
|
+
|
|
432
|
+
for await (const result of stream) {
|
|
433
|
+
results.push(result);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
results.forEach((r) => {
|
|
437
|
+
expect(r.vector).toBeDefined();
|
|
438
|
+
expect(r.vector).toHaveLength(384);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should allow filtering with transform stream', async () => {
|
|
443
|
+
const query = normalizedVector(384);
|
|
444
|
+
const minScore = 0.7;
|
|
445
|
+
|
|
446
|
+
const searchStream = client.searchStream(query, {
|
|
447
|
+
k: 100,
|
|
448
|
+
metric: 'cosine',
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const filterStream = createScoreFilterTransform(minScore);
|
|
452
|
+
|
|
453
|
+
const results: StreamSearchResult[] = [];
|
|
454
|
+
|
|
455
|
+
await pipeline(
|
|
456
|
+
searchStream,
|
|
457
|
+
filterStream,
|
|
458
|
+
new Writable({
|
|
459
|
+
objectMode: true,
|
|
460
|
+
write(chunk, encoding, callback) {
|
|
461
|
+
results.push(chunk);
|
|
462
|
+
callback();
|
|
463
|
+
},
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
results.forEach((r) => {
|
|
468
|
+
expect(r.score).toBeGreaterThanOrEqual(minScore);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should support enrichment transforms', async () => {
|
|
473
|
+
const query = normalizedVector(384);
|
|
474
|
+
|
|
475
|
+
const searchStream = client.searchStream(query, {
|
|
476
|
+
k: 10,
|
|
477
|
+
metric: 'cosine',
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const enrichStream = createEnrichmentTransform(async (result) => ({
|
|
481
|
+
...result,
|
|
482
|
+
metadata: {
|
|
483
|
+
...result.metadata,
|
|
484
|
+
enrichedAt: new Date().toISOString(),
|
|
485
|
+
source: 'ruvector',
|
|
486
|
+
},
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
const results: StreamSearchResult[] = [];
|
|
490
|
+
|
|
491
|
+
await pipeline(
|
|
492
|
+
searchStream,
|
|
493
|
+
enrichStream,
|
|
494
|
+
new Writable({
|
|
495
|
+
objectMode: true,
|
|
496
|
+
write(chunk, encoding, callback) {
|
|
497
|
+
results.push(chunk);
|
|
498
|
+
callback();
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
results.forEach((r) => {
|
|
504
|
+
expect(r.metadata?.enrichedAt).toBeDefined();
|
|
505
|
+
expect(r.metadata?.source).toBe('ruvector');
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ==========================================================================
|
|
511
|
+
// Backpressure Handling Tests
|
|
512
|
+
// ==========================================================================
|
|
513
|
+
|
|
514
|
+
describe('Backpressure Handling', () => {
|
|
515
|
+
it('should handle backpressure from slow consumers', async () => {
|
|
516
|
+
const query = normalizedVector(384);
|
|
517
|
+
|
|
518
|
+
const searchStream = client.searchStream(query, {
|
|
519
|
+
k: 100,
|
|
520
|
+
metric: 'cosine',
|
|
521
|
+
batchSize: 10,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Slow consumer - 5ms per item
|
|
525
|
+
const slowConsumer = createSlowConsumer(5);
|
|
526
|
+
|
|
527
|
+
let itemsProcessed = 0;
|
|
528
|
+
|
|
529
|
+
await pipeline(
|
|
530
|
+
searchStream,
|
|
531
|
+
new Transform({
|
|
532
|
+
objectMode: true,
|
|
533
|
+
transform(chunk, encoding, callback) {
|
|
534
|
+
itemsProcessed++;
|
|
535
|
+
this.push(chunk);
|
|
536
|
+
callback();
|
|
537
|
+
},
|
|
538
|
+
}),
|
|
539
|
+
slowConsumer
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// Should process all items despite backpressure
|
|
543
|
+
expect(itemsProcessed).toBeGreaterThan(0);
|
|
544
|
+
expect(itemsProcessed).toBeLessThanOrEqual(100);
|
|
545
|
+
}, 10000); // Longer timeout for slow consumer
|
|
546
|
+
|
|
547
|
+
it('should not overwhelm memory with large result sets', async () => {
|
|
548
|
+
const query = normalizedVector(384);
|
|
549
|
+
|
|
550
|
+
// Get memory before streaming
|
|
551
|
+
const memBefore = process.memoryUsage().heapUsed;
|
|
552
|
+
|
|
553
|
+
const stream = client.searchStream(query, {
|
|
554
|
+
k: 5000,
|
|
555
|
+
metric: 'cosine',
|
|
556
|
+
batchSize: 50,
|
|
557
|
+
includeVector: true,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
let count = 0;
|
|
561
|
+
|
|
562
|
+
for await (const result of stream) {
|
|
563
|
+
count++;
|
|
564
|
+
// Don't store results - just count them
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const memAfter = process.memoryUsage().heapUsed;
|
|
568
|
+
const memDelta = memAfter - memBefore;
|
|
569
|
+
|
|
570
|
+
// Should process items (limited by mock data size of 10000)
|
|
571
|
+
expect(count).toBeGreaterThan(0);
|
|
572
|
+
expect(count).toBeLessThanOrEqual(5000);
|
|
573
|
+
// Memory should not grow excessively for streaming
|
|
574
|
+
expect(memDelta).toBeLessThan(100 * 1024 * 1024);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should pause and resume based on consumer speed', async () => {
|
|
578
|
+
const data: StreamSearchResult[] = Array.from({ length: 1000 }, (_, i) => ({
|
|
579
|
+
id: `item-${i}`,
|
|
580
|
+
score: 1 - i / 1000,
|
|
581
|
+
distance: i / 1000,
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
const cursor = createCursor(data);
|
|
585
|
+
const stream = createSearchResultStream(cursor, 10);
|
|
586
|
+
|
|
587
|
+
let pauseCount = 0;
|
|
588
|
+
let resumeCount = 0;
|
|
589
|
+
|
|
590
|
+
stream.on('pause', () => pauseCount++);
|
|
591
|
+
stream.on('resume', () => resumeCount++);
|
|
592
|
+
|
|
593
|
+
// Variable speed consumer
|
|
594
|
+
let processed = 0;
|
|
595
|
+
|
|
596
|
+
await pipeline(
|
|
597
|
+
stream,
|
|
598
|
+
new Transform({
|
|
599
|
+
objectMode: true,
|
|
600
|
+
highWaterMark: 5, // Low watermark to trigger backpressure
|
|
601
|
+
async transform(chunk, encoding, callback) {
|
|
602
|
+
processed++;
|
|
603
|
+
// Occasionally slow down
|
|
604
|
+
if (processed % 100 === 0) {
|
|
605
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
606
|
+
}
|
|
607
|
+
this.push(chunk);
|
|
608
|
+
callback();
|
|
609
|
+
},
|
|
610
|
+
}),
|
|
611
|
+
new Writable({
|
|
612
|
+
objectMode: true,
|
|
613
|
+
write(chunk, encoding, callback) {
|
|
614
|
+
callback();
|
|
615
|
+
},
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(processed).toBe(1000);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ==========================================================================
|
|
624
|
+
// Stream Batch Inserts Tests
|
|
625
|
+
// ==========================================================================
|
|
626
|
+
|
|
627
|
+
describe('Stream Batch Inserts', () => {
|
|
628
|
+
it('should stream batch inserts', async () => {
|
|
629
|
+
const vectorCount = 500;
|
|
630
|
+
const batchSize = 50;
|
|
631
|
+
|
|
632
|
+
const generator = createVectorGeneratorStream(vectorCount, 384);
|
|
633
|
+
const inserter = client.insertStream({
|
|
634
|
+
tableName: 'test_vectors',
|
|
635
|
+
batchSize,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await pipeline(generator, inserter);
|
|
639
|
+
|
|
640
|
+
expect(inserter.totalWritten).toBe(vectorCount);
|
|
641
|
+
expect(inserter.batchCount).toBe(Math.ceil(vectorCount / batchSize));
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should handle partial final batch', async () => {
|
|
645
|
+
const vectorCount = 175; // Not divisible by batch size
|
|
646
|
+
const batchSize = 50;
|
|
647
|
+
|
|
648
|
+
const generator = createVectorGeneratorStream(vectorCount, 384);
|
|
649
|
+
const inserter = client.insertStream({
|
|
650
|
+
tableName: 'test_vectors',
|
|
651
|
+
batchSize,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
await pipeline(generator, inserter);
|
|
655
|
+
|
|
656
|
+
expect(inserter.totalWritten).toBe(vectorCount);
|
|
657
|
+
expect(inserter.batchCount).toBe(4); // 50 + 50 + 50 + 25
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('should respect insert throughput', async () => {
|
|
661
|
+
const vectorCount = 1000;
|
|
662
|
+
const batchSize = 100;
|
|
663
|
+
|
|
664
|
+
const generator = createVectorGeneratorStream(vectorCount, 384);
|
|
665
|
+
const inserter = client.insertStream({
|
|
666
|
+
tableName: 'test_vectors',
|
|
667
|
+
batchSize,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const { durationMs } = await measureAsync(async () => {
|
|
671
|
+
await pipeline(generator, inserter);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const throughput = vectorCount / (durationMs / 1000);
|
|
675
|
+
|
|
676
|
+
expect(inserter.totalWritten).toBe(vectorCount);
|
|
677
|
+
expect(throughput).toBeGreaterThan(100); // At least 100 vectors/sec
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('should transform vectors before insert', async () => {
|
|
681
|
+
const vectorCount = 100;
|
|
682
|
+
const dimensions = 384;
|
|
683
|
+
|
|
684
|
+
const generator = createVectorGeneratorStream(vectorCount, dimensions, false);
|
|
685
|
+
|
|
686
|
+
// Normalize vectors before insert
|
|
687
|
+
const normalizer = new Transform({
|
|
688
|
+
objectMode: true,
|
|
689
|
+
transform(chunk, encoding, callback) {
|
|
690
|
+
const magnitude = Math.sqrt(
|
|
691
|
+
chunk.vector.reduce((sum: number, v: number) => sum + v * v, 0)
|
|
692
|
+
);
|
|
693
|
+
const normalized = {
|
|
694
|
+
...chunk,
|
|
695
|
+
vector: chunk.vector.map((v: number) => v / magnitude),
|
|
696
|
+
metadata: { normalized: true },
|
|
697
|
+
};
|
|
698
|
+
this.push(normalized);
|
|
699
|
+
callback();
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const insertedVectors: Array<{ id: string; vector: number[] }> = [];
|
|
704
|
+
|
|
705
|
+
const inserter = createBatchWriteStream(async (batch) => {
|
|
706
|
+
insertedVectors.push(...batch);
|
|
707
|
+
}, 25);
|
|
708
|
+
|
|
709
|
+
await pipeline(generator, normalizer, inserter);
|
|
710
|
+
|
|
711
|
+
expect(insertedVectors).toHaveLength(vectorCount);
|
|
712
|
+
|
|
713
|
+
// Check all vectors are normalized
|
|
714
|
+
insertedVectors.forEach(({ vector }) => {
|
|
715
|
+
const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
|
|
716
|
+
expect(magnitude).toBeCloseTo(1, 5);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('should handle insert errors gracefully', async () => {
|
|
721
|
+
const vectorCount = 100;
|
|
722
|
+
let errorTriggered = false;
|
|
723
|
+
|
|
724
|
+
const generator = createVectorGeneratorStream(vectorCount, 384);
|
|
725
|
+
|
|
726
|
+
const failingInserter = createBatchWriteStream(async (batch) => {
|
|
727
|
+
if (!errorTriggered && batch.length > 0) {
|
|
728
|
+
errorTriggered = true;
|
|
729
|
+
throw new Error('Simulated insert failure');
|
|
730
|
+
}
|
|
731
|
+
}, 50);
|
|
732
|
+
|
|
733
|
+
await expect(
|
|
734
|
+
pipeline(generator, failingInserter)
|
|
735
|
+
).rejects.toThrow('Simulated insert failure');
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// ==========================================================================
|
|
740
|
+
// Cursor Operations Tests
|
|
741
|
+
// ==========================================================================
|
|
742
|
+
|
|
743
|
+
describe('Cursor Operations', () => {
|
|
744
|
+
it('should create and iterate cursor', async () => {
|
|
745
|
+
const cursor = await client.createCursor('SELECT * FROM vectors');
|
|
746
|
+
|
|
747
|
+
const batches: Array<Record<string, unknown>[]> = [];
|
|
748
|
+
let batch: Record<string, unknown>[];
|
|
749
|
+
|
|
750
|
+
while ((batch = await cursor.read(100)).length > 0) {
|
|
751
|
+
batches.push(batch);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
expect(batches.length).toBeGreaterThan(0);
|
|
755
|
+
expect(cursor.exhausted).toBe(true);
|
|
756
|
+
|
|
757
|
+
await cursor.close();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('should support cursor with small batch size', async () => {
|
|
761
|
+
const cursor = await client.createCursor('SELECT * FROM vectors');
|
|
762
|
+
|
|
763
|
+
const items: Record<string, unknown>[] = [];
|
|
764
|
+
let batch: Record<string, unknown>[];
|
|
765
|
+
|
|
766
|
+
while ((batch = await cursor.read(10)).length > 0 && items.length < 50) {
|
|
767
|
+
items.push(...batch);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
expect(items).toHaveLength(50);
|
|
771
|
+
|
|
772
|
+
await cursor.close();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('should close cursor properly', async () => {
|
|
776
|
+
const cursor = await client.createCursor('SELECT * FROM vectors');
|
|
777
|
+
|
|
778
|
+
// Read some data
|
|
779
|
+
await cursor.read(50);
|
|
780
|
+
expect(cursor.exhausted).toBe(false);
|
|
781
|
+
|
|
782
|
+
// Close cursor
|
|
783
|
+
await cursor.close();
|
|
784
|
+
expect(cursor.exhausted).toBe(true);
|
|
785
|
+
|
|
786
|
+
// Further reads should return empty
|
|
787
|
+
const afterClose = await cursor.read(50);
|
|
788
|
+
expect(afterClose).toHaveLength(0);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('should track cursor position', async () => {
|
|
792
|
+
const cursor = await client.createCursor('SELECT * FROM vectors');
|
|
793
|
+
|
|
794
|
+
expect(cursor.position).toBe(0);
|
|
795
|
+
|
|
796
|
+
await cursor.read(100);
|
|
797
|
+
expect(cursor.position).toBe(100);
|
|
798
|
+
|
|
799
|
+
await cursor.read(50);
|
|
800
|
+
expect(cursor.position).toBe(150);
|
|
801
|
+
|
|
802
|
+
await cursor.close();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// ==========================================================================
|
|
807
|
+
// Pipeline Composition Tests
|
|
808
|
+
// ==========================================================================
|
|
809
|
+
|
|
810
|
+
describe('Pipeline Composition', () => {
|
|
811
|
+
it('should compose multiple transforms', async () => {
|
|
812
|
+
const query = normalizedVector(384);
|
|
813
|
+
|
|
814
|
+
const searchStream = client.searchStream(query, {
|
|
815
|
+
k: 100,
|
|
816
|
+
metric: 'cosine',
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Filter by score
|
|
820
|
+
const filterTransform = createScoreFilterTransform(0.6);
|
|
821
|
+
|
|
822
|
+
// Enrich results
|
|
823
|
+
const enrichTransform = createEnrichmentTransform(async (result) => ({
|
|
824
|
+
...result,
|
|
825
|
+
metadata: {
|
|
826
|
+
...result.metadata,
|
|
827
|
+
processed: true,
|
|
828
|
+
},
|
|
829
|
+
}));
|
|
830
|
+
|
|
831
|
+
// Limit results
|
|
832
|
+
let count = 0;
|
|
833
|
+
const limitTransform = new Transform({
|
|
834
|
+
objectMode: true,
|
|
835
|
+
transform(chunk, encoding, callback) {
|
|
836
|
+
if (count < 20) {
|
|
837
|
+
this.push(chunk);
|
|
838
|
+
count++;
|
|
839
|
+
}
|
|
840
|
+
callback();
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const results: StreamSearchResult[] = [];
|
|
845
|
+
|
|
846
|
+
await pipeline(
|
|
847
|
+
searchStream,
|
|
848
|
+
filterTransform,
|
|
849
|
+
enrichTransform,
|
|
850
|
+
limitTransform,
|
|
851
|
+
new Writable({
|
|
852
|
+
objectMode: true,
|
|
853
|
+
write(chunk, encoding, callback) {
|
|
854
|
+
results.push(chunk);
|
|
855
|
+
callback();
|
|
856
|
+
},
|
|
857
|
+
})
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
expect(results.length).toBeLessThanOrEqual(20);
|
|
861
|
+
results.forEach((r) => {
|
|
862
|
+
expect(r.score).toBeGreaterThanOrEqual(0.6);
|
|
863
|
+
expect(r.metadata?.processed).toBe(true);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('should handle errors in pipeline', async () => {
|
|
868
|
+
const query = normalizedVector(384);
|
|
869
|
+
|
|
870
|
+
const searchStream = client.searchStream(query, {
|
|
871
|
+
k: 100,
|
|
872
|
+
metric: 'cosine',
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const failingTransform = new Transform({
|
|
876
|
+
objectMode: true,
|
|
877
|
+
transform(chunk, encoding, callback) {
|
|
878
|
+
callback(new Error('Transform error'));
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
await expect(
|
|
883
|
+
pipeline(
|
|
884
|
+
searchStream,
|
|
885
|
+
failingTransform,
|
|
886
|
+
new Writable({
|
|
887
|
+
objectMode: true,
|
|
888
|
+
write(chunk, encoding, callback) {
|
|
889
|
+
callback();
|
|
890
|
+
},
|
|
891
|
+
})
|
|
892
|
+
)
|
|
893
|
+
).rejects.toThrow('Transform error');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should support async generators', async () => {
|
|
897
|
+
async function* generateResults() {
|
|
898
|
+
for (let i = 0; i < 100; i++) {
|
|
899
|
+
yield {
|
|
900
|
+
id: `gen-${i}`,
|
|
901
|
+
vector: normalizedVector(384),
|
|
902
|
+
metadata: { index: i },
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const results: Array<{ id: string }> = [];
|
|
908
|
+
|
|
909
|
+
for await (const item of generateResults()) {
|
|
910
|
+
results.push(item);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
expect(results).toHaveLength(100);
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// ==========================================================================
|
|
918
|
+
// Memory Efficiency Tests
|
|
919
|
+
// ==========================================================================
|
|
920
|
+
|
|
921
|
+
describe('Memory Efficiency', () => {
|
|
922
|
+
it('should process large datasets with constant memory', async () => {
|
|
923
|
+
const largeCount = 10000;
|
|
924
|
+
const batchSize = 100;
|
|
925
|
+
|
|
926
|
+
// Track memory at intervals
|
|
927
|
+
const memSnapshots: number[] = [];
|
|
928
|
+
|
|
929
|
+
const generator = createVectorGeneratorStream(largeCount, 384);
|
|
930
|
+
|
|
931
|
+
let processed = 0;
|
|
932
|
+
|
|
933
|
+
await pipeline(
|
|
934
|
+
generator,
|
|
935
|
+
new Transform({
|
|
936
|
+
objectMode: true,
|
|
937
|
+
transform(chunk, encoding, callback) {
|
|
938
|
+
processed++;
|
|
939
|
+
|
|
940
|
+
// Take memory snapshot every 1000 items
|
|
941
|
+
if (processed % 1000 === 0) {
|
|
942
|
+
memSnapshots.push(process.memoryUsage().heapUsed);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Don't accumulate - just pass through
|
|
946
|
+
callback();
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
new Writable({
|
|
950
|
+
objectMode: true,
|
|
951
|
+
write(chunk, encoding, callback) {
|
|
952
|
+
callback();
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
expect(processed).toBe(largeCount);
|
|
958
|
+
|
|
959
|
+
// Memory should not grow significantly
|
|
960
|
+
if (memSnapshots.length > 2) {
|
|
961
|
+
const firstSnapshot = memSnapshots[0];
|
|
962
|
+
const lastSnapshot = memSnapshots[memSnapshots.length - 1];
|
|
963
|
+
const growth = lastSnapshot - firstSnapshot;
|
|
964
|
+
|
|
965
|
+
// Allow up to 20MB growth
|
|
966
|
+
expect(growth).toBeLessThan(20 * 1024 * 1024);
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('should release references after streaming', async () => {
|
|
971
|
+
// Force GC if available
|
|
972
|
+
if (global.gc) {
|
|
973
|
+
global.gc();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const memBefore = process.memoryUsage().heapUsed;
|
|
977
|
+
|
|
978
|
+
// Process a large stream
|
|
979
|
+
const generator = createVectorGeneratorStream(5000, 384);
|
|
980
|
+
|
|
981
|
+
await pipeline(
|
|
982
|
+
generator,
|
|
983
|
+
new Writable({
|
|
984
|
+
objectMode: true,
|
|
985
|
+
write(chunk, encoding, callback) {
|
|
986
|
+
callback();
|
|
987
|
+
},
|
|
988
|
+
})
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
// Force GC if available
|
|
992
|
+
if (global.gc) {
|
|
993
|
+
global.gc();
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const memAfter = process.memoryUsage().heapUsed;
|
|
997
|
+
|
|
998
|
+
// Memory growth should be reasonable (allow 50MB variance for test environment)
|
|
999
|
+
// In production with proper GC, this would be much lower
|
|
1000
|
+
expect(Math.abs(memAfter - memBefore)).toBeLessThan(50 * 1024 * 1024);
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// ==========================================================================
|
|
1005
|
+
// Edge Cases
|
|
1006
|
+
// ==========================================================================
|
|
1007
|
+
|
|
1008
|
+
describe('Edge Cases', () => {
|
|
1009
|
+
it('should handle empty result stream', async () => {
|
|
1010
|
+
const cursor = createCursor<StreamSearchResult>([]);
|
|
1011
|
+
const stream = createSearchResultStream(cursor);
|
|
1012
|
+
|
|
1013
|
+
const results: StreamSearchResult[] = [];
|
|
1014
|
+
|
|
1015
|
+
for await (const result of stream) {
|
|
1016
|
+
results.push(result);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
expect(results).toHaveLength(0);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('should handle single result', async () => {
|
|
1023
|
+
const cursor = createCursor<StreamSearchResult>([
|
|
1024
|
+
{ id: 'single', score: 1, distance: 0 },
|
|
1025
|
+
]);
|
|
1026
|
+
const stream = createSearchResultStream(cursor);
|
|
1027
|
+
|
|
1028
|
+
const results: StreamSearchResult[] = [];
|
|
1029
|
+
|
|
1030
|
+
for await (const result of stream) {
|
|
1031
|
+
results.push(result);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
expect(results).toHaveLength(1);
|
|
1035
|
+
expect(results[0].id).toBe('single');
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it('should handle stream destruction', async () => {
|
|
1039
|
+
const cursor = createCursor<StreamSearchResult>(
|
|
1040
|
+
Array.from({ length: 1000 }, (_, i) => ({
|
|
1041
|
+
id: `item-${i}`,
|
|
1042
|
+
score: 1 - i / 1000,
|
|
1043
|
+
distance: i / 1000,
|
|
1044
|
+
}))
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
const stream = createSearchResultStream(cursor, 10);
|
|
1048
|
+
|
|
1049
|
+
const results: StreamSearchResult[] = [];
|
|
1050
|
+
|
|
1051
|
+
for await (const result of stream) {
|
|
1052
|
+
results.push(result);
|
|
1053
|
+
if (results.length >= 50) {
|
|
1054
|
+
stream.destroy();
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
expect(results.length).toBeLessThanOrEqual(60); // May have some buffered
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('should handle concurrent stream consumption', async () => {
|
|
1063
|
+
const query = normalizedVector(384);
|
|
1064
|
+
|
|
1065
|
+
// Create multiple concurrent streams
|
|
1066
|
+
const streams = Array.from({ length: 5 }, () =>
|
|
1067
|
+
client.searchStream(query, { k: 100, metric: 'cosine' })
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const results = await Promise.all(
|
|
1071
|
+
streams.map(async (stream) => {
|
|
1072
|
+
const items: StreamSearchResult[] = [];
|
|
1073
|
+
for await (const item of stream) {
|
|
1074
|
+
items.push(item);
|
|
1075
|
+
}
|
|
1076
|
+
return items;
|
|
1077
|
+
})
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
expect(results).toHaveLength(5);
|
|
1081
|
+
results.forEach((r) => {
|
|
1082
|
+
// Each stream should return results (limited by mock data)
|
|
1083
|
+
expect(r.length).toBeGreaterThan(0);
|
|
1084
|
+
expect(r.length).toBeLessThanOrEqual(100);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
});
|