@soulcraft/brainy 3.8.3 → 3.9.1
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 +26 -28
- package/dist/brainy.d.ts +27 -0
- package/dist/brainy.js +231 -10
- package/dist/coreTypes.d.ts +10 -0
- package/dist/hnsw/hnswIndex.d.ts +2 -0
- package/dist/hnsw/hnswIndex.js +10 -0
- package/dist/neural/improvedNeuralAPI.d.ts +14 -1
- package/dist/neural/improvedNeuralAPI.js +59 -20
- package/dist/neural/naturalLanguageProcessorStatic.d.ts +1 -0
- package/dist/neural/naturalLanguageProcessorStatic.js +3 -2
- package/dist/storage/adapters/baseStorageAdapter.d.ts +59 -0
- package/dist/storage/adapters/baseStorageAdapter.js +137 -0
- package/dist/storage/adapters/fileSystemStorage.d.ts +41 -0
- package/dist/storage/adapters/fileSystemStorage.js +227 -19
- package/dist/storage/adapters/memoryStorage.d.ts +8 -0
- package/dist/storage/adapters/memoryStorage.js +48 -1
- package/dist/storage/adapters/opfsStorage.d.ts +12 -0
- package/dist/storage/adapters/opfsStorage.js +68 -0
- package/dist/storage/adapters/s3CompatibleStorage.d.ts +34 -0
- package/dist/storage/adapters/s3CompatibleStorage.js +129 -3
- package/dist/storage/baseStorage.js +4 -3
- package/dist/storage/readOnlyOptimizations.d.ts +0 -9
- package/dist/storage/readOnlyOptimizations.js +6 -28
- package/dist/types/brainy.types.d.ts +15 -0
- package/dist/utils/metadataIndex.d.ts +5 -0
- package/dist/utils/metadataIndex.js +24 -0
- package/dist/utils/mutex.d.ts +53 -0
- package/dist/utils/mutex.js +221 -0
- package/dist/utils/paramValidation.js +20 -4
- package/package.json +1 -1
|
@@ -240,6 +240,49 @@ export class S3CompatibleStorage extends BaseStorage {
|
|
|
240
240
|
throw new Error(`Failed to initialize ${this.serviceType} storage: ${error}`);
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Set distributed components for multi-node coordination
|
|
245
|
+
* Zero-config: Automatically optimizes based on components provided
|
|
246
|
+
*/
|
|
247
|
+
setDistributedComponents(components) {
|
|
248
|
+
this.coordinator = components.coordinator;
|
|
249
|
+
this.shardManager = components.shardManager;
|
|
250
|
+
this.cacheSync = components.cacheSync;
|
|
251
|
+
this.readWriteSeparation = components.readWriteSeparation;
|
|
252
|
+
// Auto-configure based on what's available
|
|
253
|
+
if (this.shardManager) {
|
|
254
|
+
console.log(`🎯 S3 Storage: Sharding enabled with ${this.shardManager.config?.shardCount || 64} shards`);
|
|
255
|
+
}
|
|
256
|
+
if (this.coordinator) {
|
|
257
|
+
console.log(`🤝 S3 Storage: Distributed coordination active (node: ${this.coordinator.nodeId})`);
|
|
258
|
+
}
|
|
259
|
+
if (this.cacheSync) {
|
|
260
|
+
console.log('🔄 S3 Storage: Cache synchronization enabled');
|
|
261
|
+
}
|
|
262
|
+
if (this.readWriteSeparation) {
|
|
263
|
+
console.log(`📖 S3 Storage: Read/write separation with ${this.readWriteSeparation.config?.replicationFactor || 3}x replication`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get the S3 key for a noun, using sharding if available
|
|
268
|
+
*/
|
|
269
|
+
getNounKey(id) {
|
|
270
|
+
if (this.shardManager) {
|
|
271
|
+
const shardId = this.shardManager.getShardForKey(id);
|
|
272
|
+
return `shards/${shardId}/${this.nounPrefix}${id}.json`;
|
|
273
|
+
}
|
|
274
|
+
return `${this.nounPrefix}${id}.json`;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Get the S3 key for a verb, using sharding if available
|
|
278
|
+
*/
|
|
279
|
+
getVerbKey(id) {
|
|
280
|
+
if (this.shardManager) {
|
|
281
|
+
const shardId = this.shardManager.getShardForKey(id);
|
|
282
|
+
return `shards/${shardId}/${this.verbPrefix}${id}.json`;
|
|
283
|
+
}
|
|
284
|
+
return `${this.verbPrefix}${id}.json`;
|
|
285
|
+
}
|
|
243
286
|
/**
|
|
244
287
|
* Override base class method to detect S3-specific throttling errors
|
|
245
288
|
*/
|
|
@@ -668,7 +711,8 @@ export class S3CompatibleStorage extends BaseStorage {
|
|
|
668
711
|
};
|
|
669
712
|
// Import the PutObjectCommand only when needed
|
|
670
713
|
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
671
|
-
|
|
714
|
+
// Use sharding if available
|
|
715
|
+
const key = this.getNounKey(node.id);
|
|
672
716
|
const body = JSON.stringify(serializableNode, null, 2);
|
|
673
717
|
this.logger.trace(`Saving to key: ${key}`);
|
|
674
718
|
// Save the node to S3-compatible storage
|
|
@@ -1013,10 +1057,10 @@ export class S3CompatibleStorage extends BaseStorage {
|
|
|
1013
1057
|
};
|
|
1014
1058
|
// Import the PutObjectCommand only when needed
|
|
1015
1059
|
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
1016
|
-
// Save the edge to S3-compatible storage
|
|
1060
|
+
// Save the edge to S3-compatible storage using sharding if available
|
|
1017
1061
|
await this.s3Client.send(new PutObjectCommand({
|
|
1018
1062
|
Bucket: this.bucketName,
|
|
1019
|
-
Key:
|
|
1063
|
+
Key: this.getVerbKey(edge.id),
|
|
1020
1064
|
Body: JSON.stringify(serializableEdge, null, 2),
|
|
1021
1065
|
ContentType: 'application/json'
|
|
1022
1066
|
}));
|
|
@@ -2660,5 +2704,87 @@ export class S3CompatibleStorage extends BaseStorage {
|
|
|
2660
2704
|
nextCursor: result.nextCursor
|
|
2661
2705
|
};
|
|
2662
2706
|
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Initialize counts from S3 storage
|
|
2709
|
+
*/
|
|
2710
|
+
async initializeCounts() {
|
|
2711
|
+
const countsKey = `${this.systemPrefix}counts.json`;
|
|
2712
|
+
try {
|
|
2713
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
2714
|
+
// Try to load existing counts
|
|
2715
|
+
const response = await this.s3Client.send(new GetObjectCommand({
|
|
2716
|
+
Bucket: this.bucketName,
|
|
2717
|
+
Key: countsKey
|
|
2718
|
+
}));
|
|
2719
|
+
if (response.Body) {
|
|
2720
|
+
const data = await response.Body.transformToString();
|
|
2721
|
+
const counts = JSON.parse(data);
|
|
2722
|
+
// Restore counts from S3
|
|
2723
|
+
this.entityCounts = new Map(Object.entries(counts.entityCounts || {}));
|
|
2724
|
+
this.verbCounts = new Map(Object.entries(counts.verbCounts || {}));
|
|
2725
|
+
this.totalNounCount = counts.totalNounCount || 0;
|
|
2726
|
+
this.totalVerbCount = counts.totalVerbCount || 0;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
catch (error) {
|
|
2730
|
+
if (error.name !== 'NoSuchKey') {
|
|
2731
|
+
console.error('Error loading counts from S3:', error);
|
|
2732
|
+
}
|
|
2733
|
+
// If counts don't exist, initialize by scanning (one-time operation)
|
|
2734
|
+
await this.initializeCountsFromScan();
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Initialize counts by scanning S3 (fallback for missing counts file)
|
|
2739
|
+
*/
|
|
2740
|
+
async initializeCountsFromScan() {
|
|
2741
|
+
// This is expensive but only happens once for legacy data
|
|
2742
|
+
// In production, counts are maintained incrementally
|
|
2743
|
+
try {
|
|
2744
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
2745
|
+
// Count nouns
|
|
2746
|
+
const nounResponse = await this.s3Client.send(new ListObjectsV2Command({
|
|
2747
|
+
Bucket: this.bucketName,
|
|
2748
|
+
Prefix: this.nounPrefix
|
|
2749
|
+
}));
|
|
2750
|
+
this.totalNounCount = nounResponse.Contents?.filter((obj) => obj.Key?.endsWith('.json')).length || 0;
|
|
2751
|
+
// Count verbs
|
|
2752
|
+
const verbResponse = await this.s3Client.send(new ListObjectsV2Command({
|
|
2753
|
+
Bucket: this.bucketName,
|
|
2754
|
+
Prefix: this.verbPrefix
|
|
2755
|
+
}));
|
|
2756
|
+
this.totalVerbCount = verbResponse.Contents?.filter((obj) => obj.Key?.endsWith('.json')).length || 0;
|
|
2757
|
+
// Save initial counts
|
|
2758
|
+
await this.persistCounts();
|
|
2759
|
+
}
|
|
2760
|
+
catch (error) {
|
|
2761
|
+
console.error('Error initializing counts from S3 scan:', error);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Persist counts to S3 storage
|
|
2766
|
+
*/
|
|
2767
|
+
async persistCounts() {
|
|
2768
|
+
const countsKey = `${this.systemPrefix}counts.json`;
|
|
2769
|
+
try {
|
|
2770
|
+
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
2771
|
+
const counts = {
|
|
2772
|
+
entityCounts: Object.fromEntries(this.entityCounts),
|
|
2773
|
+
verbCounts: Object.fromEntries(this.verbCounts),
|
|
2774
|
+
totalNounCount: this.totalNounCount,
|
|
2775
|
+
totalVerbCount: this.totalVerbCount,
|
|
2776
|
+
lastUpdated: new Date().toISOString()
|
|
2777
|
+
};
|
|
2778
|
+
await this.s3Client.send(new PutObjectCommand({
|
|
2779
|
+
Bucket: this.bucketName,
|
|
2780
|
+
Key: countsKey,
|
|
2781
|
+
Body: JSON.stringify(counts),
|
|
2782
|
+
ContentType: 'application/json'
|
|
2783
|
+
}));
|
|
2784
|
+
}
|
|
2785
|
+
catch (error) {
|
|
2786
|
+
console.error('Error persisting counts to S3:', error);
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2663
2789
|
}
|
|
2664
2790
|
//# sourceMappingURL=s3CompatibleStorage.js.map
|
|
@@ -337,14 +337,15 @@ export class BaseStorage extends BaseStorageAdapter {
|
|
|
337
337
|
}
|
|
338
338
|
// Check if the adapter has a paginated method for getting nouns
|
|
339
339
|
if (typeof this.getNounsWithPagination === 'function') {
|
|
340
|
-
// Use the adapter's paginated method
|
|
340
|
+
// Use the adapter's paginated method - pass offset directly to adapter
|
|
341
341
|
const result = await this.getNounsWithPagination({
|
|
342
342
|
limit,
|
|
343
|
+
offset, // Let the adapter handle offset for O(1) operation
|
|
343
344
|
cursor,
|
|
344
345
|
filter: options?.filter
|
|
345
346
|
});
|
|
346
|
-
//
|
|
347
|
-
const items = result.items
|
|
347
|
+
// Don't slice here - the adapter should handle offset efficiently
|
|
348
|
+
const items = result.items;
|
|
348
349
|
// CRITICAL SAFETY CHECK: Prevent infinite loops
|
|
349
350
|
// If we have no items but hasMore is true, force hasMore to false
|
|
350
351
|
// This prevents pagination bugs from causing infinite loops
|
|
@@ -6,7 +6,6 @@ import { HNSWNoun, Vector } from '../coreTypes.js';
|
|
|
6
6
|
declare enum CompressionType {
|
|
7
7
|
NONE = "none",
|
|
8
8
|
GZIP = "gzip",
|
|
9
|
-
BROTLI = "brotli",
|
|
10
9
|
QUANTIZATION = "quantization",
|
|
11
10
|
HYBRID = "hybrid"
|
|
12
11
|
}
|
|
@@ -74,14 +73,6 @@ export declare class ReadOnlyOptimizations {
|
|
|
74
73
|
* GZIP decompression
|
|
75
74
|
*/
|
|
76
75
|
private gzipDecompress;
|
|
77
|
-
/**
|
|
78
|
-
* Brotli compression (placeholder - similar to GZIP)
|
|
79
|
-
*/
|
|
80
|
-
private brotliCompress;
|
|
81
|
-
/**
|
|
82
|
-
* Brotli decompression (placeholder)
|
|
83
|
-
*/
|
|
84
|
-
private brotliDecompress;
|
|
85
76
|
/**
|
|
86
77
|
* Create prebuilt index segments for faster loading
|
|
87
78
|
*/
|
|
@@ -7,9 +7,9 @@ var CompressionType;
|
|
|
7
7
|
(function (CompressionType) {
|
|
8
8
|
CompressionType["NONE"] = "none";
|
|
9
9
|
CompressionType["GZIP"] = "gzip";
|
|
10
|
-
CompressionType["BROTLI"] = "brotli";
|
|
11
10
|
CompressionType["QUANTIZATION"] = "quantization";
|
|
12
11
|
CompressionType["HYBRID"] = "hybrid";
|
|
12
|
+
// BROTLI removed - was not actually implemented
|
|
13
13
|
})(CompressionType || (CompressionType = {}));
|
|
14
14
|
// Vector quantization methods
|
|
15
15
|
var QuantizationType;
|
|
@@ -67,10 +67,7 @@ export class ReadOnlyOptimizations {
|
|
|
67
67
|
const gzipBuffer = new Float32Array(vector).buffer;
|
|
68
68
|
compressedData = await this.gzipCompress(gzipBuffer.slice(0));
|
|
69
69
|
break;
|
|
70
|
-
|
|
71
|
-
const brotliBuffer = new Float32Array(vector).buffer;
|
|
72
|
-
compressedData = await this.brotliCompress(brotliBuffer.slice(0));
|
|
73
|
-
break;
|
|
70
|
+
// Brotli removed - was not implemented
|
|
74
71
|
case CompressionType.HYBRID:
|
|
75
72
|
// First quantize, then compress
|
|
76
73
|
const quantized = await this.quantizeVector(vector, segmentId);
|
|
@@ -99,9 +96,7 @@ export class ReadOnlyOptimizations {
|
|
|
99
96
|
case CompressionType.GZIP:
|
|
100
97
|
const gzipDecompressed = await this.gzipDecompress(compressedData);
|
|
101
98
|
return Array.from(new Float32Array(gzipDecompressed));
|
|
102
|
-
|
|
103
|
-
const brotliDecompressed = await this.brotliDecompress(compressedData);
|
|
104
|
-
return Array.from(new Float32Array(brotliDecompressed));
|
|
99
|
+
// Brotli removed - was not implemented
|
|
105
100
|
case CompressionType.HYBRID:
|
|
106
101
|
const gzipStage = await this.gzipDecompress(compressedData);
|
|
107
102
|
return this.dequantizeVector(gzipStage, segmentId, originalDimension);
|
|
@@ -219,21 +214,7 @@ export class ReadOnlyOptimizations {
|
|
|
219
214
|
return compressedData;
|
|
220
215
|
}
|
|
221
216
|
}
|
|
222
|
-
|
|
223
|
-
* Brotli compression (placeholder - similar to GZIP)
|
|
224
|
-
*/
|
|
225
|
-
async brotliCompress(data) {
|
|
226
|
-
// Would implement Brotli compression here
|
|
227
|
-
console.warn('Brotli compression not implemented, falling back to GZIP');
|
|
228
|
-
return this.gzipCompress(data);
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Brotli decompression (placeholder)
|
|
232
|
-
*/
|
|
233
|
-
async brotliDecompress(compressedData) {
|
|
234
|
-
console.warn('Brotli decompression not implemented, falling back to GZIP');
|
|
235
|
-
return this.gzipDecompress(compressedData);
|
|
236
|
-
}
|
|
217
|
+
// Brotli methods removed - were not implemented
|
|
237
218
|
/**
|
|
238
219
|
* Create prebuilt index segments for faster loading
|
|
239
220
|
*/
|
|
@@ -277,8 +258,7 @@ export class ReadOnlyOptimizations {
|
|
|
277
258
|
switch (this.config.compression.metadataCompression) {
|
|
278
259
|
case CompressionType.GZIP:
|
|
279
260
|
return this.gzipCompress(data.buffer.slice(0));
|
|
280
|
-
|
|
281
|
-
return this.brotliCompress(data.buffer.slice(0));
|
|
261
|
+
// Brotli removed - was not implemented
|
|
282
262
|
default:
|
|
283
263
|
return data.buffer.slice(0);
|
|
284
264
|
}
|
|
@@ -329,9 +309,7 @@ export class ReadOnlyOptimizations {
|
|
|
329
309
|
case CompressionType.GZIP:
|
|
330
310
|
decompressed = await this.gzipDecompress(compressedData);
|
|
331
311
|
break;
|
|
332
|
-
|
|
333
|
-
decompressed = await this.brotliDecompress(compressedData);
|
|
334
|
-
break;
|
|
312
|
+
// Brotli removed - was not implemented
|
|
335
313
|
default:
|
|
336
314
|
decompressed = compressedData;
|
|
337
315
|
break;
|
|
@@ -269,10 +269,25 @@ export interface BrainyConfig {
|
|
|
269
269
|
ttl?: number;
|
|
270
270
|
};
|
|
271
271
|
augmentations?: Record<string, any>;
|
|
272
|
+
distributed?: {
|
|
273
|
+
enabled: boolean;
|
|
274
|
+
nodeId?: string;
|
|
275
|
+
nodes?: string[];
|
|
276
|
+
coordinatorUrl?: string;
|
|
277
|
+
shardCount?: number;
|
|
278
|
+
replicationFactor?: number;
|
|
279
|
+
consensus?: 'raft' | 'none';
|
|
280
|
+
transport?: 'tcp' | 'http' | 'udp';
|
|
281
|
+
};
|
|
272
282
|
warmup?: boolean;
|
|
273
283
|
realtime?: boolean;
|
|
274
284
|
multiTenancy?: boolean;
|
|
275
285
|
telemetry?: boolean;
|
|
286
|
+
disableAutoRebuild?: boolean;
|
|
287
|
+
disableMetrics?: boolean;
|
|
288
|
+
disableAutoOptimize?: boolean;
|
|
289
|
+
batchWrites?: boolean;
|
|
290
|
+
maxConcurrentOperations?: number;
|
|
276
291
|
verbose?: boolean;
|
|
277
292
|
silent?: boolean;
|
|
278
293
|
}
|
|
@@ -68,6 +68,11 @@ export declare class MetadataIndexManager {
|
|
|
68
68
|
private totalEntitiesByType;
|
|
69
69
|
private unifiedCache;
|
|
70
70
|
constructor(storage: StorageAdapter, config?: MetadataIndexConfig);
|
|
71
|
+
/**
|
|
72
|
+
* Lazy load entity counts from storage statistics (O(1) operation)
|
|
73
|
+
* This avoids rebuilding the entire index on startup
|
|
74
|
+
*/
|
|
75
|
+
private lazyLoadCounts;
|
|
71
76
|
/**
|
|
72
77
|
* Get index key for field and value
|
|
73
78
|
*/
|
|
@@ -45,6 +45,30 @@ export class MetadataIndexManager {
|
|
|
45
45
|
});
|
|
46
46
|
// Get global unified cache for coordinated memory management
|
|
47
47
|
this.unifiedCache = getGlobalCache();
|
|
48
|
+
// Lazy load counts from storage statistics on first access
|
|
49
|
+
this.lazyLoadCounts();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Lazy load entity counts from storage statistics (O(1) operation)
|
|
53
|
+
* This avoids rebuilding the entire index on startup
|
|
54
|
+
*/
|
|
55
|
+
async lazyLoadCounts() {
|
|
56
|
+
try {
|
|
57
|
+
// Get statistics from storage (should be O(1) with our FileSystemStorage improvements)
|
|
58
|
+
const stats = await this.storage.getStatistics();
|
|
59
|
+
if (stats && stats.nounCount) {
|
|
60
|
+
// Populate entity counts from storage statistics
|
|
61
|
+
for (const [type, count] of Object.entries(stats.nounCount)) {
|
|
62
|
+
if (typeof count === 'number' && count > 0) {
|
|
63
|
+
this.totalEntitiesByType.set(type, count);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// Silently fail - counts will be populated as entities are added
|
|
70
|
+
// This maintains zero-configuration principle
|
|
71
|
+
}
|
|
48
72
|
}
|
|
49
73
|
/**
|
|
50
74
|
* Get index key for field and value
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Mutex Implementation for Thread-Safe Operations
|
|
3
|
+
* Provides consistent locking across all storage adapters
|
|
4
|
+
* Critical for preventing race conditions in count operations
|
|
5
|
+
*/
|
|
6
|
+
export interface MutexInterface {
|
|
7
|
+
acquire(key: string, timeout?: number): Promise<() => void>;
|
|
8
|
+
runExclusive<T>(key: string, fn: () => Promise<T>, timeout?: number): Promise<T>;
|
|
9
|
+
isLocked(key: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* In-memory mutex for single-process scenarios
|
|
13
|
+
* Used by MemoryStorage and as fallback for other adapters
|
|
14
|
+
*/
|
|
15
|
+
export declare class InMemoryMutex implements MutexInterface {
|
|
16
|
+
private locks;
|
|
17
|
+
acquire(key: string, timeout?: number): Promise<() => void>;
|
|
18
|
+
private release;
|
|
19
|
+
runExclusive<T>(key: string, fn: () => Promise<T>, timeout?: number): Promise<T>;
|
|
20
|
+
isLocked(key: string): boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* File-based mutex for multi-process scenarios (Node.js)
|
|
24
|
+
* Uses atomic file operations to prevent TOCTOU races
|
|
25
|
+
*/
|
|
26
|
+
export declare class FileMutex implements MutexInterface {
|
|
27
|
+
private fs;
|
|
28
|
+
private path;
|
|
29
|
+
private lockDir;
|
|
30
|
+
private processLocks;
|
|
31
|
+
private lockTimers;
|
|
32
|
+
constructor(lockDir: string);
|
|
33
|
+
acquire(key: string, timeout?: number): Promise<() => void>;
|
|
34
|
+
private release;
|
|
35
|
+
runExclusive<T>(key: string, fn: () => Promise<T>, timeout?: number): Promise<T>;
|
|
36
|
+
isLocked(key: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Clean up all locks held by this process
|
|
39
|
+
*/
|
|
40
|
+
cleanup(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Factory to create appropriate mutex for the environment
|
|
44
|
+
*/
|
|
45
|
+
export declare function createMutex(options?: {
|
|
46
|
+
type?: 'memory' | 'file';
|
|
47
|
+
lockDir?: string;
|
|
48
|
+
}): MutexInterface;
|
|
49
|
+
export declare function getGlobalMutex(): MutexInterface;
|
|
50
|
+
/**
|
|
51
|
+
* Cleanup function for graceful shutdown
|
|
52
|
+
*/
|
|
53
|
+
export declare function cleanupMutexes(): Promise<void>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Mutex Implementation for Thread-Safe Operations
|
|
3
|
+
* Provides consistent locking across all storage adapters
|
|
4
|
+
* Critical for preventing race conditions in count operations
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* In-memory mutex for single-process scenarios
|
|
8
|
+
* Used by MemoryStorage and as fallback for other adapters
|
|
9
|
+
*/
|
|
10
|
+
export class InMemoryMutex {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.locks = new Map();
|
|
13
|
+
}
|
|
14
|
+
async acquire(key, timeout = 30000) {
|
|
15
|
+
if (!this.locks.has(key)) {
|
|
16
|
+
this.locks.set(key, { queue: [], locked: false });
|
|
17
|
+
}
|
|
18
|
+
const lock = this.locks.get(key);
|
|
19
|
+
if (!lock.locked) {
|
|
20
|
+
lock.locked = true;
|
|
21
|
+
return () => this.release(key);
|
|
22
|
+
}
|
|
23
|
+
// Wait in queue
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
const index = lock.queue.indexOf(resolver);
|
|
27
|
+
if (index !== -1) {
|
|
28
|
+
lock.queue.splice(index, 1);
|
|
29
|
+
}
|
|
30
|
+
reject(new Error(`Mutex timeout for key: ${key}`));
|
|
31
|
+
}, timeout);
|
|
32
|
+
const resolver = () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
lock.locked = true;
|
|
35
|
+
resolve(() => this.release(key));
|
|
36
|
+
};
|
|
37
|
+
lock.queue.push(resolver);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
release(key) {
|
|
41
|
+
const lock = this.locks.get(key);
|
|
42
|
+
if (!lock)
|
|
43
|
+
return;
|
|
44
|
+
if (lock.queue.length > 0) {
|
|
45
|
+
const next = lock.queue.shift();
|
|
46
|
+
next();
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lock.locked = false;
|
|
50
|
+
// Clean up if no waiters
|
|
51
|
+
if (lock.queue.length === 0) {
|
|
52
|
+
this.locks.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async runExclusive(key, fn, timeout) {
|
|
57
|
+
const release = await this.acquire(key, timeout);
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
release();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
isLocked(key) {
|
|
66
|
+
return this.locks.get(key)?.locked || false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* File-based mutex for multi-process scenarios (Node.js)
|
|
71
|
+
* Uses atomic file operations to prevent TOCTOU races
|
|
72
|
+
*/
|
|
73
|
+
export class FileMutex {
|
|
74
|
+
constructor(lockDir) {
|
|
75
|
+
this.processLocks = new Map();
|
|
76
|
+
this.lockTimers = new Map();
|
|
77
|
+
this.lockDir = lockDir;
|
|
78
|
+
// Lazy load Node.js modules
|
|
79
|
+
if (typeof window === 'undefined') {
|
|
80
|
+
this.fs = require('fs');
|
|
81
|
+
this.path = require('path');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async acquire(key, timeout = 30000) {
|
|
85
|
+
if (!this.fs || !this.path) {
|
|
86
|
+
throw new Error('FileMutex is only available in Node.js environments');
|
|
87
|
+
}
|
|
88
|
+
const lockFile = this.path.join(this.lockDir, `${key}.lock`);
|
|
89
|
+
const lockId = `${Date.now()}_${Math.random()}_${process.pid}`;
|
|
90
|
+
const startTime = Date.now();
|
|
91
|
+
// Ensure lock directory exists
|
|
92
|
+
await this.fs.promises.mkdir(this.lockDir, { recursive: true });
|
|
93
|
+
while (Date.now() - startTime < timeout) {
|
|
94
|
+
try {
|
|
95
|
+
// Atomic lock creation using 'wx' flag
|
|
96
|
+
await this.fs.promises.writeFile(lockFile, JSON.stringify({
|
|
97
|
+
lockId,
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
expiresAt: Date.now() + timeout
|
|
101
|
+
}), { flag: 'wx' } // Write exclusive - fails if exists
|
|
102
|
+
);
|
|
103
|
+
// Successfully acquired lock
|
|
104
|
+
const release = () => this.release(key, lockFile, lockId);
|
|
105
|
+
this.processLocks.set(key, release);
|
|
106
|
+
// Auto-release on timeout
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
release();
|
|
109
|
+
}, timeout);
|
|
110
|
+
this.lockTimers.set(key, timer);
|
|
111
|
+
return release;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code === 'EEXIST') {
|
|
115
|
+
// Lock exists - check if expired
|
|
116
|
+
try {
|
|
117
|
+
const data = await this.fs.promises.readFile(lockFile, 'utf-8');
|
|
118
|
+
const lock = JSON.parse(data);
|
|
119
|
+
if (lock.expiresAt < Date.now()) {
|
|
120
|
+
// Expired - try to remove
|
|
121
|
+
try {
|
|
122
|
+
await this.fs.promises.unlink(lockFile);
|
|
123
|
+
continue; // Retry acquisition
|
|
124
|
+
}
|
|
125
|
+
catch (unlinkError) {
|
|
126
|
+
if (unlinkError.code !== 'ENOENT') {
|
|
127
|
+
// Someone else removed it, continue
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Can't read lock file, assume it's valid
|
|
135
|
+
}
|
|
136
|
+
// Wait before retry
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`Failed to acquire mutex for key: ${key} after ${timeout}ms`);
|
|
145
|
+
}
|
|
146
|
+
async release(key, lockFile, lockId) {
|
|
147
|
+
// Clear timer
|
|
148
|
+
const timer = this.lockTimers.get(key);
|
|
149
|
+
if (timer) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
this.lockTimers.delete(key);
|
|
152
|
+
}
|
|
153
|
+
// Remove from process locks
|
|
154
|
+
this.processLocks.delete(key);
|
|
155
|
+
try {
|
|
156
|
+
// Verify we own the lock before releasing
|
|
157
|
+
const data = await this.fs.promises.readFile(lockFile, 'utf-8');
|
|
158
|
+
const lock = JSON.parse(data);
|
|
159
|
+
if (lock.lockId === lockId) {
|
|
160
|
+
await this.fs.promises.unlink(lockFile);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Lock already released or doesn't exist
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async runExclusive(key, fn, timeout) {
|
|
168
|
+
const release = await this.acquire(key, timeout);
|
|
169
|
+
try {
|
|
170
|
+
return await fn();
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
release();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
isLocked(key) {
|
|
177
|
+
return this.processLocks.has(key);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Clean up all locks held by this process
|
|
181
|
+
*/
|
|
182
|
+
async cleanup() {
|
|
183
|
+
// Clear all timers
|
|
184
|
+
for (const timer of this.lockTimers.values()) {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
this.lockTimers.clear();
|
|
188
|
+
// Release all locks
|
|
189
|
+
const releases = Array.from(this.processLocks.values());
|
|
190
|
+
await Promise.all(releases.map(release => release()));
|
|
191
|
+
this.processLocks.clear();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Factory to create appropriate mutex for the environment
|
|
196
|
+
*/
|
|
197
|
+
export function createMutex(options) {
|
|
198
|
+
const type = options?.type || (typeof window === 'undefined' ? 'file' : 'memory');
|
|
199
|
+
if (type === 'file' && typeof window === 'undefined') {
|
|
200
|
+
const lockDir = options?.lockDir || '.brainy/locks';
|
|
201
|
+
return new FileMutex(lockDir);
|
|
202
|
+
}
|
|
203
|
+
return new InMemoryMutex();
|
|
204
|
+
}
|
|
205
|
+
// Global mutex instance for count operations
|
|
206
|
+
let globalMutex = null;
|
|
207
|
+
export function getGlobalMutex() {
|
|
208
|
+
if (!globalMutex) {
|
|
209
|
+
globalMutex = createMutex();
|
|
210
|
+
}
|
|
211
|
+
return globalMutex;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Cleanup function for graceful shutdown
|
|
215
|
+
*/
|
|
216
|
+
export async function cleanupMutexes() {
|
|
217
|
+
if (globalMutex && 'cleanup' in globalMutex) {
|
|
218
|
+
await globalMutex.cleanup();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=mutex.js.map
|
|
@@ -130,11 +130,24 @@ export function validateFindParams(params) {
|
|
|
130
130
|
export function validateAddParams(params) {
|
|
131
131
|
// Universal truth: must have data or vector
|
|
132
132
|
if (!params.data && !params.vector) {
|
|
133
|
-
throw new Error(
|
|
133
|
+
throw new Error(`Invalid add() parameters: Missing required field 'data'\n` +
|
|
134
|
+
`\nReceived: ${JSON.stringify({
|
|
135
|
+
type: params.type,
|
|
136
|
+
hasMetadata: !!params.metadata,
|
|
137
|
+
hasId: !!params.id
|
|
138
|
+
}, null, 2)}\n` +
|
|
139
|
+
`\nExpected one of:\n` +
|
|
140
|
+
` { data: 'text to store', type?: 'note', metadata?: {...} }\n` +
|
|
141
|
+
` { vector: [0.1, 0.2, ...], type?: 'embedding', metadata?: {...} }\n` +
|
|
142
|
+
`\nExamples:\n` +
|
|
143
|
+
` await brain.add({ data: 'Machine learning is AI', type: 'concept' })\n` +
|
|
144
|
+
` await brain.add({ data: { title: 'Doc', content: '...' }, type: 'document' })`);
|
|
134
145
|
}
|
|
135
146
|
// Validate noun type
|
|
136
147
|
if (!Object.values(NounType).includes(params.type)) {
|
|
137
|
-
throw new Error(`
|
|
148
|
+
throw new Error(`Invalid NounType: '${params.type}'\n` +
|
|
149
|
+
`\nValid types: ${Object.values(NounType).join(', ')}\n` +
|
|
150
|
+
`\nExample: await brain.add({ data: 'text', type: NounType.Note })`);
|
|
138
151
|
}
|
|
139
152
|
// Validate vector dimensions if provided
|
|
140
153
|
if (params.vector) {
|
|
@@ -182,8 +195,11 @@ export function validateRelateParams(params) {
|
|
|
182
195
|
if (params.from === params.to) {
|
|
183
196
|
throw new Error('cannot create self-referential relationship');
|
|
184
197
|
}
|
|
185
|
-
// Validate verb type
|
|
186
|
-
if (
|
|
198
|
+
// Validate verb type - default to RelatedTo if not specified
|
|
199
|
+
if (params.type === undefined) {
|
|
200
|
+
params.type = VerbType.RelatedTo;
|
|
201
|
+
}
|
|
202
|
+
else if (!Object.values(VerbType).includes(params.type)) {
|
|
187
203
|
throw new Error(`invalid VerbType: ${params.type}`);
|
|
188
204
|
}
|
|
189
205
|
// Universal truth: weight must be 0-1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulcraft/brainy",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.1",
|
|
4
4
|
"description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. 31 nouns × 40 verbs for infinite expressiveness.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|